|
| 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