From 452aee4a9fd7a9533cae0928c4c704ed5f3f4bd3 Mon Sep 17 00:00:00 2001 From: joel Date: Mon, 23 Sep 2024 22:29:09 +0200 Subject: [PATCH] feat: add auth-js webauthn bindings --- src/GoTrueClient.ts | 358 +++++++++++++++++++++++++++++--------------- src/lib/types.ts | 38 +++-- 2 files changed, 257 insertions(+), 139 deletions(-) diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index 3b6e98e3..770e329b 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -106,6 +106,9 @@ import type { MFAEnrollWebAuthnParams, AuthenticatorTransportFuture, RegistrationCredential, + PublicKeyCredentialCreationOptionsJSON, + RegistrationResponseJSON, + AuthenticationCredential, } from './lib/types' polyfillGlobalThis() // Make "globalThis" available @@ -2418,13 +2421,16 @@ export default class GoTrueClient { return { data, error: null } } const factorId = data.id - const rpId = new URL(window.location.href).origin + const rpId = window.location.hostname + const rpOrigins = new URL(window.location.href).origin + const webAuthn = { + rpId: rpId, + rpOrigins: rpOrigins, + } const { data: challengeData, error: challengeError } = await this._challenge({ factorId, - webAuthn: { - rpId: rpId, - }, + webAuthn, }) if (challengeError) { return { data: null, error: challengeError } @@ -2432,21 +2438,27 @@ export default class GoTrueClient { if (!challengeData) { return { data: null, error: new Error('Challenge data or options are null') } } - if (!(challengeData.type === 'webauthn' && challengeData?.options)) { + if (!(challengeData.type === 'webauthn' && challengeData?.credential_creation_options)) { return { data: null, error: new Error('Invalid challenge data for WebAuthn') } } try { - // TODO: Undo this cast - let pubKey = challengeData?.options - const options: PublicKeyCredentialCreationOptions = { - ...pubKey, - challenge: base64URLStringToBuffer(pubKey.challenge), + let challengeOptions = challengeData?.credential_creation_options.publicKey + if (!challengeOptions) { + throw new Error('Invalid challenge options') + } + + const publicKey: PublicKeyCredentialCreationOptions = { + ...challengeOptions, + challenge: base64URLStringToBuffer(challengeOptions.challenge), user: { - ...pubKey.user, - id: base64URLStringToBuffer(pubKey.user.id), + ...challengeOptions.user, + id: base64URLStringToBuffer(challengeOptions.user.id), }, - excludeCredentials: pubKey.excludeCredentials?.map(toPublicKeyCredentialDescriptor), + excludeCredentials: challengeOptions.excludeCredentials?.map( + toPublicKeyCredentialDescriptor + ), } + const options: CredentialCreationOptions = { publicKey } const credential = (await navigator.credentials.create( options @@ -2461,7 +2473,6 @@ export default class GoTrueClient { if (typeof response.getTransports === 'function') { transports = response.getTransports() } - // L3 says this is required, but browser and webview support are still not guaranteed. let responsePublicKeyAlgorithm: number | undefined = undefined if (typeof response.getPublicKeyAlgorithm === 'function') { @@ -2506,19 +2517,24 @@ export default class GoTrueClient { }, type, clientExtensionResults: credential.getClientExtensionResults(), - authenticatorAttachment: toAuthenticatorAttachment( - credential.authenticatorAttachment - ), + //authenticatorAttachment: toAuthenticatorAttachment( + // (credential as PublicKeyCredential).authenticatorAttachment + // ), } + const verifyWebAuthnParams = { ...webAuthn, creationResponse: finalCredential } - return await this._verify({ factorId, publicKey: credential }) + return await this._verify({ + factorId, + challengeId: challengeData.id, + webAuthn: verifyWebAuthnParams, + }) } catch (credentialError) { - console.log(credentialError) return { data: null, error: new Error(`Credential creation failed: ${credentialError}`), } } + // Save session here } return { data, error: null } @@ -2535,6 +2551,153 @@ export default class GoTrueClient { * {@see GoTrueMFAApi#verify} */ private async _verify(params: MFAVerifyParams): Promise { + let webAuthnFactor: Factor + if ('factorType' in params && params.factorType === 'webauthn') { + const { + data: { user }, + error: userError, + } = await this._getUser() + const factors = user?.factors || [] + + const webauthn = factors.filter( + (factor) => factor.factor_type === 'webauthn' && factor.status === 'verified' + ) + + webAuthnFactor = webauthn[0] + // + if (!webAuthnFactor) { + return { data: null, error: new AuthError('No WebAuthn factor found') } + } + } + const result = await this._useSession(async (result) => { + const { data: sessionData, error: sessionError } = result + if (sessionError) { + return { data: null, error: sessionError } + } + if ('factorType' in params && params.factorType === 'webauthn') { + // Single Step enroll + const rpId = window.location.hostname + const rpOrigins = new URL(window.location.href).origin + const webAuthn = { + rpId: rpId, + rpOrigins: rpOrigins, + } + + const { data: challengeData, error: challengeError } = await this._challenge({ + factorId: webAuthnFactor.id, + webAuthn, + }) + if ( + !challengeData || + !(challengeData.type === 'webauthn' && challengeData?.credential_request_options) + ) { + return { + data: null, + error: new Error('Invalid challenge data for WebAuthn'), + } + } + + const challengeOptions = challengeData?.credential_request_options.publicKey + + let allowCredentials + if (challengeOptions.allowCredentials?.length !== 0) { + allowCredentials = challengeOptions.allowCredentials?.map(toPublicKeyCredentialDescriptor) + } + + const publicKey: PublicKeyCredentialRequestOptions = { + ...challengeOptions, + challenge: base64URLStringToBuffer(challengeOptions.challenge), + allowCredentials, + } + const options: CredentialRequestOptions = { publicKey } + options.publicKey = publicKey + const credential = (await navigator.credentials.get(options)) as AuthenticationCredential + if (!credential) { + throw new Error('Authentication was not completed') + } + const { id, rawId, response, type } = credential + let userHandle = undefined + if (response.userHandle) { + userHandle = bufferToBase64URLString(response.userHandle) + } + const finalCredential = { + id, + rawId: bufferToBase64URLString(rawId), + response: { + authenticatorData: bufferToBase64URLString(response.authenticatorData), + clientDataJSON: bufferToBase64URLString(response.clientDataJSON), + signature: bufferToBase64URLString(response.signature), + userHandle, + }, + type, + clientExtensionResults: credential.getClientExtensionResults(), + // TODO: get this to work with authenticator attachmetn + // authenticatorAttachment: toAuthenticatorAttachment( + // credential.authenticatorAttachment + // ), + } + const verifyWebAuthnParams = { ...webAuthn, assertionResponse: finalCredential } + + const { data, error } = await _request( + this.fetch, + 'POST', + `${this.url}/factors/${webAuthnFactor.id}/verify`, + { + body: { + challenge_id: challengeData.id, + web_authn: { + rp_id: verifyWebAuthnParams.rpId, + rp_origins: verifyWebAuthnParams.rpOrigins, + assertion_response: verifyWebAuthnParams.assertionResponse, + }, + }, + headers: this.headers, + jwt: sessionData?.session?.access_token, + } + ) + 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 ('webAuthn' in params && params.webAuthn) { + const { data, error } = await _request( + this.fetch, + 'POST', + `${this.url}/factors/${params.factorId}/verify`, + { + body: { + challenge_id: params.challengeId, + web_authn: { + rp_id: params.webAuthn.rpId, + rp_origins: params.webAuthn.rpOrigins, + creation_response: params.webAuthn.creationResponse, + }, + }, + headers: this.headers, + jwt: sessionData?.session?.access_token, + } + ) + 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 } + } + return { + data: null, + error: new AuthError('Unknown error', 500, 'unknown_error'), + } + }) return this._acquireLock(-1, async () => { try { const result = await this._useSession(async (result) => { @@ -2567,27 +2730,9 @@ export default class GoTrueClient { }) 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 } } - // 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 + return { data: null, error: new AuthError('Invalid MFA parameters') } } catch (error) { if (isAuthError(error)) { return { data: null, error } @@ -2595,12 +2740,37 @@ export default class GoTrueClient { throw error } }) + return { data: null, error: new AuthError('No WebAuthn factor found') } } /** * {@see GoTrueMFAApi#challenge} */ private async _challenge(params: MFAChallengeParams): Promise { + let body: { channel?: 'sms' | 'whatsapp' } | { webAuthn?: { rpId: string } } | {} = {} + if ('webAuthn' in params && params.webAuthn?.rpId) { + body = { + web_authn: { rp_id: params.webAuthn.rpId, rp_origins: params.webAuthn.rpOrigins }, + } + return await this._useSession(async (result) => { + const { data: sessionData, error: sessionError } = result + if (sessionError) { + return { data: null, error: sessionError } + } + + return await _request( + this.fetch, + 'POST', + `${this.url}/factors/${params.factorId}/challenge`, + { + body, + headers: this.headers, + jwt: sessionData?.session?.access_token, + } + ) + }) + } + return this._acquireLock(-1, async () => { try { return await this._useSession(async (result) => { @@ -2609,14 +2779,9 @@ export default class GoTrueClient { return { data: null, error: sessionError } } - let body: { channel?: 'sms' | 'whatsapp' } | { webAuthn?: { rpId: string } } | {} = {} - if ('channel' in params) { body = { channel: params.channel } - } else if ('webAuthn' in params && params.webAuthn?.rpId) { - body = { web_authn: { rp_id: params.webAuthn.rpId } } } - return await _request( this.fetch, 'POST', @@ -2640,64 +2805,14 @@ export default class GoTrueClient { /** * {@see GoTrueMFAApi#challengeAndVerify} */ - // TODO: Undo this change private async _challengeAndVerify(params: { factorId: string code: string }): Promise - private async _challengeAndVerify(params: { - factorType: 'webauthn' - }): Promise private async _challengeAndVerify( params: MFAChallengeAndVerifyParams ): Promise { - if ('factorType' in params && params.factorType === 'webauthn') { - const { data: factors, error: factorsError } = await this._listFactors() - if (factorsError) { - return { data: null, error: factorsError } - } - - 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') } - } - - 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(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) { + if ('factorId' in params && 'code' in params) { const { data: challengeResponse, error: challengeError } = await this._challenge({ factorId: params.factorId, }) @@ -2721,34 +2836,39 @@ export default class GoTrueClient { */ private async _listFactors(): Promise { // use #getUser instead of #_getUser as the former acquires a lock - const { - data: { user }, - error: userError, - } = await this.getUser() - if (userError) { - return { data: null, error: userError } - } + try { + const { + data: { user }, + error: userError, + } = await this.getUser() + if (userError) { + return { data: null, error: userError } + } - const factors = user?.factors || [] - const totp = factors.filter( - (factor) => factor.factor_type === 'totp' && factor.status === 'verified' - ) - const phone = factors.filter( - (factor) => factor.factor_type === 'phone' && factor.status === 'verified' - ) + const factors = user?.factors || [] + const totp = factors.filter( + (factor) => factor.factor_type === 'totp' && factor.status === 'verified' + ) + const phone = factors.filter( + (factor) => factor.factor_type === 'phone' && factor.status === 'verified' + ) - const webauthn = factors.filter( - (factor) => factor.factor_type === 'webauthn' && factor.status === 'verified' - ) + const webauthn = factors.filter( + (factor) => factor.factor_type === 'webauthn' && factor.status === 'verified' + ) - return { - data: { - all: factors, - totp, - phone, - webauthn, - }, - error: null, + return { + data: { + all: factors, + totp, + phone, + webauthn, + }, + error: null, + } + } catch (error) { + console.error('Error in _listFactors:', error) + return { data: null, error: new AuthError('Failed to list factors') } } } diff --git a/src/lib/types.ts b/src/lib/types.ts index 8ce19f29..5c6df9e3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -830,11 +830,6 @@ export type MFAEnrollWebAuthnParams = { useMultiStep?: boolean } -export type CredentialReturn = { - publicKeyCredential: T - error: null -} - export type MFAVerifyTOTPParams = { /** ID of the factor being verified. Returned in enroll(). */ factorId: string @@ -856,8 +851,13 @@ export type MFAVerifyWebAuthnParams = } | { factorId: string - - publicKey: PublicKeyCredential + challengeId: string + webAuthn?: { + rpId: string + rpOrigins: string + assertionResponse?: PublicKeyCredentialJSON + creationResponse?: PublicKeyCredentialJSON + } } export type MFAChallengeTOTPParams = { @@ -872,6 +872,7 @@ export type MFAChallengeWebAuthnParams = { factorId: string webAuthn?: { rpId: string + rpOrigins: string } } @@ -889,16 +890,12 @@ export type MFAUnenrollParams = { factorId: string } -export type MFAChallengeAndVerifyParams = - | { - /** ID of the factor being verified. Returned in enroll(). */ - factorId: string - /** Verification code provided by the user. */ - code: string - } - | { - factorType: 'webauthn' - } +export type MFAChallengeAndVerifyParams = { + /** ID of the factor being verified. Returned in enroll(). */ + factorId: string + /** Verification code provided by the user. */ + code: string +} export type AuthMFAVerifyResponse = | { @@ -977,7 +974,7 @@ export type AuthMFAEnrollWebAuthnResponse = { id: string /** Type of MFA factor. */ - type: 'phone' + type: 'webauthn' /** Friendly name of the factor, useful for distinguishing between factors **/ friendly_name?: string @@ -1030,7 +1027,8 @@ export type AuthMFAChallengeResponse = /** Timestamp in UNIX seconds when this challenge will no longer be usable. */ expires_at: number - options: PublicKeyCredentialCreationOptionsJSON | PublicKeyCredentialRequestOptionsJSON + credential_creation_options: { publicKey: PublicKeyCredentialCreationOptionsJSON } + credential_request_options: { publicKey: PublicKeyCredentialRequestOptionsJSON } } error: null } @@ -1046,7 +1044,7 @@ export type AuthMFAListFactorsResponse = totp: Factor[] /** Only verified Phone factors. (A subset of `all`.) */ phone: Factor[] - /** Only verified Phone factors. (A subset of `all`.) */ + /** Only verified webauthn factors. (A subset of `all`.) */ webauthn: Factor[] } error: null