Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions packages/fxa-settings/src/lib/passkeys/webauthn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,6 @@ describe('isWebAuthnLevel3Supported', () => {
});
});

// ─── createCredential ─────────────────────────────────────────────────────────

describe('createCredential', () => {
let mockPKC: Record<string, jest.Mock>;
let mockCreate: jest.Mock;
Expand Down Expand Up @@ -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<string, jest.Mock>;
Expand Down Expand Up @@ -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()'),
});
});
});
85 changes: 43 additions & 42 deletions packages/fxa-settings/src/lib/passkeys/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,47 @@ export function isWebAuthnLevel3Supported(): boolean {
);
}

// ─── Credential extraction ──────────────────────────────────────────────────

/** Type guard for objects that can serialize to credential JSON. */
function hasToJSON(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

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;
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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');
Expand Down
Loading