diff --git a/src/lib/server/auth/__tests__/registration-bootstrap.test.ts b/src/lib/server/auth/__tests__/registration-bootstrap.test.ts new file mode 100644 index 0000000..f29467a --- /dev/null +++ b/src/lib/server/auth/__tests__/registration-bootstrap.test.ts @@ -0,0 +1,180 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("$env/dynamic/private", () => ({ + env: { + JWT_SECRET: "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars", + }, +})); + +import { normalizeEmail } from "$lib/utils"; + +import { + generateRegistrationBootstrapToken, + verifyRegistrationBootstrapToken, +} from "../registration-bootstrap"; + +const VALID_USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890"; +const VALID_EMAIL = "user@example.com"; + +describe("registration-bootstrap", () => { + describe("normalizeEmail", () => { + it("should lowercase and trim", () => { + expect(normalizeEmail(" User@Example.COM ")).toBe("user@example.com"); + }); + + it("should not change already normalised email", () => { + expect(normalizeEmail("user@example.com")).toBe("user@example.com"); + }); + }); + + describe("generateRegistrationBootstrapToken", () => { + it("should return a JWT string", async () => { + const token = await generateRegistrationBootstrapToken({ + userId: VALID_USER_ID, + email: VALID_EMAIL, + }); + + expect(typeof token).toBe("string"); + expect(token!.split(".")).toHaveLength(3); + }); + + it("should normalise email before encoding", async () => { + const token = await generateRegistrationBootstrapToken({ + userId: VALID_USER_ID, + email: "USER@EXAMPLE.COM", + }); + + const payload = await verifyRegistrationBootstrapToken(token!); + expect(payload!.email).toBe("user@example.com"); + }); + }); + + describe("verifyRegistrationBootstrapToken", () => { + it("should verify a valid token and return userId and email", async () => { + const token = await generateRegistrationBootstrapToken({ + userId: VALID_USER_ID, + email: VALID_EMAIL, + }); + + const payload = await verifyRegistrationBootstrapToken(token!); + expect(payload).not.toBeNull(); + expect(payload!.userId).toBe(VALID_USER_ID); + expect(payload!.email).toBe(VALID_EMAIL); + }); + + it("should return null for undefined input", async () => { + const result = await verifyRegistrationBootstrapToken(undefined); + expect(result).toBeNull(); + }); + + it("should return null for an empty string", async () => { + const result = await verifyRegistrationBootstrapToken(""); + expect(result).toBeNull(); + }); + + it("should return null for a malformed token", async () => { + const result = await verifyRegistrationBootstrapToken("not.a.jwt"); + expect(result).toBeNull(); + }); + + it("should return null for a token signed with a different secret", async () => { + // Manually build a token with a different secret via jose + const { SignJWT } = await import("jose"); + const wrongSecret = new TextEncoder().encode("wrong-secret-value"); + const token = await new SignJWT({ + userId: VALID_USER_ID, + email: VALID_EMAIL, + type: "webauthn-registration-bootstrap", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("15m") + .sign(wrongSecret); + + const result = await verifyRegistrationBootstrapToken(token); + expect(result).toBeNull(); + }); + + it("should return null for a token with wrong type claim", async () => { + const { SignJWT } = await import("jose"); + const secret = new TextEncoder().encode( + "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars", + ); + const token = await new SignJWT({ + userId: VALID_USER_ID, + email: VALID_EMAIL, + type: "wrong-type", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("15m") + .sign(secret); + + const result = await verifyRegistrationBootstrapToken(token); + expect(result).toBeNull(); + }); + + it("should return null for a token missing userId", async () => { + const { SignJWT } = await import("jose"); + const secret = new TextEncoder().encode( + "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars", + ); + const token = await new SignJWT({ + email: VALID_EMAIL, + type: "webauthn-registration-bootstrap", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("15m") + .sign(secret); + + const result = await verifyRegistrationBootstrapToken(token); + expect(result).toBeNull(); + }); + + it("should return null for a token missing email", async () => { + const { SignJWT } = await import("jose"); + const secret = new TextEncoder().encode( + "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars", + ); + const token = await new SignJWT({ + userId: VALID_USER_ID, + type: "webauthn-registration-bootstrap", + }) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt() + .setExpirationTime("15m") + .sign(secret); + + const result = await verifyRegistrationBootstrapToken(token); + expect(result).toBeNull(); + }); + + it("should normalise email in the returned payload", async () => { + const token = await generateRegistrationBootstrapToken({ + userId: VALID_USER_ID, + email: " USER@EXAMPLE.COM ", + }); + + const payload = await verifyRegistrationBootstrapToken(token!); + expect(payload!.email).toBe("user@example.com"); + }); + + it("should distinguish tokens for different users", async () => { + const tokenA = await generateRegistrationBootstrapToken({ + userId: VALID_USER_ID, + email: VALID_EMAIL, + }); + const tokenB = await generateRegistrationBootstrapToken({ + userId: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + email: "other@example.com", + }); + + const payloadA = await verifyRegistrationBootstrapToken(tokenA!); + const payloadB = await verifyRegistrationBootstrapToken(tokenB!); + + expect(payloadA!.userId).not.toBe(payloadB!.userId); + expect(payloadA!.email).not.toBe(payloadB!.email); + }); + }); +}); diff --git a/src/lib/server/auth/registration-bootstrap.ts b/src/lib/server/auth/registration-bootstrap.ts new file mode 100644 index 0000000..814aa9f --- /dev/null +++ b/src/lib/server/auth/registration-bootstrap.ts @@ -0,0 +1,80 @@ +import { SignJWT, jwtVerify, type JWTPayload } from "jose"; +import { env } from "$env/dynamic/private"; +import { UniversalLogger } from "$lib/logger"; +import { normalizeEmail } from "$lib/utils"; + +const logger = new UniversalLogger().setContext("RegistrationBootstrap"); + +const REGISTRATION_BOOTSTRAP_TYPE = "webauthn-registration-bootstrap"; +const REGISTRATION_BOOTSTRAP_EXPIRES = "15m"; + +type RegistrationBootstrapPayload = { + userId: string; + email: string; + type: typeof REGISTRATION_BOOTSTRAP_TYPE; +}; + +const getJwtSecret = (): Uint8Array | null => { + if (!env.JWT_SECRET) { + logger.error("JWT_SECRET missing while handling registration bootstrap token"); + return null; + } + + return new TextEncoder().encode(env.JWT_SECRET); +}; + +export async function generateRegistrationBootstrapToken(input: { + userId: string; + email: string; +}): Promise { + const jwtSecret = getJwtSecret(); + if (!jwtSecret) { + return null; + } + + const now = Math.floor(Date.now() / 1000); + const payload: RegistrationBootstrapPayload = { + userId: input.userId, + email: normalizeEmail(input.email), + type: REGISTRATION_BOOTSTRAP_TYPE, + }; + + return await new SignJWT(payload) + .setProtectedHeader({ alg: "HS256" }) + .setIssuedAt(now) + .setExpirationTime(REGISTRATION_BOOTSTRAP_EXPIRES) + .sign(jwtSecret); +} + +export async function verifyRegistrationBootstrapToken( + token?: string, +): Promise<{ userId: string; email: string } | null> { + if (!token) { + return null; + } + + const jwtSecret = getJwtSecret(); + if (!jwtSecret) { + return null; + } + + try { + const { payload } = await jwtVerify(token, jwtSecret); + const typedPayload = payload as JWTPayload & Partial; + + if ( + typedPayload.type !== REGISTRATION_BOOTSTRAP_TYPE || + typeof typedPayload.userId !== "string" || + typeof typedPayload.email !== "string" + ) { + return null; + } + + return { + userId: typedPayload.userId, + email: normalizeEmail(typedPayload.email), + }; + } catch { + return null; + } +} diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ac0b00a..c4c7a25 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -11,3 +11,13 @@ export type WithoutChild = T extends { child?: any } ? Omit : T; export type WithoutChildren = T extends { children?: any } ? Omit : T; export type WithoutChildrenOrChild = WithoutChildren>; export type WithElementRef = T & { ref?: U | null }; + +export function normalizeEmail(value: string): string; +export function normalizeEmail(value?: string | null): string | undefined; +export function normalizeEmail(value?: string | null): string | undefined { + if (!value) { + return undefined; + } + + return value.trim().toLowerCase(); +} diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 04be6c3..56ffbcf 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -1,4 +1,5 @@ import logger from "$lib/logger"; +import { normalizeEmail } from "$lib/utils"; type WebAuthnAllowCredential = { id: string; @@ -46,13 +47,16 @@ export function base64ToArrayBuffer(base64: string) { return bytes.buffer; } -export const fetchChallenge = async (email: string) => { +export const fetchChallenge = async (email: string, userId?: string) => { const resp = await fetch("/api/auth/challenge", { method: "POST", headers: { "Content-Type": "application/json", }, - body: JSON.stringify({ email }), + body: JSON.stringify({ + email, + ...(userId ? { userId } : {}), + }), }); let data; @@ -94,11 +98,13 @@ export const fetchChallenge = async (email: string) => { export const getCredentialOptions = ({ id, challenge, + userId, email, enablePRF = false, }: { id: string; challenge: string; + userId: ArrayBuffer; email: string; enablePRF?: boolean; }): { @@ -114,7 +120,7 @@ export const getCredentialOptions = ({ name: "Open Reception", }, user: { - id: new Uint8Array(16), + id: userId, name: email, displayName: email, }, @@ -139,6 +145,12 @@ export const getCredentialOptions = ({ return options; }; +const createWebAuthnUserId = async (email: string): Promise => { + const normalizedEmail = normalizeEmail(email) ?? ""; + const emailBytes = new TextEncoder().encode(normalizedEmail); + return crypto.subtle.digest("SHA-256", emailBytes); +}; + export type GeneratePasskeyResponse = { response: AuthenticatorAttestationResponse; id: string; @@ -156,7 +168,8 @@ export const generatePasskey = async ({ email: string; enablePRF?: boolean; }): Promise => { - const options = getCredentialOptions({ id, challenge, email, enablePRF }); + const userId = await createWebAuthnUserId(email); + const options = getCredentialOptions({ id, challenge, userId, email, enablePRF }); return (await navigator.credentials.create(options)) as GeneratePasskeyResponse; }; diff --git a/src/routes/(pages)/confirm/[token]/+page.server.ts b/src/routes/(pages)/confirm/[token]/+page.server.ts index b9122ff..18141f5 100644 --- a/src/routes/(pages)/confirm/[token]/+page.server.ts +++ b/src/routes/(pages)/confirm/[token]/+page.server.ts @@ -1,12 +1,13 @@ import logger from "$lib/logger"; import { removeAuthCookies } from "$lib/server/utils/cookies"; +import { generateRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap"; import type { PageServerLoad } from "./$types"; const log = logger.setContext(import.meta.filename); type Error = { success: false; isSetup: boolean }; type Success = { - success: boolean; + success: true; isSetup: boolean; id: string; email: string; @@ -16,34 +17,57 @@ export const load: PageServerLoad = async (event) => { // Remove any existing access token cookie removeAuthCookies(event); - const confirmation: Promise = event - .fetch("/api/auth/confirm", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ token: event.params.token }), - }) - .then(async (resp) => { - const success = resp.status < 400; - try { - const body = await resp.json(); - return { - success, + const resp = await event.fetch("/api/auth/confirm", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ token: event.params.token }), + }); + + const success = resp.status < 400; + try { + const rawBody = await resp.text(); + const body = rawBody ? JSON.parse(rawBody) : {}; + + if (success && typeof body.id === "string" && typeof body.email === "string") { + const registrationBootstrapToken = await generateRegistrationBootstrapToken({ + userId: body.id, + email: body.email, + }); + + if (registrationBootstrapToken) { + event.cookies.set("webauthn-registration-bootstrap", registrationBootstrapToken, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + maxAge: 60 * 15, + }); + } + + return { + confirmation: { + success: true, isSetup: body.isSetup ?? false, id: body.id, email: body.email, - tenantId: body.tenantId, - }; - } catch (error) { - log.error("Failed to parse confirm token response", { error }); - return { success: false, isSetup: false }; - } - }); + tenantId: body.tenantId ?? null, + } satisfies Success, + }; + } - return { - streaming: { - confirmation, - }, - }; + return { + confirmation: { success: false, isSetup: false } satisfies Error, + }; + } catch (error) { + log.error("Failed to parse confirm token response", { + error, + status: resp.status, + contentType: resp.headers.get("content-type"), + }); + return { + confirmation: { success: false, isSetup: false } satisfies Error, + }; + } }; diff --git a/src/routes/(pages)/confirm/[token]/+page.svelte b/src/routes/(pages)/confirm/[token]/+page.svelte index 688b3d5..f9ce94d 100644 --- a/src/routes/(pages)/confirm/[token]/+page.svelte +++ b/src/routes/(pages)/confirm/[token]/+page.svelte @@ -1,10 +1,9 @@