Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
/**
|
|
* 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<string, unknown>;
|
|
}
|
|
|
|
const EMPTY_DATA: Record<string, unknown> = {};
|
|
|
|
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<RegistrationState>({
|
|
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<unknown>(
|
|
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 (
|
|
<div className="rounded-lg border border-kumo-danger/50 bg-kumo-danger/10 p-4">
|
|
<h3 className="font-medium text-kumo-danger">{t`Passkeys Not Available Here`}</h3>
|
|
<p className="mt-1 text-sm text-kumo-subtle">
|
|
{insecureContext ? (
|
|
<>
|
|
{t`Passkeys require a`}{" "}
|
|
<strong className="text-kumo-default">{t`secure context`}</strong>
|
|
{t`: use`} <strong className="text-kumo-default">HTTPS</strong>
|
|
{t`, or open the admin at`}{" "}
|
|
<strong className="text-kumo-default">http://localhost</strong>{" "}
|
|
{t`(with your dev port).`}
|
|
{t`Plain`} <code className="text-xs">http://</code>{" "}
|
|
{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.`}
|
|
</>
|
|
)}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Passkey name input (optional) */}
|
|
{showNameInput && (
|
|
<div>
|
|
<Input
|
|
label={t`Passkey Name (optional)`}
|
|
type="text"
|
|
value={passkeyName}
|
|
onChange={(e) => setPasskeyName(e.target.value)}
|
|
placeholder={t`e.g., MacBook Pro, iPhone`}
|
|
disabled={state.status === "loading"}
|
|
/>
|
|
<p className="mt-1 text-xs text-kumo-subtle">
|
|
{t`Give this passkey a name to help you identify it later.`}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Error message */}
|
|
{state.status === "error" && (
|
|
<div className="rounded-lg bg-kumo-danger/10 p-4 text-sm text-kumo-danger">
|
|
{state.message}
|
|
</div>
|
|
)}
|
|
|
|
{/* Success message */}
|
|
{state.status === "success" && (
|
|
<div className="rounded-lg bg-green-500/10 p-4 text-sm text-green-700 dark:text-green-400">
|
|
{t`Passkey registered successfully!`}
|
|
</div>
|
|
)}
|
|
|
|
{/* Register button */}
|
|
<Button
|
|
type="button"
|
|
onClick={handleRegister}
|
|
loading={state.status === "loading"}
|
|
className="w-full justify-center"
|
|
variant="primary"
|
|
>
|
|
{state.status === "loading" ? <>{state.message}</> : resolvedButtonText}
|
|
</Button>
|
|
|
|
{/* Help text */}
|
|
<p className="text-xs text-kumo-subtle text-center">
|
|
{t`You'll be prompted to use your device's biometric authentication, security key, or PIN.`}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|