mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Boostrapping of user registration. Keypass error fixes.
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
co-authored by
Copilot
parent
78dfd9317a
commit
828b5297dc
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string | null> {
|
||||
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<RegistrationBootstrapPayload>;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -11,3 +11,13 @@ export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||
export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
|
||||
export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
|
||||
export type WithElementRef<T, U extends HTMLElement = HTMLElement> = 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();
|
||||
}
|
||||
|
||||
@@ -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<ArrayBuffer> => {
|
||||
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<GeneratePasskeyResponse> => {
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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<Success | Error> = 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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { m } from "$i18n/messages.js";
|
||||
import { CenteredCard } from "$lib/components/layouts";
|
||||
import { CenterLoadingState, CenterState } from "$lib/components/templates/empty-state";
|
||||
import { CenterState } from "$lib/components/templates/empty-state";
|
||||
import { Button } from "$lib/components/ui/button";
|
||||
import { PageWithClaim } from "$lib/components/ui/page";
|
||||
import { Skeleton } from "$lib/components/ui/skeleton";
|
||||
import Ban from "@lucide/svelte/icons/ban";
|
||||
import Check from "@lucide/svelte/icons/check";
|
||||
import { ROUTES } from "$lib/const/routes";
|
||||
@@ -21,68 +20,58 @@
|
||||
<PageWithClaim isWithLanguageSwitch>
|
||||
<CenteredCard.Root>
|
||||
<CenteredCard.Main>
|
||||
{#await data.streaming.confirmation}
|
||||
<CenterLoadingState />
|
||||
{:then confirmation}
|
||||
{#if confirmation.success}
|
||||
{#if confirmation.isSetup}
|
||||
<CenterState
|
||||
Icon={Check}
|
||||
headline={m["setup.confirm.success.title"]()}
|
||||
description={m["setup.confirm.success.description"]()}
|
||||
/>
|
||||
{:else}
|
||||
<CenterState
|
||||
Icon={Check}
|
||||
headline={m["confirm.success.title"]()}
|
||||
description={m["confirm.success.description"]()}
|
||||
/>
|
||||
{/if}
|
||||
{#if data.confirmation.success}
|
||||
{#if data.confirmation.isSetup}
|
||||
<CenterState
|
||||
Icon={Check}
|
||||
headline={m["setup.confirm.success.title"]()}
|
||||
description={m["setup.confirm.success.description"]()}
|
||||
/>
|
||||
{:else}
|
||||
<CenterState
|
||||
Icon={Ban}
|
||||
headline={m["confirm.error.title"]()}
|
||||
description={m["confirm.error.description"]()}
|
||||
Icon={Check}
|
||||
headline={m["confirm.success.title"]()}
|
||||
description={m["confirm.success.description"]()}
|
||||
/>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<CenterState
|
||||
Icon={Ban}
|
||||
headline={m["confirm.error.title"]()}
|
||||
description={m["confirm.error.description"]()}
|
||||
/>
|
||||
{/if}
|
||||
</CenteredCard.Main>
|
||||
<CenteredCard.Action>
|
||||
{#await data.streaming.confirmation}
|
||||
<Skeleton class="mx-auto h-2 w-3/4" />
|
||||
<Skeleton class="mx-auto h-2 w-1/4" />
|
||||
<Skeleton class="h-10 w-full" />
|
||||
{:then confirmation}
|
||||
{#if confirmation.success}
|
||||
{#if confirmation.isSetup}
|
||||
<CenteredCard.ActionHint>
|
||||
{m["setup.confirm.success.hint"]()}
|
||||
</CenteredCard.ActionHint>
|
||||
<Button size="lg" class="w-full" href={ROUTES.LOGIN}>
|
||||
{m["setup.confirm.success.action"]()}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-full"
|
||||
onclick={() =>
|
||||
goto(resolve(ROUTES.SETUP_PASSKEY), {
|
||||
state: {
|
||||
id: confirmation.id,
|
||||
email: confirmation.email,
|
||||
tenantId: confirmation.tenantId,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{m["confirm.success.action"]()}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if data.confirmation.success}
|
||||
{#if data.confirmation.isSetup}
|
||||
<CenteredCard.ActionHint>
|
||||
{m["setup.confirm.success.hint"]()}
|
||||
</CenteredCard.ActionHint>
|
||||
<Button size="lg" class="w-full" href={ROUTES.LOGIN}>
|
||||
{m["setup.confirm.success.action"]()}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button size="lg" class="w-full" href={ROUTES.RESEND_CONFIRMATION}>
|
||||
{m["confirm.error.action"]()}
|
||||
<Button
|
||||
size="lg"
|
||||
class="w-full"
|
||||
onclick={() =>
|
||||
goto(resolve(ROUTES.SETUP_PASSKEY), {
|
||||
state: {
|
||||
id: data.confirmation.id,
|
||||
email: data.confirmation.email,
|
||||
tenantId: data.confirmation.tenantId,
|
||||
},
|
||||
})}
|
||||
>
|
||||
{m["confirm.success.action"]()}
|
||||
</Button>
|
||||
{/if}
|
||||
{/await}
|
||||
{:else}
|
||||
<Button size="lg" class="w-full" href={ROUTES.RESEND_CONFIRMATION}>
|
||||
{m["confirm.error.action"]()}
|
||||
</Button>
|
||||
{/if}
|
||||
</CenteredCard.Action>
|
||||
</CenteredCard.Root>
|
||||
</PageWithClaim>
|
||||
|
||||
@@ -80,7 +80,7 @@
|
||||
const { KyberCrypto } = await import("$lib/crypto/utils");
|
||||
kyberKeyPair = KyberCrypto.generateKeyPair();
|
||||
|
||||
const challenge = await fetchChallenge($formData.email);
|
||||
const challenge = await fetchChallenge($formData.email, $formData.userId);
|
||||
|
||||
if (!challenge) {
|
||||
logger.error("Failed to fetch challenge", { email: $formData.email });
|
||||
@@ -134,7 +134,7 @@
|
||||
// This is the only time we can retrieve the PRF output
|
||||
// Uses email as salt for multi-passkey support
|
||||
try {
|
||||
const prfChallenge = await fetchChallenge($formData.email);
|
||||
const prfChallenge = await fetchChallenge($formData.email, $formData.userId);
|
||||
if (!prfChallenge) {
|
||||
throw new Error("Failed to fetch PRF challenge");
|
||||
}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { json } from "@sveltejs/kit";
|
||||
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
|
||||
import { UserService } from "$lib/server/services/user-service";
|
||||
import { BackendError, InternalError, logError, NotFoundError } from "$lib/server/utils/errors";
|
||||
import {
|
||||
BackendError,
|
||||
InternalError,
|
||||
logError,
|
||||
NotFoundError,
|
||||
ValidationError,
|
||||
} from "$lib/server/utils/errors";
|
||||
import type { Cookies } from "@sveltejs/kit";
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||
import { UniversalLogger } from "$lib/logger";
|
||||
import { env } from "$env/dynamic/private";
|
||||
import { challengeThrottleService } from "$lib/server/services/challenge-throttle";
|
||||
import { verifyRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
|
||||
import { normalizeEmail } from "$lib/utils";
|
||||
|
||||
const logger = new UniversalLogger().setContext("AuthChallengeAPI");
|
||||
|
||||
@@ -28,6 +37,12 @@ registerOpenAPIRoute("/auth/challenge", "POST", {
|
||||
description: "User's email address",
|
||||
example: "admin@example.com",
|
||||
},
|
||||
userId: {
|
||||
type: "string",
|
||||
format: "uuid",
|
||||
description:
|
||||
"Optional user ID for registration setup flows. If provided, it must match the confirmed account.",
|
||||
},
|
||||
},
|
||||
required: ["email"],
|
||||
},
|
||||
@@ -126,18 +141,67 @@ function getRpId(requestUrl: URL): string {
|
||||
return "localhost";
|
||||
}
|
||||
|
||||
async function validateRegistrationBootstrapSession(input: {
|
||||
cookies: Cookies;
|
||||
userId: string;
|
||||
userEmail: string;
|
||||
requestEmail: string;
|
||||
requestUserId?: string;
|
||||
}): Promise<void> {
|
||||
const bootstrapPayload = await verifyRegistrationBootstrapToken(
|
||||
input.cookies.get("webauthn-registration-bootstrap"),
|
||||
);
|
||||
|
||||
const bootstrapIsValid =
|
||||
!!bootstrapPayload &&
|
||||
bootstrapPayload.userId === input.userId &&
|
||||
bootstrapPayload.email === normalizeEmail(input.userEmail) &&
|
||||
bootstrapPayload.email === normalizeEmail(input.requestEmail) &&
|
||||
(!input.requestUserId || input.requestUserId === input.userId);
|
||||
|
||||
if (!bootstrapIsValid) {
|
||||
logger.warn("Registration challenge rejected due to invalid bootstrap session", {
|
||||
email: input.requestEmail,
|
||||
requestUserId: input.requestUserId,
|
||||
targetUserId: input.userId,
|
||||
hasBootstrapCookie: !!input.cookies.get("webauthn-registration-bootstrap"),
|
||||
});
|
||||
|
||||
throw new ValidationError("Invalid or missing registration bootstrap session");
|
||||
}
|
||||
}
|
||||
|
||||
function parseChallengeRequest(body: unknown): { requestEmail: string; requestUserId?: string } {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new ValidationError("Valid email is required");
|
||||
}
|
||||
|
||||
const parsedBody = body as { email?: unknown; userId?: unknown };
|
||||
const requestEmail =
|
||||
typeof parsedBody.email === "string" ? normalizeEmail(parsedBody.email) : undefined;
|
||||
|
||||
if (!requestEmail) {
|
||||
throw new ValidationError("Valid email is required");
|
||||
}
|
||||
|
||||
const requestUserId = typeof parsedBody.userId === "string" ? parsedBody.userId : undefined;
|
||||
|
||||
return { requestEmail, requestUserId };
|
||||
}
|
||||
|
||||
export const POST: RequestHandler = async ({ request, cookies, url }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { requestEmail, requestUserId } = parseChallengeRequest(body);
|
||||
|
||||
logger.debug("Generating WebAuthn challenge", { email: body.email });
|
||||
logger.debug("Generating WebAuthn challenge", { email: requestEmail, requestUserId });
|
||||
|
||||
// Check throttling for passkey challenges
|
||||
const throttleResult = await challengeThrottleService.checkThrottle(body.email, "passkey");
|
||||
const throttleResult = await challengeThrottleService.checkThrottle(requestEmail, "passkey");
|
||||
|
||||
if (!throttleResult.allowed) {
|
||||
logger.warn("Passkey challenge throttled", {
|
||||
email: body.email,
|
||||
email: requestEmail,
|
||||
retryAfterMs: throttleResult.retryAfterMs,
|
||||
failedAttempts: throttleResult.failedAttempts,
|
||||
});
|
||||
@@ -161,7 +225,7 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => {
|
||||
let isRegistration = false;
|
||||
|
||||
try {
|
||||
user = await UserService.getUserByEmail(body.email);
|
||||
user = await UserService.getUserByEmail(requestEmail);
|
||||
const passkeys = await UserService.getUserPasskeys(user.id);
|
||||
if (passkeys.length === 0) {
|
||||
isRegistration = true; // User exists but has no passphrase - must register
|
||||
@@ -171,18 +235,28 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => {
|
||||
// User doesn't exist yet - this is a registration flow
|
||||
isRegistration = true;
|
||||
logger.debug("User not found - generating challenge for registration", {
|
||||
email: body.email,
|
||||
email: requestEmail,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (isRegistration && user) {
|
||||
await validateRegistrationBootstrapSession({
|
||||
cookies,
|
||||
userId: user.id,
|
||||
userEmail: user.email,
|
||||
requestEmail,
|
||||
requestUserId,
|
||||
});
|
||||
}
|
||||
|
||||
// Generate challenge
|
||||
const challenge = WebAuthnService.generateChallenge();
|
||||
|
||||
if (isRegistration) {
|
||||
cookies.set("webauthn-registration-email", body.email, {
|
||||
cookies.set("webauthn-registration-email", requestEmail, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
@@ -209,11 +283,10 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => {
|
||||
// Get user's registered passkeys for login
|
||||
const passkeys = await WebAuthnService.getUserPasskeys(user.id);
|
||||
|
||||
// Format passkeys for WebAuthn API
|
||||
allowCredentials = passkeys.map((passkey) => ({
|
||||
id: passkey.id,
|
||||
type: "public-key" as const,
|
||||
transports: ["usb", "nfc", "ble", "internal"], // All possible transports
|
||||
transports: ["usb", "nfc", "ble", "internal"],
|
||||
}));
|
||||
|
||||
logger.debug("WebAuthn challenge generated for login", {
|
||||
@@ -224,7 +297,7 @@ export const POST: RequestHandler = async ({ request, cookies, url }) => {
|
||||
});
|
||||
} else {
|
||||
logger.debug("WebAuthn challenge generated for registration", {
|
||||
email: body.email,
|
||||
email: requestEmail,
|
||||
challenge: challenge.substring(0, 8) + "...",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Must mock env before any module that reads it at module scope
|
||||
vi.mock("$env/dynamic/private", () => ({
|
||||
env: {
|
||||
JWT_SECRET: "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars",
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/auth/webauthn-service", () => ({
|
||||
WebAuthnService: {
|
||||
generateChallenge: vi.fn().mockReturnValue("challenge-base64url-value"),
|
||||
getUserPasskeys: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/services/user-service", () => ({
|
||||
UserService: {
|
||||
getUserByEmail: vi.fn(),
|
||||
getUserPasskeys: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/services/challenge-throttle", () => ({
|
||||
challengeThrottleService: {
|
||||
checkThrottle: vi.fn().mockResolvedValue({ allowed: true, failedAttempts: 0, retryAfterMs: 0 }),
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../+server";
|
||||
import { UserService } from "$lib/server/services/user-service";
|
||||
import { generateRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
|
||||
|
||||
const USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
const USER_EMAIL = "user@example.com";
|
||||
|
||||
const mockUser = {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
name: "Test User",
|
||||
role: "STAFF" as const,
|
||||
tenantId: "t1",
|
||||
};
|
||||
|
||||
// Helper to build a minimal SvelteKit RequestEvent
|
||||
const buildEvent = (body: object, cookieMap: Record<string, string> = {}): any => ({
|
||||
request: new Request("http://localhost/api/auth/challenge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
cookies: {
|
||||
get: (name: string) => cookieMap[name],
|
||||
set: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
url: new URL("http://localhost/api/auth/challenge"),
|
||||
locals: {},
|
||||
params: {},
|
||||
route: { id: "/api/auth/challenge" } as any,
|
||||
fetch: {} as any,
|
||||
getClientAddress: () => "127.0.0.1",
|
||||
isDataRequest: false,
|
||||
isSubRequest: false,
|
||||
platform: undefined,
|
||||
setHeaders: vi.fn(),
|
||||
});
|
||||
|
||||
describe("POST /api/auth/challenge – registration bootstrap session", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(UserService.getUserByEmail).mockRejectedValue(
|
||||
Object.assign(new Error("not found"), { name: "NotFoundError" }),
|
||||
);
|
||||
vi.mocked(UserService.getUserPasskeys).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("login flow (user has passkeys)", () => {
|
||||
it("should not require bootstrap cookie and return 200", async () => {
|
||||
vi.mocked(UserService.getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(UserService.getUserPasskeys).mockResolvedValue([{ id: "pk1" }] as any);
|
||||
|
||||
const response = await POST(buildEvent({ email: USER_EMAIL }));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.isRegistration).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("new user (does not exist yet)", () => {
|
||||
it("should not require bootstrap cookie and return 200 with isRegistration=true", async () => {
|
||||
// getUserByEmail throws NotFoundError → user does not exist
|
||||
const err = new Error("Not found");
|
||||
err.name = "NotFoundError";
|
||||
// We need to throw a proper NotFoundError from the errors module
|
||||
// Mock it as NotFoundError via the errors class
|
||||
const { NotFoundError } = await import("$lib/server/utils/errors");
|
||||
vi.mocked(UserService.getUserByEmail).mockRejectedValue(new NotFoundError("not found"));
|
||||
|
||||
const response = await POST(buildEvent({ email: "newuser@example.com" }));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.isRegistration).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("existing user without passkeys (setup-passkey flow)", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(UserService.getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(UserService.getUserPasskeys).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("should return 422 when no bootstrap cookie is present", async () => {
|
||||
const response = await POST(buildEvent({ email: USER_EMAIL, userId: USER_ID }));
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
|
||||
it("should return 422 when bootstrap cookie contains wrong userId", async () => {
|
||||
const token = await generateRegistrationBootstrapToken({
|
||||
userId: "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(
|
||||
{ email: USER_EMAIL, userId: USER_ID },
|
||||
{ "webauthn-registration-bootstrap": token! },
|
||||
),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
|
||||
it("should return 422 when bootstrap cookie contains wrong email", async () => {
|
||||
const token = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: "other@example.com",
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(
|
||||
{ email: USER_EMAIL, userId: USER_ID },
|
||||
{ "webauthn-registration-bootstrap": token! },
|
||||
),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
|
||||
it("should return 422 when requestUserId does not match user record", async () => {
|
||||
const token = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(
|
||||
{ email: USER_EMAIL, userId: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" },
|
||||
{ "webauthn-registration-bootstrap": token! },
|
||||
),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
|
||||
it("should return 422 when bootstrap cookie is a forged JWT (wrong secret)", async () => {
|
||||
const { SignJWT } = await import("jose");
|
||||
const wrongSecret = new TextEncoder().encode("wrong-secret");
|
||||
const token = await new SignJWT({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
type: "webauthn-registration-bootstrap",
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("15m")
|
||||
.sign(wrongSecret);
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(
|
||||
{ email: USER_EMAIL, userId: USER_ID },
|
||||
{ "webauthn-registration-bootstrap": token },
|
||||
),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
|
||||
it("should return 200 with valid bootstrap cookie matching user and email", async () => {
|
||||
const token = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(
|
||||
{ email: USER_EMAIL, userId: USER_ID },
|
||||
{ "webauthn-registration-bootstrap": token! },
|
||||
),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.isRegistration).toBe(true);
|
||||
expect(data.challenge).toBeDefined();
|
||||
});
|
||||
|
||||
it("should accept email in non-normalised form when bootstrap token was created with same email", async () => {
|
||||
const token = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL, // stored as lowercase
|
||||
});
|
||||
|
||||
// Request email in uppercase – should be normalised and still match
|
||||
const response = await POST(
|
||||
buildEvent(
|
||||
{ email: "USER@EXAMPLE.COM", userId: USER_ID },
|
||||
{ "webauthn-registration-bootstrap": token! },
|
||||
),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.isRegistration).toBe(true);
|
||||
});
|
||||
|
||||
it("should accept a valid bootstrap cookie without optional userId in body", async () => {
|
||||
const token = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
// No userId in request body → the check (!requestUserId || ...) should still pass
|
||||
const response = await POST(
|
||||
buildEvent({ email: USER_EMAIL }, { "webauthn-registration-bootstrap": token! }),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.isRegistration).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import { BackendError, InternalError, logError } from "$lib/server/utils/errors"
|
||||
import type { RequestHandler } from "./$types";
|
||||
import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||
import logger from "$lib/logger";
|
||||
import { generateRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
|
||||
|
||||
// Register OpenAPI documentation
|
||||
registerOpenAPIRoute("/auth/confirm", "POST", {
|
||||
@@ -73,7 +74,7 @@ registerOpenAPIRoute("/auth/confirm", "POST", {
|
||||
},
|
||||
});
|
||||
|
||||
export const POST: RequestHandler = async ({ request }) => {
|
||||
export const POST: RequestHandler = async ({ request, cookies }) => {
|
||||
const log = logger.setContext("API");
|
||||
|
||||
try {
|
||||
@@ -89,6 +90,21 @@ export const POST: RequestHandler = async ({ request }) => {
|
||||
tenantId: confirmationResult.tenantId,
|
||||
};
|
||||
|
||||
const registrationBootstrapToken = await generateRegistrationBootstrapToken({
|
||||
userId: confirmationResult.id,
|
||||
email: confirmationResult.email,
|
||||
});
|
||||
|
||||
if (registrationBootstrapToken) {
|
||||
cookies.set("webauthn-registration-bootstrap", registrationBootstrapToken, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "strict",
|
||||
path: "/",
|
||||
maxAge: 60 * 15,
|
||||
});
|
||||
}
|
||||
|
||||
// Include recovery passphrase if it exists (for WebAuthn-only users)
|
||||
if (confirmationResult.recoveryPassphrase) {
|
||||
response.recoveryPassphrase = confirmationResult.recoveryPassphrase;
|
||||
|
||||
@@ -6,6 +6,8 @@ import type { RequestHandler } from "./$types";
|
||||
import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||
import logger from "$lib/logger";
|
||||
import { AppointmentService } from "$lib/server/services/appointment-service";
|
||||
import { verifyRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
|
||||
import { normalizeEmail } from "$lib/utils";
|
||||
|
||||
// Register OpenAPI documentation
|
||||
registerOpenAPIRoute("/auth/register", "POST", {
|
||||
@@ -110,23 +112,35 @@ export const POST: RequestHandler = async ({ params, cookies, request, url }) =>
|
||||
|
||||
// Add the passkey to the user account if provided
|
||||
// Validate that this registration was preceded by a challenge request
|
||||
const registrationEmail = cookies.get("webauthn-registration-email");
|
||||
const registrationEmail = normalizeEmail(cookies.get("webauthn-registration-email") || "");
|
||||
const requestEmail = normalizeEmail(body.email);
|
||||
const bootstrapPayload = await verifyRegistrationBootstrapToken(
|
||||
cookies.get("webauthn-registration-bootstrap"),
|
||||
);
|
||||
|
||||
// Challenge can come from:
|
||||
// 1. Request body (for tenant admins with PRF - second challenge overwrites cookie)
|
||||
// 2. Cookie (for global admins without PRF - only one challenge)
|
||||
const challengeFromSession = body.challenge || cookies.get("webauthn-challenge");
|
||||
|
||||
if (!registrationEmail || registrationEmail !== body.email) {
|
||||
if (!registrationEmail || registrationEmail !== requestEmail) {
|
||||
throw new ValidationError("Invalid or missing registration challenge cookie");
|
||||
}
|
||||
|
||||
if (
|
||||
!bootstrapPayload ||
|
||||
bootstrapPayload.userId !== userId ||
|
||||
bootstrapPayload.email !== requestEmail
|
||||
) {
|
||||
throw new ValidationError("Invalid or missing registration bootstrap session");
|
||||
}
|
||||
|
||||
if (!challengeFromSession) {
|
||||
throw new ValidationError("Invalid or missing WebAuthn challenge");
|
||||
}
|
||||
|
||||
const targetUser = await UserService.getUserById(userId);
|
||||
if (targetUser.email !== body.email) {
|
||||
if (normalizeEmail(targetUser.email) !== requestEmail) {
|
||||
throw new ValidationError("User mismatch");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,316 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Must mock $env/dynamic/private before any import that reads it at module scope
|
||||
vi.mock("$env/dynamic/private", () => ({
|
||||
env: {
|
||||
JWT_SECRET: "test-jwt-secret-for-registration-bootstrap-unit-tests-minimum-32-chars",
|
||||
NODE_ENV: "test",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/services/user-service", () => ({
|
||||
UserService: {
|
||||
getUserById: vi.fn(),
|
||||
getUserByEmail: vi.fn(),
|
||||
addPasskey: vi.fn(),
|
||||
updateUser: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/auth/webauthn-service", () => ({
|
||||
WebAuthnService: {
|
||||
verifyRegistration: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("$lib/server/services/appointment-service", () => ({
|
||||
AppointmentService: {
|
||||
forTenant: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { POST } from "../[id]/+server";
|
||||
import { UserService } from "$lib/server/services/user-service";
|
||||
import { WebAuthnService } from "$lib/server/auth/webauthn-service";
|
||||
import { AppointmentService } from "$lib/server/services/appointment-service";
|
||||
import { generateRegistrationBootstrapToken } from "$lib/server/auth/registration-bootstrap";
|
||||
|
||||
const USER_ID = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
const USER_EMAIL = "user@example.com";
|
||||
const CHALLENGE = "challenge-base64url-value-for-testing";
|
||||
|
||||
const mockUser = {
|
||||
id: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
name: "Test User",
|
||||
role: "STAFF" as const,
|
||||
tenantId: null,
|
||||
confirmationState: "EMAIL_CONFIRMED" as const,
|
||||
};
|
||||
|
||||
const mockPasskeyBody = {
|
||||
email: USER_EMAIL,
|
||||
passkey: {
|
||||
id: "cred-id-123",
|
||||
attestationObject: "attestation-object-base64",
|
||||
clientDataJSON: "client-data-json-base64",
|
||||
deviceName: "Test Device",
|
||||
},
|
||||
};
|
||||
|
||||
const buildEvent = (
|
||||
body: object,
|
||||
cookieMap: Record<string, string> = {},
|
||||
userId: string = USER_ID,
|
||||
): any => ({
|
||||
request: new Request(`http://localhost/api/auth/register/${userId}`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
cookies: {
|
||||
get: (name: string) => cookieMap[name],
|
||||
set: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
url: new URL(`http://localhost/api/auth/register/${userId}`),
|
||||
locals: {},
|
||||
params: { id: userId },
|
||||
route: { id: "/api/auth/register/[id]" } as any,
|
||||
fetch: {} as any,
|
||||
getClientAddress: () => "127.0.0.1",
|
||||
isDataRequest: false,
|
||||
isSubRequest: false,
|
||||
platform: undefined,
|
||||
setHeaders: vi.fn(),
|
||||
});
|
||||
|
||||
describe("POST /api/auth/register/[id] – registration bootstrap session", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
vi.mocked(UserService.getUserById).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(UserService.getUserByEmail).mockResolvedValue(mockUser as any);
|
||||
vi.mocked(UserService.addPasskey).mockResolvedValue(undefined as any);
|
||||
vi.mocked(UserService.updateUser).mockResolvedValue(undefined as any);
|
||||
vi.mocked(WebAuthnService.verifyRegistration).mockResolvedValue({
|
||||
credentialID: "cred-id-123",
|
||||
credentialPublicKey: "public-key-bytes",
|
||||
counter: 0,
|
||||
} as any);
|
||||
vi.mocked(AppointmentService.forTenant).mockResolvedValue({
|
||||
hasAppointments: vi.fn().mockResolvedValue(false),
|
||||
} as any);
|
||||
});
|
||||
|
||||
describe("valid registration (all cookies correct)", () => {
|
||||
it("should return 201 when bootstrap cookie and registration-email cookie are valid", async () => {
|
||||
const bootstrapToken = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(mockPasskeyBody, {
|
||||
"webauthn-registration-bootstrap": bootstrapToken!,
|
||||
"webauthn-registration-email": USER_EMAIL,
|
||||
"webauthn-challenge": CHALLENGE,
|
||||
}),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(data.message).toMatch(/passkey/i);
|
||||
});
|
||||
|
||||
it("should normalise email casing when comparing", async () => {
|
||||
const bootstrapToken = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
vi.mocked(UserService.getUserById).mockResolvedValue({
|
||||
...mockUser,
|
||||
email: "USER@EXAMPLE.COM",
|
||||
} as any);
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(
|
||||
{ ...mockPasskeyBody, email: "USER@EXAMPLE.COM" },
|
||||
{
|
||||
"webauthn-registration-bootstrap": bootstrapToken!,
|
||||
"webauthn-registration-email": "USER@EXAMPLE.COM",
|
||||
"webauthn-challenge": CHALLENGE,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing or invalid bootstrap cookie", () => {
|
||||
it("should return 422 when bootstrap cookie is absent", async () => {
|
||||
const response = await POST(
|
||||
buildEvent(mockPasskeyBody, {
|
||||
"webauthn-registration-email": USER_EMAIL,
|
||||
"webauthn-challenge": CHALLENGE,
|
||||
}),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
|
||||
it("should return 422 when bootstrap token has wrong userId", async () => {
|
||||
const bootstrapToken = await generateRegistrationBootstrapToken({
|
||||
userId: "ffffffff-ffff-ffff-ffff-ffffffffffff",
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(mockPasskeyBody, {
|
||||
"webauthn-registration-bootstrap": bootstrapToken!,
|
||||
"webauthn-registration-email": USER_EMAIL,
|
||||
"webauthn-challenge": CHALLENGE,
|
||||
}),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
|
||||
it("should return 422 when bootstrap token has wrong email", async () => {
|
||||
const bootstrapToken = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: "attacker@evil.com",
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(mockPasskeyBody, {
|
||||
"webauthn-registration-bootstrap": bootstrapToken!,
|
||||
"webauthn-registration-email": USER_EMAIL,
|
||||
"webauthn-challenge": CHALLENGE,
|
||||
}),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
|
||||
it("should return 422 when bootstrap cookie is a forged JWT", async () => {
|
||||
const { SignJWT } = await import("jose");
|
||||
const wrongSecret = new TextEncoder().encode("wrong-secret");
|
||||
const forgedToken = await new SignJWT({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
type: "webauthn-registration-bootstrap",
|
||||
})
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime("15m")
|
||||
.sign(wrongSecret);
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(mockPasskeyBody, {
|
||||
"webauthn-registration-bootstrap": forgedToken,
|
||||
"webauthn-registration-email": USER_EMAIL,
|
||||
"webauthn-challenge": CHALLENGE,
|
||||
}),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
|
||||
it("should return 422 when URL userId does not match bootstrap userId", async () => {
|
||||
const bootstrapToken = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
const differentUserId = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
|
||||
const response = await POST(
|
||||
buildEvent(
|
||||
mockPasskeyBody,
|
||||
{
|
||||
"webauthn-registration-bootstrap": bootstrapToken!,
|
||||
"webauthn-registration-email": USER_EMAIL,
|
||||
"webauthn-challenge": CHALLENGE,
|
||||
},
|
||||
differentUserId, // URL param has a different user ID
|
||||
),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/bootstrap/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing registration-email cookie", () => {
|
||||
it("should return 422 when webauthn-registration-email cookie is absent", async () => {
|
||||
const bootstrapToken = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(mockPasskeyBody, {
|
||||
"webauthn-registration-bootstrap": bootstrapToken!,
|
||||
// No webauthn-registration-email
|
||||
"webauthn-challenge": CHALLENGE,
|
||||
}),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/challenge/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("missing challenge cookie", () => {
|
||||
it("should return 422 when neither challenge cookie nor body challenge is provided", async () => {
|
||||
const bootstrapToken = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(mockPasskeyBody, {
|
||||
"webauthn-registration-bootstrap": bootstrapToken!,
|
||||
"webauthn-registration-email": USER_EMAIL,
|
||||
// No webauthn-challenge
|
||||
}),
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(422);
|
||||
expect(data.error).toMatch(/challenge/i);
|
||||
});
|
||||
|
||||
it("should use challenge from request body when challenge cookie is absent", async () => {
|
||||
const bootstrapToken = await generateRegistrationBootstrapToken({
|
||||
userId: USER_ID,
|
||||
email: USER_EMAIL,
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildEvent(
|
||||
{ ...mockPasskeyBody, challenge: CHALLENGE }, // challenge provided in body
|
||||
{
|
||||
"webauthn-registration-bootstrap": bootstrapToken!,
|
||||
"webauthn-registration-email": USER_EMAIL,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,7 @@ import { registerOpenAPIRoute } from "$lib/server/openapi";
|
||||
import { centralDb } from "$lib/server/db";
|
||||
import { user } from "$lib/server/db/central-schema";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { normalizeEmail } from "$lib/utils";
|
||||
|
||||
const ML_KEM_768_PUBLIC_KEY_BYTES = 1184;
|
||||
const ML_KEM_768_PRIVATE_KEY_BYTES = 2400;
|
||||
@@ -42,14 +43,6 @@ const hasDecodedByteLength = (value: string, expectedBytes: number): boolean =>
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeEmail = (value?: string | null): string | undefined => {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return value.trim().toLowerCase();
|
||||
};
|
||||
|
||||
// Register OpenAPI documentation
|
||||
registerOpenAPIRoute("/tenants/{id}/staff/{staffId}/crypto", "POST", {
|
||||
summary: "Store staff cryptographic keys",
|
||||
@@ -192,7 +185,8 @@ export const POST: RequestHandler = async ({ params, locals, request, cookies })
|
||||
const isAuthenticated = locals.user && locals.user.id === staffId;
|
||||
const registrationEmail = normalizeEmail(cookies.get("webauthn-registration-email"));
|
||||
const requestEmail = normalizeEmail(email);
|
||||
const isRegistration = !!registrationEmail && !!requestEmail && registrationEmail === requestEmail;
|
||||
const isRegistration =
|
||||
!!registrationEmail && !!requestEmail && registrationEmail === requestEmail;
|
||||
|
||||
if (!isAuthenticated && !isRegistration) {
|
||||
log.warn("Unauthorized crypto key storage attempt", {
|
||||
|
||||
@@ -98,12 +98,8 @@
|
||||
"name": "agent_absence_agent_id_agent_id_fk",
|
||||
"tableFrom": "agent_absence",
|
||||
"tableTo": "agent",
|
||||
"columnsFrom": [
|
||||
"agent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["agent_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -225,12 +221,8 @@
|
||||
"name": "appointment_tunnel_id_client_appointment_tunnel_id_fk",
|
||||
"tableFrom": "appointment",
|
||||
"tableTo": "client_appointment_tunnel",
|
||||
"columnsFrom": [
|
||||
"tunnel_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["tunnel_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -238,12 +230,8 @@
|
||||
"name": "appointment_channel_id_channel_id_fk",
|
||||
"tableFrom": "appointment",
|
||||
"tableTo": "channel",
|
||||
"columnsFrom": [
|
||||
"channel_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["channel_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -251,12 +239,8 @@
|
||||
"name": "appointment_agent_id_agent_id_fk",
|
||||
"tableFrom": "appointment",
|
||||
"tableTo": "agent",
|
||||
"columnsFrom": [
|
||||
"agent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["agent_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -303,12 +287,8 @@
|
||||
"name": "appointment_key_share_appointment_id_appointment_id_fk",
|
||||
"tableFrom": "appointment_key_share",
|
||||
"tableTo": "appointment",
|
||||
"columnsFrom": [
|
||||
"appointment_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["appointment_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -526,12 +506,8 @@
|
||||
"name": "channel_agent_channel_id_channel_id_fk",
|
||||
"tableFrom": "channel_agent",
|
||||
"tableTo": "channel",
|
||||
"columnsFrom": [
|
||||
"channel_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["channel_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -539,12 +515,8 @@
|
||||
"name": "channel_agent_agent_id_agent_id_fk",
|
||||
"tableFrom": "channel_agent",
|
||||
"tableTo": "agent",
|
||||
"columnsFrom": [
|
||||
"agent_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["agent_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -578,12 +550,8 @@
|
||||
"name": "channel_slot_template_channel_id_channel_id_fk",
|
||||
"tableFrom": "channel_slot_template",
|
||||
"tableTo": "channel",
|
||||
"columnsFrom": [
|
||||
"channel_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["channel_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
@@ -591,12 +559,8 @@
|
||||
"name": "channel_slot_template_slot_template_id_slotTemplate_id_fk",
|
||||
"tableFrom": "channel_slot_template",
|
||||
"tableTo": "slotTemplate",
|
||||
"columnsFrom": [
|
||||
"slot_template_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["slot_template_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -630,12 +594,8 @@
|
||||
"name": "channel_staff_channel_id_channel_id_fk",
|
||||
"tableFrom": "channel_staff",
|
||||
"tableTo": "channel",
|
||||
"columnsFrom": [
|
||||
"channel_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["channel_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -703,9 +663,7 @@
|
||||
"client_appointment_tunnel_email_hash_unique": {
|
||||
"name": "client_appointment_tunnel_email_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email_hash"
|
||||
]
|
||||
"columns": ["email_hash"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -764,9 +722,7 @@
|
||||
"client_pin_reset_token_token_unique": {
|
||||
"name": "client_pin_reset_token_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
"columns": ["token"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
@@ -816,12 +772,8 @@
|
||||
"name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk",
|
||||
"tableFrom": "client_tunnel_staff_key_share",
|
||||
"tableTo": "client_appointment_tunnel",
|
||||
"columnsFrom": [
|
||||
"tunnel_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["tunnel_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
@@ -1022,22 +974,12 @@
|
||||
"public.appointment_status": {
|
||||
"name": "appointment_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"NEW",
|
||||
"CONFIRMED",
|
||||
"HELD",
|
||||
"REJECTED",
|
||||
"NO_SHOW"
|
||||
]
|
||||
"values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"]
|
||||
},
|
||||
"public.notification_type": {
|
||||
"name": "notification_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"APPOINTMENT_CONFIRMED",
|
||||
"APPOINTMENT_CANCELLED",
|
||||
"APPOINTMENT_REQUESTED"
|
||||
]
|
||||
"values": ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED", "APPOINTMENT_REQUESTED"]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
@@ -1050,4 +992,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,4 +115,4 @@
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user