diff --git a/packages/core/auth-js/infra/docker-compose.yml b/packages/core/auth-js/infra/docker-compose.yml index eee5694b0..fcdd32db9 100644 --- a/packages/core/auth-js/infra/docker-compose.yml +++ b/packages/core/auth-js/infra/docker-compose.yml @@ -38,6 +38,8 @@ services: GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: '${GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID}' GOTRUE_SMS_AUTOCONFIRM: 'false' GOTRUE_COOKIE_KEY: 'sb' + GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: 'true' + GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: 'true' depends_on: - db restart: on-failure @@ -69,6 +71,8 @@ services: GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com GOTRUE_COOKIE_KEY: 'sb' GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: 'true' + GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: 'true' + GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: 'true' depends_on: - db restart: on-failure @@ -99,6 +103,8 @@ services: GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com GOTRUE_COOKIE_KEY: 'sb' + GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: 'true' + GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: 'true' depends_on: - db restart: on-failure @@ -128,6 +134,8 @@ services: GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS GOTRUE_SMTP_ADMIN_EMAIL: admin@email.com GOTRUE_COOKIE_KEY: 'sb' + GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: 'true' + GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: 'true' depends_on: - db restart: on-failure diff --git a/packages/core/auth-js/src/GoTrueClient.ts b/packages/core/auth-js/src/GoTrueClient.ts index 341d35ce0..4374f0283 100644 --- a/packages/core/auth-js/src/GoTrueClient.ts +++ b/packages/core/auth-js/src/GoTrueClient.ts @@ -3174,13 +3174,13 @@ export default class GoTrueClient { if (sessionError) { return { data: null, error: sessionError } } - + const { factorId, ...bodyParams } = params const response = (await _request( this.fetch, 'POST', - `${this.url}/factors/${params.factorId}/challenge`, + `${this.url}/factors/${factorId}/challenge`, { - body: params, + body: bodyParams, headers: this.headers, jwt: sessionData?.session?.access_token, } diff --git a/packages/core/auth-js/src/lib/webauthn.ts b/packages/core/auth-js/src/lib/webauthn.ts index b416db539..0336bf617 100644 --- a/packages/core/auth-js/src/lib/webauthn.ts +++ b/packages/core/auth-js/src/lib/webauthn.ts @@ -462,7 +462,7 @@ export const DEFAULT_CREATION_OPTIONS: Partial = { @@ -637,11 +637,18 @@ export class WebAuthnApi { /** webauthn will fail if either of the name/displayname are blank */ if (challengeResponse.webauthn.type === 'create') { const { user } = challengeResponse.webauthn.credential_options.publicKey - if (!user.name) { - user.name = `${user.id}:${friendlyName}` - } - if (!user.displayName) { - user.displayName = user.name + if (!user.name || !user.displayName) { + const currentUser = await this.client.getUser() + const userData = currentUser.data.user + const fallbackName = () => + userData?.user_metadata?.name || userData?.email || userData?.id || 'User' + + if (!user.name) { + user.name = friendlyName || fallbackName() + } + if (!user.displayName) { + user.displayName = friendlyName || fallbackName() + } } } diff --git a/packages/core/auth-js/test/GoTrueClient.test.ts b/packages/core/auth-js/test/GoTrueClient.test.ts index ca345c002..30837d457 100644 --- a/packages/core/auth-js/test/GoTrueClient.test.ts +++ b/packages/core/auth-js/test/GoTrueClient.test.ts @@ -1,26 +1,38 @@ -import { AuthError } from '../src/lib/errors' +import { JWK, Session } from '../src' +import GoTrueClient from '../src/GoTrueClient' +import { base64UrlToUint8Array } from '../src/lib/base64url' import { STORAGE_KEY } from '../src/lib/constants' +import { AuthError } from '../src/lib/errors' +import { setItemAsync } from '../src/lib/helpers' import { memoryLocalStorageAdapter } from '../src/lib/local-storage' -import GoTrueClient from '../src/GoTrueClient' +import { + deserializeCredentialCreationOptions, + deserializeCredentialRequestOptions, + serializeCredentialCreationResponse, + serializeCredentialRequestResponse, +} from '../src/lib/webauthn' +import type { PublicKeyCredentialFuture, PublicKeyCredentialJSON } from '../src/lib/webauthn.dom' import { authClient as auth, - authClientWithSession as authWithSession, - authClientWithAsymmetricSession as authWithAsymmetricSession, - authSubscriptionClient, - clientApiAutoConfirmOffSignupsEnabledClient as phoneClient, - clientApiAutoConfirmDisabledClient as signUpDisabledClient, - clientApiAutoConfirmEnabledClient as signUpEnabledClient, authAdminApiAutoConfirmEnabledClient, - GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, authClient, - GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON, - pkceClient, + authSubscriptionClient, + authClientWithAsymmetricSession as authWithAsymmetricSession, + authClientWithSession as authWithSession, autoRefreshClient, getClientWithSpecificStorage, + GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON, + GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, + clientApiAutoConfirmOffSignupsEnabledClient as phoneClient, + pkceClient, + clientApiAutoConfirmDisabledClient as signUpDisabledClient, + clientApiAutoConfirmEnabledClient as signUpEnabledClient, } from './lib/clients' import { mockUserCredentials } from './lib/utils' -import { JWK, Session } from '../src' -import { setItemAsync } from '../src/lib/helpers' +import { + webauthnCreationCredentialResponse, + webauthnCreationMockCredential, +} from './webauthn.fixtures' const TEST_USER_DATA = { info: 'some info' } @@ -1569,7 +1581,7 @@ describe('MFA', () => { test('should handle MFA verify without session', async () => { const { data, error } = await auth.mfa.verify({ factorId: 'test-factor-id', - challengeId: 'test-challenge-id', + challengeId: 'f7850041-ba10-4eb3-851c-8ceb7ff8463d', code: '123456', }) @@ -1587,6 +1599,223 @@ describe('MFA', () => { }) }) +describe('WebAuthn MFA', () => { + beforeEach(() => { + // Setup navigator.credentials mock + if (!global.navigator) { + global.navigator = {} as Navigator + } + + // Mock navigator.credentials using Object.defineProperty since it's read-only + Object.defineProperty(global.navigator, 'credentials', { + value: { + create: jest.fn(), + get: jest.fn(), + store: jest.fn(), + preventSilentAccess: jest.fn(), + }, + writable: false, + configurable: true, + }) + + // Mock PublicKeyCredential as a proper class so instanceof checks work + class PublicKeyCredentialMock implements Partial { + readonly id: string + readonly rawId: ArrayBuffer + readonly type: PublicKeyCredentialType = 'public-key' + readonly response: AuthenticatorResponse + readonly authenticatorAttachment: AuthenticatorAttachment | null + + constructor(data: { + id: string + rawId: string | ArrayBuffer + type: PublicKeyCredentialType + response: AuthenticatorResponse + authenticatorAttachment?: AuthenticatorAttachment | null + }) { + this.id = data.id + this.rawId = + typeof data.rawId === 'string' ? base64UrlToUint8Array(data.rawId).buffer : data.rawId + this.response = data.response + this.authenticatorAttachment = data.authenticatorAttachment ?? null + } + + getClientExtensionResults(): AuthenticationExtensionsClientOutputs { + return {} + } + + toJSON(): PublicKeyCredentialJSON { + // Use the proper serialization functions based on response type + if ('attestationObject' in this.response) { + // Registration response + return serializeCredentialCreationResponse(this as any) + } else if ('signature' in this.response) { + // Authentication response + return serializeCredentialRequestResponse(this as any) + } + throw new Error('Unknown Credential Type') + } + + static isUserVerifyingPlatformAuthenticatorAvailable = jest.fn().mockResolvedValue(true) + static isConditionalMediationAvailable = jest.fn().mockResolvedValue(true) + static parseCreationOptionsFromJSON = deserializeCredentialCreationOptions + static parseRequestOptionsFromJSON = deserializeCredentialRequestOptions + } + + ;(global as any).PublicKeyCredential = PublicKeyCredentialMock + }) + + afterAll(() => { + // @ts-ignore + delete global.navigator + // @ts-ignore + delete global.PublicKeyCredential + }) + + const setupUserWithWebAuthn = async () => { + const { email, password } = mockUserCredentials() + const { data: signUpData, error: signUpError } = await authWithSession.signUp({ + email, + password, + }) + expect(signUpError).toBeNull() + expect(signUpData.session).not.toBeNull() + + await authWithSession.initialize() + + const { error: signInError } = await authWithSession.signInWithPassword({ + email, + password, + }) + expect(signInError).toBeNull() + + return { email, password } + } + + test('enroll WebAuthn should fail without session', async () => { + await authWithSession.signOut() + const { data, error } = await authWithSession.mfa.webauthn.enroll({ + friendlyName: 'Test Device', + }) + + expect(error).not.toBeNull() + expect(error?.message).toContain('Bearer token') + expect(data).toBeNull() + }) + + test('enroll WebAuthn should allow empty friendlyName', async () => { + await setupUserWithWebAuthn() + const { data, error } = await authWithSession.mfa.webauthn.enroll({ + friendlyName: '', + }) + + // Server allows empty friendlyName + expect(error).toBeNull() + expect(data).not.toBeNull() + expect(data?.type).toBe('webauthn') + }) + + test('enroll WebAuthn should create unverified factor', async () => { + await setupUserWithWebAuthn() + const { data, error } = await authWithSession.mfa.webauthn.enroll({ + friendlyName: 'Test Security Key', + }) + + expect(error).toBeNull() + expect(data).not.toBeNull() + expect(data?.id).toBeDefined() + expect(data?.type).toBe('webauthn') + expect(data?.friendly_name).toBe('Test Security Key') + }) + + test('challenge WebAuthn should fail without session', async () => { + await authWithSession.signOut() + const { data, error } = await authWithSession.mfa.webauthn.challenge({ + factorId: 'test-factor-id', + webauthn: { + rpId: 'localhost', + rpOrigins: ['http://localhost:9999'], + }, + }) + + expect(error).not.toBeNull() + expect(error?.message).toContain('Bearer token') + expect(data).toBeNull() + }) + + test('challenge WebAuthn should fail with invalid factorId', async () => { + await setupUserWithWebAuthn() + const { data, error } = await authWithSession.mfa.webauthn.challenge({ + factorId: 'invalid-factor-id', + webauthn: { + rpId: 'localhost', + rpOrigins: ['http://localhost:9999'], + }, + }) + + expect(error).not.toBeNull() + expect(data).toBeNull() + }) + + test('verify WebAuthn should fail without session', async () => { + await authWithSession.signOut() + const { data, error } = await authWithSession.mfa.webauthn.verify({ + factorId: webauthnCreationCredentialResponse.factorId, + challengeId: webauthnCreationCredentialResponse.challengeId, + webauthn: { + type: 'create', + rpId: webauthnCreationCredentialResponse.rpId, + rpOrigins: [webauthnCreationCredentialResponse.origin], + credential_response: webauthnCreationMockCredential, + }, + }) + + expect(error).not.toBeNull() + expect(error?.message).toContain('Bearer token') + expect(data).toBeNull() + }) + + test('unenroll WebAuthn should remove factor', async () => { + await setupUserWithWebAuthn() + + const { data: enrollData } = await authWithSession.mfa.webauthn.enroll({ + friendlyName: 'Test Device', + }) + + if (!enrollData) { + throw new Error('Failed to enroll WebAuthn factor') + } + + const { error: unenrollError } = await authWithSession.mfa.unenroll({ + factorId: enrollData.id, + }) + + expect(unenrollError).toBeNull() + + // Wait for unenrollment to be processed + await new Promise((resolve) => setTimeout(resolve, 1000)) + + // Verify factor was removed + const { data: factorsData } = await authWithSession.mfa.listFactors() + const webauthnFactors = factorsData?.all.filter((f) => f.factor_type === 'webauthn') || [] + expect(webauthnFactors).toHaveLength(0) + }) + + test('should enroll WebAuthn factor', async () => { + await setupUserWithWebAuthn() + + const { data: enrollData, error: enrollError } = await authWithSession.mfa.webauthn.enroll({ + friendlyName: 'Test Yubikey', + }) + + expect(enrollError).toBeNull() + expect(enrollData).not.toBeNull() + expect(enrollData?.type).toBe('webauthn') + expect(enrollData?.id).toBeDefined() + expect(enrollData?.friendly_name).toBe('Test Yubikey') + }) +}) + describe('getClaims', () => { test('getClaims returns nothing if there is no session present', async () => { const { data, error } = await authClient.getClaims() diff --git a/packages/core/auth-js/test/webauthn.fixtures.ts b/packages/core/auth-js/test/webauthn.fixtures.ts new file mode 100644 index 000000000..3faf8a060 --- /dev/null +++ b/packages/core/auth-js/test/webauthn.fixtures.ts @@ -0,0 +1,138 @@ +/** + * Real WebAuthn test fixtures captured from actual authentication flows + * This data was captured from a successful WebAuthn authentication with Touch ID on macOS + */ + +import { AuthenticationResponseJSON, RegistrationResponseJSON } from '../src/lib/webauthn' +import { base64UrlToUint8Array } from '../src/lib/base64url' +import { AuthenticationCredential, RegistrationCredential } from '../src/lib/webauthn.dom' + +export const webauthnAssertionCredentialResponse = { + factorId: '1c339118-cf88-4cee-b393-fc787827aa44', + challengeId: '3c18b413-67d0-4e39-a78e-ab700693169f', + challenge: 'VbLuwKyYzmr6zL3lMyaWH5oeZ1-XolTc-PWKyAP9_xM', + + credentialId: + 'DdXDk8SeBbRJ9Tdzixah_kx8ss4_R6vsChaoN0og-00lrytX9ih4ohyUoU_jtiQ4ObCpgyZedT8fCm9VcgAYpQ', + rpId: 'localhost', + origin: 'http://localhost:5173', + + credentialResponse: { + id: 'DdXDk8SeBbRJ9Tdzixah_kx8ss4_R6vsChaoN0og-00lrytX9ih4ohyUoU_jtiQ4ObCpgyZedT8fCm9VcgAYpQ', + rawId: 'DdXDk8SeBbRJ9Tdzixah_kx8ss4_R6vsChaoN0og-00lrytX9ih4ohyUoU_jtiQ4ObCpgyZedT8fCm9VcgAYpQ', + type: 'public-key', + response: { + authenticatorData: 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MFAAAABQ', + clientDataJSON: + 'eyJ0eXBlIjoid2ViYXV0aG4uZ2V0IiwiY2hhbGxlbmdlIjoiVmJMdXdLeVl6bXI2ekwzbE15YVdINW9lWjEtWG9sVGMtUFdLeUFQOV94TSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0', + signature: + 'MEQCICn34eDexsucGLVWem0lrAb92HhM5Aj-U2ed2TJneNIyAiA50q-SpbRQD5MvRsqBGy8NAKonupEtZyRdOgcs70APZQ', + }, + authenticatorAttachment: 'cross-platform', + clientExtensionResults: {}, + } as AuthenticationResponseJSON, + + + authenticatorDataParsed: { + rpIdHash: 'SZYN5YgOjGh0NBcPZHZgW4/krrmihjLHmVzzuoMdl2M=', + flags: 5, + signCount: 5, + }, + + clientDataParsed: { + type: 'webauthn.get', + challenge: 'VbLuwKyYzmr6zL3lMyaWH5oeZ1-XolTc-PWKyAP9_xM', + origin: 'http://localhost:5173', + crossOrigin: false, + }, +} + +export const webauthnAssertionMockCredential = { + id: webauthnAssertionCredentialResponse.credentialResponse.id, + rawId: base64UrlToUint8Array(webauthnAssertionCredentialResponse.credentialResponse.rawId).buffer, + type: 'public-key' as const, + authenticatorAttachment: + webauthnAssertionCredentialResponse.credentialResponse.authenticatorAttachment || null, + parseCreationOptionsFromJSON: jest.fn(), + parseRequestOptionsFromJSON: jest.fn(), + toJSON: jest.fn(() => webauthnAssertionCredentialResponse.credentialResponse), + getClientExtensionResults: jest.fn( + () => webauthnAssertionCredentialResponse.credentialResponse.clientExtensionResults + ), + response: { + clientDataJSON: base64UrlToUint8Array( + webauthnAssertionCredentialResponse.credentialResponse.response.clientDataJSON + ).buffer, + authenticatorData: base64UrlToUint8Array( + webauthnAssertionCredentialResponse.credentialResponse.response.authenticatorData + ).buffer, + signature: base64UrlToUint8Array( + webauthnAssertionCredentialResponse.credentialResponse.response.signature + ).buffer, + userHandle: null, + }, +} as AuthenticationCredential + +export const webauthnCreationCredentialResponse = { + factorId: '1c339118-cf88-4cee-b393-fc787827aa44', + challengeId: '78276c27-aab0-48cb-b745-3f697055ad94', + challenge: 'W6tSIPRrwCkkBztAtl_lJyrB3umFlvSdGdcYti-OsGM', + rpId: 'localhost', + origin: 'http://localhost:5173', + credentialResponse: { + id: 'DdXDk8SeBbRJ9Tdzixah_kx8ss4_R6vsChaoN0og-00lrytX9ih4ohyUoU_jtiQ4ObCpgyZedT8fCm9VcgAYpQ', + rawId: 'DdXDk8SeBbRJ9Tdzixah_kx8ss4_R6vsChaoN0og-00lrytX9ih4ohyUoU_jtiQ4ObCpgyZedT8fCm9VcgAYpQ', + type: 'public-key', + response: { + attestationObject: + 'o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YVjESZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAgAAAAAAAAAAAAAAAAAAAAAAQA3Vw5PEngW0SfU3c4sWof5MfLLOP0er7AoWqDdKIPtNJa8rV_YoeKIclKFP47YkODmwqYMmXnU_HwpvVXIAGKWlAQIDJiABIVggbz6gtnM1dDzJghgBiBGJJZaBpLXgT19WZEcQT5JAanciWCDlC-xVpfcZ7XWG_ZWck47XX0OefXvECdEjIuTqT6MCIQ', + clientDataJSON: + 'eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmdlIjoiVzZ0U0lQUnJ3Q2trQnp0QXRsX2xKeXJCM3VtRmx2U2RHZGNZdGktT3NHTSIsIm9yaWdpbiI6Imh0dHA6Ly9sb2NhbGhvc3Q6NTE3MyIsImNyb3NzT3JpZ2luIjpmYWxzZX0', + authenticatorData: + 'SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NFAAAAAgAAAAAAAAAAAAAAAAAAAAAAQA3Vw5PEngW0SfU3c4sWof5MfLLOP0er7AoWqDdKIPtNJa8rV_YoeKIclKFP47YkODmwqYMmXnU_HwpvVXIAGKWlAQIDJiABIVggbz6gtnM1dDzJghgBiBGJJZaBpLXgT19WZEcQT5JAanciWCDlC-xVpfcZ7XWG_ZWck47XX0OefXvECdEjIuTqT6MCIQ', + publicKey: + 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEbz6gtnM1dDzJghgBiBGJJZaBpLXgT19WZEcQT5JAanflC-xVpfcZ7XWG_ZWck47XX0OefXvECdEjIuTqT6MCIQ', + publicKeyAlgorithm: -7, + transports: ['usb'], + }, + authenticatorAttachment: 'cross-platform', + } as RegistrationResponseJSON, +} + +export const webauthnCreationMockCredential = { + id: webauthnCreationCredentialResponse.credentialResponse.id, + rawId: base64UrlToUint8Array(webauthnCreationCredentialResponse.credentialResponse.rawId).buffer, + type: 'public-key' as const, + authenticatorAttachment: + webauthnCreationCredentialResponse.credentialResponse.authenticatorAttachment || null, + parseCreationOptionsFromJSON: jest.fn(), + parseRequestOptionsFromJSON: jest.fn(), + toJSON: jest.fn(() => webauthnCreationCredentialResponse.credentialResponse), + getClientExtensionResults: jest.fn(() => ({})), + response: { + clientDataJSON: base64UrlToUint8Array( + webauthnCreationCredentialResponse.credentialResponse.response.clientDataJSON + ).buffer, + attestationObject: base64UrlToUint8Array( + webauthnCreationCredentialResponse.credentialResponse.response.attestationObject + ).buffer, + getTransports: jest.fn( + () => webauthnCreationCredentialResponse.credentialResponse.response.transports || [] + ), + getAuthenticatorData: jest.fn( + () => + base64UrlToUint8Array( + webauthnCreationCredentialResponse.credentialResponse.response.authenticatorData! + ).buffer + ), + getPublicKey: jest.fn( + () => + base64UrlToUint8Array( + webauthnCreationCredentialResponse.credentialResponse.response.publicKey! + ).buffer + ), + getPublicKeyAlgorithm: jest.fn( + () => webauthnCreationCredentialResponse.credentialResponse.response.publicKeyAlgorithm! + ), + }, +} as RegistrationCredential \ No newline at end of file diff --git a/packages/core/auth-js/test/webauthn.helpers.test.ts b/packages/core/auth-js/test/webauthn.helpers.test.ts new file mode 100644 index 000000000..50ccdf9db --- /dev/null +++ b/packages/core/auth-js/test/webauthn.helpers.test.ts @@ -0,0 +1,531 @@ +import { + deserializeCredentialCreationOptions, + deserializeCredentialRequestOptions, + mergeCredentialCreationOptions, + mergeCredentialRequestOptions, + serializeCredentialCreationResponse, + serializeCredentialRequestResponse, +} from '../src/lib/webauthn' +import type { + AuthenticationCredential, + AuthenticatorTransportFuture, + PublicKeyCredentialCreationOptionsFuture, + PublicKeyCredentialRequestOptionsFuture, + RegistrationCredential, +} from '../src/lib/webauthn.dom' +import { + webauthnCreationMockCredential, + webauthnCreationCredentialResponse, + webauthnAssertionMockCredential, + webauthnAssertionCredentialResponse, +} from './webauthn.fixtures' +import { base64UrlToUint8Array } from '../src/lib/base64url' +import { DEFAULT_CREATION_OPTIONS } from '../src/lib/webauthn' + +describe('WebAuthn Serialization/Deserialization', () => { + let originalPublicKeyCredential: any + + beforeEach(() => { + originalPublicKeyCredential = (global as any).PublicKeyCredential + }) + + afterEach(() => { + ;(global as any).PublicKeyCredential = originalPublicKeyCredential + }) + + describe('deserializeCredentialCreationOptions', () => { + const validServerOptions = { + challenge: webauthnCreationCredentialResponse.challenge, + rp: { + name: 'Test RP', + id: webauthnCreationCredentialResponse.rpId, + }, + user: { + id: 'dXNlci0xMjM0NTY', + name: 'test@example.com', + displayName: 'Test User', + }, + pubKeyCredParams: [ + { type: 'public-key' as const, alg: -7 }, + { type: 'public-key' as const, alg: -257 }, + ], + timeout: 60000, + attestation: 'direct' as const, + excludeCredentials: [ + { + id: webauthnCreationCredentialResponse.credentialResponse.rawId, + type: 'public-key' as const, + transports: ['usb', 'nfc'] as AuthenticatorTransportFuture[], + }, + ], + } + + it('should convert base64url strings to ArrayBuffers using polyfill', () => { + delete (global as any).PublicKeyCredential + + const result = deserializeCredentialCreationOptions(validServerOptions) + + expect(result.challenge).toBeInstanceOf(ArrayBuffer) + const challengeBytes = new Uint8Array(result.challenge as ArrayBuffer) + const expectedChallengeBytes = base64UrlToUint8Array( + webauthnCreationCredentialResponse.challenge + ) + expect(challengeBytes).toEqual(expectedChallengeBytes) + + expect(result.user.id).toBeInstanceOf(ArrayBuffer) + + expect(result.excludeCredentials?.[0].id).toBeInstanceOf(ArrayBuffer) + const credIdBytes = new Uint8Array(result.excludeCredentials![0].id as ArrayBuffer) + const expectedCredIdBytes = base64UrlToUint8Array( + webauthnCreationCredentialResponse.credentialResponse.rawId + ) + expect(credIdBytes).toEqual(expectedCredIdBytes) + + expect(result.rp).toEqual(validServerOptions.rp) + expect(result.pubKeyCredParams).toEqual(validServerOptions.pubKeyCredParams) + expect(result.timeout).toBe(60000) + expect(result.attestation).toBe(validServerOptions.attestation) + }) + + it('should use native parseCreationOptionsFromJSON when available', () => { + const mockParseCreationOptions = jest.fn().mockReturnValue({ + challenge: new ArrayBuffer(8), + rp: validServerOptions.rp, + user: { ...validServerOptions.user, id: new ArrayBuffer(8) }, + pubKeyCredParams: validServerOptions.pubKeyCredParams, + }) + + ;(global as any).PublicKeyCredential = { + parseCreationOptionsFromJSON: mockParseCreationOptions, + } + + const result = deserializeCredentialCreationOptions(validServerOptions) + + expect(mockParseCreationOptions).toHaveBeenCalledWith(validServerOptions) + expect(result.challenge).toBeInstanceOf(ArrayBuffer) + expect(result.user.id).toBeInstanceOf(ArrayBuffer) + }) + + it('should handle missing optional fields correctly', () => { + const minimalOptions = { + challenge: 'SGVsbG8gV2ViQXV0aG4h', + rp: { name: 'Test RP' }, + user: { + id: 'dXNlci0xMjM0NTY', + name: 'test@example.com', + displayName: 'Test User', + }, + pubKeyCredParams: [{ type: 'public-key' as const, alg: -7 }], + } + + delete (global as any).PublicKeyCredential + + const result = deserializeCredentialCreationOptions(minimalOptions) + + expect(result.excludeCredentials).toBeUndefined() + expect(result.attestation).toBeUndefined() + expect(result.timeout).toBeUndefined() + }) + + it('should throw on null/undefined options', () => { + expect(() => deserializeCredentialCreationOptions(null as any)).toThrow( + 'Credential creation options are required' + ) + expect(() => deserializeCredentialCreationOptions(undefined as any)).toThrow( + 'Credential creation options are required' + ) + }) + }) + + describe('deserializeCredentialRequestOptions', () => { + const validServerOptions = { + challenge: webauthnAssertionCredentialResponse.challenge, + timeout: 60000, + rpId: webauthnAssertionCredentialResponse.rpId, + userVerification: 'preferred' as const, + allowCredentials: [ + { + id: webauthnAssertionCredentialResponse.credentialResponse.rawId, + type: 'public-key' as const, + transports: ['usb', 'nfc'] as AuthenticatorTransportFuture[], + }, + ], + } + + it('should convert base64url strings to ArrayBuffers using polyfill', () => { + delete (global as any).PublicKeyCredential + + const result = deserializeCredentialRequestOptions(validServerOptions) + + expect(result.challenge).toBeInstanceOf(ArrayBuffer) + expect(result.allowCredentials?.[0].id).toBeInstanceOf(ArrayBuffer) + + const challengeBytes = new Uint8Array(result.challenge as ArrayBuffer) + const expectedChallengeBytes = base64UrlToUint8Array( + webauthnAssertionCredentialResponse.challenge + ) + expect(challengeBytes).toEqual(expectedChallengeBytes) + + const credIdBytes = new Uint8Array(result.allowCredentials![0].id as ArrayBuffer) + const expectedCredIdBytes = base64UrlToUint8Array( + webauthnAssertionCredentialResponse.credentialResponse.rawId + ) + expect(credIdBytes).toEqual(expectedCredIdBytes) + + expect(result.rpId).toBe(webauthnAssertionCredentialResponse.rpId) + expect(result.userVerification).toBe('preferred') + expect(result.timeout).toBe(60000) + }) + + it('should use native parseRequestOptionsFromJSON when available', () => { + const mockParseRequestOptions = jest.fn().mockReturnValue({ + challenge: new ArrayBuffer(8), + rpId: 'example.com', + allowCredentials: [{ id: new ArrayBuffer(8), type: 'public-key', transports: ['usb'] }], + }) + + ;(global as any).PublicKeyCredential = { + parseRequestOptionsFromJSON: mockParseRequestOptions, + } + + const result = deserializeCredentialRequestOptions(validServerOptions) + + expect(mockParseRequestOptions).toHaveBeenCalledWith(validServerOptions) + expect(result.challenge).toBeInstanceOf(ArrayBuffer) + }) + + it('should handle empty allowCredentials array', () => { + delete (global as any).PublicKeyCredential + + const optionsWithEmptyArray = { + ...validServerOptions, + allowCredentials: [], + } + + const result = deserializeCredentialRequestOptions(optionsWithEmptyArray) + + expect(result.allowCredentials).toBeUndefined() + }) + + it('should handle missing allowCredentials', () => { + delete (global as any).PublicKeyCredential + + const optionsWithoutAllow = { + challenge: 'QXV0aGVudGljYXRlTWU', + rpId: 'example.com', + } + + const result = deserializeCredentialRequestOptions(optionsWithoutAllow) + expect(result.allowCredentials).toBeUndefined() + }) + }) + + describe('serializeCredentialCreationResponse', () => { + it('should convert ArrayBuffers to base64url strings using polyfill', () => { + const result = serializeCredentialCreationResponse(webauthnCreationMockCredential) + + expect(result.rawId).toBe(webauthnCreationMockCredential.id) + expect(result.response.attestationObject).toBe( + webauthnCreationCredentialResponse.credentialResponse.response.attestationObject + ) + expect(result.response.clientDataJSON).toBe( + webauthnCreationCredentialResponse.credentialResponse.response.clientDataJSON + ) + expect(result.authenticatorAttachment).toBe( + webauthnCreationCredentialResponse.credentialResponse.authenticatorAttachment + ) + }) + + it('should use native toJSON when available', () => { + const mockToJSON = jest.fn().mockReturnValue({ + id: 'test-id', + rawId: 'dGVzdC1yYXdJZA', + response: { + attestationObject: 'YXR0ZXN0', + clientDataJSON: 'Y2xpZW50', + }, + type: 'public-key', + authenticatorAttachment: 'cross-platform', + clientExtensionResults: {}, + }) + + const mockCredential = { + id: 'test-id', + rawId: new ArrayBuffer(8), + response: { + toJSON: mockToJSON, + attestationObject: new ArrayBuffer(8), + clientDataJSON: new ArrayBuffer(8), + }, + type: 'public-key', + toJSON: mockToJSON, + getClientExtensionResults: jest.fn().mockReturnValue({}), + } as unknown as RegistrationCredential + + const result = serializeCredentialCreationResponse(mockCredential) + + expect(mockToJSON).toHaveBeenCalled() + expect(result.id).toBe('test-id') + expect(result.rawId).toBe('dGVzdC1yYXdJZA') + }) + + it('should handle null authenticatorAttachment correctly', () => { + const mockCredential = { + id: 'test-id', + rawId: new Uint8Array([1, 2, 3]).buffer, + response: { + attestationObject: new ArrayBuffer(0), + clientDataJSON: new ArrayBuffer(0), + getPublicKey: jest.fn(), + getPublicKeyAlgorithm: jest.fn(), + getTransports: jest.fn(), + getAuthenticatorData: jest.fn(), + }, + type: 'public-key', + authenticatorAttachment: null, + getClientExtensionResults: jest.fn().mockReturnValue({}), + } as unknown as RegistrationCredential + + const result = serializeCredentialCreationResponse(mockCredential) + expect(result.authenticatorAttachment).toBeUndefined() + }) + }) + + describe('serializeCredentialRequestResponse with real data', () => { + it('should correctly serialize real WebAuthn assertion data', () => { + const result = serializeCredentialRequestResponse( + webauthnAssertionMockCredential as AuthenticationCredential + ) + + // Verify the serialized format matches what server expects + expect(result.rawId).toBe(webauthnAssertionCredentialResponse.credentialId) + expect(result.response.authenticatorData).toBe( + webauthnAssertionCredentialResponse.credentialResponse.response.authenticatorData + ) + expect(result.response.clientDataJSON).toBe( + webauthnAssertionCredentialResponse.credentialResponse.response.clientDataJSON + ) + expect(result.response.signature).toBe( + webauthnAssertionCredentialResponse.credentialResponse.response.signature + ) + expect(result.response.userHandle).toBe( + webauthnAssertionCredentialResponse.credentialResponse.response.userHandle + ) + expect(result.authenticatorAttachment).toBe( + webauthnAssertionCredentialResponse.credentialResponse.authenticatorAttachment + ) + }) + }) +}) + +describe('serializeCredentialRequestResponse', () => { + it('should convert ArrayBuffers to base64url strings using polyfill', () => { + const authDataBytes = new Uint8Array([1, 2, 3, 4, 5]) + const clientDataBytes = new Uint8Array([6, 7, 8, 9, 10]) + const signatureBytes = new Uint8Array([11, 12, 13, 14, 15]) + const userHandleBytes = new Uint8Array([16, 17, 18, 19, 20]) + const credIdBytes = new Uint8Array([21, 22, 23, 24, 25]) + + const mockCredential: AuthenticationCredential = { + id: 'credential-id-string', + rawId: credIdBytes.buffer, + response: { + authenticatorData: authDataBytes.buffer, + clientDataJSON: clientDataBytes.buffer, + signature: signatureBytes.buffer, + userHandle: userHandleBytes.buffer, + }, + type: 'public-key', + getClientExtensionResults: jest.fn().mockReturnValue({}), + authenticatorAttachment: 'cross-platform' as AuthenticatorAttachment, + } as unknown as AuthenticationCredential + + const result = serializeCredentialRequestResponse(mockCredential) + + expect(result.rawId).toBe(mockCredential.id) + expect(result.response.authenticatorData).toBe('AQIDBAU') + expect(result.response.clientDataJSON).toBe('BgcICQo') + expect(result.response.signature).toBe('CwwNDg8') + expect(result.response.userHandle).toBe('EBESExQ') + }) + + it('should handle null userHandle correctly', () => { + const mockCredential = { + id: 'test-id', + rawId: new Uint8Array([1, 2, 3]).buffer, + response: { + authenticatorData: new ArrayBuffer(0), + clientDataJSON: new ArrayBuffer(0), + signature: new ArrayBuffer(0), + userHandle: null, + }, + type: 'public-key', + getClientExtensionResults: jest.fn().mockReturnValue({}), + } as unknown as AuthenticationCredential + + const result = serializeCredentialRequestResponse(mockCredential) + expect(result.response.userHandle).toBeUndefined() + }) + + it('should use native toJSON when available', () => { + const mockToJSON = jest.fn().mockReturnValue({ + id: 'test-id', + rawId: 'dGVzdC1yYXdJZA', + response: { + authenticatorData: 'YXV0aERhdGE', + clientDataJSON: 'Y2xpZW50', + signature: 'c2lnbmF0dXJl', + userHandle: 'dXNlckhhbmRsZQ', + }, + type: 'public-key', + authenticatorAttachment: 'platform', + clientExtensionResults: {}, + }) + + const mockCredential = { + id: 'test-id', + rawId: new ArrayBuffer(8), + response: { + toJSON: mockToJSON, + authenticatorData: new ArrayBuffer(8), + clientDataJSON: new ArrayBuffer(8), + signature: new ArrayBuffer(8), + userHandle: new ArrayBuffer(8), + }, + type: 'public-key', + toJSON: mockToJSON, + getClientExtensionResults: jest.fn().mockReturnValue({}), + } as unknown as AuthenticationCredential + + const result = serializeCredentialRequestResponse(mockCredential) + + expect(mockToJSON).toHaveBeenCalled() + expect(result.response.authenticatorData).toBe('YXV0aERhdGE') + expect(result.response.signature).toBe('c2lnbmF0dXJl') + }) +}) + +describe('mergeCredentialCreationOptions', () => { + const baseOptions: PublicKeyCredentialCreationOptionsFuture = { + challenge: new Uint8Array([67, 104, 97, 108, 108, 101, 110, 103, 101, 49, 50, 51]).buffer, + rp: { name: 'Test RP', id: 'example.com' }, + user: { + id: new Uint8Array([85, 115, 101, 114, 49, 50, 51]).buffer, + name: 'user@example.com', + displayName: 'Test User', + }, + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + } + + it('should apply DEFAULT_CREATION_OPTIONS correctly', () => { + const result = mergeCredentialCreationOptions(baseOptions) + + expect(result.authenticatorSelection).toEqual(DEFAULT_CREATION_OPTIONS.authenticatorSelection) + expect(result.hints).toEqual(DEFAULT_CREATION_OPTIONS.hints) + expect(result.attestation).toBe(DEFAULT_CREATION_OPTIONS.attestation) + + expect(result.challenge).toBe(baseOptions.challenge) + expect(result.rp).toEqual(baseOptions.rp) + expect(result.user).toEqual(baseOptions.user) + }) + + it('should deep merge authenticatorSelection correctly', () => { + const result = mergeCredentialCreationOptions(baseOptions, { + authenticatorSelection: { + userVerification: 'required', + }, + }) + + expect(result.authenticatorSelection).toEqual({ + authenticatorAttachment: 'cross-platform', + requireResidentKey: false, + residentKey: 'discouraged', + userVerification: 'required', + }) + }) + + it('should allow complete override of nested objects', () => { + const result = mergeCredentialCreationOptions(baseOptions, { + authenticatorSelection: { + authenticatorAttachment: 'platform', + requireResidentKey: true, + residentKey: 'required', + userVerification: 'discouraged', + }, + attestation: 'none', + hints: ['client-device'], + }) + + expect(result.authenticatorSelection).toEqual({ + authenticatorAttachment: 'platform', + requireResidentKey: true, + residentKey: 'required', + userVerification: 'discouraged', + }) + expect(result.attestation).toBe('none') + expect(result.hints).toEqual(['client-device']) + }) + + it('should not modify ArrayBuffer fields during merge', () => { + const customChallenge = new Uint8Array([78, 101, 119, 67, 104, 97, 108, 108]).buffer + const result = mergeCredentialCreationOptions(baseOptions, { + challenge: customChallenge, + }) + + expect(result.challenge).toBe(customChallenge) + expect(result.challenge).not.toBe(baseOptions.challenge) + }) +}) + +describe('mergeCredentialRequestOptions', () => { + const baseOptions: PublicKeyCredentialRequestOptionsFuture = { + challenge: new Uint8Array([82, 101, 113, 117, 101, 115, 116]).buffer, + rpId: 'example.com', + allowCredentials: [ + { + id: new Uint8Array([67, 114, 101, 100, 49, 50, 51]).buffer, + type: 'public-key', + transports: ['usb'], + }, + ], + } + + it('should apply DEFAULT_REQUEST_OPTIONS correctly', () => { + const result = mergeCredentialRequestOptions(baseOptions) + + expect(result.userVerification).toBe('preferred') + expect(result.hints).toEqual(['security-key']) + + expect(result.challenge).toBe(baseOptions.challenge) + expect(result.allowCredentials).toBe(baseOptions.allowCredentials) + }) + + it('should allow overriding defaults', () => { + const result = mergeCredentialRequestOptions(baseOptions, { + userVerification: 'required', + hints: ['hybrid', 'security-key'], + timeout: 120000, + }) + + expect(result.userVerification).toBe('required') + expect(result.hints).toEqual(['hybrid', 'security-key']) + expect(result.timeout).toBe(120000) + }) + + it('should preserve allowCredentials ArrayBuffers', () => { + const newCreds = [ + { + id: new Uint8Array([78, 101, 119, 67, 114, 101, 100]).buffer, + type: 'public-key' as const, + transports: ['nfc'] as AuthenticatorTransportFuture[], + }, + ] + + const result = mergeCredentialRequestOptions(baseOptions, { + allowCredentials: newCreds, + }) + + expect(result.allowCredentials).toBe(newCreds) + expect(result.allowCredentials![0].id).toBeInstanceOf(ArrayBuffer) + }) +})