diff --git a/libs/accounts/errors/src/app-error.ts b/libs/accounts/errors/src/app-error.ts
index 5477bff3b79..eaf2b1800ea 100644
--- a/libs/accounts/errors/src/app-error.ts
+++ b/libs/accounts/errors/src/app-error.ts
@@ -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,
diff --git a/libs/accounts/errors/src/constants.ts b/libs/accounts/errors/src/constants.ts
index 5f38157592d..64f07233825 100644
--- a/libs/accounts/errors/src/constants.ts
+++ b/libs/accounts/errors/src/constants.ts
@@ -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,
diff --git a/libs/accounts/errors/src/index.spec.ts b/libs/accounts/errors/src/index.spec.ts
index 8aac4f12cc3..4a64c2ada19 100644
--- a/libs/accounts/errors/src/index.spec.ts
+++ b/libs/accounts/errors/src/index.spec.ts
@@ -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);
diff --git a/libs/accounts/passkey/src/lib/passkey.service.spec.ts b/libs/accounts/passkey/src/lib/passkey.service.spec.ts
index f305382c8ce..923cef2794b 100644
--- a/libs/accounts/passkey/src/lib/passkey.service.spec.ts
+++ b/libs/accounts/passkey/src/lib/passkey.service.spec.ts
@@ -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;
@@ -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,
@@ -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);
diff --git a/libs/accounts/passkey/src/lib/passkey.service.ts b/libs/accounts/passkey/src/lib/passkey.service.ts
index 0c45ede9232..e98d9e43f32 100644
--- a/libs/accounts/passkey/src/lib/passkey.service.ts
+++ b/libs/accounts/passkey/src/lib/passkey.service.ts
@@ -26,6 +26,7 @@ import {
AuthenticationVerificationResult,
generateWebauthnAuthenticationOptions,
verifyWebauthnAuthenticationResponse,
+ UserVerificationRequiredError,
} from './webauthn-adapter';
import { AppError } from '@fxa/accounts/errors';
@@ -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',
@@ -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',
@@ -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',
@@ -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', {
diff --git a/libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts b/libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts
index b6595d98b96..46605d74d23 100644
--- a/libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts
+++ b/libs/accounts/passkey/src/lib/webauthn-adapter.spec.ts
@@ -9,6 +9,7 @@ import {
generateWebauthnAuthenticationOptions,
verifyWebauthnAuthenticationResponse,
extractPrfEnabled,
+ UserVerificationRequiredError,
} from './webauthn-adapter';
import { PasskeyConfig } from './passkey.config';
import { VirtualAuthenticator } from './virtual-authenticator';
@@ -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');
@@ -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
+ );
});
});
@@ -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');
@@ -665,6 +693,6 @@ describe('verifyWebauthnAuthenticationResponse', () => {
publicKey: stored.publicKey,
signCount: stored.signCount,
})
- ).rejects.toThrow(/user could not be verified/i);
+ ).rejects.toBeInstanceOf(UserVerificationRequiredError);
});
});
diff --git a/libs/accounts/passkey/src/lib/webauthn-adapter.ts b/libs/accounts/passkey/src/lib/webauthn-adapter.ts
index 8181f534f51..54c9189d79f 100644
--- a/libs/accounts/passkey/src/lib/webauthn-adapter.ts
+++ b/libs/accounts/passkey/src/lib/webauthn-adapter.ts
@@ -17,6 +17,8 @@ import type {
PublicKeyCredentialRequestOptionsJSON,
AuthenticatorTransportFuture,
AuthenticationExtensionsClientInputs,
+ VerifiedRegistrationResponse,
+ VerifiedAuthenticationResponse,
} from '@simplewebauthn/server';
import { uuidTransformer } from '@fxa/shared/db/mysql/core';
@@ -124,6 +126,35 @@ export function extractPrfEnabled(extensions: unknown): boolean {
);
}
+/**
+ * Thrown when the authenticator completed a WebAuthn ceremony without performing
+ * user verification (the UV flag was not set) — e.g. a roaming security key with
+ * no PIN or biometric. Callers can surface a clear, actionable error and skip
+ * Sentry noise for this expected outcome, distinct from genuinely unexpected
+ * verification failures.
+ */
+export class UserVerificationRequiredError extends Error {
+ constructor() {
+ super('User verification was not performed by the authenticator');
+ this.name = 'UserVerificationRequiredError';
+ }
+}
+
+/**
+ * SimpleWebAuthn throws a generic `Error` (it exposes no error code) when UV was
+ * required but the authenticator did not verify the user. "user could not be
+ * verified" is the invariant phrase shared by both the registration
+ * ("...was required...") and authentication ("...required...") messages, so
+ * matching on it isolates this one expected case from all other library throws.
+ * The adapter UV specs exercise the real library, so a wording change on a
+ * SimpleWebAuthn upgrade fails CI rather than silently regressing UV → 500.
+ */
+function isUserVerificationError(err: unknown): boolean {
+ return (
+ err instanceof Error && /user could not be verified/i.test(err.message)
+ );
+}
+
/**
* Verify a WebAuthn registration (attestation) response.
*
@@ -131,21 +162,33 @@ export function extractPrfEnabled(extensions: unknown): boolean {
* @param input - Per-request inputs
* @returns a discriminated union indicating verification success or failure;
* `data` contains extracted credential info on success.
- * @throws wrapped library function throws `Error` on invalid input.
+ * @throws {UserVerificationRequiredError} when the authenticator did not perform
+ * user verification (UV flag unset).
+ * @throws wrapped library function throws `Error` on other invalid input.
* See source code for possible errors: https://github.com/MasterKale/SimpleWebAuthn/blob/320757144749c742e58ab3bb181087f9fbcac074/packages/server/src/registration/verifyRegistrationResponse.ts
*/
export async function verifyWebauthnRegistrationResponse(
config: PasskeyConfig,
input: VerifyRegistrationInput
): Promise