Skip to content
This repository was archived by the owner on Oct 10, 2025. It is now read-only.
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions infra/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
19 changes: 13 additions & 6 deletions src/lib/webauthn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,7 +462,7 @@ export const DEFAULT_CREATION_OPTIONS: Partial<PublicKeyCredentialCreationOption
userVerification: 'preferred',
residentKey: 'discouraged',
},
attestation: 'none',
attestation: 'direct',
}

export const DEFAULT_REQUEST_OPTIONS: Partial<PublicKeyCredentialRequestOptionsFuture> = {
Expand Down Expand Up @@ -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()
}
}
}

Expand Down
259 changes: 245 additions & 14 deletions test/GoTrueClient.test.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,40 @@
import { AuthError } from '../src/lib/errors'
import { JWK, Session } from '../src'
import GoTrueClient from '../src/GoTrueClient'
import { base64UrlToUint8Array } from '../src/lib/base64url'
import { STORAGE_KEY } from '../src/lib/constants'
import { AuthError } from '../src/lib/errors'
import { setItemAsync } from '../src/lib/helpers'
import { memoryLocalStorageAdapter } from '../src/lib/local-storage'
import GoTrueClient from '../src/GoTrueClient'
import {
deserializeCredentialCreationOptions,
deserializeCredentialRequestOptions,
serializeCredentialCreationResponse,
serializeCredentialRequestResponse,
} from '../src/lib/webauthn'
import type { PublicKeyCredentialFuture, PublicKeyCredentialJSON } from '../src/lib/webauthn.dom'
import {
authClient as auth,
authClientWithSession as authWithSession,
authClientWithAsymmetricSession as authWithAsymmetricSession,
authSubscriptionClient,
clientApiAutoConfirmOffSignupsEnabledClient as phoneClient,
clientApiAutoConfirmDisabledClient as signUpDisabledClient,
clientApiAutoConfirmEnabledClient as signUpEnabledClient,
authAdminApiAutoConfirmEnabledClient,
GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
authClient,
GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON,
pkceClient,
authSubscriptionClient,
authClientWithAsymmetricSession as authWithAsymmetricSession,
authClientWithSession as authWithSession,
autoRefreshClient,
getClientWithSpecificStorage,
GOTRUE_URL_SIGNUP_ENABLED_ASYMMETRIC_AUTO_CONFIRM_ON,
GOTRUE_URL_SIGNUP_ENABLED_AUTO_CONFIRM_ON,
clientApiAutoConfirmOffSignupsEnabledClient as phoneClient,
pkceClient,
clientApiAutoConfirmDisabledClient as signUpDisabledClient,
clientApiAutoConfirmEnabledClient as signUpEnabledClient,
} from './lib/clients'
import { mockUserCredentials } from './lib/utils'
import { JWK, Session } from '../src'
import { setItemAsync } from '../src/lib/helpers'
import {
webauthnAssertionCredentialResponse,
webauthnAssertionMockCredential,
webauthnCreationCredentialResponse,
webauthnCreationMockCredential,
} from './webauthn.fixtures'

const TEST_USER_DATA = { info: 'some info' }

Expand Down Expand Up @@ -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',
})

Expand All @@ -1587,6 +1601,223 @@ describe('MFA', () => {
})
})

