Skip to content
This repository was archived by the owner on Oct 10, 2025. It is now read-only.

Commit 0c8be61

Browse files
committed
fix: add tests for webauthn factorType, webauthn merge helpers, add webauthn ENVs to docker-compose.yml
1 parent 1cbd43e commit 0c8be61

File tree

3 files changed

+718
-11
lines changed

3 files changed

+718
-11
lines changed

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

test/GoTrueClient.test.ts

Lines changed: 190 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,30 @@
1-
import { AuthError } from '../src/lib/errors'
1+
import { JWK, Session } from '../src'
2+
import GoTrueClient from '../src/GoTrueClient'
23
import { STORAGE_KEY } from '../src/lib/constants'
4+
import { AuthError } from '../src/lib/errors'
5+
import { setItemAsync } from '../src/lib/helpers'
36
import { memoryLocalStorageAdapter } from '../src/lib/local-storage'
4-
import GoTrueClient from '../src/GoTrueClient'
57
import {
8+
authAdminApiAutoConfirmEnabledClient,
69
authClient as auth,
7-
authClientWithSession as authWithSession,
10+
authClient,
811
authClientWithAsymmetricSession as authWithAsymmetricSession,
12+
authClientWithSession as authWithSession,
913
authSubscriptionClient,
10-
clientApiAutoConfirmOffSignupsEnabledClient as phoneClient,
14+
autoRefreshClient,
1115
clientApiAutoConfirmDisabledClient as signUpDisabledClient,
1216
clientApiAutoConfirmEnabledClient as signUpEnabledClient,
13-
authAdminApiAutoConfirmEnabledClient,
14-
GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
15-
authClient,
17+
clientApiAutoConfirmOffSignupsEnabledClient as phoneClient,
18+
getClientWithSpecificStorage,
1619
GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON,
20+
GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
1721
pkceClient,
18-
autoRefreshClient,
19-
getClientWithSpecificStorage,
2022
} from './lib/clients'
2123
import { mockUserCredentials } from './lib/utils'
22-
import { JWK, Session } from '../src'
23-
import { setItemAsync } from '../src/lib/helpers'
24+
import {
25+
createMockAuthenticationCredential,
26+
createMockRegistrationCredential,
27+
} from './webauthn-test-utils'
2428

2529
const TEST_USER_DATA = { info: 'some info' }
2630

@@ -1587,6 +1591,181 @@ describe('MFA', () => {
15871591
})
15881592
})
15891593

