mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-09-28 19:54:50 +02:00
Added login with passkey
This commit is contained in:
@@ -71,6 +71,37 @@ export const generatePasskey = async ({
|
||||
return (await navigator.credentials.create({ publicKey })) as GeneratePasskeyResponse;
|
||||
};
|
||||
|
||||
export type GetCredentialResponse = PublicKeyCredential & {
|
||||
response: PublicKeyCredential;
|
||||
id: string;
|
||||
};
|
||||
export const getCredential = async ({
|
||||
id,
|
||||
challenge,
|
||||
email
|
||||
}: {
|
||||
id: string;
|
||||
challenge: string;
|
||||
email: string;
|
||||
}) => {
|
||||
const publicKey: PublicKeyCredentialCreationOptions = {
|
||||
challenge: base64ToArrayBuffer(challenge),
|
||||
rp: {
|
||||
id,
|
||||
name: "Open Reception"
|
||||
},
|
||||
user: {
|
||||
id: new Uint8Array(16),
|
||||
name: email,
|
||||
displayName: email
|
||||
},
|
||||
pubKeyCredParams: [
|
||||
{ alg: -7, type: "public-key" } // ES256
|
||||
]
|
||||
};
|
||||
return (await navigator.credentials.get({ publicKey })) as GetCredentialResponse;
|
||||
};
|
||||
|
||||
export const getCounterFromAuthenticatorData = (authenticatorData: ArrayBuffer) => {
|
||||
const view = new DataView(authenticatorData);
|
||||
// Counter is at offset 33, 4 bytes, big-endian
|
||||
|
||||
@@ -3,6 +3,7 @@ import { superValidate } from "sveltekit-superforms";
|
||||
import { zod } from "sveltekit-superforms/adapters";
|
||||
import type { Actions, PageServerLoad } from "./$types";
|
||||
import { formSchema } from "./schema";
|
||||
import type { WebAuthnCredential } from "$lib/server/auth/webauthn-service";
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
return {
|
||||
@@ -15,9 +16,32 @@ export const actions: Actions = {
|
||||
const form = await superValidate(event, zod(formSchema));
|
||||
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form
|
||||
});
|
||||
if (!form.valid) {
|
||||
return fail(400, {
|
||||
form: { ...form, data: { ...form.data, type: "passkey" } }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let body: { credential?: WebAuthnCredential; email: string; passphrase?: string } = {
|
||||
email: form.data.email
|
||||
};
|
||||
if (form.data.type === "passphrase") {
|
||||
body = { ...body, passphrase: form.data.passphrase };
|
||||
}
|
||||
|
||||
if (form.data.type === "passkey") {
|
||||
body = {
|
||||
...body,
|
||||
credential: {
|
||||
id: form.data.id,
|
||||
response: {
|
||||
clientDataJSON: form.data.clientDataBase64,
|
||||
authenticatorData: form.data.authenticatorDataBase64,
|
||||
signature: form.data.signatureBase64
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const resp = await event.fetch("/api/auth/login", {
|
||||
@@ -25,7 +49,7 @@ export const actions: Actions = {
|
||||
headers: {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
body: JSON.stringify({ email: form.data.email, passphrase: form.data.passphrase })
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
|
||||
if (resp.status < 400) {
|
||||
|
||||
@@ -7,10 +7,16 @@
|
||||
import { Input } from "$lib/components/ui/input";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
import { toast } from "svelte-sonner";
|
||||
import { writable } from "svelte/store";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { type Infer, superForm, type SuperValidated } from "sveltekit-superforms";
|
||||
import { zodClient } from "sveltekit-superforms/adapters";
|
||||
import { formSchema, type FormSchema } from "./schema";
|
||||
import { baseSchema, formSchema, type FormSchema } from "./schema";
|
||||
import { onMount } from "svelte";
|
||||
import { Passkey } from "$lib/components/ui/passkey";
|
||||
import { Text } from "$lib/components/ui/typography";
|
||||
import type { PasskeyState } from "$lib/components/ui/passkey/state.svelte";
|
||||
import { arrayBufferToBase64, fetchChallenge, getCredential } from "$lib/utils/passkey";
|
||||
import { Label } from "$lib/components/ui/label";
|
||||
|
||||
let {
|
||||
data,
|
||||
@@ -21,6 +27,11 @@
|
||||
|
||||
const form = superForm(data.form, {
|
||||
validators: zodClient(formSchema),
|
||||
onChange: (event) => {
|
||||
if (event.paths.includes("email")) {
|
||||
setProperPasskeyState();
|
||||
}
|
||||
},
|
||||
onResult: async (event) => {
|
||||
if (event.result.type === "success") {
|
||||
await goto(ROUTES.DASHBOARD.MAIN);
|
||||
@@ -32,11 +43,110 @@
|
||||
onSubmit: () => onEvent({ isSubmitting: true })
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
$formData.type = "passkey";
|
||||
});
|
||||
|
||||
const onToggle = () => {
|
||||
if ($formData.type === "passkey") {
|
||||
$formData = {
|
||||
...$formData,
|
||||
type: "passphrase",
|
||||
passphrase: ""
|
||||
};
|
||||
} else {
|
||||
$formData = {
|
||||
...$formData,
|
||||
type: "passkey",
|
||||
id: "",
|
||||
clientDataBase64: "",
|
||||
authenticatorDataBase64: "",
|
||||
signatureBase64: ""
|
||||
};
|
||||
setProperPasskeyState();
|
||||
}
|
||||
};
|
||||
|
||||
const setProperPasskeyState = () => {
|
||||
const isOk = baseSchema.shape.email.safeParse($formData.email).success;
|
||||
if (isOk) {
|
||||
$passkeyLoading = "click";
|
||||
} else {
|
||||
$passkeyLoading = "initial";
|
||||
}
|
||||
};
|
||||
|
||||
const onSetPasskey = async () => {
|
||||
$passkeyLoading = "loading";
|
||||
const challenge = await fetchChallenge($formData.email);
|
||||
|
||||
if (!challenge) {
|
||||
$passkeyLoading = "error";
|
||||
} else {
|
||||
$passkeyLoading = "user";
|
||||
const credentialResp = await getCredential({ ...challenge, email: $formData.email }).catch(
|
||||
() => {
|
||||
$passkeyLoading = "error";
|
||||
}
|
||||
);
|
||||
|
||||
if (!credentialResp) {
|
||||
$passkeyLoading = "error";
|
||||
return;
|
||||
}
|
||||
|
||||
// Update form data with passkey info
|
||||
const clientDataBase64 = arrayBufferToBase64(credentialResp.response.clientDataJSON);
|
||||
const authenticatorDataBase64 = arrayBufferToBase64(
|
||||
// @ts-expect-error response type needs to be fixed
|
||||
credentialResp.response.authenticatorData
|
||||
);
|
||||
// @ts-expect-error response type needs to be fixed
|
||||
const signatureBase64 = arrayBufferToBase64(credentialResp.response.signature);
|
||||
const { type, ...rest } = $formData;
|
||||
$formData = {
|
||||
...rest,
|
||||
type: "passkey",
|
||||
id: credentialResp.id,
|
||||
authenticatorDataBase64,
|
||||
clientDataBase64,
|
||||
signatureBase64
|
||||
};
|
||||
|
||||
// Update UI to show passkey is ready
|
||||
$passkeyLoading = "success";
|
||||
}
|
||||
};
|
||||
|
||||
const { form: formData, enhance } = form;
|
||||
const isUsingPasskey = writable(false);
|
||||
const passkeyLoading: Writable<PasskeyState> = writable("initial");
|
||||
|
||||
// Fixes TypeScript checks in svelte if-blocks
|
||||
const formDataPassphrase = $derived.by(() => {
|
||||
if ($formData.type === "passphrase") {
|
||||
return $formData as Extract<typeof $formData, { type: "passphrase" }>;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const formDataPasskey = $derived.by(() => {
|
||||
if ($formData.type === "passkey") {
|
||||
return $formData as Extract<typeof $formData, { type: "passkey" }>;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
type FormDataPassphrase = Extract<typeof $formData, { type: "passphrase" }>;
|
||||
type FormDataPasskey = Extract<typeof $formData, { type: "passkey" }>;
|
||||
</script>
|
||||
|
||||
<Form.Root {formId} {enhance}>
|
||||
<Form.Field {form} name="type" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.type} type="text" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="email">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
@@ -46,41 +156,88 @@
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="passphrase">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.passphrase"]()}</Form.Label>
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.passphrase}
|
||||
type="password"
|
||||
minlength={30}
|
||||
maxlength={100}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description>
|
||||
{m["login.or"]()}
|
||||
{#if $isUsingPasskey}
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onclick={() => isUsingPasskey.set(false)}
|
||||
class="text-inherit"
|
||||
>
|
||||
{m["login.usePassphrase"]()}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
variant="link"
|
||||
size="sm"
|
||||
onclick={() => isUsingPasskey.set(true)}
|
||||
class="text-inherit"
|
||||
>
|
||||
{#if $formData.type === "passphrase"}
|
||||
<Form.Field {form} name="passphrase">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.passphrase"]()}</Form.Label>
|
||||
<!-- prettier-ignore -->
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={($formData as FormDataPassphrase).passphrase}
|
||||
type="password"
|
||||
minlength={30}
|
||||
maxlength={100}
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
<Form.FieldErrors />
|
||||
<Form.Description>
|
||||
{m["login.or"]()}
|
||||
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
|
||||
{m["login.usePasskey"]()}
|
||||
</Button>
|
||||
{/if}.
|
||||
</Form.Description>
|
||||
</Form.Field>
|
||||
</Form.Description>
|
||||
</Form.Field>
|
||||
{/if}
|
||||
{#if $formData.type === "passkey"}
|
||||
<div>
|
||||
<Form.Field {form} name="id" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<!-- prettier-ignore -->
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={($formData as FormDataPasskey).id}
|
||||
type="hidden"
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="authenticatorDataBase64" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<!-- prettier-ignore -->
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={($formData as FormDataPasskey).authenticatorDataBase64}
|
||||
type="hidden"
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="clientDataBase64" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<!-- prettier-ignore -->
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={($formData as FormDataPasskey).clientDataBase64}
|
||||
type="hidden"
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="signatureBase64" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<!-- prettier-ignore -->
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={($formData as FormDataPasskey).signatureBase64}
|
||||
type="hidden"
|
||||
/>
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Label class="mb-2">{m["form.passkey"]()}</Label>
|
||||
<Passkey.State state={$passkeyLoading} onclick={onSetPasskey} />
|
||||
<Text style="md" color="medium">
|
||||
{m["login.or"]()}
|
||||
<Button variant="link" size="sm" onclick={onToggle} class="text-inherit">
|
||||
{m["login.usePassphrase"]()}
|
||||
</Button>.
|
||||
</Text>
|
||||
</div>
|
||||
{/if}
|
||||
</Form.Root>
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
import { m } from "$i18n/messages";
|
||||
import { z } from "zod";
|
||||
|
||||
export const formSchema = z.object({
|
||||
email: z.string().email(m["form.errors.email"]()),
|
||||
export const baseSchema = z.object({
|
||||
email: z.string().email(m["form.errors.email"]())
|
||||
});
|
||||
|
||||
const passkeySchema = baseSchema.extend({
|
||||
type: z.literal("passkey"),
|
||||
id: z.string().min(3),
|
||||
authenticatorDataBase64: z.string().base64(),
|
||||
clientDataBase64: z.string().base64(),
|
||||
signatureBase64: z.string().base64()
|
||||
});
|
||||
const passphraseSchema = baseSchema.extend({
|
||||
type: z.literal("passphrase"),
|
||||
passphrase: z.string().min(30, m["form.errors.passphrase"]())
|
||||
});
|
||||
|
||||
export const formSchema = z.discriminatedUnion("type", [passkeySchema, passphraseSchema]);
|
||||
|
||||
export type FormSchema = typeof formSchema;
|
||||
|
||||
@@ -68,16 +68,14 @@
|
||||
|
||||
const onToggle = () => {
|
||||
if ($formData.type === "passkey") {
|
||||
const { id, publicKeyBase64, authenticatorDataBase64, type, ...rest } = $formData;
|
||||
$formData = {
|
||||
...rest,
|
||||
...$formData,
|
||||
type: "passphrase",
|
||||
passphrase: ""
|
||||
};
|
||||
} else {
|
||||
const { passphrase, type, ...rest } = $formData;
|
||||
$formData = {
|
||||
...rest,
|
||||
...$formData,
|
||||
type: "passkey",
|
||||
id: "",
|
||||
publicKeyBase64: "",
|
||||
@@ -119,7 +117,6 @@
|
||||
// Update form data with passkey info
|
||||
const publicKeyBase64 = arrayBufferToBase64(publicKey);
|
||||
const authenticatorDataBase64 = arrayBufferToBase64(authenticatorData);
|
||||
|
||||
const { type, ...rest } = $formData;
|
||||
$formData = {
|
||||
...rest,
|
||||
@@ -133,10 +130,13 @@
|
||||
$passkeyLoading = "success";
|
||||
}
|
||||
};
|
||||
|
||||
type FormDataPassphrase = Extract<typeof $formData, { type: "passphrase" }>;
|
||||
type FormDataPasskey = Extract<typeof $formData, { type: "passkey" }>;
|
||||
</script>
|
||||
|
||||
<Form.Root {formId} {enhance}>
|
||||
<Form.Field {form} name="type" class="block">
|
||||
<Form.Field {form} name="type" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.type} type="text" />
|
||||
@@ -164,10 +164,10 @@
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Form.Label>{m["form.passphrase"]()}</Form.Label>
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
<Input
|
||||
{...props}
|
||||
bind:value={$formData.passphrase}
|
||||
bind:value={($formData as FormDataPassphrase).passphrase}
|
||||
type="password"
|
||||
minlength={30}
|
||||
maxlength={100}
|
||||
@@ -183,26 +183,30 @@
|
||||
</Button>.
|
||||
</Form.Description>
|
||||
</Form.Field>
|
||||
{:else if $formData.type === "passkey"}
|
||||
{/if}
|
||||
{#if $formData.type === "passkey"}
|
||||
<div>
|
||||
<Form.Field {form} name="id" class="block">
|
||||
<Form.Field {form} name="id" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.id} type="text" />
|
||||
<!-- prettier-ignore -->
|
||||
<Input {...props} bind:value={($formData as FormDataPasskey).id} type="hidden" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="publicKeyBase64" class="block">
|
||||
<Form.Field {form} name="publicKeyBase64" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.publicKeyBase64} type="text" />
|
||||
<!-- prettier-ignore -->
|
||||
<Input {...props} bind:value={($formData as FormDataPasskey).publicKeyBase64} type="hidden" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
<Form.Field {form} name="authenticatorDataBase64" class="block">
|
||||
<Form.Field {form} name="authenticatorDataBase64" class="hidden">
|
||||
<Form.Control>
|
||||
{#snippet children({ props })}
|
||||
<Input {...props} bind:value={$formData.authenticatorDataBase64} type="text" />
|
||||
<!-- prettier-ignore -->
|
||||
<Input {...props} bind:value={($formData as FormDataPasskey).authenticatorDataBase64} type="hidden" />
|
||||
{/snippet}
|
||||
</Form.Control>
|
||||
</Form.Field>
|
||||
|
||||
Reference in New Issue
Block a user