/** * PasskeyRegistration - WebAuthn credential registration component * * Handles the passkey registration flow: * 1. Fetches registration options from server * 2. Triggers browser's WebAuthn credential creation * 3. Sends attestation back to server for verification * * Used in: * - Setup wizard (first admin creation) * - User settings (adding additional passkeys) */ import { Button, Input } from "@cloudflare/kumo"; import { useLingui } from "@lingui/react/macro"; import * as React from "react"; import { apiFetch, parseApiResponse } from "../../lib/api/client"; import { isPasskeyEnvironmentUsable, isWebAuthnSecureContext, } from "../../lib/webauthn-environment"; // ============================================================================ // Constants // ============================================================================ const BASE64URL_DASH_REGEX = /-/g; const BASE64URL_UNDERSCORE_REGEX = /_/g; const BASE64_PLUS_REGEX = /\+/g; const BASE64_SLASH_REGEX = /\//g; // ============================================================================ // WebAuthn types // ============================================================================ interface PublicKeyCredentialCreationOptionsJSON { challenge: string; rp: { name: string; id: string; }; user: { id: string; name: string; displayName: string; }; pubKeyCredParams: Array<{ type: "public-key"; alg: number; }>; timeout?: number; attestation?: "none" | "indirect" | "direct"; authenticatorSelection?: { authenticatorAttachment?: "platform" | "cross-platform"; residentKey?: "discouraged" | "preferred" | "required"; requireResidentKey?: boolean; userVerification?: "discouraged" | "preferred" | "required"; }; excludeCredentials?: Array<{ type: "public-key"; id: string; transports?: AuthenticatorTransport[]; }>; } interface RegistrationResponse { id: string; rawId: string; type: "public-key"; response: { clientDataJSON: string; attestationObject: string; transports?: AuthenticatorTransport[]; }; authenticatorAttachment?: "platform" | "cross-platform"; } export interface PasskeyRegistrationProps { /** Endpoint to get registration options */ optionsEndpoint: string; /** Endpoint to verify registration */ verifyEndpoint: string; /** Called on successful registration */ onSuccess: (response: unknown) => void; /** Called on error */ onError?: (error: Error) => void; /** Button text */ buttonText?: string; /** Show passkey name input */ showNameInput?: boolean; /** Additional data to send with requests */ additionalData?: Record; } const EMPTY_DATA: Record = {}; type RegistrationState = | { status: "idle" } | { status: "loading"; message: string } | { status: "error"; message: string } | { status: "success" }; /** * Convert base64url to ArrayBuffer */ function base64urlToBuffer(base64url: string): ArrayBuffer { const base64 = base64url .replace(BASE64URL_DASH_REGEX, "+") .replace(BASE64URL_UNDERSCORE_REGEX, "/"); const padding = "=".repeat((4 - (base64.length % 4)) % 4); const binary = atob(base64 + padding); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } /** * Convert ArrayBuffer to base64url (with padding for @oslojs/encoding compatibility) */ function bufferToBase64url(buffer: ArrayBuffer): string { const bytes = new Uint8Array(buffer); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]!); } const base64 = btoa(binary); // Convert to base64url but keep padding (required by @oslojs/encoding) return base64.replace(BASE64_PLUS_REGEX, "-").replace(BASE64_SLASH_REGEX, "_"); } /** * PasskeyRegistration Component */ export function PasskeyRegistration({ optionsEndpoint, verifyEndpoint, onSuccess, onError, buttonText, showNameInput = false, additionalData = EMPTY_DATA, }: PasskeyRegistrationProps) { const { t } = useLingui(); const resolvedButtonText = buttonText ?? t`Register Passkey`; const [state, setState] = React.useState({ status: "idle", }); const [passkeyName, setPasskeyName] = React.useState(""); // Secure context (HTTPS or http://localhost) + PublicKeyCredential const isSupported = React.useMemo(() => isPasskeyEnvironmentUsable(), []); const insecureContext = React.useMemo( () => typeof window !== "undefined" && !isWebAuthnSecureContext(), [], ); const handleRegister = React.useCallback(async () => { if (!isSupported) { setState({ status: "error", message: t`WebAuthn is not supported in this browser`, }); return; } try { // Step 1: Get registration options from server setState({ status: "loading", message: t`Preparing registration...` }); const optionsResponse = await apiFetch(optionsEndpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(additionalData), }); const optionsData = await parseApiResponse<{ options: PublicKeyCredentialCreationOptionsJSON; }>(optionsResponse, "Failed to get registration options"); const { options } = optionsData; // Step 2: Create credential with browser setState({ status: "loading", message: t`Waiting for passkey...` }); // Convert options to the format expected by the browser const publicKeyOptions: PublicKeyCredentialCreationOptions = { challenge: base64urlToBuffer(options.challenge), rp: options.rp, user: { id: base64urlToBuffer(options.user.id), name: options.user.name, displayName: options.user.displayName, }, pubKeyCredParams: options.pubKeyCredParams, timeout: options.timeout, attestation: options.attestation, authenticatorSelection: options.authenticatorSelection, excludeCredentials: options.excludeCredentials?.map((cred) => ({ type: cred.type, id: base64urlToBuffer(cred.id), transports: cred.transports, })), }; const rawCredential = await navigator.credentials.create({ publicKey: publicKeyOptions, }); if (!rawCredential) { throw new Error("No credential returned from authenticator"); } // Step 3: Send credential to server for verification setState({ status: "loading", message: t`Verifying...` }); // navigator.credentials.create() with publicKey returns PublicKeyCredential const credential = rawCredential as PublicKeyCredential; const attestationResponse = credential.response as AuthenticatorAttestationResponse; // authenticatorAttachment exists at runtime on PublicKeyCredential but isn't in the base type definition const rawAttachment = "authenticatorAttachment" in credential ? credential.authenticatorAttachment : undefined; const authenticatorAttachment = rawAttachment === "platform" || rawAttachment === "cross-platform" ? rawAttachment : undefined; const registrationResponse: RegistrationResponse = { id: credential.id, rawId: bufferToBase64url(credential.rawId), type: "public-key", response: { clientDataJSON: bufferToBase64url(attestationResponse.clientDataJSON), attestationObject: bufferToBase64url(attestationResponse.attestationObject), transports: attestationResponse.getTransports?.() as AuthenticatorTransport[] | undefined, }, authenticatorAttachment, }; const verifyResponse = await apiFetch(verifyEndpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ credential: registrationResponse, name: passkeyName || undefined, ...additionalData, }), }); const result = await parseApiResponse( verifyResponse, "Failed to verify registration", ); setState({ status: "success" }); onSuccess(result); } catch (error) { const message = error instanceof Error ? error.message : "Registration failed"; // Handle specific WebAuthn errors let userMessage = message; if (error instanceof DOMException) { switch (error.name) { case "NotAllowedError": userMessage = t`Registration was cancelled or timed out. Please try again.`; break; case "InvalidStateError": userMessage = t`This passkey is already registered on this device.`; break; case "NotSupportedError": userMessage = t`Your device doesn't support the required security features.`; break; case "SecurityError": userMessage = t`Security error. Make sure you're on a secure connection.`; break; default: userMessage = t`Authentication error: ${error.message}`; } } setState({ status: "error", message: userMessage }); onError?.(new Error(userMessage)); } }, [ isSupported, optionsEndpoint, verifyEndpoint, additionalData, passkeyName, onSuccess, onError, t, ]); // Not usable (insecure origin vs missing API — browser hides WebAuthn the same way) if (!isSupported) { return (

{t`Passkeys Not Available Here`}

{insecureContext ? ( <> {t`Passkeys require a`}{" "} {t`secure context`} {t`: use`} HTTPS {t`, or open the admin at`}{" "} http://localhost{" "} {t`(with your dev port).`} {t`Plain`} http://{" "} {t`on a custom hostname is not treated as secure, even on loopback.`} ) : ( <> {t`Your browser doesn't support passkeys. Please use a modern browser like Chrome, Safari, Firefox, or Edge.`} )}

); } return (
{/* Passkey name input (optional) */} {showNameInput && (
setPasskeyName(e.target.value)} placeholder={t`e.g., MacBook Pro, iPhone`} disabled={state.status === "loading"} />

{t`Give this passkey a name to help you identify it later.`}

)} {/* Error message */} {state.status === "error" && (
{state.message}
)} {/* Success message */} {state.status === "success" && (
{t`Passkey registered successfully!`}
)} {/* Register button */} {/* Help text */}

{t`You'll be prompted to use your device's biometric authentication, security key, or PIN.`}

); }