diff --git a/src/lib/server/db/central-schema.ts b/src/lib/server/db/central-schema.ts index c7cf930..6361698 100644 --- a/src/lib/server/db/central-schema.ts +++ b/src/lib/server/db/central-schema.ts @@ -242,6 +242,8 @@ export const userInvite = pgTable( export const challengeThrottle = pgTable("challenge_throttle", { /** Primary key - identifier (email hash for PIN challenges, email for passkey challenges) */ id: text("id").primaryKey(), + /** Tenant ID for scoping throttles to single tenants to restrict global lock-out. Might be null for global throttles on administrative accounts */ + tenantId: uuid("tenant_id").references(() => tenant.id, { onDelete: "cascade" }), /** Number of failed attempts */ failedAttempts: integer("failed_attempts").default(0).notNull(), /** When the throttle was last updated */ diff --git a/src/lib/server/services/__tests__/appointment-service.test.ts b/src/lib/server/services/__tests__/appointment-service.test.ts index afc1027..12c4fa5 100644 --- a/src/lib/server/services/__tests__/appointment-service.test.ts +++ b/src/lib/server/services/__tests__/appointment-service.test.ts @@ -853,7 +853,11 @@ describe("AppointmentService", () => { ); expect(challengeStore.consume).toHaveBeenCalledWith("challenge-123", "tenant-123"); - expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith("email-hash-123", "pin"); + expect(challengeThrottleService.clearThrottle).toHaveBeenCalledWith( + "email-hash-123", + "pin", + "tenant-123", + ); }); it("should throw NotFoundError when challenge is not found", async () => { @@ -922,6 +926,7 @@ describe("AppointmentService", () => { expect(challengeThrottleService.recordFailedAttempt).toHaveBeenCalledWith( "email-hash-123", "pin", + "tenant-123", ); }); diff --git a/src/lib/server/services/appointment-service.ts b/src/lib/server/services/appointment-service.ts index e0be8a6..a80ffd9 100644 --- a/src/lib/server/services/appointment-service.ts +++ b/src/lib/server/services/appointment-service.ts @@ -1001,13 +1001,13 @@ export class AppointmentService { }); // Record failed attempt for throttling - await challengeThrottleService.recordFailedAttempt(emailHash, "pin"); + await challengeThrottleService.recordFailedAttempt(emailHash, "pin", this.tenantId); throw new ValidationError("Invalid challenge response"); } // Clear throttle on successful verification - await challengeThrottleService.clearThrottle(emailHash, "pin"); + await challengeThrottleService.clearThrottle(emailHash, "pin", this.tenantId); const db = await this.getDb(); diff --git a/src/lib/server/services/challenge-throttle.ts b/src/lib/server/services/challenge-throttle.ts index 5c36b69..130bd7f 100644 --- a/src/lib/server/services/challenge-throttle.ts +++ b/src/lib/server/services/challenge-throttle.ts @@ -9,7 +9,7 @@ import { centralDb } from "$lib/server/db"; import { challengeThrottle } from "$lib/server/db/central-schema"; -import { eq, lt, sql } from "drizzle-orm"; +import { eq, lt, sql, and } from "drizzle-orm"; import { logger } from "$lib/logger"; export type ThrottleType = "pin" | "passkey" | "passphrase"; @@ -51,15 +51,25 @@ class ChallengeThrottleService { * Check if a challenge request should be throttled * @param identifier - Email hash for PIN challenges, email for passkey challenges * @param type - Type of challenge (pin or passkey) + * @param tenantId - Tenant ID for scoping the throttle check */ - async checkThrottle(identifier: string, type: ThrottleType): Promise { + async checkThrottle( + identifier: string, + type: ThrottleType, + tenantId?: string, + ): Promise { const now = new Date(); // Get throttle record from central DB const records = await centralDb .select() .from(challengeThrottle) - .where(eq(challengeThrottle.id, identifier)) + .where( + and( + eq(challengeThrottle.id, identifier), + tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined, + ), + ) .limit(1); if (records.length === 0) { @@ -72,7 +82,14 @@ class ChallengeThrottleService { // Check if throttle has expired if (now > record.resetAt) { // Throttle expired, clean up and allow - await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier)); + await centralDb + .delete(challengeThrottle) + .where( + and( + eq(challengeThrottle.id, identifier), + tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined, + ), + ); return { allowed: true, retryAfterMs: 0, failedAttempts: 0 }; } @@ -104,8 +121,13 @@ class ChallengeThrottleService { * Record a failed challenge attempt * @param identifier - Email hash for PIN challenges, email for passkey challenges * @param type - Type of challenge (pin or passkey) + * @param tenantId - Tenant ID for scoping the throttle record */ - async recordFailedAttempt(identifier: string, type: ThrottleType): Promise { + async recordFailedAttempt( + identifier: string, + type: ThrottleType, + tenantId?: string, + ): Promise { const now = new Date(); const resetAt = new Date(now.getTime() + THROTTLE_RESET_DURATION_MS); @@ -116,6 +138,7 @@ class ChallengeThrottleService { failedAttempts: 1, lastAttemptAt: now, resetAt, + tenantId: tenantId, }) .onConflictDoUpdate({ target: challengeThrottle.id, @@ -135,9 +158,17 @@ class ChallengeThrottleService { * Clear throttle for successful authentication * @param identifier - Email hash for PIN challenges, email for passkey challenges * @param type - Type of challenge (pin or passkey) + * @param tenantId - Tenant ID for scoping the throttle clearance */ - async clearThrottle(identifier: string, type: ThrottleType): Promise { - await centralDb.delete(challengeThrottle).where(eq(challengeThrottle.id, identifier)); + async clearThrottle(identifier: string, type: ThrottleType, tenantId?: string): Promise { + await centralDb + .delete(challengeThrottle) + .where( + and( + eq(challengeThrottle.id, identifier), + tenantId ? eq(challengeThrottle.tenantId, tenantId) : undefined, + ), + ); logger.debug(`Cleared ${type} challenge throttle`, { identifier: identifier.slice(0, 8), diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts index 898dc60..7891ce9 100644 --- a/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-challenge/+server.ts @@ -107,7 +107,11 @@ export const POST: RequestHandler = async ({ request, params }) => { emailHash: body.emailHash, }); - const throttleResult = await challengeThrottleService.checkThrottle(binding, "passkey"); + const throttleResult = await challengeThrottleService.checkThrottle( + binding, + "passkey", + tenantId, + ); if (!throttleResult.allowed) { return json( { diff --git a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts index 1c237f1..6e5285b 100644 --- a/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/bootstrap-verify/+server.ts @@ -124,7 +124,7 @@ export const POST: RequestHandler = async ({ request, params }) => { } if (storedChallenge.emailHash !== binding) { - await challengeThrottleService.recordFailedAttempt(binding, "passkey"); + await challengeThrottleService.recordFailedAttempt(binding, "passkey", tenantId); throw new ValidationError("Invalid bootstrap challenge binding"); } @@ -136,11 +136,11 @@ export const POST: RequestHandler = async ({ request, params }) => { }); if (!matchesPowDifficulty(digest, NEW_CLIENT_BOOTSTRAP_DIFFICULTY)) { - await challengeThrottleService.recordFailedAttempt(binding, "passkey"); + await challengeThrottleService.recordFailedAttempt(binding, "passkey", tenantId); throw new ValidationError("Invalid bootstrap proof of work"); } - await challengeThrottleService.clearThrottle(binding, "passkey"); + await challengeThrottleService.clearThrottle(binding, "passkey", tenantId); const bookingAccessToken = await generateNewClientBootstrapToken({ tenantId, diff --git a/src/routes/api/tenants/[id]/appointments/challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/challenge/+server.ts index 7ec1eab..134b196 100644 --- a/src/routes/api/tenants/[id]/appointments/challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/challenge/+server.ts @@ -139,7 +139,7 @@ export const POST: RequestHandler = async ({ request, params }) => { const { emailHash } = requestSchema.parse(body); // Check throttling - const throttleResult = await challengeThrottleService.checkThrottle(emailHash, "pin"); + const throttleResult = await challengeThrottleService.checkThrottle(emailHash, "pin", tenantId); if (!throttleResult.allowed) { logger.warn("PIN challenge throttled", { diff --git a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts index 9e42657..e141130 100644 --- a/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts +++ b/src/routes/api/tenants/[id]/appointments/verify-challenge/+server.ts @@ -169,7 +169,11 @@ export const POST: RequestHandler = async ({ request, params }) => { }); // Record failed attempt for throttling - await challengeThrottleService.recordFailedAttempt(storedChallenge.emailHash, "pin"); + await challengeThrottleService.recordFailedAttempt( + storedChallenge.emailHash, + "pin", + tenantId, + ); throw new ValidationError("Invalid challenge response"); } @@ -209,7 +213,7 @@ export const POST: RequestHandler = async ({ request, params }) => { }); // Clear throttle on successful verification - await challengeThrottleService.clearThrottle(storedChallenge.emailHash, "pin"); + await challengeThrottleService.clearThrottle(storedChallenge.emailHash, "pin", tenantId); const response: ChallengeVerificationResponse = { valid: true,