From 81b7d52e6400a2606750b4d86603ac5264cdc4e8 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Fri, 6 Mar 2026 10:05:18 +0100 Subject: [PATCH 1/9] Do not apply shortname for system tenant generateBaseUrl Do not remove domain name parts if hostname already is a subdomain --- src/lib/server/email/email-service.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index 7426e36..f3bbf83 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -409,18 +409,10 @@ export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): s return `${protocol}//${hostname}${port}`; } - // In production, handle tenant subdomains - if (tenant?.shortName) { - const parts = hostname.split("."); - - if (parts.length > 2) { - // Complex subdomain - use only the last two parts (domain.tld) and add tenant - const domain = parts.slice(-2).join("."); - return `${protocol}//${tenant.shortName}.${domain}${port}`; - } else { - // Main domain, prepend tenant subdomain - return `${protocol}//${tenant.shortName}.${hostname}${port}`; - } + // In production, handle tenant subdomains. + // Exclude the system tenant when determining if we should use the tenant's shortName for the URL, as the system tenant does not have a shortName and should use the main domain. + if (tenant?.shortName && tenant.id !== "system") { + return `${protocol}//${tenant.shortName}.${hostname}${port}`; } // For global admin or no tenant, use main domain From 1b883c7d06fd0d0baf02d30ec6aa3a57b5a4cdda Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Tue, 10 Mar 2026 21:51:30 +0100 Subject: [PATCH 2/9] Use domain instead of shortName for tenant domain --- src/lib/server/email/email-service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/server/email/email-service.ts b/src/lib/server/email/email-service.ts index f3bbf83..2c57f3a 100644 --- a/src/lib/server/email/email-service.ts +++ b/src/lib/server/email/email-service.ts @@ -410,9 +410,9 @@ export function generateBaseUrl(requestUrl: URL, tenant: SelectTenant | null): s } // In production, handle tenant subdomains. - // Exclude the system tenant when determining if we should use the tenant's shortName for the URL, as the system tenant does not have a shortName and should use the main domain. - if (tenant?.shortName && tenant.id !== "system") { - return `${protocol}//${tenant.shortName}.${hostname}${port}`; + // Exclude the system tenant when determining if we should use the tenant's domain for the URL, as the system tenant does not have a domain and should use the main domain. + if (tenant?.domain && tenant.id !== "system") { + return `${protocol}//${tenant.domain}.${hostname}${port}`; } // For global admin or no tenant, use main domain From 294581aa10ce9ff96c493d5b3a8c3aaccd0dce2b Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Tue, 10 Mar 2026 21:56:38 +0100 Subject: [PATCH 3/9] Fixed tests --- .../email/__tests__/generate-base-url.test.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/lib/server/email/__tests__/generate-base-url.test.ts b/src/lib/server/email/__tests__/generate-base-url.test.ts index af384a9..a34d581 100644 --- a/src/lib/server/email/__tests__/generate-base-url.test.ts +++ b/src/lib/server/email/__tests__/generate-base-url.test.ts @@ -143,6 +143,7 @@ describe("generateBaseUrl", () => { const tenant: SelectTenant = { id: "tenant-1", shortName: "acme", + domain: "acme", longName: "ACME Corp", descriptions: { en: "" }, languages: ["en"], @@ -151,7 +152,6 @@ describe("generateBaseUrl", () => { setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, - domain: "tenant.example.com", createdAt: new Date(), updatedAt: new Date(), }; @@ -173,7 +173,7 @@ describe("generateBaseUrl", () => { setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, - domain: "tenant.example.com", + domain: "acme", createdAt: new Date(), updatedAt: new Date(), }; @@ -195,13 +195,13 @@ describe("generateBaseUrl", () => { setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, - domain: "tenant.example.com", + domain: "new-tenant", createdAt: new Date(), updatedAt: new Date(), }; const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://new-tenant.example.com"); + expect(result).toBe("https://new-tenant.old-tenant.example.com"); }); it("should replace existing subdomain with port", () => { @@ -217,13 +217,13 @@ describe("generateBaseUrl", () => { setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, - domain: "tenant.example.com", + domain: "new-tenant", createdAt: new Date(), updatedAt: new Date(), }; const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://new-tenant.example.com:8443"); + expect(result).toBe("https://new-tenant.old-tenant.example.com:8443"); }); it("should handle complex subdomains (keep last two parts)", () => { @@ -239,13 +239,13 @@ describe("generateBaseUrl", () => { setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, - domain: "tenant.example.com", + domain: "tenant", createdAt: new Date(), updatedAt: new Date(), }; const result = generateBaseUrl(requestUrl, tenant); - expect(result).toBe("https://tenant.example.com"); + expect(result).toBe("https://tenant.admin.api.example.com"); }); it("should handle http protocol", () => { @@ -261,7 +261,7 @@ describe("generateBaseUrl", () => { setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, - domain: "tenant.example.com", + domain: "acme", createdAt: new Date(), updatedAt: new Date(), }; @@ -283,7 +283,7 @@ describe("generateBaseUrl", () => { setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, - domain: "tenant.example.com", + domain: "", createdAt: new Date(), updatedAt: new Date(), }; @@ -308,7 +308,7 @@ describe("generateBaseUrl", () => { setupState: "SETTINGS", logo: null, links: { website: "", imprint: "", privacyStatement: "" }, - domain: "tenant.example.com", + domain: "acme", createdAt: new Date(), updatedAt: new Date(), }; From 0da63b1d2caaf75778b98df44efa8e05339841ad Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 11 Mar 2026 12:20:33 +0100 Subject: [PATCH 4/9] Obfuscate error output --- src/lib/server/db/index.ts | 4 ++-- src/lib/server/services/client-pin-reset-service.ts | 4 ++-- .../appointments/[appointmentId]/delete-by-client/+server.ts | 2 +- src/routes/api/tenants/[id]/appointments/challenge/+server.ts | 4 ++-- .../appointments/challenge/__tests__/challenge-api.test.ts | 2 +- .../api/tenants/[id]/appointments/my-appointments/+server.ts | 2 +- .../my-appointments/__tests__/my-appointments.test.ts | 2 +- .../api/tenants/[id]/appointments/verify-challenge/+server.ts | 2 +- .../api/tenants/[id]/clients/pin-reset/complete/+server.ts | 2 +- src/routes/api/tenants/[id]/clients/pin-reset/init/+server.ts | 2 +- .../clients/pin-reset/init/__tests__/pin-reset-init.test.ts | 2 +- 11 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/lib/server/db/index.ts b/src/lib/server/db/index.ts index 64e4e54..4830d97 100644 --- a/src/lib/server/db/index.ts +++ b/src/lib/server/db/index.ts @@ -36,7 +36,7 @@ export async function getTenantDb( .limit(1); if (tenant.length === 0) { - throw new Error(`Tenant with ID ${tenantId} not found`); + throw new Error(`Tenant or client not found`); } // Create tenant-specific database connection @@ -62,7 +62,7 @@ export async function getTenant(tenantId: string): Promise { tenantId, emailHashPrefix: emailHash.slice(0, 8), }); - return json({ error: "Client not found" }, { status: 404 }); + return json({ error: "Tenant or client not found" }, { status: 404 }); } const tunnel = tunnelResult[0]; diff --git a/src/routes/api/tenants/[id]/appointments/challenge/__tests__/challenge-api.test.ts b/src/routes/api/tenants/[id]/appointments/challenge/__tests__/challenge-api.test.ts index 153efc5..36af689 100644 --- a/src/routes/api/tenants/[id]/appointments/challenge/__tests__/challenge-api.test.ts +++ b/src/routes/api/tenants/[id]/appointments/challenge/__tests__/challenge-api.test.ts @@ -174,7 +174,7 @@ describe("Challenge API Route", () => { const data = await response.json(); expect(response.status).toBe(404); - expect(data.error).toBe("Client not found"); + expect(data.error).toBe("Tenant or client not found"); }); it("should handle database errors", async () => { 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 5059257..9103b92 100644 --- a/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/my-appointments/+server.ts @@ -171,7 +171,7 @@ export const GET: RequestHandler = async ({ request, params }) => { tenantId, emailHashPrefix: validatedEmailHash.slice(0, 8), }); - throw new ValidationError("Client not found"); + throw new ValidationError("Tenant or client not found"); } const tunnel = tunnelResult[0]; 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 785a58d..3714c21 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 @@ -182,7 +182,7 @@ describe("GET /api/tenants/[id]/appointments/my-appointments", () => { const data = await response.json(); expect(response.status).toBe(422); - expect(data.error).toBe("Client not found"); + expect(data.error).toBe("Tenant or client not found"); }); it("should return 422 when email hash is empty", async () => { diff --git a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts index ef50f47..eba66e0 100644 --- a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts @@ -187,7 +187,7 @@ export const POST: RequestHandler = async ({ request, params }) => { challengeId, emailHashPrefix: storedChallenge.emailHash.slice(0, 8), }); - throw new NotFoundError("Client not found"); + throw new NotFoundError("Tenant or client not found"); } const tunnel = tunnelResult[0]; diff --git a/src/routes/api/tenants/[id]/clients/pin-reset/complete/+server.ts b/src/routes/api/tenants/[id]/clients/pin-reset/complete/+server.ts index a85c7f3..1e5f713 100644 --- a/src/routes/api/tenants/[id]/clients/pin-reset/complete/+server.ts +++ b/src/routes/api/tenants/[id]/clients/pin-reset/complete/+server.ts @@ -100,7 +100,7 @@ registerOpenAPIRoute("/tenants/{id}/clients/pin-reset/complete", "POST", { }, }, "404": { - description: "Token or client not found", + description: "Tenant or client not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, diff --git a/src/routes/api/tenants/[id]/clients/pin-reset/init/+server.ts b/src/routes/api/tenants/[id]/clients/pin-reset/init/+server.ts index be2a379..4aefaff 100644 --- a/src/routes/api/tenants/[id]/clients/pin-reset/init/+server.ts +++ b/src/routes/api/tenants/[id]/clients/pin-reset/init/+server.ts @@ -99,7 +99,7 @@ registerOpenAPIRoute("/tenants/{id}/clients/pin-reset/init", "POST", { }, }, "404": { - description: "Client not found", + description: "Tenant or client not found", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, diff --git a/src/routes/api/tenants/[id]/clients/pin-reset/init/__tests__/pin-reset-init.test.ts b/src/routes/api/tenants/[id]/clients/pin-reset/init/__tests__/pin-reset-init.test.ts index a92501f..75bf6e1 100644 --- a/src/routes/api/tenants/[id]/clients/pin-reset/init/__tests__/pin-reset-init.test.ts +++ b/src/routes/api/tenants/[id]/clients/pin-reset/init/__tests__/pin-reset-init.test.ts @@ -80,7 +80,7 @@ describe("POST /api/tenants/[id]/clients/pin-reset/init", () => { }); it("should return 404 when client not found", async () => { - mockPinResetService.createResetToken.mockRejectedValue(new Error("Client not found")); + mockPinResetService.createResetToken.mockRejectedValue(new Error("Tenant or client not found")); const request = new Request("http://localhost/api", { method: "POST", From b4ca6680ea175f821acf54913420a5aed17b008e Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 11 Mar 2026 12:35:08 +0100 Subject: [PATCH 5/9] Create access tokens for clients from the challenge. --- src/lib/client/appointment-crypto.ts | 14 ++- .../__tests__/booking-access-token.test.ts | 27 +++++ src/lib/server/auth/booking-access-token.ts | 72 ++++++++++++ src/lib/types/appointment.ts | 1 + .../appointments/staff-public-keys/+server.ts | 58 +++++++++- .../__tests__/staff-public-keys-api.test.ts | 107 ++++++++++++++++++ .../appointments/verify-challenge/+server.ts | 15 ++- 7 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 src/lib/server/auth/__tests__/booking-access-token.test.ts create mode 100644 src/lib/server/auth/booking-access-token.ts create mode 100644 src/routes/api/tenants/[id]/appointments/staff-public-keys/__tests__/staff-public-keys-api.test.ts diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 42b2fd6..09b302a 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -126,6 +126,7 @@ export class UnifiedAppointmentCrypto { private clientAuthenticated: boolean = false; private serverPrivateKeyShare: string | null = null; // Server share of the private key private pin: string | null = null; + private bookingAccessToken: string | null = null; // Staff-specific properties private staffKeyPair: StaffKeyPair | null = null; @@ -392,6 +393,7 @@ export class UnifiedAppointmentCrypto { // 6. Decrypt tunnel key and store tunnel ID this.tunnelKey = await this.decryptTunnelKey(verificationData.encryptedTunnelKey, privateKey); this.tunnelId = verificationData.tunnelId; + this.bookingAccessToken = verificationData.bookingAccessToken; this.clientAuthenticated = true; @@ -756,6 +758,7 @@ export class UnifiedAppointmentCrypto { this.emailHash = null; this.tunnelId = null; this.serverPrivateKeyShare = null; + this.bookingAccessToken = null; this.clientAuthenticated = false; this.pin = null; } @@ -1057,9 +1060,18 @@ export class UnifiedAppointmentCrypto { } async fetchStaffPublicKeys(tenantId: string): Promise { + if (!this.bookingAccessToken) { + throw new Error( + "Missing booking access token. Please authenticate as existing client first.", + ); + } + const response = await fetch(`/api/tenants/${tenantId}/appointments/staff-public-keys`, { method: "GET", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.bookingAccessToken}`, + }, }); if (!response.ok) { diff --git a/src/lib/server/auth/__tests__/booking-access-token.test.ts b/src/lib/server/auth/__tests__/booking-access-token.test.ts new file mode 100644 index 0000000..65c9a18 --- /dev/null +++ b/src/lib/server/auth/__tests__/booking-access-token.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { + generateBookingAccessToken, + verifyBookingAccessToken, +} from "$lib/server/auth/booking-access-token"; + +describe("booking-access-token", () => { + it("generates and verifies a valid booking access token", async () => { + 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).not.toBeNull(); + expect(payload?.tenantId).toBe("123e4567-e89b-12d3-a456-426614174000"); + expect(payload?.tunnelId).toBe("550e8400-e29b-41d4-a716-446655440000"); + expect(payload?.scope).toBe("appointments:client"); + }); + + it("rejects malformed token", async () => { + const payload = await verifyBookingAccessToken("invalid.token"); + expect(payload).toBeNull(); + }); +}); diff --git a/src/lib/server/auth/booking-access-token.ts b/src/lib/server/auth/booking-access-token.ts new file mode 100644 index 0000000..659ec89 --- /dev/null +++ b/src/lib/server/auth/booking-access-token.ts @@ -0,0 +1,72 @@ +import { SignJWT, jwtVerify } from "jose"; +import { env } from "$env/dynamic/private"; +import { UniversalLogger } from "$lib/logger"; + +const logger = new UniversalLogger().setContext("BookingAccessToken"); + +const BOOKING_ACCESS_EXPIRES = "10m"; +const BOOKING_SCOPE = "appointments:client"; + +if (!env.JWT_SECRET) { + throw new Error("Mandatory ENV variable JWT_SECRET is missing!"); +} + +const JWT_SECRET = new TextEncoder().encode(env.JWT_SECRET); + +export interface BookingAccessTokenPayload { + tenantId: string; + emailHash: string; + tunnelId: string; + scope: typeof BOOKING_SCOPE; + iat?: number; + exp?: number; +} + +export async function generateBookingAccessToken(payload: { + tenantId: string; + emailHash: string; + tunnelId: string; +}): Promise { + const now = Math.floor(Date.now() / 1000); + + 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 verifyBookingAccessToken( + token: string, +): Promise { + try { + const { payload } = await jwtVerify(token, JWT_SECRET); + + if ( + typeof payload.tenantId !== "string" || + typeof payload.emailHash !== "string" || + typeof payload.tunnelId !== "string" || + payload.scope !== BOOKING_SCOPE + ) { + logger.warn("Invalid booking access token payload"); + return null; + } + + return { + tenantId: payload.tenantId, + emailHash: payload.emailHash, + tunnelId: payload.tunnelId, + scope: BOOKING_SCOPE, + iat: payload.iat, + exp: payload.exp, + }; + } catch (error) { + logger.warn("Booking access token verification failed", { error: String(error) }); + return null; + } +} diff --git a/src/lib/types/appointment.ts b/src/lib/types/appointment.ts index b47cdf8..7ec13a4 100644 --- a/src/lib/types/appointment.ts +++ b/src/lib/types/appointment.ts @@ -58,6 +58,7 @@ export interface ChallengeVerificationResponse { valid: boolean; encryptedTunnelKey: string; // Encrypted with client public key tunnelId: string; // ID of client tunnel (to save or load appointments) + bookingAccessToken: string; // Short-lived token for booking-scoped API calls } // Existing Client - New Appointment 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 a9e6efc..4e4297b 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 @@ -1,14 +1,22 @@ import { json, type RequestHandler } from "@sveltejs/kit"; import { logger } from "$lib/logger"; import { StaffCryptoService } from "$lib/server/services/staff-crypto.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"; +import { verifyBookingAccessToken } from "$lib/server/auth/booking-access-token"; // Register OpenAPI documentation for GET registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", { summary: "Get staff public keys", description: - "Returns public encryption keys for all staff members. Used by clients to encrypt appointment data for staff access. This is a public endpoint that doesn't require authentication.", + "Returns public encryption keys for all staff members. Requires a valid short-lived booking access token from /appointments/verify-challenge.", tags: ["Appointments", "Encryption"], parameters: [ { @@ -18,6 +26,13 @@ registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "GET", { schema: { type: "string", format: "uuid" }, description: "Tenant ID", }, + { + name: "Authorization", + in: "header", + required: true, + schema: { type: "string" }, + description: "Bearer booking access token from /appointments/verify-challenge", + }, ], responses: { "200": { @@ -61,6 +76,22 @@ registerOpenAPIRoute("/tenants/{id}/appointments/staff-public-keys", "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", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, "500": { description: "Internal server error or no staff keys found", content: { @@ -76,15 +107,34 @@ 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. - * This is a public endpoint used by clients to encrypt appointment data. + * Requires booking access token from verify-challenge endpoint. */ -export const GET: RequestHandler = async ({ params }) => { +export const GET: RequestHandler = async ({ params, request }) => { try { const tenantId = params.id; if (!tenantId) { throw new ValidationError("Tenant ID is required"); } + 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.tenantId !== tenantId) { + throw new AuthorizationError("Booking access token is not valid for this tenant"); + } + logger.info("Fetching staff public keys", { tenantId }); // Get staff public keys for encryption 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 new file mode 100644 index 0000000..6e8b68d --- /dev/null +++ b/src/routes/api/tenants/[id]/appointments/staff-public-keys/__tests__/staff-public-keys-api.test.ts @@ -0,0 +1,107 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { RequestEvent } from "@sveltejs/kit"; +import { GET } from "../+server"; + +vi.mock("$lib/logger", () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); + +vi.mock("$lib/server/services/staff-crypto.service", () => ({ + StaffCryptoService: vi.fn(() => ({ + getStaffPublicKeys: vi.fn(), + })), +})); + +vi.mock("$lib/server/auth/booking-access-token", () => ({ + verifyBookingAccessToken: vi.fn(), +})); + +import { StaffCryptoService } from "$lib/server/services/staff-crypto.service"; +import { verifyBookingAccessToken } from "$lib/server/auth/booking-access-token"; + +describe("Staff Public Keys API", () => { + const tenantId = "123e4567-e89b-12d3-a456-426614174000"; + + const serviceMock = { + getStaffPublicKeys: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + (StaffCryptoService as any).mockImplementation(() => serviceMock); + }); + + function createEvent(authorization?: string): RequestEvent { + return { + params: { id: tenantId }, + request: { + headers: new Headers(authorization ? { Authorization: authorization } : {}), + } as any, + locals: { user: null } as any, + } as RequestEvent; + } + + it("returns 401 when booking access token is missing", async () => { + const response = await GET(createEvent()); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Booking access token is required"); + }); + + it("returns 401 for invalid booking access token", async () => { + (verifyBookingAccessToken as any).mockResolvedValue(null); + + const response = await GET(createEvent("Bearer invalid")); + const data = await response.json(); + + expect(response.status).toBe(401); + expect(data.error).toBe("Invalid or expired booking access token"); + }); + + it("returns 403 when token tenant does not match request tenant", async () => { + (verifyBookingAccessToken as any).mockResolvedValue({ + tenantId: "different-tenant", + emailHash: "email-hash", + tunnelId: "tunnel-id", + scope: "appointments:client", + }); + + const response = await GET(createEvent("Bearer valid-token")); + const data = await response.json(); + + expect(response.status).toBe(403); + expect(data.error).toBe("Booking access token is not valid for this tenant"); + }); + + it("returns staff public keys for valid token", async () => { + (verifyBookingAccessToken as any).mockResolvedValue({ + tenantId, + emailHash: "email-hash", + tunnelId: "tunnel-id", + scope: "appointments:client", + }); + + const staffPublicKeys = [ + { + userId: "550e8400-e29b-41d4-a716-446655440111", + publicKey: "base64-public-key", + }, + ]; + + serviceMock.getStaffPublicKeys.mockResolvedValue(staffPublicKeys); + + const response = await GET(createEvent("Bearer valid-token")); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(data.staffPublicKeys).toEqual(staffPublicKeys); + expect(serviceMock.getStaffPublicKeys).toHaveBeenCalledWith(tenantId); + }); +}); diff --git a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts index eba66e0..1fdded5 100644 --- a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts @@ -16,6 +16,7 @@ import { } from "$lib/server/utils/errors"; import { registerOpenAPIRoute } from "$lib/server/openapi"; import { challengeThrottleService } from "$lib/server/services/challenge-throttle"; +import { generateBookingAccessToken } from "$lib/server/auth/booking-access-token"; const requestSchema = z.object({ challengeId: z.string(), @@ -84,8 +85,13 @@ registerOpenAPIRoute("/tenants/{id}/appointments/verify-challenge", "POST", { description: "Client tunnel identifier for accessing appointments", example: "550e8400-e29b-41d4-a716-446655440000", }, + bookingAccessToken: { + type: "string", + description: + "Short-lived token for authenticated client booking operations (e.g. fetching staff public keys)", + }, }, - required: ["valid", "encryptedTunnelKey", "tunnelId"], + required: ["valid", "encryptedTunnelKey", "tunnelId", "bookingAccessToken"], }, }, }, @@ -198,6 +204,12 @@ export const POST: RequestHandler = async ({ request, params }) => { tunnelId: tunnel.id, }); + const bookingAccessToken = await generateBookingAccessToken({ + tenantId, + emailHash: storedChallenge.emailHash, + tunnelId: tunnel.id, + }); + // Clear throttle on successful verification await challengeThrottleService.clearThrottle(storedChallenge.emailHash, "pin"); @@ -205,6 +217,7 @@ export const POST: RequestHandler = async ({ request, params }) => { valid: true, encryptedTunnelKey: tunnel.clientEncryptedTunnelKey, tunnelId: tunnel.id, + bookingAccessToken, }; logger.info("Successfully verified challenge", { From 17d4846b8d0b4b1c652ee241d5904aca602d100f Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Wed, 11 Mar 2026 13:28:07 +0100 Subject: [PATCH 6/9] Bootstrap tokens for new clients. Use jti on client tokens. Secure my-appointments with token. --- src/lib/client/appointment-crypto.ts | 98 +- .../__tests__/booking-access-token.test.ts | 118 +- src/lib/server/auth/booking-access-token.ts | 136 ++- src/lib/server/db/tenant-schema.ts | 29 + .../services/booking-access-token-store.ts | 96 ++ .../server/services/bootstrap-challenge.ts | 35 + src/lib/types/appointment.ts | 25 + .../bootstrap-challenge/+server.ts | 149 +++ .../__tests__/bootstrap-challenge-api.test.ts | 75 ++ .../appointments/bootstrap-verify/+server.ts | 174 +++ .../__tests__/bootstrap-verify-api.test.ts | 139 +++ .../appointments/create-new-client/+server.ts | 76 +- .../__tests__/create-new-client-api.test.ts | 72 ++ .../appointments/my-appointments/+server.ts | 67 +- .../__tests__/my-appointments.test.ts | 118 +- .../appointments/staff-public-keys/+server.ts | 7 +- .../__tests__/staff-public-keys-api.test.ts | 24 + src/server-hooks/apiAuthHandle.test.ts | 79 ++ src/server-hooks/apiAuthHandle.ts | 20 + .../0013_youthful_grim_reaper.sql | 11 + tenant-migrations/meta/0013_snapshot.json | 1025 +++++++++++++++++ tenant-migrations/meta/_journal.json | 9 +- 22 files changed, 2539 insertions(+), 43 deletions(-) create mode 100644 src/lib/server/services/booking-access-token-store.ts create mode 100644 src/lib/server/services/bootstrap-challenge.ts create mode 100644 src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts create mode 100644 src/routes/api/tenants/[id]/appointments/bootstrap-challenge/__tests__/bootstrap-challenge-api.test.ts create mode 100644 src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts create mode 100644 src/routes/api/tenants/[id]/appointments/bootstrap-verify/__tests__/bootstrap-verify-api.test.ts create mode 100644 src/server-hooks/apiAuthHandle.test.ts create mode 100644 tenant-migrations/0013_youthful_grim_reaper.sql create mode 100644 tenant-migrations/meta/0013_snapshot.json 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 From bda0862bcf313baf71cfff715d7bdebf6333f4ed Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Mon, 16 Mar 2026 10:46:08 +0100 Subject: [PATCH 7/9] If client exists staff does not need to retrieve staff key shares (since tunnel is already encrypted) --- src/lib/client/appointment-crypto.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index fdce037..c46ffec 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -545,7 +545,7 @@ export class UnifiedAppointmentCrypto { emailHash: await hashEmail(params.email), clientPublicKey: params.tunnel.clientPublicKey, decryptedTunnelKey, - staffKeyShares: await this.getStaffKeyShares(params.tenantId, decryptedTunnelKey), + staffKeyShares: [], // Not needed for existing clients as tunnel key is already encrypted for staff in this flow }; }; From 37a02c84c9385ba8bc86dbbf0f35a595b99c6507 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Mon, 16 Mar 2026 11:37:33 +0100 Subject: [PATCH 8/9] Lint and check fixes --- src/lib/server/services/bootstrap-challenge.ts | 2 +- .../__tests__/bootstrap-challenge-api.test.ts | 7 ++++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/lib/server/services/bootstrap-challenge.ts b/src/lib/server/services/bootstrap-challenge.ts index 56a6aeb..ba95aeb 100644 --- a/src/lib/server/services/bootstrap-challenge.ts +++ b/src/lib/server/services/bootstrap-challenge.ts @@ -32,4 +32,4 @@ export function createBootstrapPowDigest(input: { export function matchesPowDifficulty(digest: string, difficulty: number): boolean { return digest.startsWith("0".repeat(difficulty)); -} \ No newline at end of file +} 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 index 2d758f4..78399fe 100644 --- 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 @@ -37,7 +37,11 @@ describe("Bootstrap Challenge API", () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(challengeThrottleService.checkThrottle).mockResolvedValue({ allowed: true }); + vi.mocked(challengeThrottleService.checkThrottle).mockResolvedValue({ + allowed: true, + failedAttempts: 0, + retryAfterMs: 1500, + }); }); function createEvent(body: unknown = validBody): RequestEvent { @@ -63,6 +67,7 @@ describe("Bootstrap Challenge API", () => { it("returns 429 when throttled", async () => { vi.mocked(challengeThrottleService.checkThrottle).mockResolvedValue({ allowed: false, + failedAttempts: 0, retryAfterMs: 15000, }); From 77b81dc82c7a2ec3959d9a3ff16fc260e0a59d98 Mon Sep 17 00:00:00 2001 From: Hendrik Belitz Date: Mon, 16 Mar 2026 11:37:43 +0100 Subject: [PATCH 9/9] Format fixes --- tenant-migrations/meta/0013_snapshot.json | 112 ++++++---------------- tenant-migrations/meta/_journal.json | 2 +- 2 files changed, 28 insertions(+), 86 deletions(-) diff --git a/tenant-migrations/meta/0013_snapshot.json b/tenant-migrations/meta/0013_snapshot.json index 4d555f6..82c68f6 100644 --- a/tenant-migrations/meta/0013_snapshot.json +++ b/tenant-migrations/meta/0013_snapshot.json @@ -98,12 +98,8 @@ "name": "agent_absence_agent_id_agent_id_fk", "tableFrom": "agent_absence", "tableTo": "agent", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -219,12 +215,8 @@ "name": "appointment_tunnel_id_client_appointment_tunnel_id_fk", "tableFrom": "appointment", "tableTo": "client_appointment_tunnel", - "columnsFrom": [ - "tunnel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -232,12 +224,8 @@ "name": "appointment_channel_id_channel_id_fk", "tableFrom": "appointment", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -245,12 +233,8 @@ "name": "appointment_agent_id_agent_id_fk", "tableFrom": "appointment", "tableTo": "agent", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -297,12 +281,8 @@ "name": "appointment_key_share_appointment_id_appointment_id_fk", "tableFrom": "appointment_key_share", "tableTo": "appointment", - "columnsFrom": [ - "appointment_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["appointment_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -520,12 +500,8 @@ "name": "channel_agent_channel_id_channel_id_fk", "tableFrom": "channel_agent", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -533,12 +509,8 @@ "name": "channel_agent_agent_id_agent_id_fk", "tableFrom": "channel_agent", "tableTo": "agent", - "columnsFrom": [ - "agent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -572,12 +544,8 @@ "name": "channel_slot_template_channel_id_channel_id_fk", "tableFrom": "channel_slot_template", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -585,12 +553,8 @@ "name": "channel_slot_template_slot_template_id_slotTemplate_id_fk", "tableFrom": "channel_slot_template", "tableTo": "slotTemplate", - "columnsFrom": [ - "slot_template_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["slot_template_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -624,12 +588,8 @@ "name": "channel_staff_channel_id_channel_id_fk", "tableFrom": "channel_staff", "tableTo": "channel", - "columnsFrom": [ - "channel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["channel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -697,9 +657,7 @@ "client_appointment_tunnel_email_hash_unique": { "name": "client_appointment_tunnel_email_hash_unique", "nullsNotDistinct": false, - "columns": [ - "email_hash" - ] + "columns": ["email_hash"] } }, "policies": {}, @@ -758,9 +716,7 @@ "client_pin_reset_token_token_unique": { "name": "client_pin_reset_token_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -810,12 +766,8 @@ "name": "client_tunnel_staff_key_share_tunnel_id_client_appointment_tunnel_id_fk", "tableFrom": "client_tunnel_staff_key_share", "tableTo": "client_appointment_tunnel", - "columnsFrom": [ - "tunnel_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["tunnel_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -994,22 +946,12 @@ "public.appointment_status": { "name": "appointment_status", "schema": "public", - "values": [ - "NEW", - "CONFIRMED", - "HELD", - "REJECTED", - "NO_SHOW" - ] + "values": ["NEW", "CONFIRMED", "HELD", "REJECTED", "NO_SHOW"] }, "public.notification_type": { "name": "notification_type", "schema": "public", - "values": [ - "APPOINTMENT_CONFIRMED", - "APPOINTMENT_CANCELLED", - "APPOINTMENT_REQUESTED" - ] + "values": ["APPOINTMENT_CONFIRMED", "APPOINTMENT_CANCELLED", "APPOINTMENT_REQUESTED"] } }, "schemas": {}, @@ -1022,4 +964,4 @@ "schemas": {}, "tables": {} } -} \ No newline at end of file +} diff --git a/tenant-migrations/meta/_journal.json b/tenant-migrations/meta/_journal.json index 0d0843b..312ab27 100644 --- a/tenant-migrations/meta/_journal.json +++ b/tenant-migrations/meta/_journal.json @@ -101,4 +101,4 @@ "breakpoints": true } ] -} \ No newline at end of file +}