Init
This commit is contained in:
+946
@@ -0,0 +1,946 @@
|
||||
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 }
|
||||
// Re-export the JSON types for use in other files
|
||||
export type { RegistrationResponseJSON, AuthenticationResponseJSON }
|
||||
|
||||
/**
|
||||
* WebAuthn abort service to manage ceremony cancellation.
|
||||
* Ensures only one WebAuthn ceremony is active at a time to prevent "operation already in progress" errors.
|
||||
*
|
||||
* @experimental This class is experimental and may change in future releases
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-automation-webdriver-capability W3C WebAuthn Spec - Aborting Ceremonies}
|
||||
*/
|
||||
export class WebAuthnAbortService {
|
||||
private controller: AbortController | undefined
|
||||
|
||||
/**
|
||||
* Create an abort signal for a new WebAuthn operation.
|
||||
* Automatically cancels any existing operation.
|
||||
*
|
||||
* @returns {AbortSignal} Signal to pass to navigator.credentials.create() or .get()
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal MDN - AbortSignal}
|
||||
*/
|
||||
createNewAbortSignal(): AbortSignal {
|
||||
// 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 the current WebAuthn operation.
|
||||
* Useful for cleaning up when user cancels or navigates away.
|
||||
*
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/AbortController/abort MDN - AbortController.abort}
|
||||
*/
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton instance to ensure only one WebAuthn ceremony is active at a time.
|
||||
* This prevents "operation already in progress" errors when retrying WebAuthn operations.
|
||||
*
|
||||
* @experimental This instance is experimental and may change in future releases
|
||||
*/
|
||||
export const webAuthnAbortService = new WebAuthnAbortService()
|
||||
|
||||
/**
|
||||
* Server response format for WebAuthn credential creation options.
|
||||
* Uses W3C standard JSON format with base64url-encoded binary fields.
|
||||
*/
|
||||
export type ServerCredentialCreationOptions = PublicKeyCredentialCreationOptionsJSON
|
||||
|
||||
/**
|
||||
* Server response format for WebAuthn credential request options.
|
||||
* Uses W3C standard JSON format with base64url-encoded binary fields.
|
||||
*/
|
||||
export type ServerCredentialRequestOptions = PublicKeyCredentialRequestOptionsJSON
|
||||
|
||||
/**
|
||||
* Convert base64url encoded strings in WebAuthn credential creation options to ArrayBuffers
|
||||
* as required by the WebAuthn browser API.
|
||||
* Supports both native WebAuthn Level 3 parseCreationOptionsFromJSON and manual fallback.
|
||||
*
|
||||
* @param {ServerCredentialCreationOptions} options - JSON options from server with base64url encoded fields
|
||||
* @returns {PublicKeyCredentialCreationOptionsFuture} Options ready for navigator.credentials.create()
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-parseCreationOptionsFromJSON W3C WebAuthn Spec - parseCreationOptionsFromJSON}
|
||||
*/
|
||||
export function deserializeCredentialCreationOptions(
|
||||
options: ServerCredentialCreationOptions
|
||||
): PublicKeyCredentialCreationOptionsFuture {
|
||||
if (!options) {
|
||||
throw new Error('Credential creation options are required')
|
||||
}
|
||||
|
||||
// Check if the native parseCreationOptionsFromJSON method is available
|
||||
if (
|
||||
typeof PublicKeyCredential !== 'undefined' &&
|
||||
'parseCreationOptionsFromJSON' in PublicKeyCredential &&
|
||||
typeof (PublicKeyCredential as unknown as PublicKeyCredentialFuture)
|
||||
.parseCreationOptionsFromJSON === 'function'
|
||||
) {
|
||||
// Use the native WebAuthn Level 3 method
|
||||
return (
|
||||
PublicKeyCredential as unknown as PublicKeyCredentialFuture
|
||||
).parseCreationOptionsFromJSON(
|
||||
/** we assert the options here as typescript still doesn't know about future webauthn types */
|
||||
options as any
|
||||
) as PublicKeyCredentialCreationOptionsFuture
|
||||
}
|
||||
|
||||
// Fallback to manual parsing for browsers that don't support the native method
|
||||
// Destructure to separate fields that need transformation
|
||||
const { challenge: challengeStr, user: userOpts, excludeCredentials, ...restOptions } = options
|
||||
|
||||
// Convert challenge from base64url to ArrayBuffer
|
||||
const challenge = base64UrlToUint8Array(challengeStr).buffer as ArrayBuffer
|
||||
|
||||
// Convert user.id from base64url to ArrayBuffer
|
||||
const user: PublicKeyCredentialUserEntity = {
|
||||
...userOpts,
|
||||
id: base64UrlToUint8Array(userOpts.id).buffer as ArrayBuffer,
|
||||
}
|
||||
|
||||
// Build the result object
|
||||
const result: PublicKeyCredentialCreationOptionsFuture = {
|
||||
...restOptions,
|
||||
challenge,
|
||||
user,
|
||||
}
|
||||
|
||||
// Only add excludeCredentials if it exists
|
||||
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',
|
||||
// Cast transports to handle future transport types like "cable"
|
||||
transports: cred.transports,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64url encoded strings in WebAuthn credential request options to ArrayBuffers
|
||||
* as required by the WebAuthn browser API.
|
||||
* Supports both native WebAuthn Level 3 parseRequestOptionsFromJSON and manual fallback.
|
||||
*
|
||||
* @param {ServerCredentialRequestOptions} options - JSON options from server with base64url encoded fields
|
||||
* @returns {PublicKeyCredentialRequestOptionsFuture} Options ready for navigator.credentials.get()
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-parseRequestOptionsFromJSON W3C WebAuthn Spec - parseRequestOptionsFromJSON}
|
||||
*/
|
||||
export function deserializeCredentialRequestOptions(
|
||||
options: ServerCredentialRequestOptions
|
||||
): PublicKeyCredentialRequestOptionsFuture {
|
||||
if (!options) {
|
||||
throw new Error('Credential request options are required')
|
||||
}
|
||||
|
||||
// Check if the native parseRequestOptionsFromJSON method is available
|
||||
if (
|
||||
typeof PublicKeyCredential !== 'undefined' &&
|
||||
'parseRequestOptionsFromJSON' in PublicKeyCredential &&
|
||||
typeof (PublicKeyCredential as unknown as PublicKeyCredentialFuture)
|
||||
.parseRequestOptionsFromJSON === 'function'
|
||||
) {
|
||||
// Use the native WebAuthn Level 3 method
|
||||
return (
|
||||
PublicKeyCredential as unknown as PublicKeyCredentialFuture
|
||||
).parseRequestOptionsFromJSON(options) as PublicKeyCredentialRequestOptionsFuture
|
||||
}
|
||||
|
||||
// Fallback to manual parsing for browsers that don't support the native method
|
||||
// Destructure to separate fields that need transformation
|
||||
const { challenge: challengeStr, allowCredentials, ...restOptions } = options
|
||||
|
||||
// Convert challenge from base64url to ArrayBuffer
|
||||
const challenge = base64UrlToUint8Array(challengeStr).buffer as ArrayBuffer
|
||||
|
||||
// Build the result object
|
||||
const result: PublicKeyCredentialRequestOptionsFuture = {
|
||||
...restOptions,
|
||||
challenge,
|
||||
}
|
||||
|
||||
// Only add allowCredentials if it exists
|
||||
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',
|
||||
// Cast transports to handle future transport types like "cable"
|
||||
transports: cred.transports,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Server format for credential response with base64url-encoded binary fields
|
||||
* Can be either a registration or authentication response
|
||||
*/
|
||||
export type ServerCredentialResponse = RegistrationResponseJSON | AuthenticationResponseJSON
|
||||
|
||||
/**
|
||||
* Convert a registration/enrollment credential response to server format.
|
||||
* Serializes binary fields to base64url for JSON transmission.
|
||||
* Supports both native WebAuthn Level 3 toJSON and manual fallback.
|
||||
*
|
||||
* @param {RegistrationCredential} credential - Credential from navigator.credentials.create()
|
||||
* @returns {RegistrationResponseJSON} JSON-serializable credential for server
|
||||
* @see {@link https://w3c.github.io/webauthn/#dom-publickeycredential-tojson W3C WebAuthn Spec - toJSON}
|
||||
*/
|
||||
export function serializeCredentialCreationResponse(
|
||||
credential: RegistrationCredential
|
||||
): RegistrationResponseJSON {
|
||||
// Check if the credential instance has the toJSON method
|
||||
if ('toJSON' in credential && typeof credential.toJSON === 'function') {
|
||||
// Use the native WebAuthn Level 3 method
|
||||
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(),
|
||||
// Convert null to undefined and cast to AuthenticatorAttachment type
|
||||
authenticatorAttachment: (credentialWithAttachment.authenticatorAttachment ?? undefined) as
|
||||
| AuthenticatorAttachment
|
||||
| undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an authentication/verification credential response to server format.
|
||||
* Serializes binary fields to base64url for JSON transmission.
|
||||
* Supports both native WebAuthn Level 3 toJSON and manual fallback.
|
||||
*
|
||||
* @param {AuthenticationCredential} credential - Credential from navigator.credentials.get()
|
||||
* @returns {AuthenticationResponseJSON} JSON-serializable credential for server
|
||||
* @see {@link https://w3c.github.io/webauthn/#dom-publickeycredential-tojson W3C WebAuthn Spec - toJSON}
|
||||
*/
|
||||
export function serializeCredentialRequestResponse(
|
||||
credential: AuthenticationCredential
|
||||
): AuthenticationResponseJSON {
|
||||
// Check if the credential instance has the toJSON method
|
||||
if ('toJSON' in credential && typeof credential.toJSON === 'function') {
|
||||
// Use the native WebAuthn Level 3 method
|
||||
return (credential as AuthenticationCredential).toJSON()
|
||||
}
|
||||
|
||||
// Fallback to manual conversion for browsers that don't support toJSON
|
||||
// Access authenticatorAttachment via type assertion to handle TypeScript version differences
|
||||
// @simplewebauthn/types includes this property but base TypeScript 4.7.4 doesn't
|
||||
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, // W3C spec expects rawId to match id for JSON format
|
||||
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,
|
||||
// Convert null to undefined and cast to AuthenticatorAttachment type
|
||||
authenticatorAttachment: (credentialWithAttachment.authenticatorAttachment ?? undefined) as
|
||||
| AuthenticatorAttachment
|
||||
| undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A simple test to determine if a hostname is a properly-formatted domain name.
|
||||
* Considers localhost valid for development environments.
|
||||
*
|
||||
* A "valid domain" is defined here: https://url.spec.whatwg.org/#valid-domain
|
||||
*
|
||||
* Regex sourced from here:
|
||||
* https://www.oreilly.com/library/view/regular-expressions-cookbook/9781449327453/ch08s15.html
|
||||
*
|
||||
* @param {string} hostname - The hostname to validate
|
||||
* @returns {boolean} True if valid domain or localhost
|
||||
* @see {@link https://url.spec.whatwg.org/#valid-domain WHATWG URL Spec - Valid Domain}
|
||||
*/
|
||||
export function isValidDomain(hostname: string): boolean {
|
||||
return (
|
||||
// Consider localhost valid as well since it's okay wrt Secure Contexts
|
||||
hostname === 'localhost' || /^([a-z0-9]+(-[a-z0-9]+)*\.)+[a-z]{2,}$/i.test(hostname)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine if the browser is capable of WebAuthn.
|
||||
* Checks for necessary Web APIs: PublicKeyCredential and Credential Management.
|
||||
*
|
||||
* @returns {boolean} True if browser supports WebAuthn
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredential#browser_compatibility MDN - PublicKeyCredential Browser Compatibility}
|
||||
*/
|
||||
export function browserSupportsWebAuthn(): boolean {
|
||||
return !!(
|
||||
isBrowser() &&
|
||||
'PublicKeyCredential' in window &&
|
||||
window.PublicKeyCredential &&
|
||||
'credentials' in navigator &&
|
||||
typeof navigator?.credentials?.create === 'function' &&
|
||||
typeof navigator?.credentials?.get === 'function'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a WebAuthn credential using the browser's credentials API.
|
||||
* Wraps navigator.credentials.create() with error handling.
|
||||
*
|
||||
* @param {CredentialCreationOptions} options - Options including publicKey parameters
|
||||
* @returns {Promise<RequestResult<RegistrationCredential, WebAuthnError>>} Created credential or error
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-createCredential W3C WebAuthn Spec - Create Credential}
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/create MDN - credentials.create}
|
||||
*/
|
||||
export async function createCredential(
|
||||
options: StrictOmit<CredentialCreationOptions, 'publicKey'> & {
|
||||
publicKey: PublicKeyCredentialCreationOptionsFuture
|
||||
}
|
||||
): Promise<RequestResult<RegistrationCredential, WebAuthnError>> {
|
||||
try {
|
||||
const response = await navigator.credentials.create(
|
||||
/** we assert the type here until typescript types are updated */
|
||||
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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a WebAuthn credential using the browser's credentials API.
|
||||
* Wraps navigator.credentials.get() with error handling.
|
||||
*
|
||||
* @param {CredentialRequestOptions} options - Options including publicKey parameters
|
||||
* @returns {Promise<RequestResult<AuthenticationCredential, WebAuthnError>>} Retrieved credential or error
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-getAssertion W3C WebAuthn Spec - Get Assertion}
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/CredentialsContainer/get MDN - credentials.get}
|
||||
*/
|
||||
export async function getCredential(
|
||||
options: StrictOmit<CredentialRequestOptions, 'publicKey'> & {
|
||||
publicKey: PublicKeyCredentialRequestOptionsFuture
|
||||
}
|
||||
): Promise<RequestResult<AuthenticationCredential, WebAuthnError>> {
|
||||
try {
|
||||
const response = await navigator.credentials.get(
|
||||
/** we assert the type here until typescript types are updated */
|
||||
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,
|
||||
/** set to preferred because older yubikeys don't have PIN/Biometric */
|
||||
userVerification: 'preferred',
|
||||
residentKey: 'discouraged',
|
||||
},
|
||||
attestation: 'direct',
|
||||
}
|
||||
|
||||
export const DEFAULT_REQUEST_OPTIONS: Partial<PublicKeyCredentialRequestOptionsFuture> = {
|
||||
/** set to preferred because older yubikeys don't have PIN/Biometric */
|
||||
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)) {
|
||||
// preserve array reference, including unions like AuthenticatorTransport[]
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges WebAuthn credential creation options with overrides.
|
||||
* Sets sensible defaults for authenticator selection and extensions.
|
||||
*
|
||||
* @param {PublicKeyCredentialCreationOptionsFuture} baseOptions - The base options from the server
|
||||
* @param {PublicKeyCredentialCreationOptionsFuture} overrides - Optional overrides to apply
|
||||
* @param {string} friendlyName - Optional friendly name for the credential
|
||||
* @returns {PublicKeyCredentialCreationOptionsFuture} Merged credential creation options
|
||||
* @see {@link https://w3c.github.io/webauthn/#dictdef-authenticatorselectioncriteria W3C WebAuthn Spec - AuthenticatorSelectionCriteria}
|
||||
*/
|
||||
export function mergeCredentialCreationOptions(
|
||||
baseOptions: PublicKeyCredentialCreationOptionsFuture,
|
||||
overrides?: Partial<PublicKeyCredentialCreationOptionsFuture>
|
||||
): PublicKeyCredentialCreationOptionsFuture {
|
||||
return deepMerge(DEFAULT_CREATION_OPTIONS, baseOptions, overrides || {})
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges WebAuthn credential request options with overrides.
|
||||
* Sets sensible defaults for user verification and hints.
|
||||
*
|
||||
* @param {PublicKeyCredentialRequestOptionsFuture} baseOptions - The base options from the server
|
||||
* @param {PublicKeyCredentialRequestOptionsFuture} overrides - Optional overrides to apply
|
||||
* @returns {PublicKeyCredentialRequestOptionsFuture} Merged credential request options
|
||||
* @see {@link https://w3c.github.io/webauthn/#dictdef-publickeycredentialrequestoptions W3C WebAuthn Spec - PublicKeyCredentialRequestOptions}
|
||||
*/
|
||||
export function mergeCredentialRequestOptions(
|
||||
baseOptions: PublicKeyCredentialRequestOptionsFuture,
|
||||
overrides?: Partial<PublicKeyCredentialRequestOptionsFuture>
|
||||
): PublicKeyCredentialRequestOptionsFuture {
|
||||
return deepMerge(DEFAULT_REQUEST_OPTIONS, baseOptions, overrides || {})
|
||||
}
|
||||
|
||||
/**
|
||||
* WebAuthn API wrapper for Supabase Auth.
|
||||
* Provides methods for enrolling, challenging, verifying, authenticating, and registering WebAuthn credentials.
|
||||
*
|
||||
* @experimental This API is experimental and may change in future releases
|
||||
* @see {@link https://w3c.github.io/webauthn/ W3C WebAuthn Specification}
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/Web_Authentication_API MDN - Web Authentication API}
|
||||
*/
|
||||
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) {
|
||||
// Bind all methods so they can be destructured
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enroll a new WebAuthn factor.
|
||||
* Creates an unverified WebAuthn factor that must be verified with a credential.
|
||||
*
|
||||
* @experimental This method is experimental and may change in future releases
|
||||
* @param {Omit<MFAEnrollWebauthnParams, 'factorType'>} params - Enrollment parameters (friendlyName required)
|
||||
* @returns {Promise<AuthMFAEnrollWebauthnResponse>} Enrolled factor details or error
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-registering-a-new-credential W3C WebAuthn Spec - Registering a New Credential}
|
||||
*/
|
||||
public async _enroll(
|
||||
params: Omit<MFAEnrollWebauthnParams, 'factorType'>
|
||||
): Promise<AuthMFAEnrollWebauthnResponse> {
|
||||
return this.client.mfa.enroll({ ...params, factorType: 'webauthn' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Challenge for WebAuthn credential creation or authentication.
|
||||
* Combines server challenge with browser credential operations.
|
||||
* Handles both registration (create) and authentication (request) flows.
|
||||
*
|
||||
* @experimental This method is experimental and may change in future releases
|
||||
* @param {MFAChallengeWebauthnParams & { friendlyName?: string; signal?: AbortSignal }} params - Challenge parameters including factorId
|
||||
* @param {Object} overrides - Allows you to override the parameters passed to navigator.credentials
|
||||
* @param {PublicKeyCredentialCreationOptionsFuture} overrides.create - Override options for credential creation
|
||||
* @param {PublicKeyCredentialRequestOptionsFuture} overrides.request - Override options for credential request
|
||||
* @returns {Promise<RequestResult>} Challenge response with credential or error
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-credential-creation W3C WebAuthn Spec - Credential Creation}
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-verifying-assertion W3C WebAuthn Spec - Verifying Assertion}
|
||||
*/
|
||||
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 {
|
||||
// Get challenge from server using the client's MFA methods
|
||||
const { data: challengeResponse, error: challengeError } = await this.client.mfa.challenge({
|
||||
factorId,
|
||||
webauthn,
|
||||
})
|
||||
|
||||
if (!challengeResponse) {
|
||||
return { data: null, error: challengeError }
|
||||
}
|
||||
|
||||
const abortSignal = signal ?? webAuthnAbortService.createNewAbortSignal()
|
||||
|
||||
/** webauthn will fail if either of the name/displayname are blank */
|
||||
if (challengeResponse.webauthn.type === 'create') {
|
||||
const { user } = challengeResponse.webauthn.credential_options.publicKey
|
||||
if (!user.name) {
|
||||
// Preserve original format: use friendlyName if provided, otherwise fetch fallback
|
||||
// This maintains backward compatibility with the ${user.id}:${name} format
|
||||
const nameToUse = friendlyName
|
||||
if (!nameToUse) {
|
||||
// Only fetch user data if friendlyName is not provided (bug fix for null friendlyName)
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a WebAuthn credential with the server.
|
||||
* Completes the WebAuthn ceremony by sending the credential to the server for verification.
|
||||
*
|
||||
* @experimental This method is experimental and may change in future releases
|
||||
* @param {Object} params - Verification parameters
|
||||
* @param {string} params.challengeId - ID of the challenge being verified
|
||||
* @param {string} params.factorId - ID of the WebAuthn factor
|
||||
* @param {MFAVerifyWebauthnParams<T>['webauthn']} params.webauthn - WebAuthn credential response
|
||||
* @returns {Promise<AuthMFAVerifyResponse>} Verification result with session or error
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-verifying-assertion W3C WebAuthn Spec - Verifying an Authentication Assertion}
|
||||
* */
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete WebAuthn authentication flow.
|
||||
* Performs challenge and verification in a single operation for existing credentials.
|
||||
*
|
||||
* @experimental This method is experimental and may change in future releases
|
||||
* @param {Object} params - Authentication parameters
|
||||
* @param {string} params.factorId - ID of the WebAuthn factor to authenticate with
|
||||
* @param {Object} params.webauthn - WebAuthn configuration
|
||||
* @param {string} params.webauthn.rpId - Relying Party ID (defaults to current hostname)
|
||||
* @param {string[]} params.webauthn.rpOrigins - Allowed origins (defaults to current origin)
|
||||
* @param {AbortSignal} params.webauthn.signal - Optional abort signal
|
||||
* @param {PublicKeyCredentialRequestOptionsFuture} overrides - Override options for navigator.credentials.get
|
||||
* @returns {Promise<RequestResult<AuthMFAVerifyResponseData, WebAuthnError | AuthError>>} Authentication result
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-authentication W3C WebAuthn Spec - Authentication Ceremony}
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialRequestOptions MDN - PublicKeyCredentialRequestOptions}
|
||||
*/
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// Get challenge and credential
|
||||
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
|
||||
|
||||
// Verify credential
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete WebAuthn registration flow.
|
||||
* Performs enrollment, challenge, and verification in a single operation for new credentials.
|
||||
*
|
||||
* @experimental This method is experimental and may change in future releases
|
||||
* @param {Object} params - Registration parameters
|
||||
* @param {string} params.friendlyName - User-friendly name for the credential
|
||||
* @param {string} params.rpId - Relying Party ID (defaults to current hostname)
|
||||
* @param {string[]} params.rpOrigins - Allowed origins (defaults to current origin)
|
||||
* @param {AbortSignal} params.signal - Optional abort signal
|
||||
* @param {PublicKeyCredentialCreationOptionsFuture} overrides - Override options for navigator.credentials.create
|
||||
* @returns {Promise<RequestResult<AuthMFAVerifyResponseData, WebAuthnError | AuthError>>} Registration result
|
||||
* @see {@link https://w3c.github.io/webauthn/#sctn-registering-a-new-credential W3C WebAuthn Spec - Registration Ceremony}
|
||||
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/PublicKeyCredentialCreationOptions MDN - PublicKeyCredentialCreationOptions}
|
||||
*/
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// Enroll factor
|
||||
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 }
|
||||
}
|
||||
|
||||
// Get challenge and create credential
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user