From 637b2ad1f526a3c414b187cfb8235935e8bab885 Mon Sep 17 00:00:00 2001 From: Valerie Pomerleau Date: Fri, 17 Apr 2026 12:42:03 -0700 Subject: [PATCH] fix(passkeys): duck-type WebAuthn credential to support 1Password proxy objects --- .../src/lib/passkeys/webauthn.test.ts | 56 +++++++++++- .../fxa-settings/src/lib/passkeys/webauthn.ts | 85 ++++++++++--------- 2 files changed, 95 insertions(+), 46 deletions(-) diff --git a/packages/fxa-settings/src/lib/passkeys/webauthn.test.ts b/packages/fxa-settings/src/lib/passkeys/webauthn.test.ts index 1b2faaf7832..9c7320578da 100644 --- a/packages/fxa-settings/src/lib/passkeys/webauthn.test.ts +++ b/packages/fxa-settings/src/lib/passkeys/webauthn.test.ts @@ -110,8 +110,6 @@ describe('isWebAuthnLevel3Supported', () => { }); }); -// ─── createCredential ───────────────────────────────────────────────────────── - describe('createCredential', () => { let mockPKC: Record; let mockCreate: jest.Mock; @@ -183,9 +181,34 @@ describe('createCredential', () => { expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); -}); -// ─── getCredential ──────────────────────────────────────────────────────────── + it('resolves via duck-typed toJSON() when credential is a plain object (e.g. 1Password)', async () => { + // 1Password returns a plain object that mimics PublicKeyCredential + // but is not an actual instance of the class. + const proxyCredential = { + id: mockCredentialJSON.id, + rawId: new ArrayBuffer(8), + type: 'public-key', + authenticatorAttachment: 'platform', + response: {}, + getClientExtensionResults: jest.fn().mockReturnValue({}), + toJSON: jest.fn().mockReturnValue(mockCredentialJSON), + }; + mockCreate.mockResolvedValue(proxyCredential); + + const result = await createCredential(mockCreationOptions); + expect(result).toEqual(mockCredentialJSON); + expect(proxyCredential.toJSON).toHaveBeenCalled(); + }); + + it('throws when credential lacks toJSON()', async () => { + mockCreate.mockResolvedValue({ id: 'no-toJSON', type: 'public-key' }); + await expect(createCredential(mockCreationOptions)).rejects.toMatchObject({ + name: 'UnknownError', + message: expect.stringContaining('without toJSON()'), + }); + }); +}); describe('getCredential', () => { let mockPKC: Record; @@ -258,4 +281,29 @@ describe('getCredential', () => { expect(spy).toHaveBeenCalled(); spy.mockRestore(); }); + + it('resolves via duck-typed toJSON() when credential is a plain object (e.g. 1Password)', async () => { + const proxyCredential = { + id: mockCredentialJSON.id, + rawId: new ArrayBuffer(8), + type: 'public-key', + authenticatorAttachment: 'platform', + response: {}, + getClientExtensionResults: jest.fn().mockReturnValue({}), + toJSON: jest.fn().mockReturnValue(mockCredentialJSON), + }; + mockGet.mockResolvedValue(proxyCredential); + + const result = await getCredential(mockRequestOptions); + expect(result).toEqual(mockCredentialJSON); + expect(proxyCredential.toJSON).toHaveBeenCalled(); + }); + + it('throws when credential lacks toJSON()', async () => { + mockGet.mockResolvedValue({ id: 'no-toJSON', type: 'public-key' }); + await expect(getCredential(mockRequestOptions)).rejects.toMatchObject({ + name: 'UnknownError', + message: expect.stringContaining('without toJSON()'), + }); + }); }); diff --git a/packages/fxa-settings/src/lib/passkeys/webauthn.ts b/packages/fxa-settings/src/lib/passkeys/webauthn.ts index 0ab97a8aab7..eb5ce84447d 100644 --- a/packages/fxa-settings/src/lib/passkeys/webauthn.ts +++ b/packages/fxa-settings/src/lib/passkeys/webauthn.ts @@ -174,6 +174,47 @@ export function isWebAuthnLevel3Supported(): boolean { ); } +// ─── Credential extraction ────────────────────────────────────────────────── + +/** Type guard for objects that can serialize to credential JSON. */ +function hasToJSON( + value: unknown +): value is { toJSON: () => PublicKeyCredentialJSON } { + return ( + typeof value === 'object' && + value !== null && + 'toJSON' in value && + typeof value.toJSON === 'function' + ); +} + +/** + * Extracts `PublicKeyCredentialJSON` from a browser credential result. + * + * Prefers duck-typing (`toJSON()`) over `instanceof PublicKeyCredential` + * because password managers (e.g. 1Password) may return a plain object + * that has all the right fields but is not a real `PublicKeyCredential`. + * + * No client-side shape validation of the `toJSON()` return value — + * `@simplewebauthn/server:verifyRegistrationResponse` performs strict + * WebAuthn spec validation server-side and rejects malformed data. + */ +function toCredentialJSON( + rawCredential: Credential | null, + operation: string +): PublicKeyCredentialJSON { + if (!rawCredential) { + throw new DOMException(`${operation} returned null`, 'UnknownError'); + } + if (hasToJSON(rawCredential)) { + return rawCredential.toJSON(); + } + throw new DOMException( + `${operation} returned a credential without toJSON()`, + 'UnknownError' + ); +} + // ─── Wrapper functions ──────────────────────────────────────────────────────── const DEFAULT_TIMEOUT_MS = 60_000; @@ -217,27 +258,7 @@ export async function createCredential( signal: controller.signal, }); - if (!rawCredential) { - throw new DOMException( - 'navigator.credentials.create returned null', - 'UnknownError' - ); - } - if (rawCredential instanceof PublicKeyCredential) { - return rawCredential.toJSON(); - } - const actualType = - (rawCredential as Credential).constructor?.name ?? typeof rawCredential; - const keyInfo = Object.entries(rawCredential as object) - .map( - ([k, v]) => - `${k}: ${v instanceof ArrayBuffer ? 'ArrayBuffer' : typeof v}` - ) - .join(', '); - throw new DOMException( - `navigator.credentials.create returned unexpected credential type: ${actualType} — fields: {${keyInfo}}`, - 'UnknownError' - ); + return toCredentialJSON(rawCredential, 'navigator.credentials.create'); } catch (e) { if (timedOut) { throw new DOMException('WebAuthn operation timed out', 'TimeoutError'); @@ -287,27 +308,7 @@ export async function getCredential( signal: controller.signal, }); - if (!rawCredential) { - throw new DOMException( - 'navigator.credentials.get returned null', - 'UnknownError' - ); - } - if (rawCredential instanceof PublicKeyCredential) { - return rawCredential.toJSON(); - } - const actualType = - (rawCredential as Credential).constructor?.name ?? typeof rawCredential; - const keyInfo = Object.entries(rawCredential as object) - .map( - ([k, v]) => - `${k}: ${v instanceof ArrayBuffer ? 'ArrayBuffer' : typeof v}` - ) - .join(', '); - throw new DOMException( - `navigator.credentials.get returned unexpected credential type: ${actualType} — fields: {${keyInfo}}`, - 'UnknownError' - ); + return toCredentialJSON(rawCredential, 'navigator.credentials.get'); } catch (e) { if (timedOut) { throw new DOMException('WebAuthn operation timed out', 'TimeoutError');