diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 09b302a..60ec5ad 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -47,6 +47,7 @@ * ``` */ +import type { BootstrapChallengeResponse, BootstrapVerifyResponse } from "$lib/types/appointment"; import { OptimizedArgon2 } from "$lib/crypto/hashing"; import { pinThrottleStore } from "$lib/stores/pin-throttle"; import { KyberCrypto, AESCrypto, ShamirSecretSharing, BufferUtils } from "$lib/crypto/utils"; @@ -186,14 +187,17 @@ export class UnifiedAppointmentCrypto { pin, ); - // 6. Fetch staff public keys from server + // 6. Complete bootstrap challenge to obtain a short-lived booking token + await this.bootstrapNewClientAccess(tenantId); + + // 7. Fetch staff public keys from server const staffPublicKeys = await this.fetchStaffPublicKeys(tenantId); - // 7. Encrypt tunnel key for all staff members + // 8. Encrypt tunnel key for all staff members // Note: staffKeyShares will be used during actual appointment creation await this.encryptTunnelKeyForStaff(staffPublicKeys); - // 8. Encrypt tunnel key for client (for later use) + // 9. Encrypt tunnel key for client (for later use) // Note: clientKeyShare will be used during actual appointment creation await this.encryptTunnelKeyForClient(); @@ -467,7 +471,12 @@ export class UnifiedAppointmentCrypto { const response = await fetch(endpoint, { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + ...(isFirstAppointment && this.bookingAccessToken + ? { Authorization: `Bearer ${this.bookingAccessToken}` } + : {}), + }, body: JSON.stringify(requestData), }); @@ -500,12 +509,17 @@ export class UnifiedAppointmentCrypto { throw new Error("Client not authenticated"); } + if (!this.bookingAccessToken) { + throw new Error("Missing booking access token. Please authenticate first."); + } + try { const response = await fetch(`/api/tenants/${tenantId}/appointments/my-appointments`, { method: "GET", headers: { "Content-Type": "application/json", "X-Email-Hash": this.emailHash!, + Authorization: `Bearer ${this.bookingAccessToken}`, }, }); @@ -1061,9 +1075,7 @@ export class UnifiedAppointmentCrypto { async fetchStaffPublicKeys(tenantId: string): Promise { if (!this.bookingAccessToken) { - throw new Error( - "Missing booking access token. Please authenticate as existing client first.", - ); + throw new Error("Missing booking access token. Please authenticate or complete bootstrap."); } const response = await fetch(`/api/tenants/${tenantId}/appointments/staff-public-keys`, { @@ -1082,6 +1094,78 @@ export class UnifiedAppointmentCrypto { return data.staffPublicKeys; } + private async bootstrapNewClientAccess(tenantId: string): Promise { + if (!this.tunnelId || !this.clientKeyPair) { + throw new Error("Bootstrap requires generated client tunnel and key pair"); + } + + const challengeResponse = await fetch( + `/api/tenants/${tenantId}/appointments/bootstrap-challenge`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + tunnelId: this.tunnelId, + clientPublicKey: this.clientKeyPair.publicKey, + emailHash: this.emailHash ?? undefined, + }), + }, + ); + + if (!challengeResponse.ok) { + throw new Error(`Failed to request bootstrap challenge: ${challengeResponse.statusText}`); + } + + const challengeData: BootstrapChallengeResponse = await challengeResponse.json(); + const counter = await this.solveBootstrapProofOfWork( + challengeData.nonce, + this.tunnelId, + this.clientKeyPair.publicKey, + challengeData.difficulty, + ); + + const verifyResponse = await fetch(`/api/tenants/${tenantId}/appointments/bootstrap-verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + challengeId: challengeData.challengeId, + tunnelId: this.tunnelId, + clientPublicKey: this.clientKeyPair.publicKey, + counter, + emailHash: this.emailHash ?? undefined, + }), + }); + + if (!verifyResponse.ok) { + throw new Error(`Failed to verify bootstrap challenge: ${verifyResponse.statusText}`); + } + + const verificationData: BootstrapVerifyResponse = await verifyResponse.json(); + this.bookingAccessToken = verificationData.bookingAccessToken; + } + + private async solveBootstrapProofOfWork( + nonce: string, + tunnelId: string, + clientPublicKey: string, + difficulty: number, + ): Promise { + const targetPrefix = "0".repeat(difficulty); + const encoder = new TextEncoder(); + + for (let counter = 0; ; counter += 1) { + const input = `${nonce}:${tunnelId}:${clientPublicKey}:${counter}`; + const digestBuffer = await crypto.subtle.digest("SHA-256", encoder.encode(input)); + const digestHex = Array.from(new Uint8Array(digestBuffer)) + .map((byte) => byte.toString(16).padStart(2, "0")) + .join(""); + + if (digestHex.startsWith(targetPrefix)) { + return counter; + } + } + } + // getTenantId method removed - tenantId is now always passed explicitly async encryptTunnelKeyForStaff( diff --git a/src/lib/server/auth/__tests__/booking-access-token.test.ts b/src/lib/server/auth/__tests__/booking-access-token.test.ts index 65c9a18..412226c 100644 --- a/src/lib/server/auth/__tests__/booking-access-token.test.ts +++ b/src/lib/server/auth/__tests__/booking-access-token.test.ts @@ -1,10 +1,54 @@ -import { describe, expect, it } from "vitest"; +import { randomUUID } from "node:crypto"; +import { env } from "$env/dynamic/private"; +import { SignJWT } from "jose"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +vi.mock("$lib/server/services/booking-access-token-store", () => ({ + bookingAccessTokenStore: { + store: vi.fn(), + isActive: vi.fn(), + consume: vi.fn(), + }, +})); + import { + consumeBookingAccessToken, generateBookingAccessToken, + generateNewClientBootstrapToken, verifyBookingAccessToken, } from "$lib/server/auth/booking-access-token"; +import { bookingAccessTokenStore } from "$lib/server/services/booking-access-token-store"; + +const JWT_SECRET = new TextEncoder().encode(env.JWT_SECRET); + +async function signTestToken(payload: { + tenantId: string; + tunnelId: string; + scope: "appointments:client" | "appointments:new-client-bootstrap"; + emailHash?: string; + clientPublicKey?: string; + jti?: string; +}): Promise { + return await new SignJWT({ + tenantId: payload.tenantId, + tunnelId: payload.tunnelId, + scope: payload.scope, + emailHash: payload.emailHash, + clientPublicKey: payload.clientPublicKey, + }) + .setProtectedHeader({ alg: "HS256" }) + .setJti(payload.jti ?? randomUUID()) + .setIssuedAt() + .setExpirationTime("10m") + .sign(JWT_SECRET); +} describe("booking-access-token", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(bookingAccessTokenStore.isActive).mockResolvedValue(true); + vi.mocked(bookingAccessTokenStore.consume).mockResolvedValue(true); + }); + it("generates and verifies a valid booking access token", async () => { const token = await generateBookingAccessToken({ tenantId: "123e4567-e89b-12d3-a456-426614174000", @@ -18,10 +62,82 @@ describe("booking-access-token", () => { expect(payload?.tenantId).toBe("123e4567-e89b-12d3-a456-426614174000"); expect(payload?.tunnelId).toBe("550e8400-e29b-41d4-a716-446655440000"); expect(payload?.scope).toBe("appointments:client"); + expect(payload?.jti).toBeTypeOf("string"); + expect(bookingAccessTokenStore.store).toHaveBeenCalledOnce(); }); it("rejects malformed token", async () => { const payload = await verifyBookingAccessToken("invalid.token"); expect(payload).toBeNull(); }); + + it("generates and verifies a valid bootstrap booking access token", async () => { + const token = await generateNewClientBootstrapToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + clientPublicKey: "bootstrap-client-public-key", + emailHash: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + }); + + const payload = await verifyBookingAccessToken(token); + + expect(payload).not.toBeNull(); + expect(payload?.tenantId).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect(payload?.tunnelId).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(payload?.clientPublicKey).toBe("bootstrap-client-public-key"); + expect(payload?.scope).toBe("appointments:new-client-bootstrap"); + }); + + it("rejects existing-client token without emailHash", async () => { + const token = await signTestToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + scope: "appointments:client", + }); + + const payload = await verifyBookingAccessToken(token); + expect(payload).toBeNull(); + }); + + it("rejects bootstrap token without clientPublicKey", async () => { + const token = await signTestToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + scope: "appointments:new-client-bootstrap", + emailHash: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + }); + + const payload = await verifyBookingAccessToken(token); + expect(payload).toBeNull(); + }); + + it("rejects token that is not active in token store", async () => { + vi.mocked(bookingAccessTokenStore.isActive).mockResolvedValue(false); + + const token = await generateBookingAccessToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + emailHash: "a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + }); + + const payload = await verifyBookingAccessToken(token); + expect(payload).toBeNull(); + }); + + it("consumes token when requested", async () => { + const token = await generateNewClientBootstrapToken({ + tenantId: "123e4567-e89b-12d3-a456-426614174000", + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + clientPublicKey: "bootstrap-client-public-key", + }); + + const payload = await verifyBookingAccessToken(token); + expect(payload).not.toBeNull(); + + if (payload) { + await consumeBookingAccessToken(payload); + } + + expect(bookingAccessTokenStore.consume).toHaveBeenCalledOnce(); + }); }); diff --git a/src/lib/server/auth/booking-access-token.ts b/src/lib/server/auth/booking-access-token.ts index 659ec89..1bd02d2 100644 --- a/src/lib/server/auth/booking-access-token.ts +++ b/src/lib/server/auth/booking-access-token.ts @@ -1,11 +1,18 @@ import { SignJWT, jwtVerify } from "jose"; +import { randomUUID } from "node:crypto"; import { env } from "$env/dynamic/private"; import { UniversalLogger } from "$lib/logger"; +import { bookingAccessTokenStore } from "$lib/server/services/booking-access-token-store"; const logger = new UniversalLogger().setContext("BookingAccessToken"); const BOOKING_ACCESS_EXPIRES = "10m"; -const BOOKING_SCOPE = "appointments:client"; +const BOOKING_ACCESS_TTL_MS = 10 * 60 * 1000; +export const EXISTING_CLIENT_BOOKING_SCOPE = "appointments:client"; +export const NEW_CLIENT_BOOTSTRAP_SCOPE = "appointments:new-client-bootstrap"; +export type BookingAccessScope = + | typeof EXISTING_CLIENT_BOOKING_SCOPE + | typeof NEW_CLIENT_BOOTSTRAP_SCOPE; if (!env.JWT_SECRET) { throw new Error("Mandatory ENV variable JWT_SECRET is missing!"); @@ -15,30 +22,73 @@ const JWT_SECRET = new TextEncoder().encode(env.JWT_SECRET); export interface BookingAccessTokenPayload { tenantId: string; - emailHash: string; + emailHash?: string; tunnelId: string; - scope: typeof BOOKING_SCOPE; + clientPublicKey?: string; + scope: BookingAccessScope; + jti?: string; iat?: number; exp?: number; } +async function signBookingAccessToken(payload: { + tenantId: string; + emailHash?: string; + tunnelId: string; + clientPublicKey?: string; + scope: BookingAccessScope; +}): Promise { + const now = Math.floor(Date.now() / 1000); + const jti = randomUUID(); + const expiresAt = new Date(Date.now() + BOOKING_ACCESS_TTL_MS); + + const token = await new SignJWT({ + tenantId: payload.tenantId, + emailHash: payload.emailHash, + tunnelId: payload.tunnelId, + clientPublicKey: payload.clientPublicKey, + scope: payload.scope, + }) + .setProtectedHeader({ alg: "HS256" }) + .setJti(jti) + .setIssuedAt(now) + .setExpirationTime(BOOKING_ACCESS_EXPIRES) + .sign(JWT_SECRET); + + await bookingAccessTokenStore.store({ + id: jti, + scope: payload.scope, + tenantId: payload.tenantId, + emailHash: payload.emailHash, + tunnelId: payload.tunnelId, + clientPublicKey: payload.clientPublicKey, + expiresAt, + }); + + return token; +} + export async function generateBookingAccessToken(payload: { tenantId: string; emailHash: string; tunnelId: string; }): Promise { - const now = Math.floor(Date.now() / 1000); + return await signBookingAccessToken({ + ...payload, + scope: EXISTING_CLIENT_BOOKING_SCOPE, + }); +} - return await new SignJWT({ - tenantId: payload.tenantId, - emailHash: payload.emailHash, - tunnelId: payload.tunnelId, - scope: BOOKING_SCOPE, - }) - .setProtectedHeader({ alg: "HS256" }) - .setIssuedAt(now) - .setExpirationTime(BOOKING_ACCESS_EXPIRES) - .sign(JWT_SECRET); +export async function generateNewClientBootstrapToken(payload: { + tenantId: string; + tunnelId: string; + clientPublicKey: string; + emailHash?: string; +}): Promise { + return await signBookingAccessToken({ + ...payload, + scope: NEW_CLIENT_BOOTSTRAP_SCOPE, + }); } export async function verifyBookingAccessToken( @@ -49,19 +99,49 @@ export async function verifyBookingAccessToken( if ( typeof payload.tenantId !== "string" || - typeof payload.emailHash !== "string" || typeof payload.tunnelId !== "string" || - payload.scope !== BOOKING_SCOPE + typeof payload.jti !== "string" || + (payload.scope !== EXISTING_CLIENT_BOOKING_SCOPE && + payload.scope !== NEW_CLIENT_BOOTSTRAP_SCOPE) ) { logger.warn("Invalid booking access token payload"); return null; } + const isActive = await bookingAccessTokenStore.isActive({ + id: payload.jti, + tenantId: payload.tenantId, + scope: payload.scope, + }); + if (!isActive) { + logger.warn("Booking access token is not active", { + tenantId: payload.tenantId, + jti: payload.jti, + scope: payload.scope, + }); + return null; + } + + if (payload.scope === EXISTING_CLIENT_BOOKING_SCOPE && typeof payload.emailHash !== "string") { + logger.warn("Existing client booking access token missing emailHash"); + return null; + } + + if ( + payload.scope === NEW_CLIENT_BOOTSTRAP_SCOPE && + typeof payload.clientPublicKey !== "string" + ) { + logger.warn("New client bootstrap token missing clientPublicKey"); + return null; + } + return { tenantId: payload.tenantId, - emailHash: payload.emailHash, + emailHash: payload.emailHash as string | undefined, tunnelId: payload.tunnelId, - scope: BOOKING_SCOPE, + clientPublicKey: payload.clientPublicKey as string | undefined, + scope: payload.scope, + jti: payload.jti, iat: payload.iat, exp: payload.exp, }; @@ -70,3 +150,23 @@ export async function verifyBookingAccessToken( return null; } } + +export async function consumeBookingAccessToken(payload: BookingAccessTokenPayload): Promise { + if (!payload.jti) { + return; + } + + const consumed = await bookingAccessTokenStore.consume({ + id: payload.jti, + tenantId: payload.tenantId, + scope: payload.scope, + }); + + if (!consumed) { + logger.warn("Booking access token could not be consumed", { + tenantId: payload.tenantId, + jti: payload.jti, + scope: payload.scope, + }); + } +} diff --git a/src/lib/server/db/tenant-schema.ts b/src/lib/server/db/tenant-schema.ts index 5d2fa89..64eaf7a 100644 --- a/src/lib/server/db/tenant-schema.ts +++ b/src/lib/server/db/tenant-schema.ts @@ -368,6 +368,32 @@ export const authChallenge = pgTable("auth_challenge", { consumed: boolean("consumed").default(false).notNull(), }); +/** + * BookingAccessToken table - stores short-lived booking JWT sessions by JTI + * Enables token allowlisting, revocation and one-time consumption for bootstrap flow + * @table bookingAccessToken + */ +export const bookingAccessToken = pgTable("booking_access_token", { + /** JWT ID (jti) as primary key */ + id: text("id").primaryKey(), + /** Token scope (appointments:client / appointments:new-client-bootstrap) */ + scope: text("scope").notNull(), + /** Tenant binding */ + tenantId: uuid("tenant_id").notNull(), + /** Optional client email hash binding */ + emailHash: text("email_hash"), + /** Tunnel binding */ + tunnelId: uuid("tunnel_id").notNull(), + /** Optional client key binding for bootstrap */ + clientPublicKey: text("client_public_key"), + /** Creation timestamp */ + createdAt: timestamp("created_at").defaultNow().notNull(), + /** Expiry timestamp */ + expiresAt: timestamp("expires_at").notNull(), + /** Whether token was consumed (one-time semantics) */ + consumed: boolean("consumed").default(false).notNull(), +}); + /** * ClientPinResetToken table - stores temporary PIN reset tokens for clients * Used for secure PIN reset via QR code or email link @@ -399,3 +425,6 @@ export type SelectClientTunnelStaffKeyShare = InferSelectModel; + +/** BookingAccessToken record type for database queries */ +export type SelectBookingAccessToken = InferSelectModel; diff --git a/src/lib/server/services/booking-access-token-store.ts b/src/lib/server/services/booking-access-token-store.ts new file mode 100644 index 0000000..f5e168e --- /dev/null +++ b/src/lib/server/services/booking-access-token-store.ts @@ -0,0 +1,96 @@ +import { and, eq, lt } from "drizzle-orm"; +import { logger } from "$lib/logger"; +import { getTenantDb } from "$lib/server/db"; +import { bookingAccessToken } from "$lib/server/db/tenant-schema"; + +interface StoreBookingTokenInput { + id: string; + scope: string; + tenantId: string; + emailHash?: string; + tunnelId: string; + clientPublicKey?: string; + expiresAt: Date; +} + +class BookingAccessTokenStore { + async store(input: StoreBookingTokenInput): Promise { + const db = await getTenantDb(input.tenantId); + + await db.insert(bookingAccessToken).values({ + id: input.id, + scope: input.scope, + tenantId: input.tenantId, + emailHash: input.emailHash, + tunnelId: input.tunnelId, + clientPublicKey: input.clientPublicKey, + expiresAt: input.expiresAt, + consumed: false, + }); + + await this.cleanup(input.tenantId); + } + + async isActive(input: { id: string; tenantId: string; scope: string }): Promise { + const db = await getTenantDb(input.tenantId); + + const rows = await db + .select({ id: bookingAccessToken.id, expiresAt: bookingAccessToken.expiresAt }) + .from(bookingAccessToken) + .where( + and( + eq(bookingAccessToken.id, input.id), + eq(bookingAccessToken.tenantId, input.tenantId), + eq(bookingAccessToken.scope, input.scope), + eq(bookingAccessToken.consumed, false), + ), + ) + .limit(1); + + if (rows.length === 0) { + return false; + } + + if (rows[0].expiresAt < new Date()) { + await db.delete(bookingAccessToken).where(eq(bookingAccessToken.id, input.id)); + return false; + } + + return true; + } + + async consume(input: { id: string; tenantId: string; scope: string }): Promise { + const db = await getTenantDb(input.tenantId); + + const result = await db + .update(bookingAccessToken) + .set({ consumed: true }) + .where( + and( + eq(bookingAccessToken.id, input.id), + eq(bookingAccessToken.tenantId, input.tenantId), + eq(bookingAccessToken.scope, input.scope), + eq(bookingAccessToken.consumed, false), + ), + ) + .returning({ id: bookingAccessToken.id }); + + return result.length > 0; + } + + private async cleanup(tenantId: string): Promise { + try { + const db = await getTenantDb(tenantId); + const now = new Date(); + + await db.delete(bookingAccessToken).where(lt(bookingAccessToken.expiresAt, now)); + } catch (error) { + logger.warn("Failed to cleanup expired booking access tokens", { + tenantId, + error: String(error), + }); + } + } +} + +export const bookingAccessTokenStore = new BookingAccessTokenStore(); diff --git a/src/lib/server/services/bootstrap-challenge.ts b/src/lib/server/services/bootstrap-challenge.ts new file mode 100644 index 0000000..56a6aeb --- /dev/null +++ b/src/lib/server/services/bootstrap-challenge.ts @@ -0,0 +1,35 @@ +import { createHash } from "node:crypto"; + +export const NEW_CLIENT_BOOTSTRAP_DIFFICULTY = 4; + +export function createBootstrapBinding(input: { + tenantId: string; + tunnelId: string; + clientPublicKey: string; + emailHash?: string; +}): string { + return createHash("sha256") + .update( + [input.tenantId, input.tunnelId, input.clientPublicKey, input.emailHash ?? ""].join(":"), + "utf8", + ) + .digest("hex"); +} + +export function createBootstrapPowDigest(input: { + nonce: string; + tunnelId: string; + clientPublicKey: string; + counter: number; +}): string { + return createHash("sha256") + .update( + [input.nonce, input.tunnelId, input.clientPublicKey, input.counter.toString()].join(":"), + "utf8", + ) + .digest("hex"); +} + +export function matchesPowDifficulty(digest: string, difficulty: number): boolean { + return digest.startsWith("0".repeat(difficulty)); +} \ No newline at end of file diff --git a/src/lib/types/appointment.ts b/src/lib/types/appointment.ts index 7ec13a4..0539dc0 100644 --- a/src/lib/types/appointment.ts +++ b/src/lib/types/appointment.ts @@ -61,6 +61,31 @@ export interface ChallengeVerificationResponse { bookingAccessToken: string; // Short-lived token for booking-scoped API calls } +export interface BootstrapChallengeRequest { + tunnelId: string; + clientPublicKey: string; + emailHash?: string; +} + +export interface BootstrapChallengeResponse { + challengeId: string; + nonce: string; + difficulty: number; +} + +export interface BootstrapVerifyRequest { + challengeId: string; + tunnelId: string; + clientPublicKey: string; + counter: number; + emailHash?: string; +} + +export interface BootstrapVerifyResponse { + valid: boolean; + bookingAccessToken: string; +} + // Existing Client - New Appointment export interface AddAppointmentToTunnelRequest { emailHash: string; diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts new file mode 100644 index 0000000..898dc60 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts @@ -0,0 +1,149 @@ +import { json, type RequestHandler } from "@sveltejs/kit"; +import { z } from "zod"; +import { randomBytes } from "node:crypto"; +import { logger } from "$lib/logger"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { challengeStore } from "$lib/server/services/challenge-store"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; +import { BackendError, InternalError, ValidationError, logError } from "$lib/server/utils/errors"; +import { + createBootstrapBinding, + NEW_CLIENT_BOOTSTRAP_DIFFICULTY, +} from "$lib/server/services/bootstrap-challenge"; +import type { BootstrapChallengeResponse } from "$lib/types/appointment"; + +const requestSchema = z.object({ + tunnelId: z.string().uuid(), + clientPublicKey: z.string().min(1), + emailHash: z.string().optional(), +}); + +registerOpenAPIRoute("/tenants/{id}/appointments/bootstrap-challenge", "POST", { + summary: "Create bootstrap challenge for new client", + description: + "Creates a nonce-based proof-of-work challenge for a new client before first appointment creation.", + tags: ["Appointments", "Clients"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Bootstrap challenge input", + content: { + "application/json": { + schema: { + type: "object", + properties: { + tunnelId: { type: "string", format: "uuid" }, + clientPublicKey: { type: "string" }, + emailHash: { type: "string" }, + }, + required: ["tunnelId", "clientPublicKey"], + }, + }, + }, + }, + responses: { + "200": { + description: "Bootstrap challenge created successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + challengeId: { type: "string" }, + nonce: { type: "string" }, + difficulty: { type: "integer" }, + }, + required: ["challengeId", "nonce", "difficulty"], + }, + }, + }, + }, + "422": { + description: "Invalid request data", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "429": { + description: "Too many bootstrap attempts", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +export const POST: RequestHandler = async ({ request, params }) => { + try { + const tenantId = params.id; + if (!tenantId) { + throw new ValidationError("Tenant ID is required"); + } + + const body = requestSchema.parse(await request.json()); + const binding = createBootstrapBinding({ + tenantId, + tunnelId: body.tunnelId, + clientPublicKey: body.clientPublicKey, + emailHash: body.emailHash, + }); + + const throttleResult = await challengeThrottleService.checkThrottle(binding, "passkey"); + if (!throttleResult.allowed) { + return json( + { + error: "Too many bootstrap attempts. Please try again later.", + retryAfterMs: throttleResult.retryAfterMs, + }, + { status: 429 }, + ); + } + + const challengeId = randomBytes(16).toString("hex"); + const nonce = randomBytes(32).toString("base64"); + + await challengeStore.store(challengeId, nonce, binding, tenantId); + + const response: BootstrapChallengeResponse = { + challengeId, + nonce, + difficulty: NEW_CLIENT_BOOTSTRAP_DIFFICULTY, + }; + + logger.info("Created bootstrap challenge for new client", { + tenantId, + challengeId, + tunnelId: body.tunnelId, + }); + + return json(response); + } catch (error) { + logError(logger)("Failed to create bootstrap challenge", error); + if (error instanceof z.ZodError) { + return new ValidationError("Invalid request data").toJson(); + } + if (error instanceof BackendError) { + return error.toJson(); + } + return new InternalError().toJson(); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts new file mode 100644 index 0000000..2d758f4 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts @@ -0,0 +1,75 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestEvent } from "@sveltejs/kit"; +import { POST } from "../+server"; + +vi.mock("$lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/challenge-store", () => ({ + challengeStore: { + store: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/challenge-throttle", () => ({ + challengeThrottleService: { + checkThrottle: vi.fn(), + }, +})); + +import { challengeStore } from "$lib/server/services/challenge-store"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; + +describe("Bootstrap Challenge API", () => { + const tenantId = "123e4567-e89b-12d3-a456-426614174000"; + const validBody = { + tunnelId: "550e8400-e29b-41d4-a716-446655440000", + clientPublicKey: "client-public-key", + emailHash: "email-hash", + }; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(challengeThrottleService.checkThrottle).mockResolvedValue({ allowed: true }); + }); + + function createEvent(body: unknown = validBody): RequestEvent { + return { + params: { id: tenantId }, + request: { + json: vi.fn().mockResolvedValue(body), + } as any, + } as RequestEvent; + } + + it("returns bootstrap challenge when request is valid", async () => { + const response = await POST(createEvent()); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.challengeId).toBeTypeOf("string"); + expect(data.nonce).toBeTypeOf("string"); + expect(data.difficulty).toBe(4); + expect(challengeStore.store).toHaveBeenCalledOnce(); + }); + + it("returns 429 when throttled", async () => { + vi.mocked(challengeThrottleService.checkThrottle).mockResolvedValue({ + allowed: false, + retryAfterMs: 15000, + }); + + const response = await POST(createEvent()); + const data = await response.json(); + + expect(response.status).toBe(429); + expect(data.error).toBe("Too many bootstrap attempts. Please try again later."); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts new file mode 100644 index 0000000..1c237f1 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts @@ -0,0 +1,174 @@ +import { json, type RequestHandler } from "@sveltejs/kit"; +import { z } from "zod"; +import { logger } from "$lib/logger"; +import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { challengeStore } from "$lib/server/services/challenge-store"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; +import { + BackendError, + InternalError, + NotFoundError, + ValidationError, + logError, +} from "$lib/server/utils/errors"; +import { + createBootstrapBinding, + createBootstrapPowDigest, + matchesPowDifficulty, + NEW_CLIENT_BOOTSTRAP_DIFFICULTY, +} from "$lib/server/services/bootstrap-challenge"; +import { generateNewClientBootstrapToken } from "$lib/server/auth/booking-access-token"; +import type { BootstrapVerifyResponse } from "$lib/types/appointment"; + +const requestSchema = z.object({ + challengeId: z.string().min(1), + tunnelId: z.string().uuid(), + clientPublicKey: z.string().min(1), + counter: z.number().int().min(0), + emailHash: z.string().optional(), +}); + +registerOpenAPIRoute("/tenants/{id}/appointments/bootstrap-verify", "POST", { + summary: "Verify bootstrap challenge for new client", + description: + "Verifies the nonce-based proof-of-work challenge for a new client and returns a short-lived bootstrap token.", + tags: ["Appointments", "Clients"], + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string", format: "uuid" }, + description: "Tenant ID", + }, + ], + requestBody: { + description: "Bootstrap verification data", + content: { + "application/json": { + schema: { + type: "object", + properties: { + challengeId: { type: "string" }, + tunnelId: { type: "string", format: "uuid" }, + clientPublicKey: { type: "string" }, + counter: { type: "integer" }, + emailHash: { type: "string" }, + }, + required: ["challengeId", "tunnelId", "clientPublicKey", "counter"], + }, + }, + }, + }, + responses: { + "200": { + description: "Bootstrap challenge verified successfully", + content: { + "application/json": { + schema: { + type: "object", + properties: { + valid: { type: "boolean" }, + bookingAccessToken: { type: "string" }, + }, + required: ["valid", "bookingAccessToken"], + }, + }, + }, + }, + "404": { + description: "Bootstrap challenge not found or expired", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "422": { + description: "Invalid request data or proof of work", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "500": { + description: "Internal server error", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + }, +}); + +export const POST: RequestHandler = async ({ request, params }) => { + try { + const tenantId = params.id; + if (!tenantId) { + throw new ValidationError("Tenant ID is required"); + } + + const body = requestSchema.parse(await request.json()); + const binding = createBootstrapBinding({ + tenantId, + tunnelId: body.tunnelId, + clientPublicKey: body.clientPublicKey, + emailHash: body.emailHash, + }); + + const storedChallenge = await challengeStore.consume(body.challengeId, tenantId); + if (!storedChallenge) { + throw new NotFoundError("Bootstrap challenge not found or expired"); + } + + if (storedChallenge.emailHash !== binding) { + await challengeThrottleService.recordFailedAttempt(binding, "passkey"); + throw new ValidationError("Invalid bootstrap challenge binding"); + } + + const digest = createBootstrapPowDigest({ + nonce: storedChallenge.challenge, + tunnelId: body.tunnelId, + clientPublicKey: body.clientPublicKey, + counter: body.counter, + }); + + if (!matchesPowDifficulty(digest, NEW_CLIENT_BOOTSTRAP_DIFFICULTY)) { + await challengeThrottleService.recordFailedAttempt(binding, "passkey"); + throw new ValidationError("Invalid bootstrap proof of work"); + } + + await challengeThrottleService.clearThrottle(binding, "passkey"); + + const bookingAccessToken = await generateNewClientBootstrapToken({ + tenantId, + tunnelId: body.tunnelId, + clientPublicKey: body.clientPublicKey, + emailHash: body.emailHash, + }); + + const response: BootstrapVerifyResponse = { + valid: true, + bookingAccessToken, + }; + + logger.info("Verified bootstrap challenge for new client", { + tenantId, + challengeId: body.challengeId, + tunnelId: body.tunnelId, + }); + + return json(response); + } catch (error) { + logError(logger)("Failed to verify bootstrap challenge", error); + if (error instanceof z.ZodError) { + return new ValidationError("Invalid request data").toJson(); + } + if (error instanceof BackendError) { + return error.toJson(); + } + return new InternalError().toJson(); + } +}; diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts new file mode 100644 index 0000000..51b07f0 --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts @@ -0,0 +1,139 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestEvent } from "@sveltejs/kit"; +import { POST } from "../+server"; +import { createBootstrapPowDigest } from "$lib/server/services/bootstrap-challenge"; + +vi.mock("$lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/challenge-store", () => ({ + challengeStore: { + consume: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/challenge-throttle", () => ({ + challengeThrottleService: { + recordFailedAttempt: vi.fn(), + clearThrottle: vi.fn(), + }, +})); + +vi.mock("$lib/server/auth/booking-access-token", () => ({ + generateNewClientBootstrapToken: vi.fn(), +})); + +import { challengeStore } from "$lib/server/services/challenge-store"; +import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; +import { generateNewClientBootstrapToken } from "$lib/server/auth/booking-access-token"; + +describe("Bootstrap Verify API", () => { + const tenantId = "123e4567-e89b-12d3-a456-426614174000"; + const nonce = "bootstrap-nonce"; + const tunnelId = "550e8400-e29b-41d4-a716-446655440000"; + const clientPublicKey = "client-public-key"; + const emailHash = "email-hash"; + + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(generateNewClientBootstrapToken).mockResolvedValue("bootstrap-token"); + }); + + function createEvent(counter: number): RequestEvent { + return { + params: { id: tenantId }, + request: { + json: vi.fn().mockResolvedValue({ + challengeId: "challenge-id", + tunnelId, + clientPublicKey, + counter, + emailHash, + }), + } as any, + } as RequestEvent; + } + + async function findValidCounter(): Promise { + for (let counter = 0; ; counter += 1) { + const digest = createBootstrapPowDigest({ + nonce, + tunnelId, + clientPublicKey, + counter, + }); + + if (digest.startsWith("0000")) { + return counter; + } + } + } + + it("returns bootstrap token for valid proof of work", async () => { + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: nonce, + emailHash: "fdb1dce4daaadf27027857ba4b1947e6260fa7d8d40b975639096d5f04f0a96c", + } as any); + + const validCounter = await findValidCounter(); + const { createBootstrapBinding } = await import("$lib/server/services/bootstrap-challenge"); + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: nonce, + emailHash: createBootstrapBinding({ tenantId, tunnelId, clientPublicKey, emailHash }), + } as any); + + const response = await POST(createEvent(validCounter)); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.valid).toBe(true); + expect(data.bookingAccessToken).toBe("bootstrap-token"); + expect(challengeThrottleService.clearThrottle).toHaveBeenCalledOnce(); + }); + + it("returns 422 for invalid proof of work", async () => { + const { createBootstrapBinding } = await import("$lib/server/services/bootstrap-challenge"); + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: nonce, + emailHash: createBootstrapBinding({ tenantId, tunnelId, clientPublicKey, emailHash }), + } as any); + + const response = await POST(createEvent(0)); + const data = await response.json(); + + expect(response.status).toBe(422); + expect(data.error).toBe("Invalid bootstrap proof of work"); + expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledOnce(); + }); + + it("returns 404 when challenge is missing or expired", async () => { + vi.mocked(challengeStore.consume).mockResolvedValue(null); + + const response = await POST(createEvent(0)); + const data = await response.json(); + + expect(response.status).toBe(404); + expect(data.error).toBe("Bootstrap challenge not found or expired"); + }); + + it("returns 422 when challenge binding does not match", async () => { + vi.mocked(challengeStore.consume).mockResolvedValue({ + challenge: nonce, + emailHash: "other-binding", + } as any); + + const response = await POST(createEvent(0)); + const data = await response.json(); + + expect(response.status).toBe(422); + expect(data.error).toBe("Invalid bootstrap challenge binding"); + expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts index 02fec3d..efb66c4 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/+server.ts @@ -1,8 +1,20 @@ import { json, type RequestHandler } from "@sveltejs/kit"; import { z } from "zod"; import { logger } from "$lib/logger"; +import { + consumeBookingAccessToken, + NEW_CLIENT_BOOTSTRAP_SCOPE, + verifyBookingAccessToken, +} from "$lib/server/auth/booking-access-token"; import { AppointmentService } from "$lib/server/services/appointment-service"; -import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; +import { + AuthenticationError, + AuthorizationError, + BackendError, + InternalError, + logError, + ValidationError, +} from "$lib/server/utils/errors"; import { registerOpenAPIRoute } from "$lib/server/openapi"; const requestSchema = z.object({ @@ -31,6 +43,55 @@ const requestSchema = z.object({ clientEncryptedTunnelKey: z.string(), }); +type CreateNewClientRequest = z.infer; + +async function requireBootstrapBookingAccessToken( + request: Request, + tenantId: string, +): Promise>>> { + const authorizationHeader = request.headers.get("Authorization"); + if (!authorizationHeader?.startsWith("Bearer ")) { + throw new AuthenticationError("Bootstrap booking access token is required"); + } + + const token = authorizationHeader.substring("Bearer ".length).trim(); + if (!token) { + throw new AuthenticationError("Bootstrap booking access token is required"); + } + + const tokenPayload = await verifyBookingAccessToken(token); + if (!tokenPayload) { + throw new AuthenticationError("Invalid or expired booking access token"); + } + + if (tokenPayload.scope !== NEW_CLIENT_BOOTSTRAP_SCOPE) { + throw new AuthorizationError("Booking access token is not valid for new client bootstrap"); + } + + if (tokenPayload.tenantId !== tenantId) { + throw new AuthorizationError("Booking access token is not valid for this tenant"); + } + + return tokenPayload; +} + +function validateBootstrapTokenBinding( + tokenPayload: NonNullable>>, + requestData: CreateNewClientRequest, +): void { + if (tokenPayload.tunnelId !== requestData.tunnelId) { + throw new AuthorizationError("Booking access token is not valid for this tunnel"); + } + + if (tokenPayload.clientPublicKey !== requestData.clientPublicKey) { + throw new AuthorizationError("Booking access token is not valid for this client key"); + } + + if (tokenPayload.emailHash && tokenPayload.emailHash !== requestData.emailHash) { + throw new AuthorizationError("Booking access token is not valid for this email hash"); + } +} + // Register OpenAPI documentation for POST registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { summary: "Create new client with appointment", @@ -45,6 +106,13 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { schema: { type: "string", format: "uuid" }, description: "Tenant ID", }, + { + name: "Authorization", + in: "header", + required: true, + schema: { type: "string" }, + description: "Bearer bootstrap booking access token from /appointments/bootstrap-verify", + }, ], requestBody: { description: "New client appointment data", @@ -218,6 +286,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/create-new-client", "POST", { * * Creates a new client tunnel with their first appointment. * This handles the complete setup for new clients including tunnel creation. + * Requires bootstrap booking access token from bootstrap-verify endpoint. */ export const POST: RequestHandler = async ({ request, params }) => { try { @@ -226,8 +295,11 @@ export const POST: RequestHandler = async ({ request, params }) => { throw new ValidationError("Tenant ID is required"); } + const tokenPayload = await requireBootstrapBookingAccessToken(request, tenantId); + const body = await request.json(); const validatedData = requestSchema.parse(body); + validateBootstrapTokenBinding(tokenPayload, validatedData); if (validatedData.salutation) { throw new ValidationError("Bees incoming"); @@ -244,6 +316,8 @@ export const POST: RequestHandler = async ({ request, params }) => { const appointmentService = await AppointmentService.forTenant(tenantId); const response = await appointmentService.createNewClientWithAppointment(validatedData); + await consumeBookingAccessToken(tokenPayload); + return json(response); } catch (error) { logError(logger)("Failed to create new client appointment", error); diff --git a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts index 1bd3168..d4e2bd2 100644 --- a/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/create-new-client/__tests__/create-new-client-api.test.ts @@ -19,6 +19,12 @@ vi.mock("$lib/logger", () => ({ }, })); +vi.mock("$lib/server/auth/booking-access-token", () => ({ + NEW_CLIENT_BOOTSTRAP_SCOPE: "appointments:new-client-bootstrap", + verifyBookingAccessToken: vi.fn(), + consumeBookingAccessToken: vi.fn(), +})); + describe("Create New Client API Route", () => { const mockTenantId = "123e4567-e89b-12d3-a456-426614174000"; const mockTunnelId = "tunnel-123"; @@ -49,6 +55,14 @@ describe("Create New Client API Route", () => { clientEncryptedTunnelKey: "client-encrypted-tunnel-key", }; + const validTokenPayload = { + tenantId: mockTenantId, + tunnelId: mockTunnelId, + clientPublicKey: validRequestBody.clientPublicKey, + emailHash: validRequestBody.emailHash, + scope: "appointments:new-client-bootstrap", + }; + beforeEach(() => { vi.clearAllMocks(); }); @@ -61,6 +75,7 @@ describe("Create New Client API Route", () => { params: { id: mockTenantId }, request: { json: vi.fn().mockResolvedValue(body), + headers: new Headers({ Authorization: "Bearer valid-bootstrap-token" }), } as any, locals: { user: { @@ -77,6 +92,9 @@ describe("Create New Client API Route", () => { it("should return 200 when service successfully creates appointment", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); const { logger } = await import("$lib/logger"); + const { verifyBookingAccessToken, consumeBookingAccessToken } = await import( + "$lib/server/auth/booking-access-token" + ); // Mock successful service response const mockAppointment = { @@ -88,6 +106,7 @@ describe("Create New Client API Route", () => { createNewClientWithAppointment: vi.fn().mockResolvedValue(mockAppointment), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -96,6 +115,7 @@ describe("Create New Client API Route", () => { expect(response.status).toBe(200); expect(data).toEqual(mockAppointment); expect(mockService.createNewClientWithAppointment).toHaveBeenCalledWith(validRequestBody); + expect(consumeBookingAccessToken).toHaveBeenCalledOnce(); expect(logger.info).toHaveBeenCalledWith("Creating new client appointment tunnel", { tenantId: mockTenantId, tunnelId: mockTunnelId, @@ -107,6 +127,7 @@ describe("Create New Client API Route", () => { it("should return 200 with CONFIRMED status when service returns it", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const mockAppointment = { id: "appointment-123", @@ -117,6 +138,7 @@ describe("Create New Client API Route", () => { createNewClientWithAppointment: vi.fn().mockResolvedValue(mockAppointment), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -128,12 +150,30 @@ describe("Create New Client API Route", () => { }); describe("Validation Errors", () => { + it("should return 401 when bootstrap token is missing", async () => { + const event = createMockRequestEvent(validRequestBody, { + request: { + json: vi.fn().mockResolvedValue(validRequestBody), + headers: new Headers(), + } as any, + }); + + const response = await POST(event); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Bootstrap booking access token is required"); + }); + it("should return 422 for invalid request data", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const invalidBody = { tunnelId: mockTunnelId, // Missing required fields }; + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); + const event = createMockRequestEvent(invalidBody); const response = await POST(event); const data = await response.json(); @@ -143,6 +183,9 @@ describe("Create New Client API Route", () => { }); it("should return 422 for missing tenant ID", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); + const event = createMockRequestEvent(validRequestBody, { params: {}, // No id parameter }); @@ -155,10 +198,14 @@ describe("Create New Client API Route", () => { }); it("should return 500 for invalid JSON in request", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); + const event = { params: { id: mockTenantId }, request: { json: vi.fn().mockRejectedValue(new Error("Invalid JSON")), + headers: new Headers({ Authorization: "Bearer valid-bootstrap-token" }), }, } as any; @@ -174,6 +221,7 @@ describe("Create New Client API Route", () => { it("should return 409 when service throws ConflictError for no authorized users", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); const { logger } = await import("$lib/logger"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); // Mock service to throw ConflictError (should return 409) const mockService = { @@ -186,6 +234,7 @@ describe("Create New Client API Route", () => { ), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -206,11 +255,13 @@ describe("Create New Client API Route", () => { it("should return 500 for service initialization errors", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); // Mock service initialization failure vi.mocked(AppointmentService.forTenant).mockRejectedValue( new Error("Database connection failed"), ); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -222,6 +273,7 @@ describe("Create New Client API Route", () => { it("should return 404 when service throws NotFoundError", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const mockService = { createNewClientWithAppointment: vi @@ -229,6 +281,7 @@ describe("Create New Client API Route", () => { .mockRejectedValue(new NotFoundError("Channel not found")), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); const response = await POST(event); @@ -243,6 +296,7 @@ describe("Create New Client API Route", () => { it("should log appointment creation attempts", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); const { logger } = await import("$lib/logger"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const mockService = { createNewClientWithAppointment: vi.fn().mockResolvedValue({ @@ -252,6 +306,7 @@ describe("Create New Client API Route", () => { }), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); await POST(event); @@ -267,11 +322,13 @@ describe("Create New Client API Route", () => { it("should log errors when service fails", async () => { const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); const mockService = { createNewClientWithAppointment: vi.fn().mockRejectedValue(new Error("Service error")), }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); + vi.mocked(verifyBookingAccessToken).mockResolvedValue(validTokenPayload as any); const event = createMockRequestEvent(); await POST(event); @@ -279,5 +336,20 @@ describe("Create New Client API Route", () => { // Logger error calls are handled by logError function, so we just verify the endpoint doesn't crash expect(true).toBe(true); }); + + it("should return 403 when token binding does not match request", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + ...validTokenPayload, + clientPublicKey: "other-client-key", + } as any); + + const event = createMockRequestEvent(); + const response = await POST(event); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Booking access token is not valid for this client key"); + }); }); }); diff --git a/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts b/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts index 9103b92..c90ff03 100644 --- a/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts @@ -9,18 +9,29 @@ import type { RequestHandler } from "@sveltejs/kit"; import { json } from "@sveltejs/kit"; import { z } from "zod"; import { logger } from "$lib/logger"; -import { BackendError, InternalError, logError, ValidationError } from "$lib/server/utils/errors"; +import { + AuthenticationError, + AuthorizationError, + BackendError, + InternalError, + logError, + ValidationError, +} from "$lib/server/utils/errors"; import { AppointmentService } from "$lib/server/services/appointment-service"; import { getTenantDb } from "$lib/server/db"; import { clientAppointmentTunnel } from "$lib/server/db/tenant-schema"; import { eq } from "drizzle-orm"; import { registerOpenAPIRoute } from "$lib/server/openapi"; +import { + EXISTING_CLIENT_BOOKING_SCOPE, + verifyBookingAccessToken, +} from "$lib/server/auth/booking-access-token"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/appointments/my-appointments", "GET", { summary: "Get client's future appointments", description: - "Returns all future appointments for a client. Requires the client to be authenticated via PIN challenge-response flow. The emailHash header must match the authenticated client.", + "Returns all future appointments for a client. Requires a valid existing-client booking access token and matching email hash.", tags: ["Appointments", "Clients"], parameters: [ { @@ -37,6 +48,13 @@ registerOpenAPIRoute("/tenants/{id}/appointments/my-appointments", "GET", { schema: { type: "string" }, description: "SHA-256 hash of client email for authentication", }, + { + name: "Authorization", + in: "header", + required: true, + schema: { type: "string" }, + description: "Bearer booking access token from /appointments/verify-challenge", + }, ], responses: { "200": { @@ -107,6 +125,22 @@ registerOpenAPIRoute("/tenants/{id}/appointments/my-appointments", "GET", { }, }, }, + "401": { + description: "Missing or invalid booking access token", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + "403": { + description: "Booking access token does not match requested tenant or email hash", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, "404": { description: "Client tunnel not found", content: { @@ -132,7 +166,7 @@ const emailHashSchema = z.string().min(1); * GET /api/tenants/[id]/appointments/my-appointments * * Returns all future appointments for an authenticated client. - * The client must provide their email hash in the X-Email-Hash header. + * Requires existing-client booking token and matching email hash. */ export const GET: RequestHandler = async ({ request, params }) => { const log = logger.setContext("API.MyAppointments"); @@ -151,6 +185,33 @@ export const GET: RequestHandler = async ({ request, params }) => { const validatedEmailHash = emailHashSchema.parse(emailHash); + const authorizationHeader = request.headers.get("Authorization"); + if (!authorizationHeader?.startsWith("Bearer ")) { + throw new AuthenticationError("Booking access token is required"); + } + + const token = authorizationHeader.substring("Bearer ".length).trim(); + if (!token) { + throw new AuthenticationError("Booking access token is required"); + } + + const tokenPayload = await verifyBookingAccessToken(token); + if (!tokenPayload) { + throw new AuthenticationError("Invalid or expired booking access token"); + } + + if (tokenPayload.scope !== EXISTING_CLIENT_BOOKING_SCOPE) { + throw new AuthorizationError("Booking access token is not valid for this endpoint"); + } + + if (tokenPayload.tenantId !== tenantId) { + throw new AuthorizationError("Booking access token is not valid for this tenant"); + } + + if (tokenPayload.emailHash !== validatedEmailHash) { + throw new AuthorizationError("Booking access token is not valid for this email hash"); + } + log.debug("Fetching appointments for client", { tenantId, emailHashPrefix: validatedEmailHash.slice(0, 8), diff --git a/src/routes/api/tenants/[id]/appointments/my-appointments/__tests__/my-appointments.test.ts b/src/routes/api/tenants/[id]/appointments/my-appointments/__tests__/my-appointments.test.ts index 3714c21..800cf4d 100644 --- a/src/routes/api/tenants/[id]/appointments/my-appointments/__tests__/my-appointments.test.ts +++ b/src/routes/api/tenants/[id]/appointments/my-appointments/__tests__/my-appointments.test.ts @@ -24,6 +24,10 @@ vi.mock("$lib/server/db", () => ({ })); vi.mock("$lib/server/services/appointment-service"); +vi.mock("$lib/server/auth/booking-access-token", () => ({ + EXISTING_CLIENT_BOOKING_SCOPE: "appointments:client", + verifyBookingAccessToken: vi.fn(), +})); const mockTenantId = "tenant-123"; const mockEmailHash = "email-hash-abc123"; @@ -55,11 +59,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { vi.clearAllMocks(); }); - const createMockRequest = (emailHash?: string): RequestEvent => { + const createMockRequest = (emailHash?: string, authorization?: string): RequestEvent => { const headers = new Headers(); if (emailHash) { headers.set("X-Email-Hash", emailHash); } + if (authorization) { + headers.set("Authorization", authorization); + } return { request: { @@ -72,6 +79,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should return future appointments for authenticated client", async () => { const { getTenantDb } = await import("$lib/server/db"); const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); // Mock database query for tunnel const mockDb = { @@ -91,7 +106,7 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -114,6 +129,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should return empty array when client has no future appointments", async () => { const { getTenantDb } = await import("$lib/server/db"); const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); const mockDb = { select: vi.fn().mockReturnValue({ @@ -131,7 +154,7 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -139,8 +162,25 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { expect(data.appointments).toHaveLength(0); }); + it("should return 401 when Authorization header is missing", async () => { + const event = createMockRequest(mockEmailHash); + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Booking access token is required"); + }); + it("should return 422 when X-Email-Hash header is missing", async () => { - const event = createMockRequest(); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); + + const event = createMockRequest(undefined, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -149,9 +189,20 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }); it("should return 422 when tenant ID is missing", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); + const event = { request: { - headers: new Headers({ "X-Email-Hash": mockEmailHash }), + headers: new Headers({ + "X-Email-Hash": mockEmailHash, + Authorization: "Bearer valid-booking-token", + }), } as Request, params: { id: undefined }, } as any; @@ -165,6 +216,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should return 422 when client tunnel is not found", async () => { const { getTenantDb } = await import("$lib/server/db"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); const mockDb = { select: vi.fn().mockReturnValue({ @@ -177,7 +236,7 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }; vi.mocked(getTenantDb).mockResolvedValue(mockDb as any); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -186,7 +245,15 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }); it("should return 422 when email hash is empty", async () => { - const event = createMockRequest(""); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); + + const event = createMockRequest("", "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -196,10 +263,18 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should handle database errors gracefully", async () => { const { getTenantDb } = await import("$lib/server/db"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); vi.mocked(getTenantDb).mockRejectedValue(new Error("Database connection failed")); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); @@ -210,6 +285,14 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { it("should handle service errors gracefully", async () => { const { getTenantDb } = await import("$lib/server/db"); const { AppointmentService } = await import("$lib/server/services/appointment-service"); + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: mockEmailHash, + scope: "appointments:client", + } as any); const mockDb = { select: vi.fn().mockReturnValue({ @@ -227,11 +310,28 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { }; vi.mocked(AppointmentService.forTenant).mockResolvedValue(mockService as any); - const event = createMockRequest(mockEmailHash); + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); const response = await GET(event); const data = await response.json(); expect(response.status).toBe(500); expect(data.error).toBeDefined(); }); + + it("should return 403 when token email hash does not match header", async () => { + const { verifyBookingAccessToken } = await import("$lib/server/auth/booking-access-token"); + vi.mocked(verifyBookingAccessToken).mockResolvedValue({ + tenantId: mockTenantId, + tunnelId: mockTunnelId, + emailHash: "other-email-hash", + scope: "appointments:client", + } as any); + + const event = createMockRequest(mockEmailHash, "Bearer valid-booking-token"); + const response = await GET(event); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Booking access token is not valid for this email hash"); + }); }); diff --git a/src/routes/api/tenants/[id]/appointments/staff-public-keys/+server.ts b/src/routes/api/tenants/[id]/appointments/staff-public-keys/+server.ts index 4e4297b..35451cd 100644 --- a/src/routes/api/tenants/[id]/appointments/staff-public-keys/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/staff-public-keys/+server.ts @@ -16,7 +16,7 @@ import { verifyBookingAccessToken } from "$lib/server/auth/booking-access-token" registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", { summary: "Get staff public keys", description: - "Returns public encryption keys for all staff members. Requires a valid short-lived booking access token from /appointments/verify-challenge.", + "Returns public encryption keys for all staff members. Requires a valid short-lived booking access token from /appointments/verify-challenge or /appointments/bootstrap-verify.", tags: ["Appointments", "Encryption"], parameters: [ { @@ -31,7 +31,8 @@ registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", { in: "header", required: true, schema: { type: "string" }, - description: "Bearer booking access token from /appointments/verify-challenge", + description: + "Bearer booking access token from /appointments/verify-challenge or /appointments/bootstrap-verify", }, ], responses: { @@ -107,7 +108,7 @@ registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", { * GET /api/tenants/[id]/appointments/staff-public-keys * * Returns the public keys of all staff members for encryption. - * Requires booking access token from verify-challenge endpoint. + * Requires booking access token from verify-challenge or bootstrap-verify endpoint. */ export const GET: RequestHandler = async ({ params, request }) => { try { diff --git a/src/routes/api/tenants/[id]/appointments/staff-public-keys/__tests__/staff-public-keys-api.test.ts b/src/routes/api/tenants/[id]/appointments/staff-public-keys/__tests__/staff-public-keys-api.test.ts index 6e8b68d..c879ef1 100644 --- a/src/routes/api/tenants/[id]/appointments/staff-public-keys/__tests__/staff-public-keys-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/staff-public-keys/__tests__/staff-public-keys-api.test.ts @@ -104,4 +104,28 @@ describe("Staff Public Keys API", () => { expect(data.staffPublicKeys).toEqual(staffPublicKeys); expect(serviceMock.getStaffPublicKeys).toHaveBeenCalledWith(tenantId); }); + + it("accepts valid bootstrap-scope booking token", async () => { + (verifyBookingAccessToken as any).mockResolvedValue({ + tenantId, + tunnelId: "tunnel-id", + clientPublicKey: "client-public-key", + scope: "appointments:new-client-bootstrap", + }); + + const staffPublicKeys = [ + { + userId: "550e8400-e29b-41d4-a716-446655440111", + publicKey: "base64-public-key", + }, + ]; + + serviceMock.getStaffPublicKeys.mockResolvedValue(staffPublicKeys); + + const response = await GET(createEvent("Bearer bootstrap-token")); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.staffPublicKeys).toEqual(staffPublicKeys); + }); }); diff --git a/src/server-hooks/apiAuthHandle.test.ts b/src/server-hooks/apiAuthHandle.test.ts new file mode 100644 index 0000000..ed21747 --- /dev/null +++ b/src/server-hooks/apiAuthHandle.test.ts @@ -0,0 +1,79 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { RequestEvent } from "@sveltejs/kit"; +import { apiAuthHandle } from "./apiAuthHandle"; + +vi.mock("$lib/server/auth/session-service", () => ({ + SessionService: { + validateTokenWithDB: vi.fn(), + }, +})); + +vi.mock("$lib/server/auth/authorization-service", () => ({ + AuthorizationService: { + hasRole: vi.fn(), + hasAnyRole: vi.fn(), + }, +})); + +import { SessionService } from "$lib/server/auth/session-service"; + +function createEvent(path: string, opts?: { bearer?: string; cookieToken?: string }): RequestEvent { + const headers = new Headers(); + if (opts?.bearer) { + headers.set("authorization", `Bearer ${opts.bearer}`); + } + + return { + url: new URL(`http://localhost${path}`), + request: new Request(`http://localhost${path}`, { headers }), + cookies: { + get: vi.fn((name: string) => { + if (name === "access_token") { + return opts?.cookieToken ?? undefined; + } + return undefined; + }), + } as any, + locals: {}, + } as RequestEvent; +} + +describe("apiAuthHandle", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("skips session token validation for public client appointment route with bearer booking token", async () => { + const resolve = vi.fn(async () => new Response("ok", { status: 200 })); + const event = createEvent( + "/api/tenants/901d0d2a-4bf1-4755-99f2-9e1b7e11486b/appointments/staff-public-keys", + { + bearer: "booking-token", + }, + ); + + const response = await apiAuthHandle({ event, resolve }); + + expect(response.status).toBe(200); + expect(resolve).toHaveBeenCalledOnce(); + expect(SessionService.validateTokenWithDB).not.toHaveBeenCalled(); + }); + + it("still validates session token for non-public route", async () => { + vi.mocked(SessionService.validateTokenWithDB).mockResolvedValue(null); + + const resolve = vi.fn(async () => new Response("ok", { status: 200 })); + const event = createEvent("/api/tenants/901d0d2a-4bf1-4755-99f2-9e1b7e11486b/users", { + bearer: "not-a-session-token", + }); + + const response = await apiAuthHandle({ event, resolve }); + const body = await response.json(); + + expect(response.status).toBe(401); + expect(body.error).toBe("Invalid or expired access token"); + expect(SessionService.validateTokenWithDB).toHaveBeenCalledOnce(); + expect(resolve).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server-hooks/apiAuthHandle.ts b/src/server-hooks/apiAuthHandle.ts index ac5f20a..5978c42 100644 --- a/src/server-hooks/apiAuthHandle.ts +++ b/src/server-hooks/apiAuthHandle.ts @@ -9,6 +9,21 @@ const logger = new UniversalLogger().setContext("AuthHandle"); const GLOBAL_ADMIN_PATHS = ["/api/admin"]; +const CLIENT_APPOINTMENT_PUBLIC_ROUTE_PATTERNS: RegExp[] = [ + /^\/api\/tenants\/[^/]+\/appointments\/challenge$/, + /^\/api\/tenants\/[^/]+\/appointments\/verify-challenge$/, + /^\/api\/tenants\/[^/]+\/appointments\/bootstrap-challenge$/, + /^\/api\/tenants\/[^/]+\/appointments\/bootstrap-verify$/, + /^\/api\/tenants\/[^/]+\/appointments\/staff-public-keys$/, + /^\/api\/tenants\/[^/]+\/appointments\/create-new-client$/, + /^\/api\/tenants\/[^/]+\/appointments\/add-to-tunnel$/, + /^\/api\/tenants\/[^/]+\/appointments\/my-appointments$/, + /^\/api\/tenants\/[^/]+\/appointments\/[^/]+\/delete-by-client$/, +]; + +const isClientAppointmentPublicRoute = (path: string): boolean => + CLIENT_APPOINTMENT_PUBLIC_ROUTE_PATTERNS.some((pattern) => pattern.test(path)); + export const apiAuthHandle: Handle = async ({ event, resolve }) => { const { url } = event; const path = url.pathname; @@ -29,6 +44,11 @@ export const apiAuthHandle: Handle = async ({ event, resolve }) => { const isGlobalAdminPath = GLOBAL_ADMIN_PATHS.some((gadPath) => path.startsWith(gadPath)); const isAdminPath = false; + const hasSessionCookie = Boolean(event.cookies.get("access_token")); + if (isClientAppointmentPublicRoute(path) && !hasSessionCookie) { + return resolve(event); + } + const accessToken: string | null = getAccessToken(event); let sessionData: { user: SelectUser; diff --git a/tenant-migrations/0013_youthful_grim_reaper.sql b/tenant-migrations/0013_youthful_grim_reaper.sql new file mode 100644 index 0000000..ced14b9 --- /dev/null +++ b/tenant-migrations/0013_youthful_grim_reaper.sql @@ -0,0 +1,11 @@ +CREATE TABLE "booking_access_token" ( + "id" text PRIMARY KEY NOT NULL, + "scope" text NOT NULL, + "tenant_id" uuid NOT NULL, + "email_hash" text, + "tunnel_id" uuid NOT NULL, + "client_public_key" text, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp NOT NULL, + "consumed" boolean DEFAULT false NOT NULL +); diff --git a/tenant-migrations/meta/0013_snapshot.json b/tenant-migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..4d555f6 --- /dev/null +++ b/tenant-migrations/meta/0013_snapshot.json @@ -0,0 +1,1025 @@ +{ + "id": "726f8502-95ab-458f-83e5-9897a8b46a03", + "prevId": "a189259c-e77c-4561-8a6c-c2f2fe5c9eb4", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "varchar(250000)", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_absence": { + "name": "agent_absence", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "start_date": { + "name": "start_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_date": { + "name": "end_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "absence_type": { + "name": "absence_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "agent_absence_agent_id_agent_id_fk": { + "name": "agent_absence_agent_id_agent_id_fk", + "tableFrom": "agent_absence", + "tableTo": "agent", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment": { + "name": "appointment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "appointment_date": { + "name": "appointment_date", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "expiry_date": { + "name": "expiry_date", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "appointment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "encrypted_data": { + "name": "encrypted_data", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "data_key": { + "name": "data_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_payload": { + "name": "encrypted_payload", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "iv": { + "name": "iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_tag": { + "name": "auth_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_tunnel_id_client_appointment_tunnel_id_fk": { + "name": "appointment_tunnel_id_client_appointment_tunnel_id_fk", + "tableFrom": "appointment", + "tableTo": "client_appointment_tunnel", + "columnsFrom": [ + "tunnel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_channel_id_channel_id_fk": { + "name": "appointment_channel_id_channel_id_fk", + "tableFrom": "appointment", + "tableTo": "channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "appointment_agent_id_agent_id_fk": { + "name": "appointment_agent_id_agent_id_fk", + "tableFrom": "appointment", + "tableTo": "agent", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.appointment_key_share": { + "name": "appointment_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "appointment_id": { + "name": "appointment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "appointment_key_share_appointment_id_appointment_id_fk": { + "name": "appointment_key_share_appointment_id_appointment_id_fk", + "tableFrom": "appointment_key_share", + "tableTo": "appointment", + "columnsFrom": [ + "appointment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_challenge": { + "name": "auth_challenge", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "challenge": { + "name": "challenge", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.booking_access_token": { + "name": "booking_access_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tenant_id": { + "name": "tenant_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel": { + "name": "channel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "names": { + "name": "names", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paused": { + "name": "paused", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "descriptions": { + "name": "descriptions", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "requires_confirmation": { + "name": "requires_confirmation", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_agent": { + "name": "channel_agent", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_agent_channel_id_channel_id_fk": { + "name": "channel_agent_channel_id_channel_id_fk", + "tableFrom": "channel_agent", + "tableTo": "channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_agent_agent_id_agent_id_fk": { + "name": "channel_agent_agent_id_agent_id_fk", + "tableFrom": "channel_agent", + "tableTo": "agent", + "columnsFrom": [ + "agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_slot_template": { + "name": "channel_slot_template", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slot_template_id": { + "name": "slot_template_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_slot_template_channel_id_channel_id_fk": { + "name": "channel_slot_template_channel_id_channel_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "channel_slot_template_slot_template_id_slotTemplate_id_fk": { + "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", + "tableFrom": "channel_slot_template", + "tableTo": "slotTemplate", + "columnsFrom": [ + "slot_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.channel_staff": { + "name": "channel_staff", + "schema": "", + "columns": { + "channel_id": { + "name": "channel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "channel_staff_channel_id_channel_id_fk": { + "name": "channel_staff_channel_id_channel_id_fk", + "tableFrom": "channel_staff", + "tableTo": "channel", + "columnsFrom": [ + "channel_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_appointment_tunnel": { + "name": "client_appointment_tunnel", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_public_key": { + "name": "client_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_key_share": { + "name": "client_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_appointment_tunnel_email_hash_unique": { + "name": "client_appointment_tunnel_email_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "email_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_pin_reset_token": { + "name": "client_pin_reset_token", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "token": { + "name": "token", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email_hash": { + "name": "email_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "used": { + "name": "used", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "client_pin_reset_token_token_unique": { + "name": "client_pin_reset_token_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.client_tunnel_staff_key_share": { + "name": "client_tunnel_staff_key_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "tunnel_id": { + "name": "tunnel_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "encrypted_tunnel_key": { + "name": "encrypted_tunnel_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk": { + "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" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notification": { + "name": "notification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "staff_id": { + "name": "staff_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "notification_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'APPOINTMENT_CONFIRMED'" + }, + "meta_data": { + "name": "meta_data", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_read": { + "name": "is_read", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.slotTemplate": { + "name": "slotTemplate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "weekdays": { + "name": "weekdays", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "from": { + "name": "from", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "to": { + "name": "to", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.staff_crypto": { + "name": "staff_crypto", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "private_key_share": { + "name": "private_key_share", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "passkey_id": { + "name": "passkey_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.appointment_status": { + "name": "appointment_status", + "schema": "public", + "values": [ + "NEW", + "CONFIRMED", + "HELD", + "REJECTED", + "NO_SHOW" + ] + }, + "public.notification_type": { + "name": "notification_type", + "schema": "public", + "values": [ + "APPOINTMENT_CONFIRMED", + "APPOINTMENT_CANCELLED", + "APPOINTMENT_REQUESTED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index f6f3b09..0d0843b 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -92,6 +92,13 @@ "when": 1769078158060, "tag": "0012_military_puppet_master", "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1773231146366, + "tag": "0013_youthful_grim_reaper", + "breakpoints": true } ] -} +} \ No newline at end of file