Boostrapping of user registration. Keypass error fixes.

Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
Hendrik Belitz
2026-05-08 15:40:21 +02:00
co-authored by Copilot
parent 78dfd9317a
commit 828b5297dc
15 changed files with 1106 additions and 197 deletions
@@ -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;
}
}
+10
View File
@@ -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();
}
+17 -4
View File
@@ -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,
};
}
};
+44 -55
View File
@@ -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");
}
+83 -10
View File
@@ -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);
});
});
});
+17 -1
View File
@@ -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;
+17 -3
View File
@@ -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", {
+27 -85
View File
@@ -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": {}
}
}
}
+1 -1
View File
@@ -115,4 +115,4 @@
"breakpoints": true
}
]
}
}