From 0c8be61162d60ba0cb9babb3ccab945d321cbd41 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Thu, 25 Sep 2025 07:42:42 +0000 Subject: [PATCH 1/6] fix: add tests for webauthn factorType, webauthn merge helpers, add webauthn ENVs to docker-compose.yml --- infra/docker-compose.yml | 8 + test/GoTrueClient.test.ts | 201 ++++++++++++- test/webauthn.helpers.test.ts | 520 ++++++++++++++++++++++++++++++++++ 3 files changed, 718 insertions(+), 11 deletions(-) create mode 100644 test/webauthn.helpers.test.ts diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index eee5694b..fcdd32db 100644 --- a/infra/docker-compose.yml +++ b/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/test/GoTrueClient.test.ts b/test/GoTrueClient.test.ts index ca345c00..f6df6dfd 100644 --- a/test/GoTrueClient.test.ts +++ b/test/GoTrueClient.test.ts @@ -1,26 +1,30 @@ -import { AuthError } from '../src/lib/errors' +import { JWK, Session } from '../src' +import GoTrueClient from '../src/GoTrueClient' 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 { + authAdminApiAutoConfirmEnabledClient, authClient as auth, - authClientWithSession as authWithSession, + authClient, authClientWithAsymmetricSession as authWithAsymmetricSession, + authClientWithSession as authWithSession, authSubscriptionClient, - clientApiAutoConfirmOffSignupsEnabledClient as phoneClient, + autoRefreshClient, clientApiAutoConfirmDisabledClient as signUpDisabledClient, clientApiAutoConfirmEnabledClient as signUpEnabledClient, - authAdminApiAutoConfirmEnabledClient, - GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, - authClient, + clientApiAutoConfirmOffSignupsEnabledClient as phoneClient, + getClientWithSpecificStorage, GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON, + GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON, pkceClient, - autoRefreshClient, - getClientWithSpecificStorage, } from './lib/clients' import { mockUserCredentials } from './lib/utils' -import { JWK, Session } from '../src' -import { setItemAsync } from '../src/lib/helpers' +import { + createMockAuthenticationCredential, + createMockRegistrationCredential, +} from './webauthn-test-utils' const TEST_USER_DATA = { info: 'some info' } @@ -1587,6 +1591,181 @@ describe('MFA', () => { }) }) +describe('WebAuthn MFA', () => { + 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: 'test-factor-id', + challengeId: 'test-challenge-id', + webauthn: { + type: 'create', + rpId: 'localhost', + rpOrigins: ['http://localhost:9999'], + credential_response: { + id: 'test-credential-id', + rawId: new ArrayBuffer(8), + response: { + attestationObject: new ArrayBuffer(8), + clientDataJSON: new ArrayBuffer(8), + }, + type: 'public-key', + getClientExtensionResults: () => ({}), + } as any, + }, + }) + + 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('listFactors should include WebAuthn factors', async () => { + // Mock getUser to include WebAuthn factors + authWithSession.getUser = jest.fn().mockResolvedValue({ + data: { + user: { + id: 'test-user', + factors: [ + { id: '1', factor_type: 'webauthn', status: 'verified', friendly_name: 'YubiKey 5' }, + { id: '2', factor_type: 'webauthn', status: 'unverified', friendly_name: 'Touch ID' }, + { id: '3', factor_type: 'totp', status: 'verified' }, + ], + }, + }, + error: null, + }) + + const { data, error } = await authWithSession.mfa.listFactors() + + expect(error).toBeNull() + expect(data).not.toBeNull() + if (data) { + expect(data.all).toHaveLength(3) + const webauthnFactors = data.all.filter((f) => f.factor_type === 'webauthn') + expect(webauthnFactors).toHaveLength(2) + expect(webauthnFactors.filter((f) => f.status === 'verified')).toHaveLength(1) + } + }) +}) + describe('getClaims', () => { test('getClaims returns nothing if there is no session present', async () => { const { data, error } = await authClient.getClaims() diff --git a/test/webauthn.helpers.test.ts b/test/webauthn.helpers.test.ts new file mode 100644 index 00000000..68ec8669 --- /dev/null +++ b/test/webauthn.helpers.test.ts @@ -0,0 +1,520 @@ +import { + deserializeCredentialCreationOptions, + deserializeCredentialRequestOptions, mergeCredentialCreationOptions, + mergeCredentialRequestOptions, serializeCredentialCreationResponse, + serializeCredentialRequestResponse +} from '../src/lib/webauthn' +import type { + AuthenticationCredential, + AuthenticatorTransportFuture, PublicKeyCredentialCreationOptionsFuture, + PublicKeyCredentialRequestOptionsFuture, RegistrationCredential +} from '../src/lib/webauthn.dom' + +describe('WebAuthn Serialization/Deserialization', () => { + let originalPublicKeyCredential: any + + beforeEach(() => { + originalPublicKeyCredential = (global as any).PublicKeyCredential + }) + + afterEach(() => { + ;(global as any).PublicKeyCredential = originalPublicKeyCredential + }) + + describe('deserializeCredentialCreationOptions', () => { + const validServerOptions = { + challenge: 'dGVzdC1jaGFsbGVuZ2U', // "test-challenge" in base64url + rp: { + name: 'Test RP', + id: 'example.com', + }, + user: { + id: 'dXNlci1pZA', // "user-id" in base64url + 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: 'Y3JlZC1pZA', // "cred-id" in base64url + type: 'public-key' as const, + transports: ['usb', 'nfc'] as AuthenticatorTransportFuture[], + }, + ], + } + + it('should convert base64url strings to ArrayBuffers using polyfill', () => { + // Force polyfill path by removing PublicKeyCredential + delete (global as any).PublicKeyCredential + + const result = deserializeCredentialCreationOptions(validServerOptions) + + // Verify challenge was converted to ArrayBuffer + expect(result.challenge).toBeInstanceOf(ArrayBuffer) + const challengeBytes = new Uint8Array(result.challenge) + expect(challengeBytes).toEqual( + new Uint8Array([116, 101, 115, 116, 45, 99, 104, 97, 108, 108, 101, 110, 103, 101]) + ) + + // Verify user.id was converted to ArrayBuffer + expect(result.user.id).toBeInstanceOf(ArrayBuffer) + const userIdBytes = new Uint8Array(result.user.id) + expect(userIdBytes).toEqual(new Uint8Array([117, 115, 101, 114, 45, 105, 100])) + + // Verify excludeCredentials[0].id was converted to ArrayBuffer + expect(result.excludeCredentials![0].id).toBeInstanceOf(ArrayBuffer) + const credIdBytes = new Uint8Array(result.excludeCredentials![0].id as ArrayBuffer) + expect(credIdBytes).toEqual(new Uint8Array([99, 114, 101, 100, 45, 105, 100])) + + // Verify other fields are preserved + expect(result.rp).toEqual(validServerOptions.rp) + expect(result.pubKeyCredParams).toEqual(validServerOptions.pubKeyCredParams) + expect(result.timeout).toBe(60000) + expect(result.attestation).toBe('direct') + }) + + 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: 'dGVzdC1jaGFsbGVuZ2U', + rp: { name: 'Test RP' }, + user: { + id: 'dXNlci1pZA', + 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: 'dGVzdC1jaGFsbGVuZ2U', + timeout: 60000, + rpId: 'example.com', + userVerification: 'preferred' as const, + allowCredentials: [ + { + id: 'Y3JlZC1pZA', + 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) + + // Verify challenge was converted + expect(result.challenge).toBeInstanceOf(ArrayBuffer) + const challengeBytes = new Uint8Array(result.challenge) + expect(challengeBytes).toEqual( + new Uint8Array([116, 101, 115, 116, 45, 99, 104, 97, 108, 108, 101, 110, 103, 101]) + ) + + // Verify allowCredentials[0].id was converted + expect(result.allowCredentials![0].id).toBeInstanceOf(ArrayBuffer) + const credIdBytes = new Uint8Array(result.allowCredentials![0].id as ArrayBuffer) + expect(credIdBytes).toEqual(new Uint8Array([99, 114, 101, 100, 45, 105, 100])) + + // Verify other fields preserved + expect(result.rpId).toBe('example.com') + 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) + // Empty array is not added to result per implementation + expect(result.allowCredentials).toBeUndefined() + }) + + it('should handle missing allowCredentials', () => { + delete (global as any).PublicKeyCredential + + const optionsWithoutAllow = { + challenge: 'dGVzdC1jaGFsbGVuZ2U', + rpId: 'example.com', + } + + const result = deserializeCredentialRequestOptions(optionsWithoutAllow) + expect(result.allowCredentials).toBeUndefined() + }) + }) + + describe('serializeCredentialCreationResponse', () => { + it('should convert ArrayBuffers to base64url strings using polyfill', () => { + const attestationBytes = new Uint8Array([1, 2, 3, 4, 5]) + const clientDataBytes = new Uint8Array([6, 7, 8, 9, 10]) + const credIdBytes = new Uint8Array([11, 12, 13, 14, 15]) + + const mockCredential: RegistrationCredential = { + id: 'credential-id-string', + rawId: credIdBytes.buffer, + response: { + attestationObject: attestationBytes.buffer, + clientDataJSON: clientDataBytes.buffer, + getPublicKey: jest.fn().mockReturnValue(null), + getPublicKeyAlgorithm: jest.fn().mockReturnValue(-7), + getTransports: jest.fn().mockReturnValue(['usb']), + getAuthenticatorData: jest.fn().mockReturnValue(new ArrayBuffer(0)), + }, + type: 'public-key', + getClientExtensionResults: jest.fn().mockReturnValue({ credProps: { rk: true } }), + authenticatorAttachment: 'platform' as AuthenticatorAttachment, + } as unknown as RegistrationCredential + + const result = serializeCredentialCreationResponse(mockCredential) + + // Verify ArrayBuffers were converted to base64url + expect(result.rawId).toBe(mockCredential.id) // Now correctly converts rawId ArrayBuffer to base64url + expect(result.response.attestationObject).toBe('AQIDBAU') + expect(result.response.clientDataJSON).toBe('BgcICQo') + expect(result.authenticatorAttachment).toBe('platform') + expect(result.clientExtensionResults).toEqual({ credProps: { rk: true } }) + }) + + 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', () => { + 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) + + // Verify conversions + expect(result.rawId).toBe(mockCredential.id) // Now correctly converts rawId ArrayBuffer to base64url + 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([1, 2, 3, 4]).buffer, + rp: { name: 'Test RP', id: 'example.com' }, + user: { + id: new Uint8Array([5, 6, 7, 8]).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) + + // Verify defaults are applied + expect(result.authenticatorSelection).toEqual({ + authenticatorAttachment: 'cross-platform', + requireResidentKey: false, + userVerification: 'preferred', + residentKey: 'discouraged', + }) + expect(result.hints).toEqual(['security-key']) + expect(result.attestation).toBe('direct') + + // Verify base options are preserved + 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', + }, + }) + + // Should merge, not replace + expect(result.authenticatorSelection).toEqual({ + authenticatorAttachment: 'cross-platform', + requireResidentKey: false, + residentKey: 'discouraged', + userVerification: 'required', // Override applied + }) + }) + + 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([9, 10, 11, 12]).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([1, 2, 3, 4]).buffer, + rpId: 'example.com', + allowCredentials: [ + { + id: new Uint8Array([5, 6, 7, 8]).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']) + + // Base options preserved + 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([9, 10, 11, 12]).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) + }) + }) +}) From 056da53e29fd6d10dc399afcc51315c62cacd469 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Thu, 25 Sep 2025 07:43:06 +0000 Subject: [PATCH 2/6] fix: destructure params in _challenge and only send factorId in url params, not body. --- src/GoTrueClient.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index fd1ad970..1d2227f5 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -3156,12 +3156,13 @@ export default class GoTrueClient { 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, } From 750d2803d5c8e8450f88ae1c1ba6bb8d81780a6e Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Thu, 25 Sep 2025 07:52:25 +0000 Subject: [PATCH 3/6] fix: use real base64 converted values in the helpers --- test/webauthn.helpers.test.ts | 91 ++++++++++++++++++----------------- 1 file changed, 48 insertions(+), 43 deletions(-) diff --git a/test/webauthn.helpers.test.ts b/test/webauthn.helpers.test.ts index 68ec8669..8e04c68f 100644 --- a/test/webauthn.helpers.test.ts +++ b/test/webauthn.helpers.test.ts @@ -23,13 +23,13 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('deserializeCredentialCreationOptions', () => { const validServerOptions = { - challenge: 'dGVzdC1jaGFsbGVuZ2U', // "test-challenge" in base64url + challenge: 'SGVsbG8gV2ViQXV0aG4h', rp: { name: 'Test RP', id: 'example.com', }, user: { - id: 'dXNlci1pZA', // "user-id" in base64url + id: 'dXNlci0xMjM0NTY', name: 'test@example.com', displayName: 'Test User', }, @@ -41,7 +41,7 @@ describe('WebAuthn Serialization/Deserialization', () => { attestation: 'direct' as const, excludeCredentials: [ { - id: 'Y3JlZC1pZA', // "cred-id" in base64url + id: 'Y3JlZGVudGlhbC1hYmMteHl6', type: 'public-key' as const, transports: ['usb', 'nfc'] as AuthenticatorTransportFuture[], }, @@ -49,29 +49,32 @@ describe('WebAuthn Serialization/Deserialization', () => { } it('should convert base64url strings to ArrayBuffers using polyfill', () => { - // Force polyfill path by removing PublicKeyCredential + delete (global as any).PublicKeyCredential const result = deserializeCredentialCreationOptions(validServerOptions) - // Verify challenge was converted to ArrayBuffer + expect(result.challenge).toBeInstanceOf(ArrayBuffer) const challengeBytes = new Uint8Array(result.challenge) + expect(challengeBytes).toEqual( - new Uint8Array([116, 101, 115, 116, 45, 99, 104, 97, 108, 108, 101, 110, 103, 101]) + new Uint8Array([72, 101, 108, 108, 111, 32, 87, 101, 98, 65, 117, 116, 104, 110, 33]) ) - // Verify user.id was converted to ArrayBuffer + expect(result.user.id).toBeInstanceOf(ArrayBuffer) const userIdBytes = new Uint8Array(result.user.id) - expect(userIdBytes).toEqual(new Uint8Array([117, 115, 101, 114, 45, 105, 100])) + + expect(userIdBytes).toEqual(new Uint8Array([117, 115, 101, 114, 45, 49, 50, 51, 52, 53, 54])) - // Verify excludeCredentials[0].id was converted to ArrayBuffer + expect(result.excludeCredentials![0].id).toBeInstanceOf(ArrayBuffer) const credIdBytes = new Uint8Array(result.excludeCredentials![0].id as ArrayBuffer) - expect(credIdBytes).toEqual(new Uint8Array([99, 114, 101, 100, 45, 105, 100])) + + expect(credIdBytes).toEqual(new Uint8Array([99, 114, 101, 100, 101, 110, 116, 105, 97, 108, 45, 97, 98, 99, 45, 120, 121, 122])) - // Verify other fields are preserved + expect(result.rp).toEqual(validServerOptions.rp) expect(result.pubKeyCredParams).toEqual(validServerOptions.pubKeyCredParams) expect(result.timeout).toBe(60000) @@ -99,10 +102,10 @@ describe('WebAuthn Serialization/Deserialization', () => { it('should handle missing optional fields correctly', () => { const minimalOptions = { - challenge: 'dGVzdC1jaGFsbGVuZ2U', + challenge: 'SGVsbG8gV2ViQXV0aG4h', rp: { name: 'Test RP' }, user: { - id: 'dXNlci1pZA', + id: 'dXNlci0xMjM0NTY', name: 'test@example.com', displayName: 'Test User', }, @@ -130,13 +133,13 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('deserializeCredentialRequestOptions', () => { const validServerOptions = { - challenge: 'dGVzdC1jaGFsbGVuZ2U', + challenge: 'QXV0aGVudGljYXRlTWU', timeout: 60000, rpId: 'example.com', userVerification: 'preferred' as const, allowCredentials: [ { - id: 'Y3JlZC1pZA', + id: 'YWxsb3dlZC1jcmVkLTEyMw', type: 'public-key' as const, transports: ['usb', 'nfc'] as AuthenticatorTransportFuture[], }, @@ -148,19 +151,21 @@ describe('WebAuthn Serialization/Deserialization', () => { const result = deserializeCredentialRequestOptions(validServerOptions) - // Verify challenge was converted + expect(result.challenge).toBeInstanceOf(ArrayBuffer) const challengeBytes = new Uint8Array(result.challenge) + expect(challengeBytes).toEqual( - new Uint8Array([116, 101, 115, 116, 45, 99, 104, 97, 108, 108, 101, 110, 103, 101]) + new Uint8Array([65, 117, 116, 104, 101, 110, 116, 105, 99, 97, 116, 101, 77, 101]) ) - // Verify allowCredentials[0].id was converted + expect(result.allowCredentials![0].id).toBeInstanceOf(ArrayBuffer) const credIdBytes = new Uint8Array(result.allowCredentials![0].id as ArrayBuffer) - expect(credIdBytes).toEqual(new Uint8Array([99, 114, 101, 100, 45, 105, 100])) + + expect(credIdBytes).toEqual(new Uint8Array([97, 108, 108, 111, 119, 101, 100, 45, 99, 114, 101, 100, 45, 49, 50, 51])) - // Verify other fields preserved + expect(result.rpId).toBe('example.com') expect(result.userVerification).toBe('preferred') expect(result.timeout).toBe(60000) @@ -192,7 +197,7 @@ describe('WebAuthn Serialization/Deserialization', () => { } const result = deserializeCredentialRequestOptions(optionsWithEmptyArray) - // Empty array is not added to result per implementation + expect(result.allowCredentials).toBeUndefined() }) @@ -200,7 +205,7 @@ describe('WebAuthn Serialization/Deserialization', () => { delete (global as any).PublicKeyCredential const optionsWithoutAllow = { - challenge: 'dGVzdC1jaGFsbGVuZ2U', + challenge: 'QXV0aGVudGljYXRlTWU', rpId: 'example.com', } @@ -233,10 +238,10 @@ describe('WebAuthn Serialization/Deserialization', () => { const result = serializeCredentialCreationResponse(mockCredential) - // Verify ArrayBuffers were converted to base64url - expect(result.rawId).toBe(mockCredential.id) // Now correctly converts rawId ArrayBuffer to base64url - expect(result.response.attestationObject).toBe('AQIDBAU') - expect(result.response.clientDataJSON).toBe('BgcICQo') + + expect(result.rawId).toBe(mockCredential.id) + expect(result.response.attestationObject).toBe('AQIDBAU') + expect(result.response.clientDataJSON).toBe('BgcICQo') expect(result.authenticatorAttachment).toBe('platform') expect(result.clientExtensionResults).toEqual({ credProps: { rk: true } }) }) @@ -320,12 +325,12 @@ describe('WebAuthn Serialization/Deserialization', () => { const result = serializeCredentialRequestResponse(mockCredential) - // Verify conversions - expect(result.rawId).toBe(mockCredential.id) // Now correctly converts rawId ArrayBuffer to base64url - expect(result.response.authenticatorData).toBe('AQIDBAU') - expect(result.response.clientDataJSON).toBe('BgcICQo') - expect(result.response.signature).toBe('CwwNDg8') - expect(result.response.userHandle).toBe('EBESExQ') + + 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', () => { @@ -386,10 +391,10 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('mergeCredentialCreationOptions', () => { const baseOptions: PublicKeyCredentialCreationOptionsFuture = { - challenge: new Uint8Array([1, 2, 3, 4]).buffer, + 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([5, 6, 7, 8]).buffer, + id: new Uint8Array([85, 115, 101, 114, 49, 50, 51]).buffer, name: 'user@example.com', displayName: 'Test User', }, @@ -399,7 +404,7 @@ describe('WebAuthn Serialization/Deserialization', () => { it('should apply DEFAULT_CREATION_OPTIONS correctly', () => { const result = mergeCredentialCreationOptions(baseOptions) - // Verify defaults are applied + expect(result.authenticatorSelection).toEqual({ authenticatorAttachment: 'cross-platform', requireResidentKey: false, @@ -409,7 +414,7 @@ describe('WebAuthn Serialization/Deserialization', () => { expect(result.hints).toEqual(['security-key']) expect(result.attestation).toBe('direct') - // Verify base options are preserved + expect(result.challenge).toBe(baseOptions.challenge) expect(result.rp).toEqual(baseOptions.rp) expect(result.user).toEqual(baseOptions.user) @@ -422,12 +427,12 @@ describe('WebAuthn Serialization/Deserialization', () => { }, }) - // Should merge, not replace + expect(result.authenticatorSelection).toEqual({ authenticatorAttachment: 'cross-platform', requireResidentKey: false, residentKey: 'discouraged', - userVerification: 'required', // Override applied + userVerification: 'required', }) }) @@ -454,7 +459,7 @@ describe('WebAuthn Serialization/Deserialization', () => { }) it('should not modify ArrayBuffer fields during merge', () => { - const customChallenge = new Uint8Array([9, 10, 11, 12]).buffer + const customChallenge = new Uint8Array([78, 101, 119, 67, 104, 97, 108, 108]).buffer const result = mergeCredentialCreationOptions(baseOptions, { challenge: customChallenge, }) @@ -466,11 +471,11 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('mergeCredentialRequestOptions', () => { const baseOptions: PublicKeyCredentialRequestOptionsFuture = { - challenge: new Uint8Array([1, 2, 3, 4]).buffer, + challenge: new Uint8Array([82, 101, 113, 117, 101, 115, 116]).buffer, rpId: 'example.com', allowCredentials: [ { - id: new Uint8Array([5, 6, 7, 8]).buffer, + id: new Uint8Array([67, 114, 101, 100, 49, 50, 51]).buffer, type: 'public-key', transports: ['usb'], }, @@ -483,7 +488,7 @@ describe('WebAuthn Serialization/Deserialization', () => { expect(result.userVerification).toBe('preferred') expect(result.hints).toEqual(['security-key']) - // Base options preserved + expect(result.challenge).toBe(baseOptions.challenge) expect(result.allowCredentials).toBe(baseOptions.allowCredentials) }) @@ -503,7 +508,7 @@ describe('WebAuthn Serialization/Deserialization', () => { it('should preserve allowCredentials ArrayBuffers', () => { const newCreds = [ { - id: new Uint8Array([9, 10, 11, 12]).buffer, + id: new Uint8Array([78, 101, 119, 67, 114, 101, 100]).buffer, type: 'public-key' as const, transports: ['nfc'] as AuthenticatorTransportFuture[], }, From 6883941c035a3bbc6f6d505a97e8bbb1c8b510ec Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Thu, 25 Sep 2025 08:06:08 +0000 Subject: [PATCH 4/6] fix: remove unused imports --- test/GoTrueClient.test.ts | 4 -- test/webauthn.helpers.test.ts | 92 +++++++++++++++++------------------ 2 files changed, 44 insertions(+), 52 deletions(-) diff --git a/test/GoTrueClient.test.ts b/test/GoTrueClient.test.ts index f6df6dfd..4ef40ff7 100644 --- a/test/GoTrueClient.test.ts +++ b/test/GoTrueClient.test.ts @@ -21,10 +21,6 @@ import { pkceClient, } from './lib/clients' import { mockUserCredentials } from './lib/utils' -import { - createMockAuthenticationCredential, - createMockRegistrationCredential, -} from './webauthn-test-utils' const TEST_USER_DATA = { info: 'some info' } diff --git a/test/webauthn.helpers.test.ts b/test/webauthn.helpers.test.ts index 8e04c68f..02641096 100644 --- a/test/webauthn.helpers.test.ts +++ b/test/webauthn.helpers.test.ts @@ -1,13 +1,17 @@ import { deserializeCredentialCreationOptions, - deserializeCredentialRequestOptions, mergeCredentialCreationOptions, - mergeCredentialRequestOptions, serializeCredentialCreationResponse, - serializeCredentialRequestResponse + deserializeCredentialRequestOptions, + mergeCredentialCreationOptions, + mergeCredentialRequestOptions, + serializeCredentialCreationResponse, + serializeCredentialRequestResponse, } from '../src/lib/webauthn' import type { AuthenticationCredential, - AuthenticatorTransportFuture, PublicKeyCredentialCreationOptionsFuture, - PublicKeyCredentialRequestOptionsFuture, RegistrationCredential + AuthenticatorTransportFuture, + PublicKeyCredentialCreationOptionsFuture, + PublicKeyCredentialRequestOptionsFuture, + RegistrationCredential, } from '../src/lib/webauthn.dom' describe('WebAuthn Serialization/Deserialization', () => { @@ -23,13 +27,13 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('deserializeCredentialCreationOptions', () => { const validServerOptions = { - challenge: 'SGVsbG8gV2ViQXV0aG4h', + challenge: 'SGVsbG8gV2ViQXV0aG4h', rp: { name: 'Test RP', id: 'example.com', }, user: { - id: 'dXNlci0xMjM0NTY', + id: 'dXNlci0xMjM0NTY', name: 'test@example.com', displayName: 'Test User', }, @@ -41,7 +45,7 @@ describe('WebAuthn Serialization/Deserialization', () => { attestation: 'direct' as const, excludeCredentials: [ { - id: 'Y3JlZGVudGlhbC1hYmMteHl6', + id: 'Y3JlZGVudGlhbC1hYmMteHl6', type: 'public-key' as const, transports: ['usb', 'nfc'] as AuthenticatorTransportFuture[], }, @@ -49,32 +53,31 @@ describe('WebAuthn Serialization/Deserialization', () => { } 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) - + expect(challengeBytes).toEqual( new Uint8Array([72, 101, 108, 108, 111, 32, 87, 101, 98, 65, 117, 116, 104, 110, 33]) ) - expect(result.user.id).toBeInstanceOf(ArrayBuffer) const userIdBytes = new Uint8Array(result.user.id) - + expect(userIdBytes).toEqual(new Uint8Array([117, 115, 101, 114, 45, 49, 50, 51, 52, 53, 54])) - expect(result.excludeCredentials![0].id).toBeInstanceOf(ArrayBuffer) const credIdBytes = new Uint8Array(result.excludeCredentials![0].id as ArrayBuffer) - - expect(credIdBytes).toEqual(new Uint8Array([99, 114, 101, 100, 101, 110, 116, 105, 97, 108, 45, 97, 98, 99, 45, 120, 121, 122])) - + expect(credIdBytes).toEqual( + new Uint8Array([ + 99, 114, 101, 100, 101, 110, 116, 105, 97, 108, 45, 97, 98, 99, 45, 120, 121, 122, + ]) + ) + expect(result.rp).toEqual(validServerOptions.rp) expect(result.pubKeyCredParams).toEqual(validServerOptions.pubKeyCredParams) expect(result.timeout).toBe(60000) @@ -102,10 +105,10 @@ describe('WebAuthn Serialization/Deserialization', () => { it('should handle missing optional fields correctly', () => { const minimalOptions = { - challenge: 'SGVsbG8gV2ViQXV0aG4h', + challenge: 'SGVsbG8gV2ViQXV0aG4h', rp: { name: 'Test RP' }, user: { - id: 'dXNlci0xMjM0NTY', + id: 'dXNlci0xMjM0NTY', name: 'test@example.com', displayName: 'Test User', }, @@ -133,13 +136,13 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('deserializeCredentialRequestOptions', () => { const validServerOptions = { - challenge: 'QXV0aGVudGljYXRlTWU', + challenge: 'QXV0aGVudGljYXRlTWU', timeout: 60000, rpId: 'example.com', userVerification: 'preferred' as const, allowCredentials: [ { - id: 'YWxsb3dlZC1jcmVkLTEyMw', + id: 'YWxsb3dlZC1jcmVkLTEyMw', type: 'public-key' as const, transports: ['usb', 'nfc'] as AuthenticatorTransportFuture[], }, @@ -151,21 +154,20 @@ describe('WebAuthn Serialization/Deserialization', () => { const result = deserializeCredentialRequestOptions(validServerOptions) - expect(result.challenge).toBeInstanceOf(ArrayBuffer) const challengeBytes = new Uint8Array(result.challenge) - + expect(challengeBytes).toEqual( new Uint8Array([65, 117, 116, 104, 101, 110, 116, 105, 99, 97, 116, 101, 77, 101]) ) - expect(result.allowCredentials![0].id).toBeInstanceOf(ArrayBuffer) const credIdBytes = new Uint8Array(result.allowCredentials![0].id as ArrayBuffer) - - expect(credIdBytes).toEqual(new Uint8Array([97, 108, 108, 111, 119, 101, 100, 45, 99, 114, 101, 100, 45, 49, 50, 51])) - + expect(credIdBytes).toEqual( + new Uint8Array([97, 108, 108, 111, 119, 101, 100, 45, 99, 114, 101, 100, 45, 49, 50, 51]) + ) + expect(result.rpId).toBe('example.com') expect(result.userVerification).toBe('preferred') expect(result.timeout).toBe(60000) @@ -197,7 +199,7 @@ describe('WebAuthn Serialization/Deserialization', () => { } const result = deserializeCredentialRequestOptions(optionsWithEmptyArray) - + expect(result.allowCredentials).toBeUndefined() }) @@ -205,7 +207,7 @@ describe('WebAuthn Serialization/Deserialization', () => { delete (global as any).PublicKeyCredential const optionsWithoutAllow = { - challenge: 'QXV0aGVudGljYXRlTWU', + challenge: 'QXV0aGVudGljYXRlTWU', rpId: 'example.com', } @@ -238,10 +240,9 @@ describe('WebAuthn Serialization/Deserialization', () => { const result = serializeCredentialCreationResponse(mockCredential) - expect(result.rawId).toBe(mockCredential.id) - expect(result.response.attestationObject).toBe('AQIDBAU') - expect(result.response.clientDataJSON).toBe('BgcICQo') + expect(result.response.attestationObject).toBe('AQIDBAU') + expect(result.response.clientDataJSON).toBe('BgcICQo') expect(result.authenticatorAttachment).toBe('platform') expect(result.clientExtensionResults).toEqual({ credProps: { rk: true } }) }) @@ -325,12 +326,11 @@ describe('WebAuthn Serialization/Deserialization', () => { 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') + 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', () => { @@ -391,10 +391,10 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('mergeCredentialCreationOptions', () => { const baseOptions: PublicKeyCredentialCreationOptionsFuture = { - challenge: new Uint8Array([67, 104, 97, 108, 108, 101, 110, 103, 101, 49, 50, 51]).buffer, + 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, + id: new Uint8Array([85, 115, 101, 114, 49, 50, 51]).buffer, name: 'user@example.com', displayName: 'Test User', }, @@ -404,7 +404,6 @@ describe('WebAuthn Serialization/Deserialization', () => { it('should apply DEFAULT_CREATION_OPTIONS correctly', () => { const result = mergeCredentialCreationOptions(baseOptions) - expect(result.authenticatorSelection).toEqual({ authenticatorAttachment: 'cross-platform', requireResidentKey: false, @@ -414,7 +413,6 @@ describe('WebAuthn Serialization/Deserialization', () => { expect(result.hints).toEqual(['security-key']) expect(result.attestation).toBe('direct') - expect(result.challenge).toBe(baseOptions.challenge) expect(result.rp).toEqual(baseOptions.rp) expect(result.user).toEqual(baseOptions.user) @@ -427,12 +425,11 @@ describe('WebAuthn Serialization/Deserialization', () => { }, }) - expect(result.authenticatorSelection).toEqual({ authenticatorAttachment: 'cross-platform', requireResidentKey: false, residentKey: 'discouraged', - userVerification: 'required', + userVerification: 'required', }) }) @@ -459,7 +456,7 @@ describe('WebAuthn Serialization/Deserialization', () => { }) it('should not modify ArrayBuffer fields during merge', () => { - const customChallenge = new Uint8Array([78, 101, 119, 67, 104, 97, 108, 108]).buffer + const customChallenge = new Uint8Array([78, 101, 119, 67, 104, 97, 108, 108]).buffer const result = mergeCredentialCreationOptions(baseOptions, { challenge: customChallenge, }) @@ -471,11 +468,11 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('mergeCredentialRequestOptions', () => { const baseOptions: PublicKeyCredentialRequestOptionsFuture = { - challenge: new Uint8Array([82, 101, 113, 117, 101, 115, 116]).buffer, + 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, + id: new Uint8Array([67, 114, 101, 100, 49, 50, 51]).buffer, type: 'public-key', transports: ['usb'], }, @@ -488,7 +485,6 @@ describe('WebAuthn Serialization/Deserialization', () => { expect(result.userVerification).toBe('preferred') expect(result.hints).toEqual(['security-key']) - expect(result.challenge).toBe(baseOptions.challenge) expect(result.allowCredentials).toBe(baseOptions.allowCredentials) }) @@ -508,7 +504,7 @@ describe('WebAuthn Serialization/Deserialization', () => { it('should preserve allowCredentials ArrayBuffers', () => { const newCreds = [ { - id: new Uint8Array([78, 101, 119, 67, 114, 101, 100]).buffer, + id: new Uint8Array([78, 101, 119, 67, 114, 101, 100]).buffer, type: 'public-key' as const, transports: ['nfc'] as AuthenticatorTransportFuture[], }, From 27b69938c4b4645f4006e3a06ee0ab58b81e45af Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Thu, 25 Sep 2025 08:20:30 +0000 Subject: [PATCH 5/6] fix: type error in webauth helpers, cast challenge to ArrayBuffer --- test/webauthn.helpers.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/webauthn.helpers.test.ts b/test/webauthn.helpers.test.ts index 02641096..26dcf8a3 100644 --- a/test/webauthn.helpers.test.ts +++ b/test/webauthn.helpers.test.ts @@ -58,14 +58,14 @@ describe('WebAuthn Serialization/Deserialization', () => { const result = deserializeCredentialCreationOptions(validServerOptions) expect(result.challenge).toBeInstanceOf(ArrayBuffer) - const challengeBytes = new Uint8Array(result.challenge) + const challengeBytes = new Uint8Array(result.challenge as ArrayBuffer) expect(challengeBytes).toEqual( new Uint8Array([72, 101, 108, 108, 111, 32, 87, 101, 98, 65, 117, 116, 104, 110, 33]) ) expect(result.user.id).toBeInstanceOf(ArrayBuffer) - const userIdBytes = new Uint8Array(result.user.id) + const userIdBytes = new Uint8Array(result.user.id as ArrayBuffer) expect(userIdBytes).toEqual(new Uint8Array([117, 115, 101, 114, 45, 49, 50, 51, 52, 53, 54])) @@ -155,7 +155,7 @@ describe('WebAuthn Serialization/Deserialization', () => { const result = deserializeCredentialRequestOptions(validServerOptions) expect(result.challenge).toBeInstanceOf(ArrayBuffer) - const challengeBytes = new Uint8Array(result.challenge) + const challengeBytes = new Uint8Array(result.challenge as ArrayBuffer) expect(challengeBytes).toEqual( new Uint8Array([65, 117, 116, 104, 101, 110, 116, 105, 99, 97, 116, 101, 77, 101]) From 165eadd60ad64ac905dd202af44ba5e398b980f7 Mon Sep 17 00:00:00 2001 From: Bewinxed Date: Fri, 3 Oct 2025 21:08:25 +0000 Subject: [PATCH 6/6] fix: fix tests, fix default friendlyName naming, use real data in fixtures. --- src/lib/webauthn.ts | 19 +- test/GoTrueClient.test.ts | 144 ++++++---- test/webauthn.fixtures.ts | 138 ++++++++++ test/webauthn.helpers.test.ts | 477 +++++++++++++++++----------------- 4 files changed, 492 insertions(+), 286 deletions(-) create mode 100644 test/webauthn.fixtures.ts diff --git a/src/lib/webauthn.ts b/src/lib/webauthn.ts index b416db53..0336bf61 100644 --- a/src/lib/webauthn.ts +++ b/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/test/GoTrueClient.test.ts b/test/GoTrueClient.test.ts index 4ef40ff7..6a507cfe 100644 --- a/test/GoTrueClient.test.ts +++ b/test/GoTrueClient.test.ts @@ -1,26 +1,40 @@ 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 { - authAdminApiAutoConfirmEnabledClient, + deserializeCredentialCreationOptions, + deserializeCredentialRequestOptions, + serializeCredentialCreationResponse, + serializeCredentialRequestResponse, +} from '../src/lib/webauthn' +import type { PublicKeyCredentialFuture, PublicKeyCredentialJSON } from '../src/lib/webauthn.dom' +import { authClient as auth, + authAdminApiAutoConfirmEnabledClient, authClient, + authSubscriptionClient, authClientWithAsymmetricSession as authWithAsymmetricSession, authClientWithSession as authWithSession, - authSubscriptionClient, autoRefreshClient, - clientApiAutoConfirmDisabledClient as signUpDisabledClient, - clientApiAutoConfirmEnabledClient as signUpEnabledClient, - clientApiAutoConfirmOffSignupsEnabledClient as phoneClient, 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 { + webauthnAssertionCredentialResponse, + webauthnAssertionMockCredential, + webauthnCreationCredentialResponse, + webauthnCreationMockCredential, +} from './webauthn.fixtures' const TEST_USER_DATA = { info: 'some info' } @@ -1569,7 +1583,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', }) @@ -1588,6 +1602,71 @@ 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 @@ -1683,22 +1762,13 @@ describe('WebAuthn MFA', () => { test('verify WebAuthn should fail without session', async () => { await authWithSession.signOut() const { data, error } = await authWithSession.mfa.webauthn.verify({ - factorId: 'test-factor-id', - challengeId: 'test-challenge-id', + factorId: webauthnCreationCredentialResponse.factorId, + challengeId: webauthnCreationCredentialResponse.challengeId, webauthn: { type: 'create', - rpId: 'localhost', - rpOrigins: ['http://localhost:9999'], - credential_response: { - id: 'test-credential-id', - rawId: new ArrayBuffer(8), - response: { - attestationObject: new ArrayBuffer(8), - clientDataJSON: new ArrayBuffer(8), - }, - type: 'public-key', - getClientExtensionResults: () => ({}), - } as any, + rpId: webauthnCreationCredentialResponse.rpId, + rpOrigins: [webauthnCreationCredentialResponse.origin], + credential_response: webauthnCreationMockCredential, }, }) @@ -1733,32 +1803,18 @@ describe('WebAuthn MFA', () => { expect(webauthnFactors).toHaveLength(0) }) - test('listFactors should include WebAuthn factors', async () => { - // Mock getUser to include WebAuthn factors - authWithSession.getUser = jest.fn().mockResolvedValue({ - data: { - user: { - id: 'test-user', - factors: [ - { id: '1', factor_type: 'webauthn', status: 'verified', friendly_name: 'YubiKey 5' }, - { id: '2', factor_type: 'webauthn', status: 'unverified', friendly_name: 'Touch ID' }, - { id: '3', factor_type: 'totp', status: 'verified' }, - ], - }, - }, - error: null, - }) + test('should enroll WebAuthn factor', async () => { + await setupUserWithWebAuthn() - const { data, error } = await authWithSession.mfa.listFactors() + const { data: enrollData, error: enrollError } = await authWithSession.mfa.webauthn.enroll({ + friendlyName: 'Test Yubikey', + }) - expect(error).toBeNull() - expect(data).not.toBeNull() - if (data) { - expect(data.all).toHaveLength(3) - const webauthnFactors = data.all.filter((f) => f.factor_type === 'webauthn') - expect(webauthnFactors).toHaveLength(2) - expect(webauthnFactors.filter((f) => f.status === 'verified')).toHaveLength(1) - } + expect(enrollError).toBeNull() + expect(enrollData).not.toBeNull() + expect(enrollData?.type).toBe('webauthn') + expect(enrollData?.id).toBeDefined() + expect(enrollData?.friendly_name).toBe('Test Yubikey') }) }) diff --git a/test/webauthn.fixtures.ts b/test/webauthn.fixtures.ts new file mode 100644 index 00000000..b2a42181 --- /dev/null +++ b/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 diff --git a/test/webauthn.helpers.test.ts b/test/webauthn.helpers.test.ts index 26dcf8a3..c4904a4f 100644 --- a/test/webauthn.helpers.test.ts +++ b/test/webauthn.helpers.test.ts @@ -13,6 +13,17 @@ import type { 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, + DEFAULT_REQUEST_OPTIONS, +} from '../src/lib/webauthn' describe('WebAuthn Serialization/Deserialization', () => { let originalPublicKeyCredential: any @@ -27,10 +38,10 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('deserializeCredentialCreationOptions', () => { const validServerOptions = { - challenge: 'SGVsbG8gV2ViQXV0aG4h', + challenge: webauthnCreationCredentialResponse.challenge, rp: { name: 'Test RP', - id: 'example.com', + id: webauthnCreationCredentialResponse.rpId, }, user: { id: 'dXNlci0xMjM0NTY', @@ -45,7 +56,7 @@ describe('WebAuthn Serialization/Deserialization', () => { attestation: 'direct' as const, excludeCredentials: [ { - id: 'Y3JlZGVudGlhbC1hYmMteHl6', + id: webauthnCreationCredentialResponse.credentialResponse.rawId, type: 'public-key' as const, transports: ['usb', 'nfc'] as AuthenticatorTransportFuture[], }, @@ -59,29 +70,20 @@ describe('WebAuthn Serialization/Deserialization', () => { expect(result.challenge).toBeInstanceOf(ArrayBuffer) const challengeBytes = new Uint8Array(result.challenge as ArrayBuffer) - - expect(challengeBytes).toEqual( - new Uint8Array([72, 101, 108, 108, 111, 32, 87, 101, 98, 65, 117, 116, 104, 110, 33]) - ) + const expectedChallengeBytes = base64UrlToUint8Array(webauthnCreationCredentialResponse.challenge) + expect(challengeBytes).toEqual(expectedChallengeBytes) expect(result.user.id).toBeInstanceOf(ArrayBuffer) - const userIdBytes = new Uint8Array(result.user.id as ArrayBuffer) - - expect(userIdBytes).toEqual(new Uint8Array([117, 115, 101, 114, 45, 49, 50, 51, 52, 53, 54])) - expect(result.excludeCredentials![0].id).toBeInstanceOf(ArrayBuffer) + expect(result.excludeCredentials?.[0].id).toBeInstanceOf(ArrayBuffer) const credIdBytes = new Uint8Array(result.excludeCredentials![0].id as ArrayBuffer) - - expect(credIdBytes).toEqual( - new Uint8Array([ - 99, 114, 101, 100, 101, 110, 116, 105, 97, 108, 45, 97, 98, 99, 45, 120, 121, 122, - ]) - ) + 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('direct') + expect(result.attestation).toBe(validServerOptions.attestation) }) it('should use native parseCreationOptionsFromJSON when available', () => { @@ -136,13 +138,13 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('deserializeCredentialRequestOptions', () => { const validServerOptions = { - challenge: 'QXV0aGVudGljYXRlTWU', + challenge: webauthnAssertionCredentialResponse.challenge, timeout: 60000, - rpId: 'example.com', + rpId: webauthnAssertionCredentialResponse.rpId, userVerification: 'preferred' as const, allowCredentials: [ { - id: 'YWxsb3dlZC1jcmVkLTEyMw', + id: webauthnAssertionCredentialResponse.credentialResponse.rawId, type: 'public-key' as const, transports: ['usb', 'nfc'] as AuthenticatorTransportFuture[], }, @@ -155,20 +157,17 @@ describe('WebAuthn Serialization/Deserialization', () => { const result = deserializeCredentialRequestOptions(validServerOptions) expect(result.challenge).toBeInstanceOf(ArrayBuffer) - const challengeBytes = new Uint8Array(result.challenge as ArrayBuffer) + expect(result.allowCredentials?.[0].id).toBeInstanceOf(ArrayBuffer) - expect(challengeBytes).toEqual( - new Uint8Array([65, 117, 116, 104, 101, 110, 116, 105, 99, 97, 116, 101, 77, 101]) - ) + const challengeBytes = new Uint8Array(result.challenge as ArrayBuffer) + const expectedChallengeBytes = base64UrlToUint8Array(webauthnAssertionCredentialResponse.challenge) + expect(challengeBytes).toEqual(expectedChallengeBytes) - expect(result.allowCredentials![0].id).toBeInstanceOf(ArrayBuffer) const credIdBytes = new Uint8Array(result.allowCredentials![0].id as ArrayBuffer) + const expectedCredIdBytes = base64UrlToUint8Array(webauthnAssertionCredentialResponse.credentialResponse.rawId) + expect(credIdBytes).toEqual(expectedCredIdBytes) - expect(credIdBytes).toEqual( - new Uint8Array([97, 108, 108, 111, 119, 101, 100, 45, 99, 114, 101, 100, 45, 49, 50, 51]) - ) - - expect(result.rpId).toBe('example.com') + expect(result.rpId).toBe(webauthnAssertionCredentialResponse.rpId) expect(result.userVerification).toBe('preferred') expect(result.timeout).toBe(60000) }) @@ -218,33 +217,18 @@ describe('WebAuthn Serialization/Deserialization', () => { describe('serializeCredentialCreationResponse', () => { it('should convert ArrayBuffers to base64url strings using polyfill', () => { - const attestationBytes = new Uint8Array([1, 2, 3, 4, 5]) - const clientDataBytes = new Uint8Array([6, 7, 8, 9, 10]) - const credIdBytes = new Uint8Array([11, 12, 13, 14, 15]) + const result = serializeCredentialCreationResponse(webauthnCreationMockCredential) - const mockCredential: RegistrationCredential = { - id: 'credential-id-string', - rawId: credIdBytes.buffer, - response: { - attestationObject: attestationBytes.buffer, - clientDataJSON: clientDataBytes.buffer, - getPublicKey: jest.fn().mockReturnValue(null), - getPublicKeyAlgorithm: jest.fn().mockReturnValue(-7), - getTransports: jest.fn().mockReturnValue(['usb']), - getAuthenticatorData: jest.fn().mockReturnValue(new ArrayBuffer(0)), - }, - type: 'public-key', - getClientExtensionResults: jest.fn().mockReturnValue({ credProps: { rk: true } }), - authenticatorAttachment: 'platform' as AuthenticatorAttachment, - } as unknown as RegistrationCredential - - const result = serializeCredentialCreationResponse(mockCredential) - - expect(result.rawId).toBe(mockCredential.id) - expect(result.response.attestationObject).toBe('AQIDBAU') - expect(result.response.clientDataJSON).toBe('BgcICQo') - expect(result.authenticatorAttachment).toBe('platform') - expect(result.clientExtensionResults).toEqual({ credProps: { rk: true } }) + 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', () => { @@ -302,220 +286,241 @@ describe('WebAuthn Serialization/Deserialization', () => { }) }) - 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 + describe('serializeCredentialRequestResponse with real data', () => { + it('should correctly serialize real WebAuthn assertion data', () => { + const result = serializeCredentialRequestResponse( + webauthnAssertionMockCredential as AuthenticationCredential + ) - const result = serializeCredentialRequestResponse(mockCredential) - expect(result.response.userHandle).toBeUndefined() + // 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 + ) }) + }) +}) - 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 +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') + }) - const result = serializeCredentialRequestResponse(mockCredential) + 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 - expect(mockToJSON).toHaveBeenCalled() - expect(result.response.authenticatorData).toBe('YXV0aERhdGE') - expect(result.response.signature).toBe('c2lnbmF0dXJl') - }) + const result = serializeCredentialRequestResponse(mockCredential) + expect(result.response.userHandle).toBeUndefined() }) - 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', + 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', }, - pubKeyCredParams: [{ type: 'public-key', alg: -7 }], - } + type: 'public-key', + authenticatorAttachment: 'platform', + clientExtensionResults: {}, + }) - it('should apply DEFAULT_CREATION_OPTIONS correctly', () => { - const result = mergeCredentialCreationOptions(baseOptions) + 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 - expect(result.authenticatorSelection).toEqual({ - authenticatorAttachment: 'cross-platform', - requireResidentKey: false, - userVerification: 'preferred', - residentKey: 'discouraged', - }) - expect(result.hints).toEqual(['security-key']) - expect(result.attestation).toBe('direct') + const result = serializeCredentialRequestResponse(mockCredential) - expect(result.challenge).toBe(baseOptions.challenge) - expect(result.rp).toEqual(baseOptions.rp) - expect(result.user).toEqual(baseOptions.user) - }) + expect(mockToJSON).toHaveBeenCalled() + expect(result.response.authenticatorData).toBe('YXV0aERhdGE') + expect(result.response.signature).toBe('c2lnbmF0dXJl') + }) +}) - it('should deep merge authenticatorSelection correctly', () => { - const result = mergeCredentialCreationOptions(baseOptions, { - authenticatorSelection: { - userVerification: 'required', - }, - }) +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) + }) - expect(result.authenticatorSelection).toEqual({ - authenticatorAttachment: 'cross-platform', - requireResidentKey: false, - residentKey: 'discouraged', + it('should deep merge authenticatorSelection correctly', () => { + const result = mergeCredentialCreationOptions(baseOptions, { + authenticatorSelection: { 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: 'cross-platform', + requireResidentKey: false, + residentKey: 'discouraged', + userVerification: 'required', + }) + }) - expect(result.authenticatorSelection).toEqual({ + it('should allow complete override of nested objects', () => { + const result = mergeCredentialCreationOptions(baseOptions, { + authenticatorSelection: { authenticatorAttachment: 'platform', requireResidentKey: true, residentKey: 'required', userVerification: 'discouraged', - }) - expect(result.attestation).toBe('none') - expect(result.hints).toEqual(['client-device']) + }, + attestation: 'none', + hints: ['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) + expect(result.authenticatorSelection).toEqual({ + authenticatorAttachment: 'platform', + requireResidentKey: true, + residentKey: 'required', + userVerification: 'discouraged', }) + expect(result.attestation).toBe('none') + expect(result.hints).toEqual(['client-device']) }) - 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 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) + }) +}) - it('should apply DEFAULT_REQUEST_OPTIONS correctly', () => { - const result = mergeCredentialRequestOptions(baseOptions) +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'], + }, + ], + } - expect(result.userVerification).toBe('preferred') - expect(result.hints).toEqual(['security-key']) + it('should apply DEFAULT_REQUEST_OPTIONS correctly', () => { + const result = mergeCredentialRequestOptions(baseOptions) - expect(result.challenge).toBe(baseOptions.challenge) - expect(result.allowCredentials).toBe(baseOptions.allowCredentials) - }) + expect(result.userVerification).toBe('preferred') + expect(result.hints).toEqual(['security-key']) - it('should allow overriding defaults', () => { - const result = mergeCredentialRequestOptions(baseOptions, { - userVerification: 'required', - hints: ['hybrid', 'security-key'], - timeout: 120000, - }) + expect(result.challenge).toBe(baseOptions.challenge) + expect(result.allowCredentials).toBe(baseOptions.allowCredentials) + }) - expect(result.userVerification).toBe('required') - expect(result.hints).toEqual(['hybrid', 'security-key']) - expect(result.timeout).toBe(120000) + it('should allow overriding defaults', () => { + const result = mergeCredentialRequestOptions(baseOptions, { + userVerification: 'required', + hints: ['hybrid', 'security-key'], + timeout: 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[], - }, - ] + expect(result.userVerification).toBe('required') + expect(result.hints).toEqual(['hybrid', 'security-key']) + expect(result.timeout).toBe(120000) + }) - const result = mergeCredentialRequestOptions(baseOptions, { - allowCredentials: newCreds, - }) + 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[], + }, + ] - expect(result.allowCredentials).toBe(newCreds) - expect(result.allowCredentials![0].id).toBeInstanceOf(ArrayBuffer) + const result = mergeCredentialRequestOptions(baseOptions, { + allowCredentials: newCreds, }) + + expect(result.allowCredentials).toBe(newCreds) + expect(result.allowCredentials![0].id).toBeInstanceOf(ArrayBuffer) }) })