1- import { AuthError } from '../src/lib/errors'
1+ import { JWK , Session } from '../src'
2+ import GoTrueClient from '../src/GoTrueClient'
3+ import { base64UrlToUint8Array } from '../src/lib/base64url'
24import { STORAGE_KEY } from '../src/lib/constants'
5+ import { AuthError } from '../src/lib/errors'
6+ import { setItemAsync } from '../src/lib/helpers'
37import { memoryLocalStorageAdapter } from '../src/lib/local-storage'
4- import GoTrueClient from '../src/GoTrueClient'
8+ import {
9+ deserializeCredentialCreationOptions ,
10+ deserializeCredentialRequestOptions ,
11+ serializeCredentialCreationResponse ,
12+ serializeCredentialRequestResponse ,
13+ } from '../src/lib/webauthn'
14+ import type { PublicKeyCredentialFuture , PublicKeyCredentialJSON } from '../src/lib/webauthn.dom'
515import {
616 authClient as auth ,
7- authClientWithSession as authWithSession ,
8- authClientWithAsymmetricSession as authWithAsymmetricSession ,
9- authSubscriptionClient ,
10- clientApiAutoConfirmOffSignupsEnabledClient as phoneClient ,
11- clientApiAutoConfirmDisabledClient as signUpDisabledClient ,
12- clientApiAutoConfirmEnabledClient as signUpEnabledClient ,
1317 authAdminApiAutoConfirmEnabledClient ,
14- GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON ,
1518 authClient ,
16- GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON ,
17- pkceClient ,
19+ authSubscriptionClient ,
20+ authClientWithAsymmetricSession as authWithAsymmetricSession ,
21+ authClientWithSession as authWithSession ,
1822 autoRefreshClient ,
1923 getClientWithSpecificStorage ,
24+ GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON ,
25+ GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON ,
26+ clientApiAutoConfirmOffSignupsEnabledClient as phoneClient ,
27+ pkceClient ,
28+ clientApiAutoConfirmDisabledClient as signUpDisabledClient ,
29+ clientApiAutoConfirmEnabledClient as signUpEnabledClient ,
2030} from './lib/clients'
2131import { mockUserCredentials } from './lib/utils'
22- import { JWK , Session } from '../src'
23- import { setItemAsync } from '../src/lib/helpers'
32+ import {
33+ webauthnCreationCredentialResponse ,
34+ webauthnCreationMockCredential ,
35+ } from './webauthn.fixtures'
2436
2537const TEST_USER_DATA = { info : 'some info' }
2638
@@ -1569,7 +1581,7 @@ describe('MFA', () => {
15691581 test ( 'should handle MFA verify without session' , async ( ) => {
15701582 const { data, error } = await auth . mfa . verify ( {
15711583 factorId : 'test-factor-id' ,
1572- challengeId : 'test-challenge-id ' ,
1584+ challengeId : 'f7850041-ba10-4eb3-851c-8ceb7ff8463d ' ,
15731585 code : '123456' ,
15741586 } )
15751587
@@ -1587,6 +1599,223 @@ describe('MFA', () => {
15871599 } )
15881600} )
15891601
1602+ describe ( 'WebAuthn MFA' , ( ) => {
1603+ beforeEach ( ( ) => {
1604+ // Setup navigator.credentials mock
1605+ if ( ! global . navigator ) {
1606+ global . navigator = { } as Navigator
1607+ }
1608+
1609+ // Mock navigator.credentials using Object.defineProperty since it's read-only
1610+ Object . defineProperty ( global . navigator , 'credentials' , {
1611+ value : {
1612+ create : jest . fn ( ) ,
1613+ get : jest . fn ( ) ,
1614+ store : jest . fn ( ) ,
1615+ preventSilentAccess : jest . fn ( ) ,
1616+ } ,
1617+ writable : false ,
1618+ configurable : true ,
1619+ } )
1620+
1621+ // Mock PublicKeyCredential as a proper class so instanceof checks work
1622+ class PublicKeyCredentialMock implements Partial < PublicKeyCredentialFuture > {
1623+ readonly id : string
1624+ readonly rawId : ArrayBuffer
1625+ readonly type : PublicKeyCredentialType = 'public-key'
1626+ readonly response : AuthenticatorResponse
1627+ readonly authenticatorAttachment : AuthenticatorAttachment | null
1628+
1629+ constructor ( data : {
1630+ id : string
1631+ rawId : string | ArrayBuffer
1632+ type : PublicKeyCredentialType
1633+ response : AuthenticatorResponse
1634+ authenticatorAttachment ?: AuthenticatorAttachment | null
1635+ } ) {
1636+ this . id = data . id
1637+ this . rawId =
1638+ typeof data . rawId === 'string' ? base64UrlToUint8Array ( data . rawId ) . buffer : data . rawId
1639+ this . response = data . response
1640+ this . authenticatorAttachment = data . authenticatorAttachment ?? null
1641+ }
1642+
1643+ getClientExtensionResults ( ) : AuthenticationExtensionsClientOutputs {
1644+ return { }
1645+ }
1646+
1647+ toJSON ( ) : PublicKeyCredentialJSON {
1648+ // Use the proper serialization functions based on response type
1649+ if ( 'attestationObject' in this . response ) {
1650+ // Registration response
1651+ return serializeCredentialCreationResponse ( this as any )
1652+ } else if ( 'signature' in this . response ) {
1653+ // Authentication response
1654+ return serializeCredentialRequestResponse ( this as any )
1655+ }
1656+ throw new Error ( 'Unknown Credential Type' )
1657+ }
1658+
1659+ static isUserVerifyingPlatformAuthenticatorAvailable = jest . fn ( ) . mockResolvedValue ( true )
1660+ static isConditionalMediationAvailable = jest . fn ( ) . mockResolvedValue ( true )
1661+ static parseCreationOptionsFromJSON = deserializeCredentialCreationOptions
1662+ static parseRequestOptionsFromJSON = deserializeCredentialRequestOptions
1663+ }
1664+
1665+ ; ( global as any ) . PublicKeyCredential = PublicKeyCredentialMock
1666+ } )
1667+
1668+ afterAll ( ( ) => {
1669+ // @ts -ignore
1670+ delete global . navigator
1671+ // @ts -ignore
1672+ delete global . PublicKeyCredential
1673+ } )
1674+
1675+ const setupUserWithWebAuthn = async ( ) => {
1676+ const { email, password } = mockUserCredentials ( )
1677+ const { data : signUpData , error : signUpError } = await authWithSession . signUp ( {
1678+ email,
1679+ password,
1680+ } )
1681+ expect ( signUpError ) . toBeNull ( )
1682+ expect ( signUpData . session ) . not . toBeNull ( )
1683+
1684+ await authWithSession . initialize ( )
1685+
1686+ const { error : signInError } = await authWithSession . signInWithPassword ( {
1687+ email,
1688+ password,
1689+ } )
1690+ expect ( signInError ) . toBeNull ( )
1691+
1692+ return { email, password }
1693+ }
1694+
1695+ test ( 'enroll WebAuthn should fail without session' , async ( ) => {
1696+ await authWithSession . signOut ( )
1697+ const { data, error } = await authWithSession . mfa . webauthn . enroll ( {
1698+ friendlyName : 'Test Device' ,
1699+ } )
1700+
1701+ expect ( error ) . not . toBeNull ( )
1702+ expect ( error ?. message ) . toContain ( 'Bearer token' )
1703+ expect ( data ) . toBeNull ( )
1704+ } )
1705+
1706+ test ( 'enroll WebAuthn should allow empty friendlyName' , async ( ) => {
1707+ await setupUserWithWebAuthn ( )
1708+ const { data, error } = await authWithSession . mfa . webauthn . enroll ( {
1709+ friendlyName : '' ,
1710+ } )
1711+
1712+ // Server allows empty friendlyName
1713+ expect ( error ) . toBeNull ( )
1714+ expect ( data ) . not . toBeNull ( )
1715+ expect ( data ?. type ) . toBe ( 'webauthn' )
1716+ } )
1717+
1718+ test ( 'enroll WebAuthn should create unverified factor' , async ( ) => {
1719+ await setupUserWithWebAuthn ( )
1720+ const { data, error } = await authWithSession . mfa . webauthn . enroll ( {
1721+ friendlyName : 'Test Security Key' ,
1722+ } )
1723+
1724+ expect ( error ) . toBeNull ( )
1725+ expect ( data ) . not . toBeNull ( )
1726+ expect ( data ?. id ) . toBeDefined ( )
1727+ expect ( data ?. type ) . toBe ( 'webauthn' )
1728+ expect ( data ?. friendly_name ) . toBe ( 'Test Security Key' )
1729+ } )
1730+
1731+ test ( 'challenge WebAuthn should fail without session' , async ( ) => {
1732+ await authWithSession . signOut ( )
1733+ const { data, error } = await authWithSession . mfa . webauthn . challenge ( {
1734+ factorId : 'test-factor-id' ,
1735+ webauthn : {
1736+ rpId : 'localhost' ,
1737+ rpOrigins : [ 'http://localhost:9999' ] ,
1738+ } ,
1739+ } )
1740+
1741+ expect ( error ) . not . toBeNull ( )
1742+ expect ( error ?. message ) . toContain ( 'Bearer token' )
1743+ expect ( data ) . toBeNull ( )
1744+ } )
1745+
1746+ test ( 'challenge WebAuthn should fail with invalid factorId' , async ( ) => {
1747+ await setupUserWithWebAuthn ( )
1748+ const { data, error } = await authWithSession . mfa . webauthn . challenge ( {
1749+ factorId : 'invalid-factor-id' ,
1750+ webauthn : {
1751+ rpId : 'localhost' ,
1752+ rpOrigins : [ 'http://localhost:9999' ] ,
1753+ } ,
1754+ } )
1755+
1756+ expect ( error ) . not . toBeNull ( )
1757+ expect ( data ) . toBeNull ( )
1758+ } )
1759+
1760+ test ( 'verify WebAuthn should fail without session' , async ( ) => {
1761+ await authWithSession . signOut ( )
1762+ const { data, error } = await authWithSession . mfa . webauthn . verify ( {
1763+ factorId : webauthnCreationCredentialResponse . factorId ,
1764+ challengeId : webauthnCreationCredentialResponse . challengeId ,
1765+ webauthn : {
1766+ type : 'create' ,
1767+ rpId : webauthnCreationCredentialResponse . rpId ,
1768+ rpOrigins : [ webauthnCreationCredentialResponse . origin ] ,
1769+ credential_response : webauthnCreationMockCredential ,
1770+ } ,
1771+ } )
1772+
1773+ expect ( error ) . not . toBeNull ( )
1774+ expect ( error ?. message ) . toContain ( 'Bearer token' )
1775+ expect ( data ) . toBeNull ( )
1776+ } )
1777+
1778+ test ( 'unenroll WebAuthn should remove factor' , async ( ) => {
1779+ await setupUserWithWebAuthn ( )
1780+
1781+ const { data : enrollData } = await authWithSession . mfa . webauthn . enroll ( {
1782+ friendlyName : 'Test Device' ,
1783+ } )
1784+
1785+ if ( ! enrollData ) {
1786+ throw new Error ( 'Failed to enroll WebAuthn factor' )
1787+ }
1788+
1789+ const { error : unenrollError } = await authWithSession . mfa . unenroll ( {
1790+ factorId : enrollData . id ,
1791+ } )
1792+
1793+ expect ( unenrollError ) . toBeNull ( )
1794+
1795+ // Wait for unenrollment to be processed
1796+ await new Promise ( ( resolve ) => setTimeout ( resolve , 1000 ) )
1797+
1798+ // Verify factor was removed
1799+ const { data : factorsData } = await authWithSession . mfa . listFactors ( )
1800+ const webauthnFactors = factorsData ?. all . filter ( ( f ) => f . factor_type === 'webauthn' ) || [ ]
1801+ expect ( webauthnFactors ) . toHaveLength ( 0 )
1802+ } )
1803+
1804+ test ( 'should enroll WebAuthn factor' , async ( ) => {
1805+ await setupUserWithWebAuthn ( )
1806+
1807+ const { data : enrollData , error : enrollError } = await authWithSession . mfa . webauthn . enroll ( {
1808+ friendlyName : 'Test Yubikey' ,
1809+ } )
1810+
1811+ expect ( enrollError ) . toBeNull ( )
1812+ expect ( enrollData ) . not . toBeNull ( )
1813+ expect ( enrollData ?. type ) . toBe ( 'webauthn' )
1814+ expect ( enrollData ?. id ) . toBeDefined ( )
1815+ expect ( enrollData ?. friendly_name ) . toBe ( 'Test Yubikey' )
1816+ } )
1817+ } )
1818+
15901819describe ( 'getClaims' , ( ) => {
15911820 test ( 'getClaims returns nothing if there is no session present' , async ( ) => {
15921821 const { data, error } = await authClient . getClaims ( )
0 commit comments