Skip to content

Commit bc666c7

Browse files
committed
task(functional-tests): Add tests for passkey registration
1 parent 5ac5cc1 commit bc666c7

6 files changed

Lines changed: 444 additions & 256 deletions

File tree

.circleci/config.yml

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,23 @@ commands:
404404
cd packages/functional-tests
405405
yarn playwright test --project=<< parameters.project >> --grep "#phone" --workers=1
406406
407+
run-passkey-tests:
408+
parameters:
409+
project:
410+
type: string
411+
steps:
412+
- run:
413+
name: Passkey tests (serialized)
414+
command: |
415+
if [ "${CIRCLE_NODE_INDEX:-0}" != "0" ]; then
416+
echo "Passkey tests run only on node 0; this is node ${CIRCLE_NODE_INDEX:-0}. Skipping.";
417+
exit 0;
418+
fi
419+
cd packages/functional-tests
420+
yarn playwright test --project=<< parameters.project >> --grep "passkey" --workers=1
421+
environment:
422+
NODE_OPTIONS: --dns-result-order=ipv4first
423+
407424
store-artifacts:
408425
steps:
409426
- run:
@@ -860,6 +877,8 @@ jobs:
860877
no_output_timeout: 20m
861878
- run-phone-tests-serialized:
862879
project: local
880+
- run-passkey-tests:
881+
project: local
863882
- run-playwright-tests:
864883
project: local
865884
grep_invert: '#phone'
Lines changed: 342 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,342 @@
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 type { Page } from '@playwright/test';
6+
// Import the virtual-authenticator module directly rather than through the
7+
// `@fxa/accounts/passkey` barrel — the barrel re-exports NestJS-decorated
8+
// services whose parameter decorators Playwright's Babel loader cannot parse.
9+
import {
10+
VirtualAuthenticator,
11+
type VirtualCredential,
12+
} from '../../../libs/accounts/passkey/src/lib/virtual-authenticator';
13+
import type {
14+
RegistrationResponseJSON,
15+
AuthenticationResponseJSON,
16+
} from '@simplewebauthn/server';
17+
18+
type Mode = 'pending' | 'success' | 'cancel';
19+
type Trigger = () => Promise<void>;
20+
type PostCheck = () => Promise<void>;
21+
22+
// JSON wire-format shapes passed from the browser to Node (subset of the
23+
// parts the polyfill actually consumes).
24+
type CreationOptions = {
25+
challenge: string;
26+
rp?: { id?: string };
27+
allowCredentials?: Array<{ id: string }>;
28+
};
29+
type RequestOptions = {
30+
challenge: string;
31+
rpId?: string;
32+
allowCredentials?: Array<{ id: string }>;
33+
};
34+
35+
/**
36+
* Browser polyfill for WebAuthn that works in both Firefox and Chromium.
37+
*
38+
* Unlike the previous CDP-based approach (Chromium only), this patches
39+
* `window.PublicKeyCredential` and `navigator.credentials.{create,get}` via
40+
* `page.addInitScript`, delegating the cryptographic work to the Node-side
41+
* VirtualAuthenticator from `@fxa/accounts/passkey`. The resulting attestation
42+
* and assertion responses are cryptographically valid, so the auth-server
43+
* `verifyRegistration/AuthenticationResponse` checks pass end-to-end.
44+
*
45+
* Modes:
46+
* - `pending` (default): the polyfill's promise never resolves. Useful for
47+
* asserting mid-ceremony UI (e.g. a Cancel button) without the ceremony
48+
* racing to completion.
49+
* - `success`: the polyfill resolves with a valid attestation/assertion.
50+
* - `cancel`: the polyfill rejects with a `NotAllowedError` DOMException,
51+
* mirroring a user-cancelled browser prompt.
52+
*/
53+
export class PasskeyPolyfill {
54+
private credentials: VirtualCredential[] = [];
55+
private mode: Mode = 'pending';
56+
private onCredentialAdded?: () => void;
57+
private installed = false;
58+
59+
/**
60+
* Install the polyfill on the given Playwright page. Exposes the Node-side
61+
* crypto callbacks and registers the browser init script so the polyfill is
62+
* active for the current page and all subsequent navigations.
63+
*/
64+
async install(page: Page) {
65+
if (this.installed) return;
66+
this.installed = true;
67+
68+
await page.exposeFunction(
69+
'__fxaPasskeyCreate',
70+
async (options: CreationOptions, origin: string) =>
71+
this.handleCreate(options, origin)
72+
);
73+
74+
await page.exposeFunction(
75+
'__fxaPasskeyGet',
76+
async (options: RequestOptions, origin: string) =>
77+
this.handleGet(options, origin)
78+
);
79+
80+
await page.addInitScript(BROWSER_POLYFILL);
81+
82+
// addInitScript only runs on subsequent navigations; apply to the current
83+
// page too so callers can install the polyfill at any point.
84+
try {
85+
await page.evaluate(BROWSER_POLYFILL);
86+
} catch {
87+
// The page may be at about:blank or mid-navigation; safe to ignore
88+
// since addInitScript will apply on the next load.
89+
}
90+
}
91+
92+
/**
93+
* Simulate a successful ceremony: switch to `success` mode, run the trigger
94+
* that initiates the ceremony in the browser, and wait until the
95+
* VirtualAuthenticator has issued a credential. Restores `pending` mode on
96+
* exit so subsequent ceremonies hang again by default.
97+
*/
98+
async success(trigger: Trigger) {
99+
const credentialAdded = new Promise<void>((resolve) => {
100+
this.onCredentialAdded = resolve;
101+
});
102+
103+
const previous = this.mode;
104+
this.mode = 'success';
105+
try {
106+
await trigger();
107+
await credentialAdded;
108+
} finally {
109+
this.onCredentialAdded = undefined;
110+
this.mode = previous;
111+
}
112+
}
113+
114+
/**
115+
* Simulate a user-cancelled browser prompt: switch to `cancel` mode so the
116+
* polyfill rejects the ceremony with a `NotAllowedError` DOMException, then
117+
* run the trigger and the caller's postCheck.
118+
*/
119+
async fail(trigger: Trigger, postCheck: PostCheck) {
120+
const previous = this.mode;
121+
this.mode = 'cancel';
122+
try {
123+
await trigger();
124+
await postCheck();
125+
} finally {
126+
this.mode = previous;
127+
}
128+
}
129+
130+
/**
131+
* Number of credentials the VirtualAuthenticator has minted during the
132+
* lifetime of this polyfill. Tests use this to assert registration happened
133+
* (or didn't).
134+
*/
135+
getCredentials() {
136+
return this.credentials.map((c) => ({
137+
credentialId: c.id.toString('base64url'),
138+
signCount: c.signCount,
139+
}));
140+
}
141+
142+
/** Clear all minted credentials and reset to `pending` mode. */
143+
async cleanup() {
144+
this.credentials = [];
145+
this.mode = 'pending';
146+
this.onCredentialAdded = undefined;
147+
this.installed = false;
148+
}
149+
150+
private async handleCreate(
151+
options: CreationOptions,
152+
origin: string
153+
): Promise<RegistrationResponseJSON> {
154+
await this.waitForReleasableMode();
155+
156+
const cred = VirtualAuthenticator.createCredential();
157+
this.credentials.push(cred);
158+
159+
const rpId = options.rp?.id ?? new URL(origin).hostname;
160+
const response = VirtualAuthenticator.createAttestationResponse(cred, {
161+
challenge: options.challenge,
162+
origin,
163+
rpId,
164+
});
165+
166+
this.onCredentialAdded?.();
167+
return response;
168+
}
169+
170+
private async handleGet(
171+
options: RequestOptions,
172+
origin: string
173+
): Promise<AuthenticationResponseJSON> {
174+
await this.waitForReleasableMode();
175+
176+
const cred = this.pickCredential(options.allowCredentials);
177+
if (!cred) {
178+
throw makeDomExceptionLike(
179+
'NotAllowedError',
180+
'No matching credentials for assertion'
181+
);
182+
}
183+
184+
const rpId = options.rpId ?? new URL(origin).hostname;
185+
return VirtualAuthenticator.createAssertionResponse(cred, {
186+
challenge: options.challenge,
187+
origin,
188+
rpId,
189+
});
190+
}
191+
192+
private pickCredential(allow?: Array<{ id: string }>) {
193+
if (!allow?.length) return this.credentials[0];
194+
const allowed = new Set(allow.map((c) => c.id));
195+
return this.credentials.find((c) =>
196+
allowed.has(c.id.toString('base64url'))
197+
);
198+
}
199+
200+
private async waitForReleasableMode() {
201+
// If mode is `pending`, wait indefinitely — callers flip to `success` or
202+
// `cancel` via success()/fail() to release the ceremony. If mode is
203+
// already `cancel`, reject immediately.
204+
while (this.mode === 'pending') {
205+
await new Promise((resolve) => setTimeout(resolve, 25));
206+
}
207+
if (this.mode === 'cancel') {
208+
throw makeDomExceptionLike(
209+
'NotAllowedError',
210+
'The operation either timed out or was not allowed.'
211+
);
212+
}
213+
}
214+
}
215+
216+
/**
217+
* Playwright serialises thrown Errors, so a plain `new Error()` from Node
218+
* loses its `name`. Build an Error with `name` set explicitly so the browser
219+
* shim can re-raise it as a DOMException with the correct type.
220+
*/
221+
function makeDomExceptionLike(name: string, message: string) {
222+
const err = new Error(message);
223+
err.name = name;
224+
return err;
225+
}
226+
227+
/**
228+
* Injected into the page. Replaces window.PublicKeyCredential with a stub
229+
* class so (a) `PublicKeyCredential.parseCreationOptionsFromJSON` and the
230+
* Level 3 feature-detection in fxa-settings pass, and (b) credentials
231+
* returned from our fake `navigator.credentials.create` pass
232+
* `instanceof PublicKeyCredential` inside the wrapper. The stub delegates
233+
* the cryptographic work to Node via the `__fxaPasskey*` functions exposed
234+
* by {@link PasskeyPolyfill.install}.
235+
*/
236+
const BROWSER_POLYFILL = `(() => {
237+
try {
238+
if (window.__fxaPasskeyPolyfillInstalled) return;
239+
window.__fxaPasskeyPolyfillInstalled = true;
240+
241+
class FakePublicKeyCredential {
242+
constructor(json) {
243+
this._json = json;
244+
this.id = json.id;
245+
this.rawId = null;
246+
this.type = 'public-key';
247+
this.authenticatorAttachment = json.authenticatorAttachment;
248+
this.response = null;
249+
}
250+
static isUserVerifyingPlatformAuthenticatorAvailable() {
251+
return Promise.resolve(true);
252+
}
253+
static isConditionalMediationAvailable() {
254+
return Promise.resolve(false);
255+
}
256+
static parseCreationOptionsFromJSON(o) { return o; }
257+
static parseRequestOptionsFromJSON(o) { return o; }
258+
toJSON() { return this._json; }
259+
}
260+
261+
// Best-effort: may throw on some browsers where PublicKeyCredential is a
262+
// non-configurable accessor. Guarded by the outer try/catch.
263+
try {
264+
Object.defineProperty(window, 'PublicKeyCredential', {
265+
value: FakePublicKeyCredential,
266+
writable: true,
267+
configurable: true,
268+
});
269+
} catch (_) {
270+
// Fall back to plain assignment.
271+
try { window.PublicKeyCredential = FakePublicKeyCredential; } catch (__) {}
272+
}
273+
274+
async function invoke(name, options) {
275+
const fn = window[name];
276+
if (typeof fn !== 'function') {
277+
// exposeFunction binding hasn't attached on this frame yet — treat
278+
// like a missing authenticator so the app can retry or error out
279+
// cleanly instead of throwing an unhandled rejection into the
280+
// webpack-dev-server overlay.
281+
throw new DOMException(
282+
'Passkey polyfill unavailable on this frame',
283+
'NotAllowedError'
284+
);
285+
}
286+
try {
287+
return await fn(options, window.location.origin);
288+
} catch (err) {
289+
// exposeFunction serialises Errors; rebuild a DOMException so the
290+
// fxa-settings webauthn-errors handler categorises correctly.
291+
const errName = (err && err.name) || 'NotAllowedError';
292+
const msg = (err && err.message) || 'WebAuthn operation failed';
293+
throw new DOMException(msg, errName);
294+
}
295+
}
296+
297+
const credentialsApi = {
298+
create: async (opts) => {
299+
const publicKey = (opts && opts.publicKey) || {};
300+
const json = await invoke('__fxaPasskeyCreate', publicKey);
301+
return new FakePublicKeyCredential(json);
302+
},
303+
get: async (opts) => {
304+
const publicKey = (opts && opts.publicKey) || {};
305+
const json = await invoke('__fxaPasskeyGet', publicKey);
306+
return new FakePublicKeyCredential(json);
307+
},
308+
};
309+
310+
try {
311+
if (!navigator.credentials) {
312+
Object.defineProperty(navigator, 'credentials', {
313+
value: credentialsApi,
314+
writable: true,
315+
configurable: true,
316+
});
317+
} else {
318+
// navigator.credentials.create/get are non-writable accessor
319+
// properties in Firefox — defineProperty works where plain
320+
// assignment silently fails.
321+
Object.defineProperty(navigator.credentials, 'create', {
322+
value: credentialsApi.create,
323+
writable: true,
324+
configurable: true,
325+
});
326+
Object.defineProperty(navigator.credentials, 'get', {
327+
value: credentialsApi.get,
328+
writable: true,
329+
configurable: true,
330+
});
331+
}
332+
} catch (_) {
333+
// If the browser refuses to let us patch credentials, bail out
334+
// silently. The feature-detection check will still pass via the fake
335+
// PublicKeyCredential above, but WebAuthn calls will fall through to
336+
// the native implementation — tests that rely on the polyfill will
337+
// fail visibly, which is the right behaviour.
338+
}
339+
} catch (_) {
340+
// Never let the polyfill crash the host page.
341+
}
342+
})();`;

0 commit comments

Comments
 (0)