diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index 57e49e3d..352ca2e2 100644 --- a/src/GoTrueClient.ts +++ b/src/GoTrueClient.ts @@ -35,6 +35,10 @@ import { supportsLocalStorage, parseParametersFromURL, getCodeChallengeAndMethod, + base64URLStringToBuffer, + bufferToBase64URLString, + startRegistration, + startAuthentication, } from './lib/helpers' import { localStorageAdapter, memoryLocalStorageAdapter } from './lib/local-storage' import { polyfillGlobalThis } from './lib/polyfills' @@ -71,7 +75,6 @@ import type { GoTrueMFAApi, MFAEnrollParams, MFAVerifyParams, - AuthMFAEnrollResponse, MFAChallengeParams, AuthMFAChallengeResponse, MFAUnenrollParams, @@ -91,6 +94,13 @@ import type { LockFunc, UserIdentity, SignInAnonymouslyCredentials, + AuthenticatorTransportFuture, + RegistrationCredential, + PublicKeyCredentialCreationOptionsJSON, + RegistrationResponseJSON, + AuthenticationCredential, + MFAVerifySingleStepWebAuthnParams, + AuthMFAEnrollResponse, } from './lib/types' import { MFAEnrollTOTPParams, @@ -2385,10 +2395,36 @@ export default class GoTrueClient { if (error) { return { data: null, error } } - 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' && data.type === 'webauthn') { + if (params.useMultiStep) { + return { data, error: null } + } + const factorId = data.id + const webAuthn = this._getWebAuthnRpDetails() + const { data: challengeData, error: challengeError } = await this._challenge({ + factorId, + webAuthn, + }) + if (challengeError) { + return { data: null, error: challengeError } + } + + if (!(challengeData.type === 'webauthn' && challengeData?.credential_creation_options)) { + return { data: null, error: new Error('Invalid challenge data for WebAuthn') } + } + let challengeOptions = challengeData?.credential_creation_options.publicKey + let credential = await startRegistration(challengeOptions) + const verifyWebAuthnParams = { ...webAuthn, creationResponse: credential } + + return await this._verify({ + factorId, + challengeId: challengeData.id, + webAuthn: verifyWebAuthnParams, + }) + } return { data, error: null } }) @@ -2400,6 +2436,12 @@ export default class GoTrueClient { } } + private _getWebAuthnRpDetails() { + const rpId = window.location.hostname + const rpOrigins = new URL(window.location.href).origin + return { rpId, rpOrigins } + } + /** * {@see GoTrueMFAApi#verify} */ @@ -2409,67 +2451,14 @@ export default class GoTrueClient { private async _verify(params: MFAVerifyParams): Promise { return this._acquireLock(-1, async () => { try { - const result = await this._useSession(async (result) => { - const { data: sessionData, error: sessionError } = result - if (sessionError) { - return { data: null, error: sessionError } - } - - 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 } - } - 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') { - // TODO: Replace the placeholder - const { data, error } = await _request( - this.fetch, - 'POST', - `${this.url}/factors/verify`, - { - body: { - use_multi_step: params.useMultiStep, - factorType: params.factorType, - }, - 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 } - } - // 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 thsi hack - return result + if ('code' in params && 'challengeId' in params && 'factorId' in params) { + return this._verifyCodeChallenge(params) + } else if ('factorType' in params && params.factorType === 'webauthn') { + return this._verifyWebAuthnSingleStep(params) + } else if ('webAuthn' in params && params.webAuthn) { + return this._verifyWebAuthnCreation(params) + } + return { data: null, error: new AuthError('Invalid MFA parameters') } } catch (error) { if (isAuthError(error)) { return { data: null, error } @@ -2479,6 +2468,150 @@ export default class GoTrueClient { }) } + private async _verifyWebAuthnSingleStep( + params: MFAVerifyWebAuthnParams + ): Promise { + const { + data: { user }, + error: userError, + } = await this._getUser() + const factors = user?.factors || [] + + const webauthn = factors.filter( + (factor) => factor.factor_type === 'webauthn' && factor.status === 'verified' + ) + + const webAuthnFactor = webauthn[0] + if (!webAuthnFactor) { + return { data: null, error: new AuthError('No WebAuthn factor found') } + } + return this._useSession(async (sessionResult) => { + const { data: sessionData, error: sessionError } = sessionResult + if (sessionError) { + return { data: null, error: sessionError } + } + // Single Step enroll + const webAuthn = this._getWebAuthnRpDetails() + + 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 + const finalCredential = await startAuthentication(challengeOptions) + 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 } + }) + } + + private async _verifyWebAuthnCreation( + params: MFAVerifySingleStepWebAuthnParams + ): Promise { + return this._useSession(async (sessionResult) => { + const { data: sessionData, error: sessionError } = sessionResult + if (sessionError) return { data: null, error: sessionError } + + if (!params.webAuthn) { + return { data: null, error: new AuthError('Invalid MFA parameters') } + } + + 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 } + }) + } + + private async _verifyCodeChallenge( + params: MFAVerifyTOTPParams | MFAVerifyPhoneParams + ): Promise { + return this._useSession(async (sessionResult) => { + const { data: sessionData, error: sessionError } = sessionResult + 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 (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 } + }) + } + /** * {@see GoTrueMFAApi#challenge} */ @@ -2491,12 +2624,24 @@ export default class GoTrueClient { return { data: null, error: sessionError } } + let body: Record = {} + if ('webAuthn' in params && params.webAuthn?.rpId) { + body = { + web_authn: { + rp_id: params.webAuthn.rpId, + rp_origins: params.webAuthn.rpOrigins, + }, + } + } else if ('channel' in params) { + body = { channel: params.channel } + } + return await _request( this.fetch, 'POST', `${this.url}/factors/${params.factorId}/challenge`, { - body: { channel: params.channel }, + body, headers: this.headers, jwt: sessionData?.session?.access_token, } @@ -2514,22 +2659,29 @@ export default class GoTrueClient { /** * {@see GoTrueMFAApi#challengeAndVerify} */ + private async _challengeAndVerify(params: { + factorId: string + code: string + }): Promise private async _challengeAndVerify( params: MFAChallengeAndVerifyParams ): Promise { - // both _challenge and _verify independently acquire the lock, so no need - // to acquire it here - - const { data: challengeData, error: challengeError } = await this._challenge({ + if (!('factorId' in params && 'code' in params)) { + return { + data: null, + error: new AuthError('Invalid parameters', 400, 'invalid_parameters'), + } + } + const { factorId, code } = 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: challengeData.id, + challengeId: challengeResponse.id, code: params.code, }) } diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 22e22c97..ad7c3a03 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -1,5 +1,15 @@ import { API_VERSION_HEADER_NAME } from './constants' -import { SupportedStorage } from './types' +import { + SupportedStorage, + PublicKeyCredentialDescriptorJSON, + AuthenticatorTransportFuture, + RegistrationCredential, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, + AuthenticationResponseJSON, + AuthenticationCredential, +} from './types' export function expiresAt(expiresIn: number) { const timeNow = Math.round(Date.now() / 1000) @@ -344,3 +354,292 @@ export function parseResponseAPIVersion(response: Response) { return null } } + +// Taken from simplewebauthn + +/** + * Convert from a Base64URL-encoded string to an Array Buffer. Best used when converting a + * credential ID from a JSON string to an ArrayBuffer, like in allowCredentials or + * excludeCredentials + * + * Helper method to compliment `bufferToBase64URLString` + */ +export function base64URLStringToBuffer(base64URLString: string): ArrayBuffer { + // Convert from Base64URL to Base64 + const base64 = base64URLString.replace(/-/g, '+').replace(/_/g, '/') + /** + * Pad with '=' until it's a multiple of four + * (4 - (85 % 4 = 1) = 3) % 4 = 3 padding + * (4 - (86 % 4 = 2) = 2) % 4 = 2 padding + * (4 - (87 % 4 = 3) = 1) % 4 = 1 padding + * (4 - (88 % 4 = 0) = 4) % 4 = 0 padding + */ + const padLength = (4 - (base64.length % 4)) % 4 + const padded = base64.padEnd(base64.length + padLength, '=') + + // Convert to a binary string + const binary = atob(padded) + + // Convert binary string to buffer + const buffer = new ArrayBuffer(binary.length) + const bytes = new Uint8Array(buffer) + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i) + } + + return buffer +} + +/** + * Convert the given array buffer into a Base64URL-encoded string. Ideal for converting various + * credential response ArrayBuffers to string for sending back to the server as JSON. + * + * Helper method to compliment `base64URLStringToBuffer` + */ +export function bufferToBase64URLString(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer) + let str = '' + + for (const charCode of bytes) { + str += String.fromCharCode(charCode) + } + + const base64String = btoa(str) + + return base64String.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '') +} + +function toPublicKeyCredentialDescriptor( + descriptor: PublicKeyCredentialDescriptorJSON +): PublicKeyCredentialDescriptor { + const { id } = descriptor + + return { + ...descriptor, + id: base64URLStringToBuffer(id), + /** + * `descriptor.transports` is an array of our `AuthenticatorTransportFuture` that includes newer + * transports that TypeScript's DOM lib is ignorant of. Convince TS that our list of transports + * are fine to pass to WebAuthn since browsers will recognize the new value. + */ + transports: descriptor.transports as AuthenticatorTransport[], + } +} + +/** + * Visibly warn when we detect an issue related to a passkey provider intercepting WebAuthn API + * calls + */ +function warnOnBrokenImplementation(methodName: string, cause: Error): void { + console.warn( + `The browser extension that intercepted this WebAuthn API call incorrectly implemented ${methodName}. You should report this error to them.\n`, + cause + ) +} + +class BaseWebAuthnAbortService { + private controller: AbortController | undefined + + /** + * Prepare an abort signal that will help support multiple auth attempts without needing to + * reload the page. This is automatically called whenever `startRegistration()` and + * `startAuthentication()` are called. + */ + createNewAbortSignal() { + // Abort any existing calls to navigator.credentials.create() or navigator.credentials.get() + if (this.controller) { + const abortError = new Error('Cancelling existing WebAuthn API call for new one') + abortError.name = 'AbortError' + this.controller.abort(abortError) + } + + const newController = new AbortController() + + this.controller = newController + return newController.signal + } + + /** + * Manually cancel any active WebAuthn registration or authentication attempt. + */ + cancelCeremony() { + if (this.controller) { + const abortError = new Error('Manually cancelling existing WebAuthn API call') + abortError.name = 'AbortError' + this.controller.abort(abortError) + + this.controller = undefined + } + } +} + +/** + * A service singleton to help ensure that only a single WebAuthn ceremony is active at a time. + * + * Users of **@simplewebauthn/browser** shouldn't typically need to use this, but it can help e.g. + * developers building projects that use client-side routing to better control the behavior of + * their UX in response to router navigation events. + */ +export const WebAuthnAbortService = new BaseWebAuthnAbortService() + +/** + * Begin authenticator "registration" via WebAuthn attestation + * + * @param optionsJSON Output from **@simplewebauthn/server**'s `generateRegistrationOptions()` + */ +export async function startRegistration( + optionsJSON: PublicKeyCredentialCreationOptionsJSON +): Promise { + if (!browserSupportsWebAuthn()) { + throw new Error('WebAuthn is not supported in this browser') + } + + // We need to convert some values to Uint8Arrays before passing the credentials to the navigator + const publicKey: PublicKeyCredentialCreationOptions = { + ...optionsJSON, + challenge: base64URLStringToBuffer(optionsJSON.challenge), + user: { + ...optionsJSON.user, + id: base64URLStringToBuffer(optionsJSON.user.id), + }, + excludeCredentials: optionsJSON.excludeCredentials?.map(toPublicKeyCredentialDescriptor), + } + + // Finalize options + const options: CredentialCreationOptions = { publicKey } + // Set up the ability to cancel this request if the user attempts another + options.signal = WebAuthnAbortService.createNewAbortSignal() + + // Wait for the user to complete attestation + const credential = (await navigator.credentials.create(options)) as RegistrationCredential + + if (!credential) { + throw new Error('Registration was not completed') + } + + const { id, rawId, response, type } = credential + + // Continue to play it safe with `getTransports()` for now, even when L3 types say it's required + let transports: AuthenticatorTransportFuture[] | undefined = undefined + 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') { + try { + responsePublicKeyAlgorithm = response.getPublicKeyAlgorithm() + } catch (error) { + warnOnBrokenImplementation('getPublicKeyAlgorithm()', error as Error) + } + } + + let responsePublicKey: string | undefined = undefined + if (typeof response.getPublicKey === 'function') { + try { + const _publicKey = response.getPublicKey() + if (_publicKey !== null) { + responsePublicKey = bufferToBase64URLString(_publicKey) + } + } catch (error) { + warnOnBrokenImplementation('getPublicKey()', error as Error) + } + } + + // L3 says this is required, but browser and webview support are still not guaranteed. + let responseAuthenticatorData: string | undefined + if (typeof response.getAuthenticatorData === 'function') { + try { + responseAuthenticatorData = bufferToBase64URLString(response.getAuthenticatorData()) + } catch (error) { + warnOnBrokenImplementation('getAuthenticatorData()', error as Error) + } + } + + return { + id, + rawId: bufferToBase64URLString(rawId), + response: { + attestationObject: bufferToBase64URLString(response.attestationObject), + clientDataJSON: bufferToBase64URLString(response.clientDataJSON), + transports, + publicKeyAlgorithm: responsePublicKeyAlgorithm, + publicKey: responsePublicKey, + authenticatorData: responseAuthenticatorData, + }, + type, + clientExtensionResults: credential.getClientExtensionResults(), + } +} + +/** + * Begin authenticator "login" via WebAuthn assertion + * + * @param optionsJSON Output from **@simplewebauthn/server**'s `generateAuthenticationOptions()` + * @param useBrowserAutofill (Optional) Initialize conditional UI to enable logging in via browser autofill prompts. Defaults to `false`. + */ +export async function startAuthentication( + optionsJSON: PublicKeyCredentialRequestOptionsJSON +): Promise { + if (!browserSupportsWebAuthn()) { + throw new Error('WebAuthn is not supported in this browser') + } + + // We need to avoid passing empty array to avoid blocking retrieval + // of public key + let allowCredentials + if (optionsJSON.allowCredentials?.length !== 0) { + allowCredentials = optionsJSON.allowCredentials?.map(toPublicKeyCredentialDescriptor) + } + + // We need to convert some values to Uint8Arrays before passing the credentials to the navigator + const publicKey: PublicKeyCredentialRequestOptions = { + ...optionsJSON, + challenge: base64URLStringToBuffer(optionsJSON.challenge), + allowCredentials, + } + + // Prepare options for `.get()` + const options: CredentialRequestOptions = {} + + // Finalize options + options.publicKey = publicKey + // Set up the ability to cancel this request if the user attempts another + options.signal = WebAuthnAbortService.createNewAbortSignal() + + // Wait for the user to complete assertion + 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) + } + + // Convert values to base64 to make it easier to send back to the server + return { + id, + rawId: bufferToBase64URLString(rawId), + response: { + authenticatorData: bufferToBase64URLString(response.authenticatorData), + clientDataJSON: bufferToBase64URLString(response.clientDataJSON), + signature: bufferToBase64URLString(response.signature), + userHandle, + }, + type, + clientExtensionResults: credential.getClientExtensionResults(), + } +} + +export function browserSupportsWebAuthn(): boolean { + return ( + window?.PublicKeyCredential !== undefined && typeof window.PublicKeyCredential === 'function' + ) +} diff --git a/src/lib/internal-types.ts b/src/lib/internal-types.ts index 1aee5c3b..3f81e9f1 100644 --- a/src/lib/internal-types.ts +++ b/src/lib/internal-types.ts @@ -28,7 +28,7 @@ export type MFAEnrollWebAuthnParams = { webAuthn?: Object /** Have the Auth client library handle the browser-authenticator interaction for you */ - useMultiStepEnroll: boolean + useMultiStep: boolean } export type AuthMFAEnrollTOTPResponse = diff --git a/src/lib/types.ts b/src/lib/types.ts index 975f483e..60052d78 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -310,9 +310,9 @@ export interface Factor { friendly_name?: string /** - * Type of factor. `totp` and `phone` supported with this version + * Type of factor. `totp`, `webauthn`, and `phone` supported with this version */ - factor_type: 'totp' | 'phone' | string + factor_type: 'totp' | 'phone' | 'webauthn' | string /** Factor's status. */ status: 'verified' | 'unverified' @@ -808,7 +808,6 @@ export type GenerateLinkType = | 'email_change_current' | 'email_change_new' - export type MFAVerifyTOTPParams = { /** ID of the factor being verified. Returned in enroll(). */ factorId: string @@ -823,16 +822,47 @@ export type MFAVerifyTOTPParams = { // Declared as a separate type to allow for future changes export type MFAVerifyPhoneParams = MFAVerifyTOTPParams -export type MFAVerifyWebAuthnParams = { - /** The type of factor being enrolled. */ - factorType: 'webauthn' +export type MFAVerifyWebAuthnParams = + | { + /** The type of factor being enrolled. */ + factorType: 'webauthn' + } + | MFAVerifySingleStepWebAuthnParams + +export type MFAVerifySingleStepWebAuthnParams = { + factorId: string + challengeId: string + webAuthn?: { + rpId: string + rpOrigins: string + assertionResponse?: PublicKeyCredentialJSON + creationResponse?: PublicKeyCredentialJSON + } +} + +export type MFAChallengeTOTPParams = { + /** ID of the factor to be challenged. Returned in enroll(). */ + factorId: string +} + +export type MFAChallengePhoneParams = MFAChallengeTOTPParams & { channel?: 'sms' | 'whatsapp' } - /** Have the Auth client library handle the browser-authenticator interaction for you */ - useMultiStep?: boolean +export type MFAChallengeWebAuthnParams = { + /** ID of the factor to be challenged. Returned in enroll(). */ + factorId: string + webAuthn?: { + rpId: string + rpOrigins: string + } } export type MFAEnrollParams = MFAEnrollTOTPParams | MFAEnrollPhoneParams | MFAEnrollWebAuthnParams +export type MFAChallengeParams = + | MFAChallengeTOTPParams + | MFAChallengePhoneParams + | MFAChallengeWebAuthnParams + export type MFAVerifyParams = MFAVerifyTOTPParams | MFAVerifyPhoneParams | MFAVerifyWebAuthnParams export type MFAUnenrollParams = { @@ -840,13 +870,6 @@ export type MFAUnenrollParams = { factorId: string } -export type MFAChallengeParams = { - /** ID of the factor to be challenged. Returned in enroll(). */ - factorId: string - /** Messaging channel to use (e.g. whatsapp or sms). Only relevant for phone factors */ - channel?: 'sms' | 'whatsapp' -} - export type MFAChallengeAndVerifyParams = { /** ID of the factor being verified. Returned in enroll(). */ factorId: string @@ -908,6 +931,21 @@ export type AuthMFAChallengeResponse = } error: null } + | { + data: { + /** ID of the newly created challenge. */ + id: string + + /** Factor Type which generated the challenge */ + type: 'webauthn' + + /** Timestamp in UNIX seconds when this challenge will no longer be usable. */ + expires_at: number + credential_creation_options?: { publicKey: PublicKeyCredentialCreationOptionsJSON } + credential_request_options?: { publicKey: PublicKeyCredentialRequestOptionsJSON } + } + error: null + } | { data: null; error: AuthError } export type AuthMFAListFactorsResponse = @@ -920,8 +958,7 @@ export type AuthMFAListFactorsResponse = totp: Factor[] /** Only verified Phone factors. (A subset of `all`.) */ phone: Factor[] - /** Only verified Phone factors. (A subset of `all`.) */ - // TODO: Hgihglight that it's not webAuthn since totp and phone it's lower case, and then delete this comment. + /** Only verified webauthn factors. (A subset of `all`.) */ webauthn: Factor[] } error: null @@ -972,6 +1009,7 @@ export interface GoTrueMFAApi { */ enroll(params: MFAEnrollTOTPParams): Promise enroll(params: MFAEnrollPhoneParams): Promise + enroll(params: MFAEnrollWebAuthnParams): Promise enroll(params: MFAEnrollParams): Promise /** @@ -1153,3 +1191,202 @@ export type SignOut = { */ scope?: 'global' | 'local' | 'others' } + +/** + * The two types of credentials as defined by bit 3 ("Backup Eligibility") in authenticator data: + * - `"singleDevice"` credentials will never be backed up + * - `"multiDevice"` credentials can be backed up + */ +export type CredentialDeviceType = 'singleDevice' | 'multiDevice' +export type AuthenticatorAttachment = 'cross-platform' | 'platform' + +/** + * https://w3c.github.io/webauthn/#dictdef-publickeycredentialdescriptorjson + */ +export interface PublicKeyCredentialDescriptorJSON { + id: Base64URLString + type: PublicKeyCredentialType + transports?: AuthenticatorTransportFuture[] +} + +export interface PublicKeyCredentialCreationOptionsJSON { + rp: PublicKeyCredentialRpEntity + user: PublicKeyCredentialUserEntityJSON + challenge: Base64URLString + pubKeyCredParams: PublicKeyCredentialParameters[] + timeout?: number + excludeCredentials?: PublicKeyCredentialDescriptorJSON[] + authenticatorSelection?: AuthenticatorSelectionCriteria + attestation?: AttestationConveyancePreference + extensions?: AuthenticationExtensionsClientInputs +} + +/** + * A variant of PublicKeyCredentialRequestOptions suitable for JSON transmission to the browser to + * (eventually) get passed into navigator.credentials.get(...) in the browser. + */ +export interface PublicKeyCredentialRequestOptionsJSON { + challenge: Base64URLString + timeout?: number + rpId?: string + allowCredentials?: PublicKeyCredentialDescriptorJSON[] + userVerification?: UserVerificationRequirement + extensions?: AuthenticationExtensionsClientInputs +} + +/** + * https://w3c.github.io/webauthn/#dictdef-publickeycredentialdescriptorjson + */ +export interface PublicKeyCredentialDescriptorJSON { + id: Base64URLString + type: PublicKeyCredentialType + transports?: AuthenticatorTransportFuture[] +} + +/** + * https://w3c.github.io/webauthn/#dictdef-publickeycredentialuserentityjson + */ +export interface PublicKeyCredentialUserEntityJSON { + id: string + name: string + displayName: string +} + +/** + * The value returned from navigator.credentials.create() + */ +export interface RegistrationCredential extends PublicKeyCredentialFuture { + response: AuthenticatorAttestationResponseFuture +} + +/** + * A slightly-modified RegistrationCredential to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + * + * https://w3c.github.io/webauthn/#dictdef-registrationresponsejson + */ +export interface RegistrationResponseJSON { + id: Base64URLString + rawId: Base64URLString + response: AuthenticatorAttestationResponseJSON + authenticatorAttachment?: AuthenticatorAttachment + clientExtensionResults: AuthenticationExtensionsClientOutputs + type: PublicKeyCredentialType +} + +/** + * The value returned from navigator.credentials.get() + */ +export interface AuthenticationCredential extends PublicKeyCredentialFuture { + response: AuthenticatorAssertionResponse +} + +/** + * A slightly-modified AuthenticationCredential to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + * + * https://w3c.github.io/webauthn/#dictdef-authenticationresponsejson + */ +export interface AuthenticationResponseJSON { + id: Base64URLString + rawId: Base64URLString + response: AuthenticatorAssertionResponseJSON + authenticatorAttachment?: AuthenticatorAttachment + clientExtensionResults: AuthenticationExtensionsClientOutputs + type: PublicKeyCredentialType +} + +export interface AuthenticatorAttestationResponse extends AuthenticatorResponse { + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/AuthenticatorAttestationResponse/attestationObject) */ + readonly attestationObject: ArrayBuffer + getAuthenticatorData(): ArrayBuffer + getPublicKey(): ArrayBuffer | null + getPublicKeyAlgorithm(): COSEAlgorithmIdentifier + /** [MDN Reference](https://developer.mozilla.org/docs/Web/API/AuthenticatorAttestationResponse/getTransports) */ + getTransports(): string[] +} + +/** + * A slightly-modified AuthenticatorAttestationResponse to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + * + * https://w3c.github.io/webauthn/#dictdef-authenticatorattestationresponsejson + */ +export interface AuthenticatorAttestationResponseJSON { + clientDataJSON: Base64URLString + attestationObject: Base64URLString + // Optional in L2, but becomes required in L3. Play it safe until L3 becomes Recommendation + authenticatorData?: Base64URLString + // Optional in L2, but becomes required in L3. Play it safe until L3 becomes Recommendation + transports?: AuthenticatorTransportFuture[] + // Optional in L2, but becomes required in L3. Play it safe until L3 becomes Recommendation + publicKeyAlgorithm?: COSEAlgorithmIdentifier + publicKey?: Base64URLString +} + +/** + * A slightly-modified AuthenticatorAssertionResponse to simplify working with ArrayBuffers that + * are Base64URL-encoded in the browser so that they can be sent as JSON to the server. + * + * https://w3c.github.io/webauthn/#dictdef-authenticatorassertionresponsejson + */ +export interface AuthenticatorAssertionResponseJSON { + clientDataJSON: Base64URLString + authenticatorData: Base64URLString + signature: Base64URLString + userHandle?: Base64URLString +} + +/** + * An attempt to communicate that this isn't just any string, but a Base64URL-encoded string + */ +export type Base64URLString = string + +/** + * AuthenticatorAttestationResponse in TypeScript's DOM lib is outdated (up through v3.9.7). + * Maintain an augmented version here so we can implement additional properties as the WebAuthn + * spec evolves. + * + * See https://www.w3.org/TR/webauthn-2/#iface-authenticatorattestationresponse + * + * Properties marked optional are not supported in all browsers. + */ +export interface AuthenticatorAttestationResponseFuture extends AuthenticatorAttestationResponse { + getTransports(): AuthenticatorTransportFuture[] +} + +/** + * A super class of TypeScript's `AuthenticatorTransport` that includes support for the latest + * transports. Should eventually be replaced by TypeScript's when TypeScript gets updated to + * know about it (sometime after 4.6.3) + */ +export type AuthenticatorTransportFuture = + | 'ble' + | 'cable' + | 'hybrid' + | 'internal' + | 'nfc' + | 'smart-card' + | 'usb' + +/** */ +export type PublicKeyCredentialJSON = RegistrationResponseJSON | AuthenticationResponseJSON + +/** + * A super class of TypeScript's `PublicKeyCredential` that knows about upcoming WebAuthn features + */ +export interface PublicKeyCredentialFuture extends PublicKeyCredential { + type: PublicKeyCredentialType + // See https://github.com/w3c/webauthn/issues/1745 + isConditionalMediationAvailable?(): Promise + // See https://w3c.github.io/webauthn/#sctn-parseCreationOptionsFromJSON + parseCreationOptionsFromJSON?( + options: PublicKeyCredentialCreationOptionsJSON + ): PublicKeyCredentialCreationOptions + // See https://w3c.github.io/webauthn/#sctn-parseRequestOptionsFromJSON + parseRequestOptionsFromJSON?( + options: PublicKeyCredentialRequestOptionsJSON + ): PublicKeyCredentialRequestOptions + // See https://w3c.github.io/webauthn/#dom-publickeycredential-tojson + toJSON?(): PublicKeyCredentialJSON +}