describe('WebAuthn MFA', () => {
beforeEach(() => {
// Setup navigator.credentials mock
if (!global.navigator) {
global.navigator = {} as Navigator
}

// Mock navigator.credentials using Object.defineProperty since it's read-only
Object.defineProperty(global.navigator, 'credentials', {
value: {
create: jest.fn(),
get: jest.fn(),
store: jest.fn(),
preventSilentAccess: jest.fn(),
},
writable: false,
configurable: true,
})

// Mock PublicKeyCredential as a proper class so instanceof checks work
class PublicKeyCredentialMock implements Partial<PublicKeyCredentialFuture> {
readonly id: string
readonly rawId: ArrayBuffer
readonly type: PublicKeyCredentialType = 'public-key'
readonly response: AuthenticatorResponse
readonly authenticatorAttachment: AuthenticatorAttachment | null

constructor(data: {
id: string
rawId: string | ArrayBuffer
type: PublicKeyCredentialType
response: AuthenticatorResponse
authenticatorAttachment?: AuthenticatorAttachment | null
}) {
this.id = data.id
this.rawId =
typeof data.rawId === 'string' ? base64UrlToUint8Array(data.rawId).buffer : data.rawId
this.response = data.response
this.authenticatorAttachment = data.authenticatorAttachment ?? null
}

getClientExtensionResults(): AuthenticationExtensionsClientOutputs {
return {}
}

toJSON(): PublicKeyCredentialJSON {
// Use the proper serialization functions based on response type
if ('attestationObject' in this.response) {
// Registration response
return serializeCredentialCreationResponse(this as any)
} else if ('signature' in this.response) {
// Authentication response
return serializeCredentialRequestResponse(this as any)
}
throw new Error('Unknown Credential Type')
}

static isUserVerifyingPlatformAuthenticatorAvailable = jest.fn().mockResolvedValue(true)
static isConditionalMediationAvailable = jest.fn().mockResolvedValue(true)
static parseCreationOptionsFromJSON = deserializeCredentialCreationOptions
static parseRequestOptionsFromJSON = deserializeCredentialRequestOptions
}

;(global as any).PublicKeyCredential = PublicKeyCredentialMock
})

afterAll(() => {
// @ts-ignore
delete global.navigator
// @ts-ignore
delete global.PublicKeyCredential
})

const setupUserWithWebAuthn = async () => {
const { email, password } = mockUserCredentials()
const { data: signUpData, error: signUpError } = await authWithSession.signUp({
email,
password,
})
expect(signUpError).toBeNull()
expect(signUpData.session).not.toBeNull()

await authWithSession.initialize()

const { error: signInError } = await authWithSession.signInWithPassword({
email,
password,
})
expect(signInError).toBeNull()

return { email, password }
}

test('enroll WebAuthn should fail without session', async () => {
await authWithSession.signOut()
const { data, error } = await authWithSession.mfa.webauthn.enroll({
friendlyName: 'Test Device',
})

expect(error).not.toBeNull()
expect(error?.message).toContain('Bearer token')
expect(data).toBeNull()
})

test('enroll WebAuthn should allow empty friendlyName', async () => {
await setupUserWithWebAuthn()
const { data, error } = await authWithSession.mfa.webauthn.enroll({
friendlyName: '',
})

// Server allows empty friendlyName
expect(error).toBeNull()
expect(data).not.toBeNull()
expect(data?.type).toBe('webauthn')
})

test('enroll WebAuthn should create unverified factor', async () => {
await setupUserWithWebAuthn()
const { data, error } = await authWithSession.mfa.webauthn.enroll({
friendlyName: 'Test Security Key',
})

expect(error).toBeNull()
expect(data).not.toBeNull()
expect(data?.id).toBeDefined()
expect(data?.type).toBe('webauthn')
expect(data?.friendly_name).toBe('Test Security Key')
})

test('challenge WebAuthn should fail without session', async () => {
await authWithSession.signOut()
const { data, error } = await authWithSession.mfa.webauthn.challenge({
factorId: 'test-factor-id',
webauthn: {
rpId: 'localhost',
rpOrigins: ['http://localhost:9999'],
},
})

expect(error).not.toBeNull()
expect(error?.message).toContain('Bearer token')
expect(data).toBeNull()
})

test('challenge WebAuthn should fail with invalid factorId', async () => {
await setupUserWithWebAuthn()
const { data, error } = await authWithSession.mfa.webauthn.challenge({
factorId: 'invalid-factor-id',
webauthn: {
rpId: 'localhost',
rpOrigins: ['http://localhost:9999'],
},
})

expect(error).not.toBeNull()
expect(data).toBeNull()
})

test('verify WebAuthn should fail without session', async () => {
await authWithSession.signOut()
const { data, error } = await authWithSession.mfa.webauthn.verify({
factorId: webauthnCreationCredentialResponse.factorId,
challengeId: webauthnCreationCredentialResponse.challengeId,
webauthn: {
type: 'create',
rpId: webauthnCreationCredentialResponse.rpId,
rpOrigins: [webauthnCreationCredentialResponse.origin],
credential_response: webauthnCreationMockCredential,
},
})

expect(error).not.toBeNull()
expect(error?.message).toContain('Bearer token')
expect(data).toBeNull()
})

test('unenroll WebAuthn should remove factor', async () => {
await setupUserWithWebAuthn()

const { data: enrollData } = await authWithSession.mfa.webauthn.enroll({
friendlyName: 'Test Device',
})

if (!enrollData) {
throw new Error('Failed to enroll WebAuthn factor')
}

const { error: unenrollError } = await authWithSession.mfa.unenroll({
factorId: enrollData.id,
})

expect(unenrollError).toBeNull()

// Wait for unenrollment to be processed
await new Promise((resolve) => setTimeout(resolve, 1000))

// Verify factor was removed
const { data: factorsData } = await authWithSession.mfa.listFactors()
const webauthnFactors = factorsData?.all.filter((f) => f.factor_type === 'webauthn') || []
expect(webauthnFactors).toHaveLength(0)
})

test('should enroll WebAuthn factor', async () => {
await setupUserWithWebAuthn()

const { data: enrollData, error: enrollError } = await authWithSession.mfa.webauthn.enroll({
friendlyName: 'Test Yubikey',
})

expect(enrollError).toBeNull()
expect(enrollData).not.toBeNull()
expect(enrollData?.type).toBe('webauthn')
expect(enrollData?.id).toBeDefined()
expect(enrollData?.friendly_name).toBe('Test Yubikey')
})
})

describe('getClaims', () => {
test('getClaims returns nothing if there is no session present', async () => {
const { data, error } = await authClient.getClaims()
Expand Down
Loading