diff --git a/src/lib/client/appointment-crypto.ts b/src/lib/client/appointment-crypto.ts index 92d20e2..fdb524f 100644 --- a/src/lib/client/appointment-crypto.ts +++ b/src/lib/client/appointment-crypto.ts @@ -205,10 +205,14 @@ export class UnifiedAppointmentCrypto { if (!challengeResponse.ok) { if (challengeResponse.status === 429) { const errorData = await challengeResponse.json(); - const retryAfterSeconds = Math.ceil((errorData.retryAfterMs || 0) / 1000); - throw new Error( - `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, - ); + const retryAfterSeconds = Math.ceil((errorData.retryAfterMs || 60000) / 1000); + if (retryAfterSeconds > 0) { + throw new Error( + `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, + ); + } else { + throw new Error("Too many failed attempts. Please try again later."); + } } throw new Error("Challenge could not be retrieved"); } @@ -240,11 +244,15 @@ export class UnifiedAppointmentCrypto { if (!verificationResponse.ok) { const errorData = await verificationResponse.json(); console.error("❌ Challenge verification failed:", errorData); - if (verificationResponse.status === 429 && errorData.retryAfterMs) { - const retryAfterSeconds = Math.ceil(errorData.retryAfterMs / 1000); - throw new Error( - `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, - ); + if (verificationResponse.status === 429) { + const retryAfterSeconds = Math.ceil((errorData.retryAfterMs || 60000) / 1000); + if (retryAfterSeconds > 0) { + throw new Error( + `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, + ); + } else { + throw new Error("Too many failed attempts. Please try again later."); + } } throw new Error("Challenge verification failed"); } diff --git a/src/lib/server/services/challenge-throttle.ts b/src/lib/server/services/challenge-throttle.ts index 3ad3e8e..c1fdd88 100644 --- a/src/lib/server/services/challenge-throttle.ts +++ b/src/lib/server/services/challenge-throttle.ts @@ -20,11 +20,17 @@ interface ThrottleResult { failedAttempts: number; } +// Throttle reset duration: 1 hour +const THROTTLE_RESET_DURATION_MS = 60 * 60 * 1000; + /** - * Calculate exponential backoff delay for PIN challenges - * First 2 retries: wait for a few seconds - * Third retry: wait for one minute - * Scale up to thirty minutes + * Calculate escalating delay for PIN challenges + * Uses a fixed escalation pattern that increases with each failed attempt: + * - 1st failure: 2 seconds + * - 2nd failure: 10 seconds + * - 3rd failure: 1 minute + * - 4th failure: 5 minutes + * - 5+ failures: 30 minutes */ function calculatePinThrottleDelay(failedAttempts: number): number { if (failedAttempts === 0) return 0; @@ -184,7 +190,7 @@ class ChallengeThrottleService { if (records.length === 0) { // Create new throttle record - const resetAt = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now + const resetAt = new Date(now.getTime() + THROTTLE_RESET_DURATION_MS); await db.insert(challengeThrottle).values({ id: identifier, failedAttempts: 1, @@ -219,7 +225,7 @@ class ChallengeThrottleService { if (records.length === 0) { // Create new throttle record - const resetAt = new Date(now.getTime() + 60 * 60 * 1000); // 1 hour from now + const resetAt = new Date(now.getTime() + THROTTLE_RESET_DURATION_MS); await db.insert(passkeyChallengeThrottle).values({ id: identifier, failedAttempts: 1, diff --git a/src/lib/utils/passkey.ts b/src/lib/utils/passkey.ts index 242fdc0..9b00faf 100644 --- a/src/lib/utils/passkey.ts +++ b/src/lib/utils/passkey.ts @@ -49,34 +49,39 @@ export const fetchChallenge = async (email: string) => { body: JSON.stringify({ email }), }); + let data; try { - const data = await resp.json(); + data = await resp.json(); + } catch (error) { + logger.error("Failed to parse challenge response", { email, error }); + return null; + } - // Handle throttling - if (resp.status === 429) { - const retryAfterSeconds = Math.ceil((data.retryAfterMs || 60000) / 1000); - logger.error("Challenge request throttled", { - email, - retryAfterSeconds, - }); + // Handle throttling + if (resp.status === 429) { + const retryAfterSeconds = Math.ceil((data.retryAfterMs || 60000) / 1000); + logger.error("Challenge request throttled", { + email, + retryAfterSeconds, + }); + if (retryAfterSeconds > 0) { throw new Error( `Too many failed attempts. Please try again in ${retryAfterSeconds} seconds.`, ); + } else { + throw new Error("Too many failed attempts. Please try again later."); } - - if (!resp.ok) { - logger.error("Failed to fetch challenge", { email, status: resp.status }); - return null; - } - - return { - id: data.rpId, - challenge: data.challenge, - }; - } catch (error) { - logger.error("Failed to fetch challenge", { email, status: resp.status, error }); - throw error; } + + if (!resp.ok) { + logger.error("Failed to fetch challenge", { email, status: resp.status }); + return null; + } + + return { + id: data.rpId, + challenge: data.challenge, + }; }; export const getCredentialOptions = ({