diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 9ce3892..f40258b 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -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 diff --git a/src/routes/(pages)/login/+page.server.ts b/src/routes/(pages)/login/+page.server.ts index 788ca12..b2699d0 100644 --- a/src/routes/(pages)/login/+page.server.ts +++ b/src/routes/(pages)/login/+page.server.ts @@ -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) { diff --git a/src/routes/(pages)/login/login-form.svelte b/src/routes/(pages)/login/login-form.svelte index 64a9214..1f9ba03 100644 --- a/src/routes/(pages)/login/login-form.svelte +++ b/src/routes/(pages)/login/login-form.svelte @@ -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 = writable("initial"); + + // Fixes TypeScript checks in svelte if-blocks + const formDataPassphrase = $derived.by(() => { + if ($formData.type === "passphrase") { + return $formData as Extract; + } + return null; + }); + const formDataPasskey = $derived.by(() => { + if ($formData.type === "passkey") { + return $formData as Extract; + } + return null; + }); + + type FormDataPassphrase = Extract; + type FormDataPasskey = Extract; + {#snippet children({ props })} @@ -46,41 +156,88 @@ - - - {#snippet children({ props })} - {m["form.passphrase"]()} - - {/snippet} - - - - {m["login.or"]()} - {#if $isUsingPasskey} - - {:else} - - {/if}. - - + + + {/if} + {#if $formData.type === "passkey"} +
+ + + + + + + + {m["login.or"]()} + . + +
+ {/if}
diff --git a/src/routes/(pages)/login/schema.ts b/src/routes/(pages)/login/schema.ts index 53f6013..0d4ded3 100644 --- a/src/routes/(pages)/login/schema.ts +++ b/src/routes/(pages)/login/schema.ts @@ -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; diff --git a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte index cc1846a..4ff43e6 100644 --- a/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte +++ b/src/routes/(pages)/setup/create-admin-account/create-account-form.svelte @@ -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; + type FormDataPasskey = Extract; - + - {:else if $formData.type === "passkey"} + {/if} + {#if $formData.type === "passkey"}
- + - + - +