Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions packages/fxa-auth-server/config/rate-limit-rules.txt
Original file line number Diff line number Diff line change
Expand Up @@ -157,22 +157,24 @@ 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
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here.

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.
#
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.
Expand Down
70 changes: 70 additions & 0 deletions packages/fxa-auth-server/lib/routes/passkeys.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
2 changes: 2 additions & 0 deletions packages/fxa-auth-server/lib/routes/passkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down