Skip to content

Commit 20f51f8

Browse files
Bewinxedmandarini
authored andcommitted
chore(auth): add webauthn tests
1 parent 30d8caa commit 20f51f8

File tree

6 files changed

+936
-23
lines changed

6 files changed

+936
-23
lines changed

packages/core/auth-js/infra/docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ services:
3838
GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID: '${GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID}'
3939
GOTRUE_SMS_AUTOCONFIRM: 'false'
4040
GOTRUE_COOKIE_KEY: 'sb'
41+
GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: 'true'
42+
GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: 'true'
4143
depends_on:
4244
- db
4345
restart: on-failure
@@ -69,6 +71,8 @@ services:
6971
GOTRUE_SMTP_ADMIN_EMAIL: [email protected]
7072
GOTRUE_COOKIE_KEY: 'sb'
7173
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: 'true'
74+
GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: 'true'
75+
GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: 'true'
7276
depends_on:
7377
- db
7478
restart: on-failure
@@ -99,6 +103,8 @@ services:
99103
GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS
100104
GOTRUE_SMTP_ADMIN_EMAIL: [email protected]
101105
GOTRUE_COOKIE_KEY: 'sb'
106+
GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: 'true'
107+
GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: 'true'
102108
depends_on:
103109
- db
104110
restart: on-failure
@@ -128,6 +134,8 @@ services:
128134
GOTRUE_SMTP_PASS: GOTRUE_SMTP_PASS
129135
GOTRUE_SMTP_ADMIN_EMAIL: [email protected]
130136
GOTRUE_COOKIE_KEY: 'sb'
137+
GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED: 'true'
138+
GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED: 'true'
131139
depends_on:
132140
- db
133141
restart: on-failure

packages/core/auth-js/src/GoTrueClient.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3174,13 +3174,13 @@ export default class GoTrueClient {
31743174
if (sessionError) {
31753175
return { data: null, error: sessionError }
31763176
}
3177-
3177+
const { factorId, ...bodyParams } = params
31783178
const response = (await _request(
31793179
this.fetch,
31803180
'POST',
3181-
`${this.url}/factors/${params.factorId}/challenge`,
3181+
`${this.url}/factors/${factorId}/challenge`,
31823182
{
3183-
body: params,
3183+
body: bodyParams,
31843184
headers: this.headers,
31853185
jwt: sessionData?.session?.access_token,
31863186
}

packages/core/auth-js/src/lib/webauthn.ts

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,7 @@ export const DEFAULT_CREATION_OPTIONS: Partial<PublicKeyCredentialCreationOption
462462
userVerification: 'preferred',
463463
residentKey: 'discouraged',
464464
},
465-
attestation: 'none',
465+
attestation: 'direct',
466466
}
467467

468468
export const DEFAULT_REQUEST_OPTIONS: Partial<PublicKeyCredentialRequestOptionsFuture> = {
@@ -637,11 +637,18 @@ export class WebAuthnApi {
637637
/** webauthn will fail if either of the name/displayname are blank */
638638
if (challengeResponse.webauthn.type === 'create') {
639639
const { user } = challengeResponse.webauthn.credential_options.publicKey
640-
if (!user.name) {
641-
user.name = `${user.id}:${friendlyName}`
642-
}
643-
if (!user.displayName) {
644-
user.displayName = user.name
640+
if (!user.name || !user.displayName) {
641+
const currentUser = await this.client.getUser()
642+
const userData = currentUser.data.user
643+
const fallbackName = () =>
644+
userData?.user_metadata?.name || userData?.email || userData?.id || 'User'
645+
646+
if (!user.name) {
647+
user.name = friendlyName || fallbackName()
648+
}
649+
if (!user.displayName) {
650+
user.displayName = friendlyName || fallbackName()
651+
}
645652
}
646653
}
647654

packages/core/auth-js/test/GoTrueClient.test.ts

Lines changed: 243 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,38 @@
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'
24
import { STORAGE_KEY } from '../src/lib/constants'
5+
import { AuthError } from '../src/lib/errors'
6+
import { setItemAsync } from '../src/lib/helpers'
37
import { 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'
515
import {
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'
2131
import { 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

2537
const 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+
15901819
describe('getClaims', () => {
15911820
test('getClaims returns nothing if there is no session present', async () => {
15921821
const { data, error } = await authClient.getClaims()

0 commit comments

Comments
 (0)