Secure staff public keys (#231)

* Do not apply shortname for system tenant generateBaseUrl
Do not remove domain name parts if hostname already is a subdomain

* Use domain instead of shortName for tenant domain

* Fixed tests

* Obfuscate error output

* Create access tokens for clients from the challenge.

* Bootstrap tokens for new clients. Use jti on client tokens.
Secure my-appointments with token.

* If client exists staff does not need to retrieve staff key shares (since tunnel is already encrypted)

* Lint and check fixes

* Format fixes

---------

Co-authored-by: Karl Ludwig Weise <ludwig@ludwigweise.de>
This commit is contained in:
Karl Ludwig Weise
2026-04-07 18:34:17 +02:00
committed by GitHub
co-authored by Karl Ludwig Weise
23 changed files with 2749 additions and 24 deletions
+102 -6
View File
@@ -47,6 +47,7 @@
* ```
*/
import type { BootstrapChallengeResponse, BootstrapVerifyResponse } from "$lib/types/appointment";
import { OptimizedArgon2 } from "$lib/crypto/hashing";
import { AESCrypto, BufferUtils, KyberCrypto, ShamirSecretSharing } from "$lib/crypto/utils";
import type { ClientTunnelResponse } from "$lib/server/services/appointment-service";
@@ -138,6 +139,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;
@@ -197,14 +199,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();
@@ -400,6 +405,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;
@@ -473,7 +479,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),
});
@@ -534,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
};
};
@@ -643,12 +654,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}`,
},
});
@@ -901,6 +917,7 @@ export class UnifiedAppointmentCrypto {
this.emailHash = null;
this.tunnelId = null;
this.serverPrivateKeyShare = null;
this.bookingAccessToken = null;
this.clientAuthenticated = false;
this.pin = null;
}
@@ -1204,9 +1221,16 @@ export class UnifiedAppointmentCrypto {
}
async fetchStaffPublicKeys(tenantId: string): Promise<StaffPublicKey[]> {
if (!this.bookingAccessToken) {
throw new Error("Missing booking access token. Please authenticate or complete bootstrap.");
}
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) {
@@ -1217,6 +1241,78 @@ export class UnifiedAppointmentCrypto {
return data.staffPublicKeys;
}
private async bootstrapNewClientAccess(tenantId: string): Promise<void> {
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<number> {
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(
@@ -0,0 +1,143 @@
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<string> {
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",
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");
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();
});
});
+172
View File
@@ -0,0 +1,172 @@
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_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!");
}
const JWT_SECRET = new TextEncoder().encode(env.JWT_SECRET);
export interface BookingAccessTokenPayload {
tenantId: string;
emailHash?: string;
tunnelId: string;
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<string> {
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<string> {
return await signBookingAccessToken({
...payload,
scope: EXISTING_CLIENT_BOOKING_SCOPE,
});
}
export async function generateNewClientBootstrapToken(payload: {
tenantId: string;
tunnelId: string;
clientPublicKey: string;
emailHash?: string;
}): Promise<string> {
return await signBookingAccessToken({
...payload,
scope: NEW_CLIENT_BOOTSTRAP_SCOPE,
});
}
export async function verifyBookingAccessToken(
token: string,
): Promise<BookingAccessTokenPayload | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET);
if (
typeof payload.tenantId !== "string" ||
typeof payload.tunnelId !== "string" ||
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 as string | undefined,
tunnelId: payload.tunnelId,
clientPublicKey: payload.clientPublicKey as string | undefined,
scope: payload.scope,
jti: payload.jti,
iat: payload.iat,
exp: payload.exp,
};
} catch (error) {
logger.warn("Booking access token verification failed", { error: String(error) });
return null;
}
}
export async function consumeBookingAccessToken(payload: BookingAccessTokenPayload): Promise<void> {
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,
});
}
}
+29
View File
@@ -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<typeof clientTunn
/** ClientPinResetToken record type for database queries */
export type SelectClientPinResetToken = InferSelectModel<typeof clientPinResetToken>;
/** BookingAccessToken record type for database queries */
export type SelectBookingAccessToken = InferSelectModel<typeof bookingAccessToken>;
@@ -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<void> {
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<boolean> {
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<boolean> {
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<void> {
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();
@@ -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));
}
+26
View File
@@ -58,6 +58,32 @@ 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
}
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
@@ -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();
}
};
@@ -0,0 +1,80 @@
/* 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,
failedAttempts: 0,
retryAfterMs: 1500,
});
});
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,
failedAttempts: 0,
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.");
});
});
@@ -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();
}
};
@@ -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<number> {
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();
});
});
@@ -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<typeof requestSchema>;
async function requireBootstrapBookingAccessToken(
request: Request,
tenantId: string,
): Promise<NonNullable<Awaited<ReturnType<typeof verifyBookingAccessToken>>>> {
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<Awaited<ReturnType<typeof verifyBookingAccessToken>>>,
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");
@@ -241,6 +313,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);
@@ -20,6 +20,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";
@@ -50,6 +56,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();
});
@@ -62,6 +76,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: {
@@ -78,6 +93,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 = {
@@ -89,6 +107,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);
@@ -97,6 +116,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.debug).toHaveBeenCalledWith("Creating new client appointment tunnel", {
tenantId: mockTenantId,
tunnelId: mockTunnelId,
@@ -105,6 +125,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",
@@ -115,6 +136,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);
@@ -126,12 +148,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();
@@ -141,6 +181,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
});
@@ -153,10 +196,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;
@@ -172,6 +219,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 = {
@@ -184,6 +232,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);
@@ -201,11 +250,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);
@@ -217,6 +268,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
@@ -224,6 +276,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);
@@ -238,6 +291,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({
@@ -247,6 +301,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);
@@ -259,11 +314,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);
@@ -271,5 +328,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");
});
});
});
@@ -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),
@@ -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");
});
});
@@ -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 or /appointments/bootstrap-verify.",
tags: ["Appointments", "Encryption"],
parameters: [
{
@@ -18,6 +26,14 @@ 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 or /appointments/bootstrap-verify",
},
],
responses: {
"200": {
@@ -61,6 +77,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 +108,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 or bootstrap-verify 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
@@ -0,0 +1,131 @@
/* 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);
});
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);
});
});
@@ -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"],
},
},
},
@@ -196,6 +202,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");
@@ -203,6 +215,7 @@ export const POST: RequestHandler = async ({ request, params }) => {
valid: true,
encryptedTunnelKey: tunnel.clientEncryptedTunnelKey,
tunnelId: tunnel.id,
bookingAccessToken,
};
logger.debug("Successfully verified challenge", {
+79
View File
@@ -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();
});
});
+20
View File
@@ -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;
@@ -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
);
+967
View File
@@ -0,0 +1,967 @@
{
"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": {}
}
}
+7
View File
@@ -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
}
]
}