Added login with passkey

This commit is contained in:
Karl Ludwig Weise
2025-08-26 21:04:36 +02:00
parent 94c9f59152
commit 93d6b00d94
5 changed files with 288 additions and 59 deletions
+31
View File
@@ -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
+28 -4
View File
@@ -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) {
+195 -38
View File
@@ -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>
+15 -2
View File
@@ -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>