Skip to content
Draft
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
9 changes: 9 additions & 0 deletions libs/accounts/errors/src/app-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1783,6 +1783,15 @@ export class AppError extends Error {
});
}

static passkeyUserVerificationRequired() {
return new AppError({
code: 422,
error: 'Unprocessable Entity',
errno: ERRNO.PASSKEY_USER_VERIFICATION_REQUIRED,
message: 'Passkey requires user verification (PIN or biometric)',
});
}

static passkeyChallengeExpired() {
return new AppError({
code: 401,
Expand Down
1 change: 1 addition & 0 deletions libs/accounts/errors/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export const ERRNO = {
PASSKEY_INVALID_NAME: 230,
PASSKEY_DELETE_FAILED: 231,
PASSKEY_RENAME_FAILED: 232,
PASSKEY_USER_VERIFICATION_REQUIRED: 233,
// Jump to 238 to leave room for future passkey errors
PASSKEY_CHALLENGE_EXPIRED: 238,
ACCOUNT_DELETION_FAILED: 239,
Expand Down
16 changes: 16 additions & 0 deletions libs/accounts/errors/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,22 @@ describe('AppErrors', () => {
});
});

it('creates passkeyUserVerificationRequired', () => {
const result = AppError.passkeyUserVerificationRequired();
expect(result).toBeInstanceOf(AppError);
expect(result).toMatchObject({
errno: 233,
message: 'Passkey requires user verification (PIN or biometric)',
output: {
statusCode: 422,
payload: {
error: 'Unprocessable Entity',
errno: 233,
},
},
});
});

it('creates passkeyChallengeExpired', () => {
const result = AppError.passkeyChallengeExpired();
expect(result).toBeInstanceOf(AppError);
Expand Down
106 changes: 106 additions & 0 deletions libs/accounts/passkey/src/lib/passkey.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,28 @@ import { PasskeyService } from './passkey.service';
import { PasskeyManager } from './passkey.manager';
import { PasskeyChallengeManager } from './passkey.challenge.manager';
import { AppError } from '@fxa/accounts/errors';
import * as Sentry from '@sentry/nestjs';

// Keep the real UserVerificationRequiredError class (used for instanceof checks
// in the service) while stubbing the adapter's ceremony functions.
jest.mock('./webauthn-adapter', () => ({
...jest.requireActual('./webauthn-adapter'),
generateWebauthnRegistrationOptions: jest.fn(),
verifyWebauthnRegistrationResponse: jest.fn(),
generateWebauthnAuthenticationOptions: jest.fn(),
verifyWebauthnAuthenticationResponse: jest.fn(),
}));

// Stub only captureException so we can assert what does/doesn't reach Sentry.
jest.mock('@sentry/nestjs', () => ({
...jest.requireActual('@sentry/nestjs'),
captureException: jest.fn(),
}));

import * as webauthnAdapter from './webauthn-adapter';
import { UserVerificationRequiredError } from './webauthn-adapter';

const mockCaptureException = Sentry.captureException as jest.Mock;

const mockGenerateWebauthnRegistrationOptions =
webauthnAdapter.generateWebauthnRegistrationOptions as jest.Mock;
Expand Down Expand Up @@ -317,6 +330,84 @@ describe('PasskeyService', () => {
});
});

it('captures an unexpected verification error in Sentry', async () => {
const err = new Error('Invalid attestation format');
mockVerifyWebauthnRegistrationResponse.mockRejectedValue(err);
await expect(
service.createPasskeyFromRegistrationResponse(
MOCK_UID,
mockResponse,
MOCK_CHALLENGE
)
).rejects.toThrow();
expect(mockCaptureException).toHaveBeenCalledWith(err);
});

it('increments a verificationError metric on an unexpected error', async () => {
mockVerifyWebauthnRegistrationResponse.mockRejectedValue(
new Error('Invalid attestation format')
);
await expect(
service.createPasskeyFromRegistrationResponse(
MOCK_UID,
mockResponse,
MOCK_CHALLENGE
)
).rejects.toThrow();
expect(mockMetrics.increment).toHaveBeenCalledWith(
'passkey.registration.failed',
{ reason: 'verificationError' }
);
});

it('throws passkeyUserVerificationRequired AppError when the authenticator does not verify the user', async () => {
mockVerifyWebauthnRegistrationResponse.mockRejectedValue(
new UserVerificationRequiredError()
);
await expect(
service.createPasskeyFromRegistrationResponse(
MOCK_UID,
mockResponse,
MOCK_CHALLENGE
)
).rejects.toMatchObject({
errno: 233,
message: 'Passkey requires user verification (PIN or biometric)',
code: 422,
});
});

it('does not capture a user-verification failure in Sentry', async () => {
mockVerifyWebauthnRegistrationResponse.mockRejectedValue(
new UserVerificationRequiredError()
);
await expect(
service.createPasskeyFromRegistrationResponse(
MOCK_UID,
mockResponse,
MOCK_CHALLENGE
)
).rejects.toThrow();
expect(mockCaptureException).not.toHaveBeenCalled();
});

it('increments a userVerificationFailed metric on a user-verification failure', async () => {
mockVerifyWebauthnRegistrationResponse.mockRejectedValue(
new UserVerificationRequiredError()
);
await expect(
service.createPasskeyFromRegistrationResponse(
MOCK_UID,
mockResponse,
MOCK_CHALLENGE
)
).rejects.toThrow();
expect(mockMetrics.increment).toHaveBeenCalledWith(
'passkey.registration.failed',
{ reason: 'userVerificationFailed' }
);
});

it('calls challengeManager.consumeRegistrationChallenge with challenge and uid', async () => {
await service.createPasskeyFromRegistrationResponse(
MOCK_UID,
Expand Down Expand Up @@ -760,6 +851,21 @@ describe('PasskeyService', () => {
);
});

it('increments a userVerificationFailed metric when the authenticator does not verify the user', async () => {
(
webauthnAdapter.verifyWebauthnAuthenticationResponse as jest.Mock
).mockRejectedValue(new UserVerificationRequiredError());

await expect(
service.verifyAuthenticationResponse(mockResponse, MOCK_CHALLENGE)
).rejects.toMatchObject({ errno: 227 });

expect(mockMetrics.increment).toHaveBeenCalledWith(
'passkey.authentication.failed',
{ reason: 'userVerificationFailed' }
);
});

it('throws a passkeyAuthenticationFailed AppError and logs when updatePasskeyAfterAuth returns false', async () => {
mockManager.updatePasskeyAfterAuth.mockResolvedValue(false);

Expand Down
24 changes: 21 additions & 3 deletions libs/accounts/passkey/src/lib/passkey.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
AuthenticationVerificationResult,
generateWebauthnAuthenticationOptions,
verifyWebauthnAuthenticationResponse,
UserVerificationRequiredError,
} from './webauthn-adapter';
import { AppError } from '@fxa/accounts/errors';

Expand Down Expand Up @@ -172,6 +173,14 @@ export class PasskeyService {
challenge: storedChallenge.challenge,
});
} catch (err: unknown) {
if (err instanceof UserVerificationRequiredError) {
// Expected, user-actionable outcome (e.g. a PIN-less roaming security
// key). Not a server fault — surface a specific 4xx and skip Sentry.
this.metrics.increment('passkey.registration.failed', {
reason: 'userVerificationFailed',
});
throw AppError.passkeyUserVerificationRequired();
}
Sentry.captureException(err);
this.metrics.increment('passkey.registration.failed', {
reason: 'verificationError',
Expand Down Expand Up @@ -249,7 +258,7 @@ export class PasskeyService {
credentialId,
trimmed
);
} catch (err) {
} catch (err: unknown) {
Sentry.captureException(err);
this.metrics.increment('passkey.rename.failed', {
reason: 'dbError',
Expand Down Expand Up @@ -290,7 +299,7 @@ export class PasskeyService {
let deleted = false;
try {
deleted = await this.passkeyManager.deletePasskey(uid, credentialId);
} catch (err) {
} catch (err: unknown) {
Sentry.captureException(err);
this.metrics.increment('passkey.delete.failed', {
reason: 'dbError',
Expand Down Expand Up @@ -463,7 +472,16 @@ export class PasskeyService {
publicKey: passkey.publicKey,
signCount: passkey.signCount,
});
} catch (err) {
} catch (err: unknown) {
if (err instanceof UserVerificationRequiredError) {
// Authenticator did not perform UV. Keep the generic 401 (no distinct
// errno — avoids leaking credential-capability details on sign-in), but
// tag the metric so this cause is separable from other failures.
this.metrics.increment('passkey.authentication.failed', {
reason: 'userVerificationFailed',
});
throw AppError.passkeyAuthenticationFailed();
}
// simplewebauthn throws when signCount decrements
if (err instanceof Error && /^Response counter value/.test(err.message)) {
this.log?.warn('passkey.signCount.rollback', {
Expand Down
36 changes: 32 additions & 4 deletions libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
generateWebauthnAuthenticationOptions,
verifyWebauthnAuthenticationResponse,
extractPrfEnabled,
UserVerificationRequiredError,
} from './webauthn-adapter';
import { PasskeyConfig } from './passkey.config';
import { VirtualAuthenticator } from './virtual-authenticator';
Expand Down Expand Up @@ -366,7 +367,7 @@ describe('verifyWebauthnRegistrationResponse', () => {
).rejects.toThrow();
});

it('rejects an attestation without user verification', async () => {
it('throws UserVerificationRequiredError for an attestation without user verification', async () => {
const cred = VirtualAuthenticator.createCredential();
const challenge = randomBytes(32).toString('base64url');

Expand All @@ -387,7 +388,34 @@ describe('verifyWebauthnRegistrationResponse', () => {
response: attestation,
challenge,
})
).rejects.toThrow(/user could not be verified/i);
).rejects.toBeInstanceOf(UserVerificationRequiredError);
});

it('does not classify a non-UV failure (wrong rpId) as UserVerificationRequiredError', async () => {
const cred = VirtualAuthenticator.createCredential();
const challenge = randomBytes(32).toString('base64url');

const options = await generateWebauthnRegistrationOptions(config, {
uid: Buffer.alloc(16, 0xaa).toString('hex'),
email: 'test@example.com',
challenge,
});

const attestation = VirtualAuthenticator.createAttestationResponse(cred, {
challenge: options.challenge,
origin: TEST_ORIGIN,
rpId: 'evil.example.com',
});

const attempt = verifyWebauthnRegistrationResponse(config, {
response: attestation,
challenge,
});
// Still rejects (for the rpId mismatch) — just not classified as a UV failure.
await expect(attempt).rejects.toThrow();
await expect(attempt).rejects.not.toBeInstanceOf(
UserVerificationRequiredError
);
});
});

Expand Down Expand Up @@ -642,7 +670,7 @@ describe('verifyWebauthnAuthenticationResponse', () => {
).rejects.toThrow();
});

it('rejects an assertion without user verification', async () => {
it('throws UserVerificationRequiredError for an assertion without user verification', async () => {
const { cred, stored } = await registerCredential(config);
const challenge = randomBytes(32).toString('base64url');

Expand All @@ -665,6 +693,6 @@ describe('verifyWebauthnAuthenticationResponse', () => {
publicKey: stored.publicKey,
signCount: stored.signCount,
})
).rejects.toThrow(/user could not be verified/i);
).rejects.toBeInstanceOf(UserVerificationRequiredError);
});
});
Loading
Loading