Skip to content

Commit b481375

Browse files
authored
Merge pull request #20088 from mozilla/FXA-12911
task(settings): Add Webauth browser helpers in fxa-settings
2 parents 362fb5d + fea47e2 commit b481375

6 files changed

Lines changed: 2162 additions & 4 deletions

File tree

libs/accounts/passkey/src/lib/passkey.config.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,17 +60,20 @@ export class PasskeyConfig {
6060
* @example 'required'
6161
*/
6262
@IsString()
63-
public userVerification?: string;
63+
public userVerification?: 'required' | 'preferred' | 'discouraged';
6464

6565
/**
6666
* Resident key (discoverable credential) requirement.
67-
* - 'required': Credential must be discoverable (stored on authenticator)
67+
* - 'required': Credential must be discoverable (stored on authenticator).
68+
* Must be set to 'required' for the passwordless / usernameless sign-in
69+
* flow — non-discoverable credentials cannot be surfaced by the browser
70+
* without a prior username.
6871
* - 'preferred': Discoverable credential preferred but not required
6972
* - 'discouraged': Non-discoverable credential preferred
70-
* @example 'preferred'
73+
* @example 'required'
7174
*/
7275
@IsString()
73-
public residentKey?: string;
76+
public residentKey?: 'required' | 'preferred' | 'discouraged';
7477

7578
/**
7679
* Authenticator attachment preference.
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
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+
createCredential,
7+
getCredential,
8+
isWebAuthnLevel3Supported,
9+
PublicKeyCredentialCreationOptionsJSON,
10+
PublicKeyCredentialJSON,
11+
PublicKeyCredentialRequestOptionsJSON,
12+
} from './webauthn';
13+
14+
// ─── Fixtures ────────────────────────────────────────────────────────────────
15+
16+
const mockCredentialJSON: PublicKeyCredentialJSON = {
17+
id: 'bW9jay1pZA',
18+
rawId: 'bW9jay1yYXctaWQ',
19+
type: 'public-key',
20+
response: {
21+
clientDataJSON: 'bW9jay1jbGllbnQtZGF0YQ',
22+
attestationObject: 'bW9jay1hdHRlc3RhdGlvbg',
23+
},
24+
clientExtensionResults: {},
25+
};
26+
27+
const mockCreationOptions: PublicKeyCredentialCreationOptionsJSON = {
28+
rp: { name: 'Mozilla Accounts', id: 'accounts.firefox.com' },
29+
user: {
30+
id: 'dXNlci1pZA',
31+
32+
displayName: '[email protected]',
33+
},
34+
challenge: 'Y2hhbGxlbmdl',
35+
pubKeyCredParams: [{ type: 'public-key', alg: -7 }],
36+
};
37+
38+
const mockRequestOptions: PublicKeyCredentialRequestOptionsJSON = {
39+
challenge: 'Y2hhbGxlbmdl',
40+
userVerification: 'required',
41+
};
42+
43+
// ─── Helpers ─────────────────────────────────────────────────────────────────
44+
45+
function setupMockPKC(
46+
overrides: Record<string, unknown> = {}
47+
): Record<string, jest.Mock> {
48+
// Use a class so that mockCredential instances pass `instanceof PublicKeyCredential`.
49+
class MockPublicKeyCredential {}
50+
const statics = {
51+
parseCreationOptionsFromJSON: jest.fn().mockReturnValue({}),
52+
parseRequestOptionsFromJSON: jest.fn().mockReturnValue({}),
53+
...overrides,
54+
};
55+
Object.assign(MockPublicKeyCredential, statics);
56+
(global as any).PublicKeyCredential = MockPublicKeyCredential;
57+
return statics as Record<string, jest.Mock>;
58+
}
59+
60+
function setupMockCredentials(result: PublicKeyCredentialJSON): {
61+
mockCreate: jest.Mock;
62+
mockGet: jest.Mock;
63+
} {
64+
// Create an instance of the mock class so `instanceof PublicKeyCredential` passes.
65+
const MockPKC = (global as any).PublicKeyCredential as { prototype: object };
66+
const mockCredential = Object.create(MockPKC.prototype);
67+
mockCredential.toJSON = jest.fn().mockReturnValue(result);
68+
69+
const mockCreate = jest.fn().mockResolvedValue(mockCredential);
70+
const mockGet = jest.fn().mockResolvedValue(mockCredential);
71+
72+
Object.defineProperty(global.navigator, 'credentials', {
73+
value: { create: mockCreate, get: mockGet },
74+
configurable: true,
75+
writable: true,
76+
});
77+
78+
return { mockCreate, mockGet };
79+
}
80+
81+
// ─── isWebAuthnLevel3Supported ──────────────────────────────────────────────────────
82+
83+
describe('isWebAuthnLevel3Supported', () => {
84+
afterEach(() => {
85+
(global as any).PublicKeyCredential = undefined;
86+
});
87+
88+
it('returns false when PublicKeyCredential is absent', () => {
89+
(global as any).PublicKeyCredential = undefined;
90+
expect(isWebAuthnLevel3Supported()).toBe(false);
91+
});
92+
93+
it('returns false when parseCreationOptionsFromJSON is missing', () => {
94+
(global as any).PublicKeyCredential = {
95+
parseRequestOptionsFromJSON: jest.fn(),
96+
};
97+
expect(isWebAuthnLevel3Supported()).toBe(false);
98+
});
99+
100+
it('returns false when parseRequestOptionsFromJSON is missing', () => {
101+
(global as any).PublicKeyCredential = {
102+
parseCreationOptionsFromJSON: jest.fn(),
103+
};
104+
expect(isWebAuthnLevel3Supported()).toBe(false);
105+
});
106+
107+
it('returns true when both JSON helpers are present', () => {
108+
setupMockPKC();
109+
expect(isWebAuthnLevel3Supported()).toBe(true);
110+
});
111+
});
112+
113+
// ─── createCredential ─────────────────────────────────────────────────────────
114+
115+
describe('createCredential', () => {
116+
let mockPKC: Record<string, jest.Mock>;
117+
let mockCreate: jest.Mock;
118+
119+
beforeEach(() => {
120+
jest.useFakeTimers();
121+
mockPKC = setupMockPKC();
122+
({ mockCreate } = setupMockCredentials(mockCredentialJSON));
123+
});
124+
125+
afterEach(() => {
126+
jest.useRealTimers();
127+
(global as any).PublicKeyCredential = undefined;
128+
});
129+
130+
it('resolves with credential JSON on success', async () => {
131+
const result = await createCredential(mockCreationOptions);
132+
expect(result).toEqual(mockCredentialJSON);
133+
});
134+
135+
it('passes parsed options and AbortSignal to navigator.credentials.create', async () => {
136+
await createCredential(mockCreationOptions);
137+
expect(mockPKC['parseCreationOptionsFromJSON']).toHaveBeenCalledWith(
138+
mockCreationOptions
139+
);
140+
const callArg = mockCreate.mock.calls[0][0];
141+
expect(callArg).toHaveProperty('publicKey');
142+
expect(callArg.signal).toBeInstanceOf(AbortSignal);
143+
});
144+
145+
it('throws NotSupportedError when WebAuthn APIs are absent', async () => {
146+
(global as any).PublicKeyCredential = undefined;
147+
await expect(createCredential(mockCreationOptions)).rejects.toMatchObject({
148+
name: 'NotSupportedError',
149+
});
150+
});
151+
152+
it('propagates DOMException from navigator.credentials.create', async () => {
153+
const err = new DOMException('User cancelled', 'NotAllowedError');
154+
mockCreate.mockRejectedValue(err);
155+
await expect(createCredential(mockCreationOptions)).rejects.toBe(err);
156+
});
157+
158+
it('throws TimeoutError when the timeout elapses', async () => {
159+
mockCreate.mockImplementation(({ signal }: { signal: AbortSignal }) => {
160+
return new Promise((_, reject) => {
161+
signal.addEventListener('abort', () =>
162+
reject(new DOMException('Aborted', 'AbortError'))
163+
);
164+
});
165+
});
166+
167+
const promise = createCredential(mockCreationOptions, 1_000);
168+
jest.advanceTimersByTime(1_000);
169+
await expect(promise).rejects.toMatchObject({ name: 'TimeoutError' });
170+
});
171+
172+
it('clears the timeout after successful credential creation', async () => {
173+
const spy = jest.spyOn(global, 'clearTimeout');
174+
await createCredential(mockCreationOptions);
175+
expect(spy).toHaveBeenCalled();
176+
spy.mockRestore();
177+
});
178+
179+
it('clears the timeout after a failed credential creation', async () => {
180+
const spy = jest.spyOn(global, 'clearTimeout');
181+
mockCreate.mockRejectedValue(new DOMException('Fail', 'NotAllowedError'));
182+
await expect(createCredential(mockCreationOptions)).rejects.toBeDefined();
183+
expect(spy).toHaveBeenCalled();
184+
spy.mockRestore();
185+
});
186+
});
187+
188+
// ─── getCredential ────────────────────────────────────────────────────────────
189+
190+
describe('getCredential', () => {
191+
let mockPKC: Record<string, jest.Mock>;
192+
let mockGet: jest.Mock;
193+
194+
beforeEach(() => {
195+
jest.useFakeTimers();
196+
mockPKC = setupMockPKC();
197+
({ mockGet } = setupMockCredentials(mockCredentialJSON));
198+
});
199+
200+
afterEach(() => {
201+
jest.useRealTimers();
202+
(global as any).PublicKeyCredential = undefined;
203+
});
204+
205+
it('resolves with credential JSON on success', async () => {
206+
const result = await getCredential(mockRequestOptions);
207+
expect(result).toEqual(mockCredentialJSON);
208+
});
209+
210+
it('passes parsed options and AbortSignal to navigator.credentials.get', async () => {
211+
await getCredential(mockRequestOptions);
212+
expect(mockPKC['parseRequestOptionsFromJSON']).toHaveBeenCalledWith(
213+
mockRequestOptions
214+
);
215+
const callArg = mockGet.mock.calls[0][0];
216+
expect(callArg).toHaveProperty('publicKey');
217+
expect(callArg.signal).toBeInstanceOf(AbortSignal);
218+
});
219+
220+
it('throws NotSupportedError when WebAuthn APIs are absent', async () => {
221+
(global as any).PublicKeyCredential = undefined;
222+
await expect(getCredential(mockRequestOptions)).rejects.toMatchObject({
223+
name: 'NotSupportedError',
224+
});
225+
});
226+
227+
it('propagates DOMException from navigator.credentials.get', async () => {
228+
const err = new DOMException('User cancelled', 'NotAllowedError');
229+
mockGet.mockRejectedValue(err);
230+
await expect(getCredential(mockRequestOptions)).rejects.toBe(err);
231+
});
232+
233+
it('throws TimeoutError when the timeout elapses', async () => {
234+
mockGet.mockImplementation(({ signal }: { signal: AbortSignal }) => {
235+
return new Promise((_, reject) => {
236+
signal.addEventListener('abort', () =>
237+
reject(new DOMException('Aborted', 'AbortError'))
238+
);
239+
});
240+
});
241+
242+
const promise = getCredential(mockRequestOptions, 1_000);
243+
jest.advanceTimersByTime(1_000);
244+
await expect(promise).rejects.toMatchObject({ name: 'TimeoutError' });
245+
});
246+
247+
it('clears the timeout after successful credential retrieval', async () => {
248+
const spy = jest.spyOn(global, 'clearTimeout');
249+
await getCredential(mockRequestOptions);
250+
expect(spy).toHaveBeenCalled();
251+
spy.mockRestore();
252+
});
253+
254+
it('clears the timeout after a failed credential retrieval', async () => {
255+
const spy = jest.spyOn(global, 'clearTimeout');
256+
mockGet.mockRejectedValue(new DOMException('Fail', 'NotAllowedError'));
257+
await expect(getCredential(mockRequestOptions)).rejects.toBeDefined();
258+
expect(spy).toHaveBeenCalled();
259+
spy.mockRestore();
260+
});
261+
});

0 commit comments

Comments
 (0)