From 6ebce44f2d58ba1b3408295aa26f7afeadb5fb1a Mon Sep 17 00:00:00 2001 From: joel Date: Tue, 17 Sep 2024 21:45:17 +0200 Subject: [PATCH] feat: add webauthn --- src/GoTrueClient.ts | 273 ++++++++++++++++++++++++++++++------ src/lib/base64url.ts | 265 +++++++++++++++++++++++++++++++++++ src/lib/helpers.ts | 249 ++++++++++++++++++++++++++++++++- src/lib/internal-types.ts | 33 +++++ src/lib/types.ts | 281 ++++++++++++++++++++++++++++++++++++-- 5 files changed, 1048 insertions(+), 53 deletions(-) create mode 100644 src/lib/base64url.ts diff --git a/src/GoTrueClient.ts b/src/GoTrueClient.ts index f0ebd2a85..d6b151055 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' @@ -70,12 +74,14 @@ import type { VerifyOtpParams, GoTrueMFAApi, MFAEnrollParams, - AuthMFAEnrollResponse, + MFAVerifyParams, MFAChallengeParams, AuthMFAChallengeResponse, MFAUnenrollParams, AuthMFAUnenrollResponse, - MFAVerifyParams, + MFAVerifyTOTPParams, + MFAVerifyPhoneParams, + MFAVerifyWebAuthnParams, AuthMFAVerifyResponse, AuthMFAListFactorsResponse, AMREntry, @@ -88,12 +94,19 @@ import type { LockFunc, UserIdentity, SignInAnonymouslyCredentials, + AuthenticatorTransportFuture, + PublicKeyCredentialCreationOptionsJSON, + RegistrationResponseJSON, + MFAVerifySingleStepWebAuthnParams, + AuthMFAEnrollResponse, } from './lib/types' import { MFAEnrollTOTPParams, MFAEnrollPhoneParams, + MFAEnrollWebAuthnParams, AuthMFAEnrollTOTPResponse, AuthMFAEnrollPhoneResponse, + AuthMFAEnrollWebAuthnResponse, } from './lib/internal-types' polyfillGlobalThis() // Make "globalThis" available @@ -2356,6 +2369,7 @@ export default class GoTrueClient { */ private async _enroll(params: MFAEnrollTOTPParams): Promise private async _enroll(params: MFAEnrollPhoneParams): Promise + private async _enroll(params: MFAEnrollWebAuthnParams): Promise private async _enroll(params: MFAEnrollParams): Promise { try { return await this._useSession(async (result) => { @@ -2379,10 +2393,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 } }) @@ -2394,40 +2434,29 @@ 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} */ + private async _verify(params: MFAVerifyTOTPParams): Promise + private async _verify(params: MFAVerifyPhoneParams): Promise + private async _verify(params: MFAVerifyWebAuthnParams): Promise private async _verify(params: MFAVerifyParams): Promise { return this._acquireLock(-1, async () => { try { - return 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 (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 } - }) + 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 } @@ -2437,6 +2466,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} */ @@ -2449,12 +2622,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, } @@ -2472,22 +2657,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, }) } @@ -2513,11 +2705,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, } diff --git a/src/lib/base64url.ts b/src/lib/base64url.ts new file mode 100644 index 000000000..0fb291d81 --- /dev/null +++ b/src/lib/base64url.ts @@ -0,0 +1,265 @@ +/** + * Avoid modifying this file. It's part of + * https://github.com/supabase-community/base64url-js. Submit all fixes on + * that repo! + */ + +/** + * An array of characters that encode 6 bits into a Base64-URL alphabet + * character. + */ +const TO_BASE64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'.split('') + +/** + * An array of characters that can appear in a Base64-URL encoded string but + * should be ignored. + */ +const IGNORE_BASE64URL = ' \t\n\r='.split('') + +/** + * An array of 128 numbers that map a Base64-URL character to 6 bits, or if -2 + * used to skip the character, or if -1 used to error out. + */ +const FROM_BASE64URL = (() => { + const charMap: number[] = new Array(128) + + for (let i = 0; i < charMap.length; i += 1) { + charMap[i] = -1 + } + + for (let i = 0; i < IGNORE_BASE64URL.length; i += 1) { + charMap[IGNORE_BASE64URL[i].charCodeAt(0)] = -2 + } + + for (let i = 0; i < TO_BASE64URL.length; i += 1) { + charMap[TO_BASE64URL[i].charCodeAt(0)] = i + } + + return charMap +})() + +/** + * Converts a byte to a Base64-URL string. + * + * @param byte The byte to convert, or null to flush at the end of the byte sequence. + * @param state The Base64 conversion state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`. + * @param emit A function called with the next Base64 character when ready. + */ +export function byteToBase64URL( + byte: number | null, + state: { queue: number; queuedBits: number }, + emit: (char: string) => void +) { + if (byte !== null) { + state.queue = (state.queue << 8) | byte + state.queuedBits += 8 + + while (state.queuedBits >= 6) { + const pos = (state.queue >> (state.queuedBits - 6)) & 63 + emit(TO_BASE64URL[pos]) + state.queuedBits -= 6 + } + } else if (state.queuedBits > 0) { + state.queue = state.queue << (6 - state.queuedBits) + state.queuedBits = 6 + + while (state.queuedBits >= 6) { + const pos = (state.queue >> (state.queuedBits - 6)) & 63 + emit(TO_BASE64URL[pos]) + state.queuedBits -= 6 + } + } +} + +/** + * Converts a String char code (extracted using `string.charCodeAt(position)`) to a sequence of Base64-URL characters. + * + * @param charCode The char code of the JavaScript string. + * @param state The Base64 state. Pass an initial value of `{ queue: 0, queuedBits: 0 }`. + * @param emit A function called with the next byte. + */ +export function byteFromBase64URL( + charCode: number, + state: { queue: number; queuedBits: number }, + emit: (byte: number) => void +) { + const bits = FROM_BASE64URL[charCode] + + if (bits > -1) { + // valid Base64-URL character + state.queue = (state.queue << 6) | bits + state.queuedBits += 6 + + while (state.queuedBits >= 8) { + emit((state.queue >> (state.queuedBits - 8)) & 0xff) + state.queuedBits -= 8 + } + } else if (bits === -2) { + // ignore spaces, tabs, newlines, = + return + } else { + throw new Error(`Invalid Base64-URL character "${String.fromCharCode(charCode)}"`) + } +} + +/** + * Converts a JavaScript string (which may include any valid character) into a + * Base64-URL encoded string. The string is first encoded in UTF-8 which is + * then encoded as Base64-URL. + * + * @param str The string to convert. + */ +export function stringToBase64URL(str: string) { + const base64: string[] = [] + + const emitter = (char: string) => { + base64.push(char) + } + + const state = { queue: 0, queuedBits: 0 } + + stringToUTF8(str, (byte: number) => { + byteToBase64URL(byte, state, emitter) + }) + + byteToBase64URL(null, state, emitter) + + return base64.join('') +} + +/** + * Converts a Base64-URL encoded string into a JavaScript string. It is assumed + * that the underlying string has been encoded as UTF-8. + * + * @param str The Base64-URL encoded string. + */ +export function stringFromBase64URL(str: string) { + const conv: string[] = [] + + const utf8Emit = (codepoint: number) => { + conv.push(String.fromCodePoint(codepoint)) + } + + const utf8State = { + utf8seq: 0, + codepoint: 0, + } + + const b64State = { queue: 0, queuedBits: 0 } + + const byteEmit = (byte: number) => { + stringFromUTF8(byte, utf8State, utf8Emit) + } + + for (let i = 0; i < str.length; i += 1) { + byteFromBase64URL(str.charCodeAt(i), b64State, byteEmit) + } + + return conv.join('') +} + +/** + * Converts a Unicode codepoint to a multi-byte UTF-8 sequence. + * + * @param codepoint The Unicode codepoint. + * @param emit Function which will be called for each UTF-8 byte that represents the codepoint. + */ +export function codepointToUTF8(codepoint: number, emit: (byte: number) => void) { + if (codepoint <= 0x7f) { + emit(codepoint) + return + } else if (codepoint <= 0x7ff) { + emit(0xc0 | (codepoint >> 6)) + emit(0x80 | (codepoint & 0x3f)) + return + } else if (codepoint <= 0xffff) { + emit(0xe0 | (codepoint >> 12)) + emit(0x80 | ((codepoint >> 6) & 0x3f)) + emit(0x80 | (codepoint & 0x3f)) + return + } else if (codepoint <= 0x10ffff) { + emit(0xf0 | (codepoint >> 18)) + emit(0x80 | ((codepoint >> 12) & 0x3f)) + emit(0x80 | ((codepoint >> 6) & 0x3f)) + emit(0x80 | (codepoint & 0x3f)) + return + } + + throw new Error(`Unrecognized Unicode codepoint: ${codepoint.toString(16)}`) +} + +/** + * Converts a JavaScript string to a sequence of UTF-8 bytes. + * + * @param str The string to convert to UTF-8. + * @param emit Function which will be called for each UTF-8 byte of the string. + */ +export function stringToUTF8(str: string, emit: (byte: number) => void) { + for (let i = 0; i < str.length; i += 1) { + let codepoint = str.charCodeAt(i) + + if (codepoint > 0xd7ff && codepoint <= 0xdbff) { + // most UTF-16 codepoints are Unicode codepoints, except values in this + // range where the next UTF-16 codepoint needs to be combined with the + // current one to get the Unicode codepoint + const highSurrogate = ((codepoint - 0xd800) * 0x400) & 0xffff + const lowSurrogate = (str.charCodeAt(i + 1) - 0xdc00) & 0xffff + codepoint = (lowSurrogate | highSurrogate) + 0x10000 + i += 1 + } + + codepointToUTF8(codepoint, emit) + } +} + +/** + * Converts a UTF-8 byte to a Unicode codepoint. + * + * @param byte The UTF-8 byte next in the sequence. + * @param state The shared state between consecutive UTF-8 bytes in the + * sequence, an object with the shape `{ utf8seq: 0, codepoint: 0 }`. + * @param emit Function which will be called for each codepoint. + */ +export function stringFromUTF8( + byte: number, + state: { utf8seq: number; codepoint: number }, + emit: (codepoint: number) => void +) { + if (state.utf8seq === 0) { + if (byte <= 0x7f) { + emit(byte) + return + } + + // count the number of 1 leading bits until you reach 0 + for (let leadingBit = 1; leadingBit < 6; leadingBit += 1) { + if (((byte >> (7 - leadingBit)) & 1) === 0) { + state.utf8seq = leadingBit + break + } + } + + if (state.utf8seq === 2) { + state.codepoint = byte & 31 + } else if (state.utf8seq === 3) { + state.codepoint = byte & 15 + } else if (state.utf8seq === 4) { + state.codepoint = byte & 7 + } else { + throw new Error('Invalid UTF-8 sequence') + } + + state.utf8seq -= 1 + } else if (state.utf8seq > 0) { + if (byte <= 0x7f) { + throw new Error('Invalid UTF-8 sequence') + } + + state.codepoint = (state.codepoint << 6) | (byte & 63) + state.utf8seq -= 1 + + if (state.utf8seq === 0) { + emit(state.codepoint) + } + } +} diff --git a/src/lib/helpers.ts b/src/lib/helpers.ts index 22e22c978..283d39531 100644 --- a/src/lib/helpers.ts +++ b/src/lib/helpers.ts @@ -1,5 +1,17 @@ import { API_VERSION_HEADER_NAME } from './constants' -import { SupportedStorage } from './types' +import { + SupportedStorage, + PublicKeyCredentialDescriptorJSON, + AuthenticatorTransportFuture, + RegistrationCredential, + PublicKeyCredentialCreationOptionsJSON, + PublicKeyCredentialRequestOptionsJSON, + RegistrationResponseJSON, + AuthenticationResponseJSON, + AuthenticationCredential, +} from './types' + +import { byteToBase64URL, byteFromBase64URL } from './base64url' export function expiresAt(expiresIn: number) { const timeNow = Math.round(Date.now() / 1000) @@ -344,3 +356,238 @@ export function parseResponseAPIVersion(response: Response) { return null } } + +/** + * 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 { + const result: number[] = [] + const state = { queue: 0, queuedBits: 0 } + + const onByte = (byte: number) => { + result.push(byte) + } + + for (let i = 0; i < base64URLString.length; i += 1) { + byteFromBase64URL(base64URLString.charCodeAt(i), state, onByte) + } + + const bytes = new Uint8Array(result) + return bytes +} + +/** + * 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) + const result: string[] = [] + const state = { queue: 0, queuedBits: 0 } + const onChar = (char: string) => { + result.push(char) + } + + for (let i = 0; i < bytes.length; i++) { + byteToBase64URL(bytes[i], state, onChar) + } + + // always call with `null` after processing all bytes + byteToBase64URL(null, state, onChar) + + return result.join('') +} + +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 key 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 + ) +} + +/** + * 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. If using in SSR please use this in client components or pages.' + ) + } + + // 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 } + + // 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()` + */ +export async function startAuthentication( + optionsJSON: PublicKeyCredentialRequestOptionsJSON +): Promise { + if (!browserSupportsWebAuthn()) { + throw new Error( + 'WebAuthn is not supported in this browser. If using in SSR please use this in client components or pages.' + ) + } + + // 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 + + // 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 7b7719bb8..3f81e9f16 100644 --- a/src/lib/internal-types.ts +++ b/src/lib/internal-types.ts @@ -16,6 +16,20 @@ export type MFAEnrollPhoneParams = { /** Phone number associated with a factor. Number should conform to E.164 format */ phone: string } +export type MFAEnrollWebAuthnParams = { + /** The type of factor being enrolled. */ + factorType: 'webauthn' + /** Domain which the user is enrolled with. */ + issuer?: string + /** Human readable name assigned to the factor. */ + friendlyName?: string + + /** WebAuthn specific parameters*/ + webAuthn?: Object + + /** Have the Auth client library handle the browser-authenticator interaction for you */ + useMultiStep: boolean +} export type AuthMFAEnrollTOTPResponse = | { @@ -73,3 +87,22 @@ export type AuthMFAEnrollPhoneResponse = data: null error: AuthError } + +export type AuthMFAEnrollWebAuthnResponse = + | { + data: { + /** ID of the factor that was just enrolled (in an unverified state). */ + id: string + + /** Type of MFA factor. */ + type: 'phone' + + /** Friendly name of the factor, useful for distinguishing between factors **/ + friendly_name?: string + } + error: null + } + | { + data: null + error: AuthError + } diff --git a/src/lib/types.ts b/src/lib/types.ts index 04e672793..1d67ccd3f 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -3,8 +3,10 @@ import { Fetch } from './fetch' import { MFAEnrollTOTPParams, MFAEnrollPhoneParams, + MFAEnrollWebAuthnParams, AuthMFAEnrollTOTPResponse, AuthMFAEnrollPhoneResponse, + AuthMFAEnrollWebAuthnResponse, } from './internal-types' /** One of the providers supported by GoTrue. */ @@ -308,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' @@ -806,14 +808,7 @@ export type GenerateLinkType = | 'email_change_current' | 'email_change_new' -export type MFAEnrollParams = MFAEnrollTOTPParams | MFAEnrollPhoneParams - -export type MFAUnenrollParams = { - /** ID of the factor being unenrolled. */ - factorId: string -} - -export type MFAVerifyParams = { +export type MFAVerifyTOTPParams = { /** ID of the factor being verified. Returned in enroll(). */ factorId: string @@ -824,11 +819,55 @@ export type MFAVerifyParams = { code: string } -export type MFAChallengeParams = { +// 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' + } + | 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 - /** Messaging channel to use (e.g. whatsapp or sms). Only relevant for phone factors */ - channel?: 'sms' | 'whatsapp' +} + +export type MFAChallengePhoneParams = MFAChallengeTOTPParams & { channel?: 'sms' | 'whatsapp' } + +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 = { + /** ID of the factor being unenrolled. */ + factorId: string } export type MFAChallengeAndVerifyParams = { @@ -863,7 +902,10 @@ export type AuthMFAVerifyResponse = error: AuthError } -export type AuthMFAEnrollResponse = AuthMFAEnrollTOTPResponse | AuthMFAEnrollPhoneResponse +export type AuthMFAEnrollResponse = + | AuthMFAEnrollTOTPResponse + | AuthMFAEnrollPhoneResponse + | AuthMFAEnrollWebAuthnResponse export type AuthMFAUnenrollResponse = | { @@ -889,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 = @@ -901,6 +958,8 @@ export type AuthMFAListFactorsResponse = totp: Factor[] /** Only verified Phone factors. (A subset of `all`.) */ phone: Factor[] + /** Only verified webauthn factors. (A subset of `all`.) */ + webauthn: Factor[] } error: null } @@ -950,6 +1009,7 @@ export interface GoTrueMFAApi { */ enroll(params: MFAEnrollTOTPParams): Promise enroll(params: MFAEnrollPhoneParams): Promise + enroll(params: MFAEnrollWebAuthnParams): Promise enroll(params: MFAEnrollParams): Promise /** @@ -1131,3 +1191,196 @@ export type SignOut = { */ scope?: 'global' | 'local' | 'others' } + +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 +}