import { base64UrlToBytes, bytesToArrayBuffer, bytesToBase64Url, utf8ToBytes, } from "../crypto/encoding"; export interface ExtensionCapabilities { available: boolean; values: Record; warnings: string[]; } export interface ExtensionLabConfiguration { credProps?: boolean; appid?: string; appidExclude?: string; prf?: { enabled: boolean; first: string; second?: string; }; largeBlob?: { registrationSupport?: "preferred" | "required"; read?: boolean; write?: string; }; } type ExtensionInputsWithAppIdExclude = AuthenticationExtensionsClientInputs & { appidExclude?: string; }; function extensionBytes(value: string, name: string): ArrayBuffer { const trimmed = value.trim(); if (!trimmed) throw new Error(`${name} must not be empty.`); const bytes = trimmed.startsWith("base64url:") ? base64UrlToBytes(trimmed.slice("base64url:".length)) : utf8ToBytes(trimmed); if (bytes.byteLength === 0 || bytes.byteLength > 1024) throw new Error(`${name} must contain between 1 and 1,024 bytes.`); return bytesToArrayBuffer(bytes); } function appId(value: string, name: string): string { const trimmed = value.trim(); let parsed: URL; try { parsed = new URL(trimmed); } catch { throw new Error(`${name} must be an absolute HTTPS URL.`); } if (parsed.protocol !== "https:" || parsed.username || parsed.password) throw new Error(`${name} must be an absolute HTTPS URL.`); return parsed.href; } export async function detectExtensionCapabilities(): Promise { if (typeof PublicKeyCredential === "undefined") { return { available: false, values: {}, warnings: ["PublicKeyCredential is unavailable in this browser."], }; } const getCapabilities = PublicKeyCredential.getClientCapabilities; if (typeof getCapabilities !== "function") { return { available: false, values: {}, warnings: [ "This browser does not expose getClientCapabilities(); extension support remains unknown until a ceremony is attempted.", ], }; } try { const values = await getCapabilities.call(PublicKeyCredential); return { available: true, values: Object.fromEntries( Object.entries(values).sort(([left], [right]) => left.localeCompare(right), ), ), warnings: [], }; } catch (error) { return { available: false, values: {}, warnings: [ error instanceof Error ? `Capability detection failed: ${error.message}` : "Capability detection failed.", ], }; } } export function registrationExtensions( configuration: ExtensionLabConfiguration, ): AuthenticationExtensionsClientInputs { const extensions: ExtensionInputsWithAppIdExclude = {}; if (configuration.credProps) extensions.credProps = true; if (configuration.appidExclude) extensions.appidExclude = appId(configuration.appidExclude, "appidExclude"); if (configuration.prf?.enabled) { extensions.prf = { eval: { first: extensionBytes(configuration.prf.first, "PRF first input"), ...(configuration.prf.second?.trim() ? { second: extensionBytes( configuration.prf.second, "PRF second input", ), } : {}), }, }; } if (configuration.largeBlob?.registrationSupport) extensions.largeBlob = { support: configuration.largeBlob.registrationSupport, }; return extensions; } export function authenticationExtensions( configuration: ExtensionLabConfiguration, ): AuthenticationExtensionsClientInputs { const extensions: AuthenticationExtensionsClientInputs = {}; if (configuration.appid) extensions.appid = appId(configuration.appid, "appid"); if (configuration.prf?.enabled) { extensions.prf = { eval: { first: extensionBytes(configuration.prf.first, "PRF first input"), ...(configuration.prf.second?.trim() ? { second: extensionBytes( configuration.prf.second, "PRF second input", ), } : {}), }, }; } if (configuration.largeBlob?.read) extensions.largeBlob = { read: true }; if (configuration.largeBlob?.write?.trim()) { if (configuration.largeBlob.read) throw new Error("largeBlob read and write cannot be requested together."); extensions.largeBlob = { write: extensionBytes(configuration.largeBlob.write, "largeBlob value"), }; } return extensions; } export function extensionOutputToJson(value: unknown): unknown { if (value instanceof ArrayBuffer) return { base64url: bytesToBase64Url(new Uint8Array(value)), bytes: value.byteLength, }; if (ArrayBuffer.isView(value)) { const bytes = new Uint8Array( value.buffer, value.byteOffset, value.byteLength, ); return { base64url: bytesToBase64Url(bytes), bytes: bytes.byteLength }; } if (Array.isArray(value)) return value.map(extensionOutputToJson); if (value && typeof value === "object") return Object.fromEntries( Object.entries(value as Record).map(([key, item]) => [ key, extensionOutputToJson(item), ]), ); return value; } export function extensionRequestToJson( configuration: ExtensionLabConfiguration, ): Record { return extensionOutputToJson(configuration) as Record; }