| import GoTrueClient from '../GoTrueClient' |
| import { base64UrlToUint8Array, bytesToBase64URL } from './base64url' |
| import { AuthError, AuthUnknownError, isAuthError } from './errors' |
| import { |
| AuthMFAEnrollWebauthnResponse, |
| AuthMFAVerifyResponse, |
| AuthMFAVerifyResponseData, |
| MFAChallengeWebauthnParams, |
| MFAEnrollWebauthnParams, |
| MFAVerifyWebauthnParamFields, |
| MFAVerifyWebauthnParams, |
| RequestResult, |
| StrictOmit, |
| } from './types' |
| import { isBrowser } from './helpers' |
| import type { |
| AuthenticationCredential, |
| AuthenticationResponseJSON, |
| AuthenticatorAttachment, |
| PublicKeyCredentialCreationOptionsFuture, |
| PublicKeyCredentialCreationOptionsJSON, |
| PublicKeyCredentialFuture, |
| PublicKeyCredentialRequestOptionsFuture, |
| PublicKeyCredentialRequestOptionsJSON, |
| RegistrationCredential, |
| RegistrationResponseJSON, |
| } from './webauthn.dom' |
|
|
| import { |
| identifyAuthenticationError, |
| identifyRegistrationError, |
| isWebAuthnError, |
| WebAuthnError, |
| WebAuthnUnknownError, |
| } from './webauthn.errors' |
|
|
| export { WebAuthnError, isWebAuthnError, identifyRegistrationError, identifyAuthenticationError } |
| |
| export type { RegistrationResponseJSON, AuthenticationResponseJSON } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export class WebAuthnAbortService { |
| private controller: AbortController | undefined |
|
|
| |
| |
| |
| |
| |
| |
| |
| createNewAbortSignal(): AbortSignal { |
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| |
| |
| cancelCeremony(): void { |
| if (this.controller) { |
| const abortError = new Error('Manually cancelling existing WebAuthn API call') |
| abortError.name = 'AbortError' |
| this.controller.abort(abortError) |
| this.controller = undefined |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export const webAuthnAbortService = new WebAuthnAbortService() |
|
|
| |
| |
| |
| |
| export type ServerCredentialCreationOptions = PublicKeyCredentialCreationOptionsJSON |
|
|
| |
| |
| |
| |
| export type ServerCredentialRequestOptions = PublicKeyCredentialRequestOptionsJSON |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function deserializeCredentialCreationOptions( |
| options: ServerCredentialCreationOptions |
| ): PublicKeyCredentialCreationOptionsFuture { |
| if (!options) { |
| throw new Error('Credential creation options are required') |
| } |
|
|
| |
| if ( |
| typeof PublicKeyCredential !== 'undefined' && |
| 'parseCreationOptionsFromJSON' in PublicKeyCredential && |
| typeof (PublicKeyCredential as unknown as PublicKeyCredentialFuture) |
| .parseCreationOptionsFromJSON === 'function' |
| ) { |
| |
| return ( |
| PublicKeyCredential as unknown as PublicKeyCredentialFuture |
| ).parseCreationOptionsFromJSON( |
| |
| options as any |
| ) as PublicKeyCredentialCreationOptionsFuture |
| } |
|
|
| |
| |
| const { challenge: challengeStr, user: userOpts, excludeCredentials, ...restOptions } = options |
|
|
| |
| const challenge = base64UrlToUint8Array(challengeStr).buffer as ArrayBuffer |
|
|
| |
| const user: PublicKeyCredentialUserEntity = { |
| ...userOpts, |
| id: base64UrlToUint8Array(userOpts.id).buffer as ArrayBuffer, |
| } |
|
|
| |
| const result: PublicKeyCredentialCreationOptionsFuture = { |
| ...restOptions, |
| challenge, |
| user, |
| } |
|
|
| |
| if (excludeCredentials && excludeCredentials.length > 0) { |
| result.excludeCredentials = new Array(excludeCredentials.length) |
|
|
| for (let i = 0; i < excludeCredentials.length; i++) { |
| const cred = excludeCredentials[i] |
| result.excludeCredentials[i] = { |
| ...cred, |
| id: base64UrlToUint8Array(cred.id).buffer, |
| type: cred.type || 'public-key', |
| |
| transports: cred.transports, |
| } |
| } |
| } |
|
|
| return result |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function deserializeCredentialRequestOptions( |
| options: ServerCredentialRequestOptions |
| ): PublicKeyCredentialRequestOptionsFuture { |
| if (!options) { |
| throw new Error('Credential request options are required') |
| } |
|
|
| |
| if ( |
| typeof PublicKeyCredential !== 'undefined' && |
| 'parseRequestOptionsFromJSON' in PublicKeyCredential && |
| typeof (PublicKeyCredential as unknown as PublicKeyCredentialFuture) |
| .parseRequestOptionsFromJSON === 'function' |
| ) { |
| |
| return ( |
| PublicKeyCredential as unknown as PublicKeyCredentialFuture |
| ).parseRequestOptionsFromJSON(options) as PublicKeyCredentialRequestOptionsFuture |
| } |
|
|
| |
| |
| const { challenge: challengeStr, allowCredentials, ...restOptions } = options |
|
|
| |
| const challenge = base64UrlToUint8Array(challengeStr).buffer as ArrayBuffer |
|
|
| |
| const result: PublicKeyCredentialRequestOptionsFuture = { |
| ...restOptions, |
| challenge, |
| } |
|
|
| |
| if (allowCredentials && allowCredentials.length > 0) { |
| result.allowCredentials = new Array(allowCredentials.length) |
|
|
| for (let i = 0; i < allowCredentials.length; i++) { |
| const cred = allowCredentials[i] |
| result.allowCredentials[i] = { |
| ...cred, |
| id: base64UrlToUint8Array(cred.id).buffer, |
| type: cred.type || 'public-key', |
| |
| transports: cred.transports, |
| } |
| } |
| } |
|
|
| return result |
| } |
|
|
| |
| |
| |
| |
| export type ServerCredentialResponse = RegistrationResponseJSON | AuthenticationResponseJSON |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function serializeCredentialCreationResponse( |
| credential: RegistrationCredential |
| ): RegistrationResponseJSON { |
| |
| if ('toJSON' in credential && typeof credential.toJSON === 'function') { |
| |
| return (credential as RegistrationCredential).toJSON() |
| } |
| const credentialWithAttachment = credential as PublicKeyCredential & { |
| response: AuthenticatorAttestationResponse |
| authenticatorAttachment?: string | null |
| } |
|
|
| return { |
| id: credential.id, |
| rawId: credential.id, |
| response: { |
| attestationObject: bytesToBase64URL(new Uint8Array(credential.response.attestationObject)), |
| clientDataJSON: bytesToBase64URL(new Uint8Array(credential.response.clientDataJSON)), |
| }, |
| type: 'public-key', |
| clientExtensionResults: credential.getClientExtensionResults(), |
| |
| authenticatorAttachment: (credentialWithAttachment.authenticatorAttachment ?? undefined) as |
| | AuthenticatorAttachment |
| | undefined, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function serializeCredentialRequestResponse( |
| credential: AuthenticationCredential |
| ): AuthenticationResponseJSON { |
| |
| if ('toJSON' in credential && typeof credential.toJSON === 'function') { |
| |
| return (credential as AuthenticationCredential).toJSON() |
| } |
|
|
| |
| |
| |
| const credentialWithAttachment = credential as PublicKeyCredential & { |
| response: AuthenticatorAssertionResponse |
| authenticatorAttachment?: string | null |
| } |
|
|
| const clientExtensionResults = credential.getClientExtensionResults() |
| const assertionResponse = credential.response |
|
|
| return { |
| id: credential.id, |
| rawId: credential.id, |
| response: { |
| authenticatorData: bytesToBase64URL(new Uint8Array(assertionResponse.authenticatorData)), |
| clientDataJSON: bytesToBase64URL(new Uint8Array(assertionResponse.clientDataJSON)), |
| signature: bytesToBase64URL(new Uint8Array(assertionResponse.signature)), |
| userHandle: assertionResponse.userHandle |
| ? bytesToBase64URL(new Uint8Array(assertionResponse.userHandle)) |
| : undefined, |
| }, |
| type: 'public-key', |
| clientExtensionResults, |
| |
| authenticatorAttachment: (credentialWithAttachment.authenticatorAttachment ?? undefined) as |
| | AuthenticatorAttachment |
| | undefined, |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function isValidDomain(hostname: string): boolean { |
| return ( |
| |
| hostname === 'localhost' || /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(hostname) |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function browserSupportsWebAuthn(): boolean { |
| return !!( |
| isBrowser() && |
| 'PublicKeyCredential' in window && |
| window.PublicKeyCredential && |
| 'credentials' in navigator && |
| typeof navigator?.credentials?.create === 'function' && |
| typeof navigator?.credentials?.get === 'function' |
| ) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function createCredential( |
| options: StrictOmit<CredentialCreationOptions, 'publicKey'> & { |
| publicKey: PublicKeyCredentialCreationOptionsFuture |
| } |
| ): Promise<RequestResult<RegistrationCredential, WebAuthnError>> { |
| try { |
| const response = await navigator.credentials.create( |
| |
| options as Parameters<typeof navigator.credentials.create>[0] |
| ) |
| if (!response) { |
| return { |
| data: null, |
| error: new WebAuthnUnknownError('Empty credential response', response), |
| } |
| } |
| if (!(response instanceof PublicKeyCredential)) { |
| return { |
| data: null, |
| error: new WebAuthnUnknownError('Browser returned unexpected credential type', response), |
| } |
| } |
| return { data: response as RegistrationCredential, error: null } |
| } catch (err) { |
| return { |
| data: null, |
| error: identifyRegistrationError({ |
| error: err as Error, |
| options, |
| }), |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export async function getCredential( |
| options: StrictOmit<CredentialRequestOptions, 'publicKey'> & { |
| publicKey: PublicKeyCredentialRequestOptionsFuture |
| } |
| ): Promise<RequestResult<AuthenticationCredential, WebAuthnError>> { |
| try { |
| const response = await navigator.credentials.get( |
| |
| options as Parameters<typeof navigator.credentials.get>[0] |
| ) |
| if (!response) { |
| return { |
| data: null, |
| error: new WebAuthnUnknownError('Empty credential response', response), |
| } |
| } |
| if (!(response instanceof PublicKeyCredential)) { |
| return { |
| data: null, |
| error: new WebAuthnUnknownError('Browser returned unexpected credential type', response), |
| } |
| } |
| return { data: response as AuthenticationCredential, error: null } |
| } catch (err) { |
| return { |
| data: null, |
| error: identifyAuthenticationError({ |
| error: err as Error, |
| options, |
| }), |
| } |
| } |
| } |
|
|
| export const DEFAULT_CREATION_OPTIONS: Partial<PublicKeyCredentialCreationOptionsFuture> = { |
| hints: ['security-key'], |
| authenticatorSelection: { |
| authenticatorAttachment: 'cross-platform', |
| requireResidentKey: false, |
| |
| userVerification: 'preferred', |
| residentKey: 'discouraged', |
| }, |
| attestation: 'direct', |
| } |
|
|
| export const DEFAULT_REQUEST_OPTIONS: Partial<PublicKeyCredentialRequestOptionsFuture> = { |
| |
| userVerification: 'preferred', |
| hints: ['security-key'], |
| attestation: 'direct', |
| } |
|
|
| function deepMerge<T>(...sources: Partial<T>[]): T { |
| const isObject = (val: unknown): val is Record<string, unknown> => |
| val !== null && typeof val === 'object' && !Array.isArray(val) |
|
|
| const isArrayBufferLike = (val: unknown): val is ArrayBuffer | ArrayBufferView => |
| val instanceof ArrayBuffer || ArrayBuffer.isView(val) |
|
|
| const result: Partial<T> = {} |
|
|
| for (const source of sources) { |
| if (!source) continue |
|
|
| for (const key in source) { |
| const value = source[key] |
| if (value === undefined) continue |
|
|
| if (Array.isArray(value)) { |
| |
| result[key] = value as T[typeof key] |
| } else if (isArrayBufferLike(value)) { |
| result[key] = value as T[typeof key] |
| } else if (isObject(value)) { |
| const existing = result[key] |
| if (isObject(existing)) { |
| result[key] = deepMerge(existing, value) as unknown as T[typeof key] |
| } else { |
| result[key] = deepMerge(value) as unknown as T[typeof key] |
| } |
| } else { |
| result[key] = value as T[typeof key] |
| } |
| } |
| } |
|
|
| return result as T |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function mergeCredentialCreationOptions( |
| baseOptions: PublicKeyCredentialCreationOptionsFuture, |
| overrides?: Partial<PublicKeyCredentialCreationOptionsFuture> |
| ): PublicKeyCredentialCreationOptionsFuture { |
| return deepMerge(DEFAULT_CREATION_OPTIONS, baseOptions, overrides || {}) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function mergeCredentialRequestOptions( |
| baseOptions: PublicKeyCredentialRequestOptionsFuture, |
| overrides?: Partial<PublicKeyCredentialRequestOptionsFuture> |
| ): PublicKeyCredentialRequestOptionsFuture { |
| return deepMerge(DEFAULT_REQUEST_OPTIONS, baseOptions, overrides || {}) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| export class WebAuthnApi { |
| public enroll: typeof WebAuthnApi.prototype._enroll |
| public challenge: typeof WebAuthnApi.prototype._challenge |
| public verify: typeof WebAuthnApi.prototype._verify |
| public authenticate: typeof WebAuthnApi.prototype._authenticate |
| public register: typeof WebAuthnApi.prototype._register |
|
|
| constructor(private client: GoTrueClient) { |
| |
| this.enroll = this._enroll.bind(this) |
| this.challenge = this._challenge.bind(this) |
| this.verify = this._verify.bind(this) |
| this.authenticate = this._authenticate.bind(this) |
| this.register = this._register.bind(this) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| public async _enroll( |
| params: Omit<MFAEnrollWebauthnParams, 'factorType'> |
| ): Promise<AuthMFAEnrollWebauthnResponse> { |
| return this.client.mfa.enroll({ ...params, factorType: 'webauthn' }) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| public async _challenge( |
| { |
| factorId, |
| webauthn, |
| friendlyName, |
| signal, |
| }: MFAChallengeWebauthnParams & { friendlyName?: string; signal?: AbortSignal }, |
| overrides?: |
| | { |
| create?: Partial<PublicKeyCredentialCreationOptionsFuture> |
| request?: never |
| } |
| | { |
| create?: never |
| request?: Partial<PublicKeyCredentialRequestOptionsFuture> |
| } |
| ): Promise< |
| RequestResult< |
| { factorId: string; challengeId: string } & { |
| webauthn: StrictOmit< |
| MFAVerifyWebauthnParamFields<'create' | 'request'>['webauthn'], |
| 'rpId' | 'rpOrigins' |
| > |
| }, |
| WebAuthnError | AuthError |
| > |
| > { |
| try { |
| |
| const { data: challengeResponse, error: challengeError } = await this.client.mfa.challenge({ |
| factorId, |
| webauthn, |
| }) |
|
|
| if (!challengeResponse) { |
| return { data: null, error: challengeError } |
| } |
|
|
| const abortSignal = signal ?? webAuthnAbortService.createNewAbortSignal() |
|
|
| |
| if (challengeResponse.webauthn.type === 'create') { |
| const { user } = challengeResponse.webauthn.credential_options.publicKey |
| if (!user.name) { |
| |
| |
| const nameToUse = friendlyName |
| if (!nameToUse) { |
| |
| const currentUser = await this.client.getUser() |
| const userData = currentUser.data.user |
| const fallbackName = |
| userData?.user_metadata?.name || userData?.email || userData?.id || 'User' |
| user.name = `${user.id}:${fallbackName}` |
| } else { |
| user.name = `${user.id}:${nameToUse}` |
| } |
| } |
| if (!user.displayName) { |
| user.displayName = user.name |
| } |
| } |
|
|
| switch (challengeResponse.webauthn.type) { |
| case 'create': { |
| const options = mergeCredentialCreationOptions( |
| challengeResponse.webauthn.credential_options.publicKey, |
| overrides?.create |
| ) |
|
|
| const { data, error } = await createCredential({ |
| publicKey: options, |
| signal: abortSignal, |
| }) |
|
|
| if (data) { |
| return { |
| data: { |
| factorId, |
| challengeId: challengeResponse.id, |
| webauthn: { |
| type: challengeResponse.webauthn.type, |
| credential_response: data, |
| }, |
| }, |
| error: null, |
| } |
| } |
| return { data: null, error } |
| } |
|
|
| case 'request': { |
| const options = mergeCredentialRequestOptions( |
| challengeResponse.webauthn.credential_options.publicKey, |
| overrides?.request |
| ) |
|
|
| const { data, error } = await getCredential({ |
| ...challengeResponse.webauthn.credential_options, |
| publicKey: options, |
| signal: abortSignal, |
| }) |
|
|
| if (data) { |
| return { |
| data: { |
| factorId, |
| challengeId: challengeResponse.id, |
| webauthn: { |
| type: challengeResponse.webauthn.type, |
| credential_response: data, |
| }, |
| }, |
| error: null, |
| } |
| } |
| return { data: null, error } |
| } |
| } |
| } catch (error) { |
| if (isAuthError(error)) { |
| return { data: null, error } |
| } |
| return { |
| data: null, |
| error: new AuthUnknownError('Unexpected error in challenge', error), |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| public async _verify<T extends 'create' | 'request'>({ |
| challengeId, |
| factorId, |
| webauthn, |
| }: { |
| challengeId: string |
| factorId: string |
| webauthn: MFAVerifyWebauthnParams<T>['webauthn'] |
| }): Promise<AuthMFAVerifyResponse> { |
| return this.client.mfa.verify({ |
| factorId, |
| challengeId, |
| webauthn: webauthn, |
| }) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| public async _authenticate( |
| { |
| factorId, |
| webauthn: { |
| rpId = typeof window !== 'undefined' ? window.location.hostname : undefined, |
| rpOrigins = typeof window !== 'undefined' ? [window.location.origin] : undefined, |
| signal, |
| } = {}, |
| }: { |
| factorId: string |
| webauthn?: { |
| rpId?: string |
| rpOrigins?: string[] |
| signal?: AbortSignal |
| } |
| }, |
| overrides?: PublicKeyCredentialRequestOptionsFuture |
| ): Promise<RequestResult<AuthMFAVerifyResponseData, WebAuthnError | AuthError>> { |
| if (!rpId) { |
| return { |
| data: null, |
| error: new AuthError('rpId is required for WebAuthn authentication'), |
| } |
| } |
| try { |
| if (!browserSupportsWebAuthn()) { |
| return { |
| data: null, |
| error: new AuthUnknownError('Browser does not support WebAuthn', null), |
| } |
| } |
|
|
| |
| const { data: challengeResponse, error: challengeError } = await this.challenge( |
| { |
| factorId, |
| webauthn: { rpId, rpOrigins }, |
| signal, |
| }, |
| { request: overrides } |
| ) |
|
|
| if (!challengeResponse) { |
| return { data: null, error: challengeError } |
| } |
|
|
| const { webauthn } = challengeResponse |
|
|
| |
| return this._verify({ |
| factorId, |
| challengeId: challengeResponse.challengeId, |
| webauthn: { |
| type: webauthn.type, |
| rpId, |
| rpOrigins, |
| credential_response: webauthn.credential_response, |
| }, |
| }) |
| } catch (error) { |
| if (isAuthError(error)) { |
| return { data: null, error } |
| } |
| return { |
| data: null, |
| error: new AuthUnknownError('Unexpected error in authenticate', error), |
| } |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| public async _register( |
| { |
| friendlyName, |
| webauthn: { |
| rpId = typeof window !== 'undefined' ? window.location.hostname : undefined, |
| rpOrigins = typeof window !== 'undefined' ? [window.location.origin] : undefined, |
| signal, |
| } = {}, |
| }: { |
| friendlyName: string |
| webauthn?: { |
| rpId?: string |
| rpOrigins?: string[] |
| signal?: AbortSignal |
| } |
| }, |
| overrides?: Partial<PublicKeyCredentialCreationOptionsFuture> |
| ): Promise<RequestResult<AuthMFAVerifyResponseData, WebAuthnError | AuthError>> { |
| if (!rpId) { |
| return { |
| data: null, |
| error: new AuthError('rpId is required for WebAuthn registration'), |
| } |
| } |
| try { |
| if (!browserSupportsWebAuthn()) { |
| return { |
| data: null, |
| error: new AuthUnknownError('Browser does not support WebAuthn', null), |
| } |
| } |
|
|
| |
| const { data: factor, error: enrollError } = await this._enroll({ |
| friendlyName, |
| }) |
|
|
| if (!factor) { |
| await this.client.mfa |
| .listFactors() |
| .then((factors) => |
| factors.data?.all.find( |
| (v) => |
| v.factor_type === 'webauthn' && |
| v.friendly_name === friendlyName && |
| v.status !== 'unverified' |
| ) |
| ) |
| .then((factor) => (factor ? this.client.mfa.unenroll({ factorId: factor?.id }) : void 0)) |
| return { data: null, error: enrollError } |
| } |
|
|
| |
| const { data: challengeResponse, error: challengeError } = await this._challenge( |
| { |
| factorId: factor.id, |
| friendlyName: factor.friendly_name, |
| webauthn: { rpId, rpOrigins }, |
| signal, |
| }, |
| { |
| create: overrides, |
| } |
| ) |
|
|
| if (!challengeResponse) { |
| return { data: null, error: challengeError } |
| } |
|
|
| return this._verify({ |
| factorId: factor.id, |
| challengeId: challengeResponse.challengeId, |
| webauthn: { |
| rpId, |
| rpOrigins, |
| type: challengeResponse.webauthn.type, |
| credential_response: challengeResponse.webauthn.credential_response, |
| }, |
| }) |
| } catch (error) { |
| if (isAuthError(error)) { |
| return { data: null, error } |
| } |
| return { |
| data: null, |
| error: new AuthUnknownError('Unexpected error in register', error), |
| } |
| } |
| } |
| } |
|
|