diff --git a/packages/fxa-auth-server/config/rate-limit-rules.txt b/packages/fxa-auth-server/config/rate-limit-rules.txt index 7c47c09b42f..128e1392eda 100644 --- a/packages/fxa-auth-server/config/rate-limit-rules.txt +++ b/packages/fxa-auth-server/config/rate-limit-rules.txt @@ -157,6 +157,17 @@ mfaOtpCodeVerifyFor2fa : uid : 5 : 5 minu mfaOtpCodeVerifyForPassword : uid : 5 : 5 minutes : 15 minutes : block mfaOtpCodeVerifyForRecoveryKey : uid : 5 : 5 minutes : 15 minutes : block +# +# Passkey Authentication +# More lenient than passwords - passkeys cannot be credential-stuffed or brute-forced. +# Rate limits are primarily for DoS prevention and to ban clearly malicious IPs when high failure rate is encountered. +# +# The passkeyAuthFinishFailed rule is a clear signal of bogus requests. In this case, ban the IP, and block the UID, +# which will prevent further passkey auth attempts from that account. +# +passkeyAuthStart : ip : 100 : 15 minutes : 15 minutes : block +passkeyAuthFinish : ip : 100 : 15 minutes : 15 minutes : block +passkeyAuthFinishFailed : ip : 1000 : 24 hours : 15 minutes : ban # # Passkey Registration # MFA-protected endpoints for registering new passkeys. Moderate limits since these require existing auth. @@ -164,15 +175,6 @@ mfaOtpCodeVerifyForRecoveryKey : uid : 5 : 5 minu passkeyRegisterStart : ip_uid : 10 : 10 minutes : 10 minutes : block passkeyRegisterFinish : ip_uid : 10 : 10 minutes : 10 minutes : block -# -# Passkey Authentication -# More lenient than passwords - passkeys cannot be credential-stuffed or brute-forced. -# Rate limits are primarily for DoS prevention. -# -passkeyAuthenticationStart : ip_uid : 15 : 15 minutes : 15 minutes : block -passkeyAuthenticationVerify : ip_uid : 15 : 15 minutes : 15 minutes : block -passkeyLogin : ip_uid : 15 : 15 minutes : 15 minutes : block - # # Passkey Management # Relaxed limits for normal usage. All endpoints require verifiedSessionToken or MFA JWT. diff --git a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts index 33dec5d671b..bc2afdab0c3 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.spec.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.spec.ts @@ -1176,6 +1176,76 @@ describe('passkeys routes', () => { expect.anything() ); }); + + it('calls checkIpOnly with passkeyAuthFinishFailed when verification fails', async () => { + mockPasskeyService.verifyAuthenticationResponse = jest + .fn() + .mockRejectedValue(AppError.passkeyAuthenticationFailed()); + + await expect(() => + runTest('/passkey/authentication/finish', { + auth: { credentials: {} }, + app: { ua: {} }, + payload, + }) + ).rejects.toThrow(); + + expect(customs.checkIpOnly).toHaveBeenCalledWith( + expect.anything(), + 'passkeyAuthFinishFailed' + ); + }); + + it('rethrows the original verification error after recording the failure signal', async () => { + const verificationError = AppError.passkeyAuthenticationFailed(); + mockPasskeyService.verifyAuthenticationResponse = jest + .fn() + .mockRejectedValue(verificationError); + + await expect( + runTest('/passkey/authentication/finish', { + auth: { credentials: {} }, + app: { ua: {} }, + payload, + }) + ).rejects.toBe(verificationError); + }); + + it('does not call checkIpOnly with passkeyAuthFinishFailed on successful authentication', async () => { + await runTest('/passkey/authentication/finish', { + auth: { credentials: {} }, + app: { ua: {} }, + payload, + }); + + const allCalls = (customs.checkIpOnly as jest.Mock).mock.calls.map( + (args: string[]) => args[1] + ); + expect(allCalls).not.toContain('passkeyAuthFinishFailed'); + }); + + // Documents current behavior: if customs throws during the passkeyAuthFinishFailed + // signal, the original verification error is masked by the customs error. + // Consider wrapping in a try/catch (like the email-send pattern) to preserve + // the original error for the caller. + it('propagates a customs failure from passkeyAuthFinishFailed instead of the original verification error', async () => { + mockPasskeyService.verifyAuthenticationResponse = jest + .fn() + .mockRejectedValue(AppError.passkeyAuthenticationFailed()); + const customsError = new Error('customs service unavailable'); + customs.checkIpOnly = jest + .fn() + .mockResolvedValueOnce(undefined) // passkeyAuthFinish — succeeds + .mockRejectedValueOnce(customsError); // passkeyAuthFinishFailed — fails + + await expect( + runTest('/passkey/authentication/finish', { + auth: { credentials: {} }, + app: { ua: {} }, + payload, + }) + ).rejects.toBe(customsError); + }); }); describe('PasskeyHandler.createPasskeySessionToken', () => { diff --git a/packages/fxa-auth-server/lib/routes/passkeys.ts b/packages/fxa-auth-server/lib/routes/passkeys.ts index 78af26d912a..bb509c6d449 100644 --- a/packages/fxa-auth-server/lib/routes/passkeys.ts +++ b/packages/fxa-auth-server/lib/routes/passkeys.ts @@ -405,6 +405,8 @@ export class PasskeyHandler { db: this.db, request, }); + // Add a failure signal. This can be useful to ban clearly bad actors. + await this.customs.checkIpOnly(request, 'passkeyAuthFinishFailed'); throw err; }