Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: add MFA for WebAuthn bindings #960

Draft
wants to merge 7 commits into
base: master
Choose a base branch
from
Draft
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
195 changes: 157 additions & 38 deletions src/GoTrueClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,12 +70,15 @@ import type {
VerifyOtpParams,
GoTrueMFAApi,
MFAEnrollParams,
MFAVerifyParams,
AuthMFAEnrollResponse,
MFAChallengeParams,
AuthMFAChallengeResponse,
MFAUnenrollParams,
AuthMFAUnenrollResponse,
MFAVerifyParams,
MFAVerifyTOTPParams,
MFAVerifyPhoneParams,
MFAVerifyWebAuthnParams,
AuthMFAVerifyResponse,
AuthMFAListFactorsResponse,
AMREntry,
Expand All @@ -88,11 +91,13 @@ import type {
LockFunc,
UserIdentity,
SignInAnonymouslyCredentials,
MFAEnrollTOTPParams,
AuthMFAEnrollTOTPResponse,
AuthMFAEnrollPhoneResponse,
AuthMFAEnrollWebAuthnResponse,
AuthMFAEnrollErrorResponse,
MFAEnrollTOTPParams,
MFAEnrollPhoneParams,
AuthMFAEnrollPhoneResponse,
MFAEnrollWebAuthnParams,
} from './lib/types'

polyfillGlobalThis() // Make "globalThis" available
Expand Down Expand Up @@ -2364,7 +2369,12 @@ export default class GoTrueClient {
private async _enroll(
params: MFAEnrollPhoneParams
): Promise<AuthMFAEnrollPhoneResponse | AuthMFAEnrollErrorResponse>
private async _enroll(params: MFAEnrollParams): Promise<AuthMFAEnrollResponse> {
private async _enroll(
params: MFAEnrollWebAuthnParams
): Promise<AuthMFAEnrollWebAuthnResponse | AuthMFAVerifyResponse | AuthMFAEnrollErrorResponse>
private async _enroll(
params: MFAEnrollParams
): Promise<AuthMFAEnrollResponse | AuthMFAVerifyResponse> {
try {
return await this._useSession(async (result) => {
const { data: sessionData, error: sessionError } = result
Expand All @@ -2388,14 +2398,40 @@ export default class GoTrueClient {
return { data: null, error }
}

// TODO: Remove once: https://github.com/supabase/auth/pull/1717 is deployed
if (params.factorType === 'phone') {
delete data.totp
}

if (params.factorType === 'totp' && data?.totp?.qr_code) {
data.totp.qr_code = `data:image/svg+xml;utf-8,${data.totp.qr_code}`
}
if (params.factorType === 'webauthn' && params.useMultiStep && data.type === 'webauthn') {
const factorId = data.id
const { data: challengeData, error: challengeError } = await this._challenge({ factorId })
if (challengeError) {
return { data: null, error: challengeError }
}
if (!challengeData) {
return { data: null, error: new Error('Challenge data or options are null') }
}
if (!(challengeData.type === 'webauthn' && 'options' in challengeData)) {
return { data: null, error: new Error('Invalid challenge data for WebAuthn') }
}
try {
const publicKey = await navigator.credentials.create(
challengeData.options as CredentialCreationOptions
)
if (!publicKey) {
return { data: null, error: new Error('Failed to create credentials') }
}
return await this._verify({ factorId, publicKey })
} catch (credentialError) {
return {
data: null,
error: new Error(`Credential creation failed: ${credentialError}`),
}
}
}

return { data, error: null }
})
Expand All @@ -2413,34 +2449,57 @@ export default class GoTrueClient {
private async _verify(params: MFAVerifyParams): Promise<AuthMFAVerifyResponse> {
return this._acquireLock(-1, async () => {
try {
return await this._useSession(async (result) => {
const result = await this._useSession(async (result) => {
const { data: sessionData, error: sessionError } = result
if (sessionError) {
return { data: null, error: sessionError }
}

const { data, error } = await _request(
this.fetch,
'POST',
`${this.url}/factors/${params.factorId}/verify`,
{
body: { code: params.code, challenge_id: params.challengeId },
headers: this.headers,
jwt: sessionData?.session?.access_token,
if ('code' in params && 'challengeId' in params && 'factorId' in params) {
// This handles MFAVerifyTOTPParams and MFAVerifyPhoneParams
const { data, error } = await _request(
this.fetch,
'POST',
`${this.url}/factors/${params.factorId}/verify`,
{
body: {
code: params.code,
challenge_id: params.challengeId,
},
headers: this.headers,
jwt: sessionData?.session?.access_token,
}
)
if (error) {
return { data: null, error }
}
)
if (error) {
return { data: null, error }
await this._saveSession({
expires_at: Math.round(Date.now() / 1000) + data.expires_in,
...data,
})
await this._notifyAllSubscribers('MFA_CHALLENGE_VERIFIED', data)
return { data, error }
} else if ('factorType' in params && params.factorType === 'webauthn') {
// Single Step enroll
const { data, error } = await this._challengeAndVerify({
factorType: 'webauthn',
})
if (error) {
return { data: null, error }
}
await this._saveSession({
expires_at: Math.round(Date.now() / 1000) + data.expires_in,
...data,
})
await this._notifyAllSubscribers('MFA_CHALLENGE_VERIFIED', data)
return { data, error }
}

await this._saveSession({
expires_at: Math.round(Date.now() / 1000) + data.expires_in,
...data,
})
await this._notifyAllSubscribers('MFA_CHALLENGE_VERIFIED', data)

return { data, error }
// TODO: fix this hack
// If we reach here, it means none of the conditions were met
return { data: null, error: new Error('Invalid MFA parameters') }
})
// TODO: Fix this hack
return result
} catch (error) {
if (isAuthError(error)) {
return { data: null, error }
Expand Down Expand Up @@ -2485,24 +2544,79 @@ export default class GoTrueClient {
/**
* {@see GoTrueMFAApi#challengeAndVerify}
*/
private async _challengeAndVerify(params: {
factorId: string
code: string
}): Promise<AuthMFAVerifyResponse>
private async _challengeAndVerify(params: {
factorType: 'webauthn'
}): Promise<AuthMFAVerifyResponse>
private async _challengeAndVerify(
params: MFAChallengeAndVerifyParams
): Promise<AuthMFAVerifyResponse> {
// both _challenge and _verify independently acquire the lock, so no need
// to acquire it here
if ('factorType' in params && params.factorType === 'webauthn') {
const { data: factors, error: factorsError } = await this._listFactors()
if (factorsError) {
return { data: null, error: factorsError }
}

const { data: challengeData, error: challengeError } = await this._challenge({
factorId: params.factorId,
})
if (challengeError) {
return { data: null, error: challengeError }
}
if (!factors || !factors.all || factors.all.length === 0) {
return { data: null, error: new AuthError('No WebAuthn factor found', 400, 'MFA_ERROR') }
}
const webauthnFactor = factors.all.find((factor) => factor.factor_type === 'webauthn')
if (!webauthnFactor) {
return { data: null, error: new AuthError('No WebAuthn factor found', 400, 'MFA_ERROR') }
}

return await this._verify({
factorId: params.factorId,
challengeId: challengeData.id,
code: params.code,
})
const { data: challengeResponse, error: challengeError } = await this._challenge({
factorId: webauthnFactor.id,
})
if (challengeError) {
return { data: null, error: challengeError }
}

if (!(challengeResponse.type === 'webauthn')) {
return {
data: null,
error: new AuthError('Invalid challenge data for WebAuthn', 400, 'mfa_error'),
}
}

try {
// TODO: This needs to chagne since ChallengeAndVerify is also for enroll
const publicKey = await navigator.credentials.get(challengeResponse.options)
// TODO: handle credential error
if (!publicKey) {
return { data: null, error: new AuthError('No valid credential found', 400, 'mfa_error') }
}
return await this._verify({
factorId: webauthnFactor.id,
publicKey,
})
} catch (error) {
// TODO: Come back and fix this code
return {
data: null,
error: new AuthError('Unknown error', 500, 'unknown_error'),
}
}
} else if ('factorId' in params && 'code' in params) {
const { data: challengeResponse, error: challengeError } = await this._challenge({
factorId: params.factorId,
})
if (challengeError) {
return { data: null, error: challengeError }
}
return await this._verify({
factorId: params.factorId,
challengeId: challengeResponse.id,
code: params.code,
})
}
return {
data: null,
error: new AuthError('Unknown error', 500, 'unknown_error'),
}
}

/**
Expand All @@ -2526,11 +2640,16 @@ export default class GoTrueClient {
(factor) => factor.factor_type === 'phone' && factor.status === 'verified'
)

const webauthn = factors.filter(
(factor) => factor.factor_type === 'webauthn' && factor.status === 'verified'
)

return {
data: {
all: factors,
totp,
phone,
webauthn,
},
error: null,
}
Expand Down
Loading