mirror of
https://github.com/open-reception/appointment-booking-software.git
synced 2026-08-17 21:25:52 +02:00
Bootstrap tokens for new clients. Use jti on client tokens.
Secure my-appointments with token.
This commit is contained in:
@@ -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<StaffPublicKey[]> {
|
||||
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<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(
|
||||
|
||||
@@ -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<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",
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<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> {
|
||||
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<string> {
|
||||
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<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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
+75
@@ -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.");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
+139
@@ -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");
|
||||
@@ -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);
|
||||
|
||||
+72
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
+109
-9
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+24
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user