Skip to content

Commit 84f34db

Browse files
authored
Merge pull request #20089 from mozilla/FXA-12912
task(passkeys): Add WebAuthn error categorization utility
2 parents 361d032 + c4a326f commit 84f34db

4 files changed

Lines changed: 487 additions & 0 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
## Passkey error messages
2+
## Surfaced when a WebAuthn ceremony (registration or sign-in) fails.
3+
4+
# Registration errors
5+
6+
# User cancelled or dismissed the browser prompt, or the authenticator could not satisfy the options
7+
passkey-registration-error-not-allowed = Passkey setup failed or is unavailable. Try again or choose another method.
8+
9+
# The ceremony timed out before the user responded
10+
passkey-registration-error-timeout = Passkey setup was canceled. Try again.
11+
12+
# Browser or platform does not support passkeys or the requested options (e.g., UV, discoverable credential)
13+
passkey-registration-error-not-supported = Passkeys aren’t supported here. Try another method or device.
14+
15+
# RP ID / origin mismatch, or insecure context (e.g., embedded iframe, wrong domain)
16+
passkey-registration-error-security = Passkeys can’t be set up on this page. Use the secure site and try again.
17+
18+
# A credential for this RP already exists on the authenticator (excludeCredentials match)
19+
passkey-registration-error-invalid-state = This passkey is already registered. Use it to sign in or add a different passkey.
20+
21+
# Authenticator I/O failure (e.g., security key disconnected mid-ceremony)
22+
passkey-registration-error-not-readable = We couldn’t access the authenticator. Try again or choose another method.
23+
24+
# Attestation constraints or device-specific restrictions can't be met
25+
passkey-registration-error-constraint = Passkey setup isn’t available with this device. Try another method or device.
26+
27+
# Catch-all for unexpected errors during registration (TypeError, DataError, EncodingError, OperationError, UnknownError)
28+
passkey-registration-error-unexpected = Passkey setup failed. Try again or choose another method.
29+
30+
# Authentication errors
31+
32+
# User cancelled or dismissed the browser prompt, or no passkey is available / verification failed
33+
passkey-authentication-error-not-allowed = Sign-in with passkey failed or is unavailable. Try again or choose another method.
34+
35+
# The ceremony timed out before the user responded
36+
passkey-authentication-error-timeout = Passkey request timed out. Please try again.
37+
38+
# Browser or platform does not support passkeys
39+
passkey-authentication-error-not-supported = Passkeys aren’t supported. Try another method or device.
40+
41+
# RP ID / origin mismatch, or insecure context (e.g., embedded iframe)
42+
passkey-authentication-error-security = Passkeys can’t be used on this page. Check you’re on the correct secure site and try again.
43+
44+
# Unexpected credential state during authentication
45+
passkey-authentication-error-invalid-state = Something went wrong with your passkey. Try again or use another sign-in method.
46+
47+
# Authenticator I/O failure (e.g., security key disconnected mid-ceremony)
48+
passkey-authentication-error-not-readable = We couldn’t access the authenticator. Try again or use another sign-in method.
49+
50+
# Catch-all for unexpected errors during authentication (TypeError, DataError, EncodingError, ConstraintError, OperationError, UnknownError)
51+
passkey-authentication-error-unexpected = Something went wrong. Try again or choose another sign-in method.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
export * from './webauthn';
6+
export * from './webauthn-errors';
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import {
6+
categorizeWebAuthnError,
7+
handleWebAuthnError,
8+
WebAuthnErrorCategory,
9+
WebAuthnErrorType,
10+
WebAuthnOperation,
11+
} from './webauthn-errors';
12+
13+
function dom(name: string): DOMException {
14+
return new DOMException('test', name);
15+
}
16+
17+
const OPERATIONS: WebAuthnOperation[] = ['registration', 'authentication'];
18+
19+
describe('categorizeWebAuthnError — user-action errors (no Sentry)', () => {
20+
const cases: [string, WebAuthnErrorType][] = [
21+
['NotAllowedError', WebAuthnErrorType.NotAllowed],
22+
['TimeoutError', WebAuthnErrorType.Timeout],
23+
];
24+
25+
describe.each(OPERATIONS)('operation: %s', (operation) => {
26+
test.each(cases)(
27+
'%s → UserAction, logToSentry false',
28+
(name, errorType) => {
29+
const result = categorizeWebAuthnError(dom(name), operation);
30+
expect(result.category).toBe(WebAuthnErrorCategory.UserAction);
31+
expect(result.errorType).toBe(errorType);
32+
expect(result.logToSentry).toBe(false);
33+
expect(result.userMessageKey).toContain(operation);
34+
expect(result.userMessageKey).toMatch(/passkey-.+-error-.+/);
35+
}
36+
);
37+
});
38+
39+
it('returns distinct keys for registration vs authentication', () => {
40+
const reg = categorizeWebAuthnError(dom('NotAllowedError'), 'registration');
41+
const auth = categorizeWebAuthnError(
42+
dom('NotAllowedError'),
43+
'authentication'
44+
);
45+
expect(reg.userMessageKey).not.toBe(auth.userMessageKey);
46+
expect(reg.userMessageKey).toContain('registration');
47+
expect(auth.userMessageKey).toContain('authentication');
48+
});
49+
});
50+
51+
describe('categorizeWebAuthnError — device/platform errors (no Sentry)', () => {
52+
const cases: [string, WebAuthnErrorType][] = [
53+
['NotSupportedError', WebAuthnErrorType.NotSupported],
54+
['SecurityError', WebAuthnErrorType.Security],
55+
['InvalidStateError', WebAuthnErrorType.InvalidState],
56+
['NotReadableError', WebAuthnErrorType.NotReadable],
57+
];
58+
59+
describe.each(OPERATIONS)('operation: %s', (operation) => {
60+
test.each(cases)(
61+
'%s → DevicePlatform, logToSentry false',
62+
(name, errorType) => {
63+
const result = categorizeWebAuthnError(dom(name), operation);
64+
expect(result.category).toBe(WebAuthnErrorCategory.DevicePlatform);
65+
expect(result.errorType).toBe(errorType);
66+
expect(result.logToSentry).toBe(false);
67+
}
68+
);
69+
});
70+
71+
it('InvalidStateError has distinct registration key (credential already exists)', () => {
72+
const reg = categorizeWebAuthnError(
73+
dom('InvalidStateError'),
74+
'registration'
75+
);
76+
const auth = categorizeWebAuthnError(
77+
dom('InvalidStateError'),
78+
'authentication'
79+
);
80+
expect(reg.userMessageKey).toContain('registration');
81+
expect(auth.userMessageKey).toContain('authentication');
82+
});
83+
});
84+
85+
describe('categorizeWebAuthnError — unexpected errors (Sentry enabled)', () => {
86+
const domCases: [string, WebAuthnErrorType][] = [
87+
['ConstraintError', WebAuthnErrorType.Constraint],
88+
['DataError', WebAuthnErrorType.Data],
89+
['EncodingError', WebAuthnErrorType.Encoding],
90+
['OperationError', WebAuthnErrorType.Operation],
91+
['UnknownError', WebAuthnErrorType.Unknown],
92+
];
93+
94+
describe.each(OPERATIONS)('operation: %s', (operation) => {
95+
test.each(domCases)(
96+
'%s → Unexpected, logToSentry true',
97+
(name, errorType) => {
98+
const result = categorizeWebAuthnError(dom(name), operation);
99+
expect(result.category).toBe(WebAuthnErrorCategory.Unexpected);
100+
expect(result.errorType).toBe(errorType);
101+
expect(result.logToSentry).toBe(true);
102+
}
103+
);
104+
});
105+
106+
test.each(OPERATIONS)('TypeError → Unexpected on %s', (operation) => {
107+
const result = categorizeWebAuthnError(
108+
new TypeError('bad options'),
109+
operation
110+
);
111+
expect(result.category).toBe(WebAuthnErrorCategory.Unexpected);
112+
expect(result.errorType).toBe(WebAuthnErrorType.Type);
113+
expect(result.logToSentry).toBe(true);
114+
});
115+
116+
test.each(OPERATIONS)(
117+
'unrecognized DOMException → Unexpected on %s',
118+
(operation) => {
119+
const result = categorizeWebAuthnError(
120+
dom('SomeNewBrowserError'),
121+
operation
122+
);
123+
expect(result.category).toBe(WebAuthnErrorCategory.Unexpected);
124+
expect(result.errorType).toBe(WebAuthnErrorType.Unknown);
125+
expect(result.logToSentry).toBe(true);
126+
expect(result.userMessageKey).toContain(operation);
127+
}
128+
);
129+
130+
it('ConstraintError has distinct registration key', () => {
131+
const reg = categorizeWebAuthnError(dom('ConstraintError'), 'registration');
132+
const auth = categorizeWebAuthnError(
133+
dom('ConstraintError'),
134+
'authentication'
135+
);
136+
expect(reg.userMessageKey).toContain('registration');
137+
expect(reg.userMessageKey).toContain('constraint');
138+
expect(auth.userMessageKey).toContain('unexpected');
139+
});
140+
});
141+
142+
describe('categorizeWebAuthnError — non-DOMException inputs default to unexpected', () => {
143+
const inputs: unknown[] = [
144+
null,
145+
undefined,
146+
0,
147+
'',
148+
{},
149+
[],
150+
new Error('plain error'),
151+
];
152+
153+
test.each(inputs.map((v) => [String(v), v]))(
154+
'%s → Unexpected, logToSentry true',
155+
(_, input) => {
156+
expect(() =>
157+
categorizeWebAuthnError(input, 'authentication')
158+
).not.toThrow();
159+
const result = categorizeWebAuthnError(input, 'authentication');
160+
expect(result.category).toBe(WebAuthnErrorCategory.Unexpected);
161+
expect(result.logToSentry).toBe(true);
162+
}
163+
);
164+
});
165+
166+
describe('handleWebAuthnError', () => {
167+
it('calls captureException for unexpected errors', () => {
168+
const captureException = jest.fn();
169+
handleWebAuthnError(new TypeError('bad'), 'registration', captureException);
170+
expect(captureException).toHaveBeenCalledWith(expect.any(TypeError));
171+
});
172+
173+
it('does not call captureException for user-action errors', () => {
174+
const captureException = jest.fn();
175+
handleWebAuthnError(
176+
dom('NotAllowedError'),
177+
'authentication',
178+
captureException
179+
);
180+
expect(captureException).not.toHaveBeenCalled();
181+
});
182+
183+
it('does not call captureException for device/platform errors', () => {
184+
const captureException = jest.fn();
185+
handleWebAuthnError(
186+
dom('NotSupportedError'),
187+
'registration',
188+
captureException
189+
);
190+
expect(captureException).not.toHaveBeenCalled();
191+
});
192+
193+
it('passes the original error to captureException, not the categorized result', () => {
194+
const captureException = jest.fn();
195+
const error = dom('ConstraintError');
196+
handleWebAuthnError(error, 'registration', captureException);
197+
expect(captureException).toHaveBeenCalledWith(error);
198+
});
199+
});

0 commit comments

Comments
 (0)