UNPKG

@auth0/auth0-spa-js

Version:

Auth0 SDK for Single Page Applications using Authorization Code Grant Flow with PKCE

462 lines (417 loc) • 17 kB
import type { Auth0Client } from '../Auth0Client'; import type { TokenEndpointResponse } from '../global'; import type { PasskeySignupOptions, PasskeyLoginOptions, PasskeyCredentialResponse, PasskeyCreationOptions, PasskeyRequestOptions, PasskeySignupChallengeOptions, PasskeyLoginChallengeOptions, PasskeySignupChallenge, PasskeyLoginChallenge, PasskeyGetTokenOptions } from './types'; import type { PasskeyClient } from '@auth0/auth0-auth-js'; import { PasskeyError, PasskeyGetTokenError } from './errors'; import { PasskeyVerificationRequiredError } from '../errors'; /** * Client for Auth0 Passkey operations. * * Provides 2 public methods: * - `signup` — Register a new user with a passkey (full flow: challenge → WebAuthn → token exchange) * - `login` — Sign in with a passkey (full flow: challenge → WebAuthn → token exchange) * * @example * ```typescript * // Signup — single call handles everything * const tokens = await auth0.passkey.signup({ email: 'user@example.com' }); * * // Login — single call handles everything * const tokens = await auth0.passkey.login(); * ``` * * @category Clients */ export class PasskeyApiClient { #passkeyClient: PasskeyClient; #auth0Client: Auth0Client; #pendingSignup: { authSession: string; publicKey: PublicKeyCredentialCreationOptions; verificationRequired: Array<'email' | 'phone'>; /** Set after the first attempt, so a retry reuses it without a second biometric prompt. */ credential?: PasskeyCredentialResponse; realm?: string; organization?: string; scope?: string; audience?: string; } | null = null; /** * @internal * Do not instantiate directly. Use Auth0Client.passkey instead. */ constructor(passkeyClient: PasskeyClient, auth0Client: Auth0Client) { this.#passkeyClient = passkeyClient; this.#auth0Client = auth0Client; } /** * Register a new user with a passkey. * * Handles the full flow: requests a signup challenge, triggers the browser * WebAuthn credential creation ceremony, serializes the result, and exchanges * it for tokens. * * If the server requires identifier verification, it stops before creating the * passkey and throws `PasskeyVerificationRequiredError`. Collect the OTP codes * and call `continueSignup()` to finish. * * @param options - Passkey signup options (user identifier, optional scope/audience) * @returns A promise that resolves to the token endpoint response containing access/ID tokens * @throws {PasskeyError} If WebAuthn is not supported in the browser * @throws {PasskeyRegisterError} If the challenge request fails * @throws {PasskeyVerificationRequiredError} If OTP codes are needed before the passkey is created * @throws {GenericError} If the token exchange fails * @throws {PasskeyError} If the user cancels the WebAuthn prompt */ async signup(options: PasskeySignupOptions): Promise<TokenEndpointResponse> { if (!window.PublicKeyCredential) { throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.'); } this.#pendingSignup = null; const { scope, audience, ...challengeOptions } = options; const challenge = await this.#passkeyClient.register(challengeOptions); if (challenge.verificationRequired && challenge.verificationRequired.length > 0) { this.#pendingSignup = { authSession: challenge.authSession, publicKey: prepareCreationOptions(challenge.authnParamsPublicKey), verificationRequired: challenge.verificationRequired, realm: challengeOptions.realm, organization: challengeOptions.organization, scope, audience, }; throw new PasskeyVerificationRequiredError(challenge.verificationRequired); } const publicKeyOptions = prepareCreationOptions( challenge.authnParamsPublicKey ); const credential = await navigator.credentials.create({ publicKey: publicKeyOptions }); if (!credential) { throw new PasskeyError( 'passkey_cancelled', 'Passkey creation was cancelled or no credential was returned.' ); } const serialized = serializeCreationCredential( credential as PublicKeyCredential ); return this.#auth0Client._requestTokenForPasskey({ authSession: challenge.authSession, credential: serialized, realm: challengeOptions.realm, organization: challengeOptions.organization, scope, audience }); } /** * Sign in with an existing passkey. * * Handles the full flow: requests a login challenge, triggers the browser * WebAuthn assertion ceremony, serializes the result, and exchanges it * for tokens. * * @param options - Optional passkey login options (optional scope/audience/realm/organization) * @returns A promise that resolves to the token endpoint response containing access/ID tokens * @throws {PasskeyError} If WebAuthn is not supported in the browser * @throws {PasskeyChallengeError} If the challenge request fails * @throws {GenericError} If the token exchange fails * @throws {PasskeyError} If the user cancels the WebAuthn prompt */ async login(options?: PasskeyLoginOptions): Promise<TokenEndpointResponse> { if (!window.PublicKeyCredential) { throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.'); } const { scope, audience, ...challengeOptions } = options || {}; const challenge = await this.#passkeyClient.challenge( Object.keys(challengeOptions).length > 0 ? challengeOptions : undefined ); const publicKeyOptions = prepareRequestOptions( challenge.authnParamsPublicKey ); const credential = await navigator.credentials.get({ publicKey: publicKeyOptions }); if (!credential) { throw new PasskeyError( 'passkey_cancelled', 'Passkey authentication was cancelled or no credential was returned.' ); } const serialized = serializeAssertionCredential( credential as PublicKeyCredential ); return this.#auth0Client._requestTokenForPasskey({ authSession: challenge.authSession, credential: serialized, realm: challengeOptions.realm, organization: challengeOptions.organization, scope, audience }); } /** * Request a passkey signup challenge. * * Step 1 of the granular signup flow. Returns the auth session and a * decoded `PublicKeyCredentialCreationOptions`. Run the WebAuthn credential * creation ceremony with `publicKey`, then hand the resulting credential and `authSession` to * `getTokenWithPasskey()`. * * @param options - Signup challenge options (user identifier, optional realm/organization/metadata) * @returns A promise resolving to `{ authSession, publicKey }` * @throws {PasskeyError} If WebAuthn is not supported in the browser * @throws {PasskeyRegisterError} If the challenge request fails */ async getSignupChallenge( options: PasskeySignupChallengeOptions ): Promise<PasskeySignupChallenge> { if (!window.PublicKeyCredential) { throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.'); } const challenge = await this.#passkeyClient.register(options); return { authSession: challenge.authSession, publicKey: prepareCreationOptions(challenge.authnParamsPublicKey), ...(challenge.verificationRequired && { verificationRequired: challenge.verificationRequired }), }; } /** * Request a passkey login challenge. * * Step 1 of the granular login flow. Returns the auth session and a * decoded `PublicKeyCredentialRequestOptions`. Run the WebAuthn assertion * ceremony with `publicKey`, then hand the resulting credential and `authSession` to * `getTokenWithPasskey()`. * * @param options - Optional login challenge options (realm/organization) * @returns A promise resolving to `{ authSession, publicKey }` * @throws {PasskeyError} If WebAuthn is not supported in the browser * @throws {PasskeyChallengeError} If the challenge request fails */ async getLoginChallenge( options?: PasskeyLoginChallengeOptions ): Promise<PasskeyLoginChallenge> { if (!window.PublicKeyCredential) { throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.'); } const challenge = await this.#passkeyClient.challenge(options); return { authSession: challenge.authSession, publicKey: prepareRequestOptions(challenge.authnParamsPublicKey) }; } /** * Exchange a signed passkey credential for tokens. * * Step 2 of the granular flow. Serializes the raw `PublicKeyCredential` * produced by the WebAuthn ceremony — either a creation (signup) or an * assertion (login) credential — and exchanges it for tokens. The credential * type (attestation vs assertion) is detected automatically. * * @param options - The auth session, raw credential, and optional realm/organization/scope/audience * @returns A promise resolving to the token endpoint response * @throws {PasskeyError} If WebAuthn is not supported in the browser * @throws {PasskeyError} If the credential is not a valid attestation or assertion response * @throws {PasskeyError} If a code in `verification` is empty. Nothing is sent, so the same session can be retried * @throws {PasskeyGetTokenError} If an OTP code is wrong and the session is still active (`isRetryable: true`) * @throws {GenericError} If the token exchange fails */ async getTokenWithPasskey( options: PasskeyGetTokenOptions ): Promise<TokenEndpointResponse> { if (!window.PublicKeyCredential) { throw new PasskeyError('passkey_not_supported', 'WebAuthn is not supported in this browser.'); } const { authSession, credential, realm, organization, scope, audience, verification } = options; const codes: Record<string, string | undefined> = verification ?? {}; if (Object.keys(codes).some(id => codes[id] === '')) { throw new PasskeyError('missing_verification_code', 'OTP codes in verification must not be empty.'); } const response = credential.response; let serialized: PasskeyCredentialResponse; if (response instanceof AuthenticatorAttestationResponse) { serialized = serializeCreationCredential(credential); } else if (response instanceof AuthenticatorAssertionResponse) { serialized = serializeAssertionCredential(credential); } else { throw new PasskeyError( 'passkey_invalid_credential', 'The provided credential is not a valid attestation or assertion response.' ); } return this.#auth0Client._requestTokenForPasskey({ authSession, credential: serialized, realm, organization, scope, audience, verification, }); } /** * Continue a signup that was interrupted by identifier verification. * * Call this after catching `PasskeyVerificationRequiredError` from `signup()`, * collecting OTP codes from the user, and passing them here. The SDK * internally retains the auth session — you do not need to pass it. * * If the codes are wrong but the session is still alive, this throws a * `PasskeyGetTokenError` with `isRetryable: true`. Collect new codes for * `error.verificationRequired` and call `continueSignup()` again; the same * passkey is reused, so the user is not prompted again. * * @param verification - Map of identifier → OTP code (e.g. `{ phone: "123456" }`) * @returns A promise resolving to the token endpoint response * @throws {PasskeyError} If there is no pending signup (signup() was not called first) * @throws {PasskeyError} If a code is missing for a required identifier. The pending signup is kept * @throws {PasskeyGetTokenError} If an OTP code is wrong and the session is still active (`isRetryable: true`) * @throws {GenericError} If the token exchange fails */ async continueSignup(verification: { email?: string; phone?: string }): Promise<TokenEndpointResponse> { if (!this.#pendingSignup) { throw new PasskeyError( 'no_pending_signup', 'No pending signup found. Call signup() first and catch PasskeyVerificationRequiredError.' ); } // Check before the passkey prompt, so a missing code doesn't leave an unused passkey. const missing = this.#pendingSignup.verificationRequired.filter(id => !verification?.[id]); if (missing.length > 0) { throw new PasskeyError( 'missing_verification_code', `Missing OTP code for: ${missing.join(', ')}. Pass a code for each identifier in verificationRequired.` ); } // Clear before any await so a concurrent call can't reuse the same session. const pending = this.#pendingSignup; this.#pendingSignup = null; const { authSession, publicKey, realm, organization, scope, audience } = pending; let serialized = pending.credential; if (!serialized) { let credential: Credential | null; try { credential = await navigator.credentials.create({ publicKey }); } catch (e) { this.#pendingSignup = pending; throw e; } if (!credential) { this.#pendingSignup = pending; throw new PasskeyError( 'passkey_cancelled', 'Passkey creation was cancelled or no credential was returned.' ); } serialized = serializeCreationCredential(credential as PublicKeyCredential); } try { return await this.#auth0Client._requestTokenForPasskey({ authSession, credential: serialized, realm, organization, scope, audience, verification, }); } catch (e) { if (e instanceof PasskeyGetTokenError && e.isRetryable) { this.#pendingSignup = { ...pending, authSession: e.authSession!, verificationRequired: e.verificationRequired!, credential: serialized }; } throw e; } } } function bufferToBase64url(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); const binary = Array.from(bytes, b => String.fromCharCode(b)).join(''); return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function base64urlToBuffer(base64url: string): ArrayBuffer { const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } function prepareCreationOptions( publicKey: PasskeyCreationOptions ): PublicKeyCredentialCreationOptions { return { ...publicKey, challenge: base64urlToBuffer(publicKey.challenge), user: { ...publicKey.user, id: base64urlToBuffer(publicKey.user.id) }, pubKeyCredParams: publicKey.pubKeyCredParams as PublicKeyCredentialParameters[], authenticatorSelection: publicKey.authenticatorSelection as AuthenticatorSelectionCriteria | undefined }; } function prepareRequestOptions( publicKey: PasskeyRequestOptions ): PublicKeyCredentialRequestOptions { return { ...publicKey, challenge: base64urlToBuffer(publicKey.challenge) } as PublicKeyCredentialRequestOptions; } function serializeCreationCredential( credential: PublicKeyCredential ): PasskeyCredentialResponse { const response = credential.response as AuthenticatorAttestationResponse; return { id: credential.id, rawId: bufferToBase64url(credential.rawId), type: credential.type, authenticatorAttachment: credential.authenticatorAttachment ?? undefined, response: { clientDataJSON: bufferToBase64url(response.clientDataJSON), attestationObject: bufferToBase64url(response.attestationObject) }, clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown> }; } function serializeAssertionCredential( credential: PublicKeyCredential ): PasskeyCredentialResponse { const response = credential.response as AuthenticatorAssertionResponse; return { id: credential.id, rawId: bufferToBase64url(credential.rawId), type: credential.type, authenticatorAttachment: credential.authenticatorAttachment ?? undefined, response: { clientDataJSON: bufferToBase64url(response.clientDataJSON), authenticatorData: bufferToBase64url(response.authenticatorData), signature: bufferToBase64url(response.signature), userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : undefined }, clientExtensionResults: credential.getClientExtensionResults() as Record<string, unknown> }; }