1594+
describe('WebAuthn MFA', () => {
1595+
afterAll(() => {
1596+
// @ts-ignore
1597+
delete global.navigator
1598+
// @ts-ignore
1599+
delete global.PublicKeyCredential
1600+
})
1601+
1602+
const setupUserWithWebAuthn = async () => {
1603+
const { email, password } = mockUserCredentials()
1604+
const { data: signUpData, error: signUpError } = await authWithSession.signUp({
1605+
email,
1606+
password,
1607+
})
1608+
expect(signUpError).toBeNull()
1609+
expect(signUpData.session).not.toBeNull()
1610+
1611+
await authWithSession.initialize()
1612+
1613+
const { error: signInError } = await authWithSession.signInWithPassword({
1614+
email,
1615+
password,
1616+
})
1617+
expect(signInError).toBeNull()
1618+
1619+
return { email, password }
1620+
}
1621+
1622+
test('enroll WebAuthn should fail without session', async () => {
1623+
await authWithSession.signOut()
1624+
const { data, error } = await authWithSession.mfa.webauthn.enroll({
1625+
friendlyName: 'Test Device',
1626+
})
1627+
1628+
expect(error).not.toBeNull()
1629+
expect(error?.message).toContain('Bearer token')
1630+
expect(data).toBeNull()
1631+
})
1632+
1633+
test('enroll WebAuthn should allow empty friendlyName', async () => {
1634+
await setupUserWithWebAuthn()
1635+
const { data, error } = await authWithSession.mfa.webauthn.enroll({
1636+
friendlyName: '',
1637+
})
1638+
1639+
// Server allows empty friendlyName
1640+
expect(error).toBeNull()
1641+
expect(data).not.toBeNull()
1642+
expect(data?.type).toBe('webauthn')
1643+
})
1644+
1645+
test('enroll WebAuthn should create unverified factor', async () => {
1646+
await setupUserWithWebAuthn()
1647+
const { data, error } = await authWithSession.mfa.webauthn.enroll({
1648+
friendlyName: 'Test Security Key',
1649+
})
1650+
1651+
expect(error).toBeNull()
1652+
expect(data).not.toBeNull()
1653+
expect(data?.id).toBeDefined()
1654+
expect(data?.type).toBe('webauthn')
1655+
expect(data?.friendly_name).toBe('Test Security Key')
1656+
})
1657+
1658+
test('challenge WebAuthn should fail without session', async () => {
1659+
await authWithSession.signOut()
1660+
const { data, error } = await authWithSession.mfa.webauthn.challenge({
1661+
factorId: 'test-factor-id',
1662+
webauthn: {
1663+
rpId: 'localhost',
1664+
rpOrigins: ['http://localhost:9999'],
1665+
},
1666+
})
1667+
1668+
expect(error).not.toBeNull()
1669+
expect(error?.message).toContain('Bearer token')
1670+
expect(data).toBeNull()
1671+
})
1672+
1673+
test('challenge WebAuthn should fail with invalid factorId', async () => {
1674+
await setupUserWithWebAuthn()
1675+
const { data, error } = await authWithSession.mfa.webauthn.challenge({
1676+
factorId: 'invalid-factor-id',
1677+
webauthn: {
1678+
rpId: 'localhost',
1679+
rpOrigins: ['http://localhost:9999'],
1680+
},
1681+
})
1682+
1683+
expect(error).not.toBeNull()
1684+
expect(data).toBeNull()
1685+
})
1686+
1687+
test('verify WebAuthn should fail without session', async () => {
1688+
await authWithSession.signOut()
1689+
const { data, error } = await authWithSession.mfa.webauthn.verify({
1690+
factorId: 'test-factor-id',
1691+
challengeId: 'test-challenge-id',
1692+
webauthn: {
1693+
type: 'create',
1694+
rpId: 'localhost',
1695+
rpOrigins: ['http://localhost:9999'],
1696+
credential_response: {
1697+
id: 'test-credential-id',
1698+
rawId: new ArrayBuffer(8),
1699+
response: {
1700+
attestationObject: new ArrayBuffer(8),
1701+
clientDataJSON: new ArrayBuffer(8),
1702+
},
1703+
type: 'public-key',
1704+
getClientExtensionResults: () => ({}),
1705+
} as any,
1706+
},
1707+
})
1708+
1709+
expect(error).not.toBeNull()
1710+
expect(error?.message).toContain('Bearer token')
1711+
expect(data).toBeNull()
1712+
})
1713+
1714+
test('unenroll WebAuthn should remove factor', async () => {
1715+
await setupUserWithWebAuthn()
1716+
1717+
const { data: enrollData } = await authWithSession.mfa.webauthn.enroll({
1718+
friendlyName: 'Test Device',
1719+
})
1720+
1721+
if (!enrollData) {
1722+
throw new Error('Failed to enroll WebAuthn factor')
1723+
}
1724+
1725+
const { error: unenrollError } = await authWithSession.mfa.unenroll({
1726+
factorId: enrollData.id,
1727+
})
1728+
1729+
expect(unenrollError).toBeNull()
1730+
1731+
// Wait for unenrollment to be processed
1732+
await new Promise((resolve) => setTimeout(resolve, 1000))
1733+
1734+
// Verify factor was removed
1735+
const { data: factorsData } = await authWithSession.mfa.listFactors()
1736+
const webauthnFactors = factorsData?.all.filter((f) => f.factor_type === 'webauthn') || []
1737+
expect(webauthnFactors).toHaveLength(0)
1738+
})
1739+
1740+
test('listFactors should include WebAuthn factors', async () => {
1741+
// Mock getUser to include WebAuthn factors
1742+
authWithSession.getUser = jest.fn().mockResolvedValue({
1743+
data: {
1744+
user: {
1745+
id: 'test-user',
1746+
factors: [
1747+
{ id: '1', factor_type: 'webauthn', status: 'verified', friendly_name: 'YubiKey 5' },
1748+
{ id: '2', factor_type: 'webauthn', status: 'unverified', friendly_name: 'Touch ID' },
1749+
{ id: '3', factor_type: 'totp', status: 'verified' },
1750+
],
1751+
},
1752+
},
1753+
error: null,
1754+
})
1755+
1756+
const { data, error } = await authWithSession.mfa.listFactors()
1757+
1758+
expect(error).toBeNull()
1759+
expect(data).not.toBeNull()
1760+
if (data) {
1761+
expect(data.all).toHaveLength(3)
1762+
const webauthnFactors = data.all.filter((f) => f.factor_type === 'webauthn')
1763+
expect(webauthnFactors).toHaveLength(2)
1764+
expect(webauthnFactors.filter((f) => f.status === 'verified')).toHaveLength(1)
1765+
}
1766+
})
1767+
})
1768+
15901769
describe('getClaims', () => {
15911770
test('getClaims returns nothing if there is no session present', async () => {
15921771
const { data, error } = await authClient.getClaims()

0 commit comments

Comments
 (0)