// Base64url encoding/decoding helpers for WebAuthn const base64urlToBuffer = (base64url) => { 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; }; const bufferToBase64url = (buffer) => { const bytes = new Uint8Array(buffer); let binary = ""; for (let i = 0; i < bytes.length; i++) { binary += String.fromCharCode(bytes[i]); } return btoa(binary) .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); }; // Convert server challenge options to format navigator.credentials expects const prepareCreationOptions = (options) => { const publicKey = options.publicKey; publicKey.challenge = base64urlToBuffer(publicKey.challenge); publicKey.user.id = base64urlToBuffer(publicKey.user.id); if (publicKey.excludeCredentials) { publicKey.excludeCredentials = publicKey.excludeCredentials.map((cred) => Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }), ); } return options; }; const prepareRequestOptions = (options) => { const publicKey = options.publicKey; publicKey.challenge = base64urlToBuffer(publicKey.challenge); if (publicKey.allowCredentials) { publicKey.allowCredentials = publicKey.allowCredentials.map((cred) => Object.assign({}, cred, { id: base64urlToBuffer(cred.id) }), ); } return options; }; // Serialize credential for sending back to server const serializeRegistrationCredential = (credential) => { const response = credential.response; return { id: credential.id, rawId: bufferToBase64url(credential.rawId), type: credential.type, response: { attestationObject: bufferToBase64url(response.attestationObject), clientDataJSON: bufferToBase64url(response.clientDataJSON), }, }; }; const serializeAuthenticationCredential = (credential) => { const response = credential.response; return { id: credential.id, rawId: bufferToBase64url(credential.rawId), type: credential.type, response: { authenticatorData: bufferToBase64url(response.authenticatorData), clientDataJSON: bufferToBase64url(response.clientDataJSON), signature: bufferToBase64url(response.signature), userHandle: response.userHandle ? bufferToBase64url(response.userHandle) : null, }, }; }; // Start passkey registration ceremony const startPasskeyRegistration = async (token, displayName, passkeyName) => { // 1. Get challenge from server const startResponse = await fetch("/api/v1/webauthn/register/start", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token, display_name: displayName, passkey_name: passkeyName, }), }); if (!startResponse.ok) { const status = startResponse.status; if (status === 401) throw new Error("Invalid registration token."); if (status === 410) throw new Error("Registration token has expired or already been used."); throw new Error(`Failed to start registration (HTTP ${status}).`); } const { challenge_id, options } = await startResponse.json(); // 2. Create credential via browser WebAuthn API const creationOptions = prepareCreationOptions(options); const credential = await navigator.credentials.create(creationOptions); // 3. Send credential to server const finishResponse = await fetch("/api/v1/webauthn/register/finish", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge_id, passkey_name: passkeyName, credential: serializeRegistrationCredential(credential), }), }); if (!finishResponse.ok) { throw new Error( `Failed to complete registration (HTTP ${finishResponse.status}).`, ); } return finishResponse.json(); }; // Start passkey authentication ceremony const startPasskeyAuthentication = async (queryParams) => { // 1. Get challenge from server const url = `/api/v1/webauthn/auth/start${queryParams || ""}`; const startResponse = await fetch(url, { credentials: "same-origin" }); if (!startResponse.ok) { const status = startResponse.status; if (status === 404) throw new Error("No passkeys registered. Please register first."); throw new Error(`Failed to start authentication (HTTP ${status}).`); } const { challenge_id, options } = await startResponse.json(); // 2. Get assertion via browser WebAuthn API const requestOptions = prepareRequestOptions(options); const credential = await navigator.credentials.get(requestOptions); // 3. Send assertion to server const finishResponse = await fetch("/api/v1/webauthn/auth/finish", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge_id, credential: serializeAuthenticationCredential(credential), }), }); if (!finishResponse.ok) { throw new Error(`Authentication failed (HTTP ${finishResponse.status}).`); } return finishResponse.json(); }; // Add a passkey to an existing authenticated account const addPasskey = async (name) => { // 1. Get challenge from server const startResponse = await fetch("/api/v1/webauthn/passkey/start", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name }), }); if (!startResponse.ok) { throw new Error( `Failed to start passkey registration (HTTP ${startResponse.status}).`, ); } const { challenge_id, options } = await startResponse.json(); // 2. Create credential via browser WebAuthn API const creationOptions = prepareCreationOptions(options); const credential = await navigator.credentials.create(creationOptions); // 3. Send credential to server const finishResponse = await fetch("/api/v1/webauthn/passkey/finish", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ challenge_id, name, credential: serializeRegistrationCredential(credential), }), }); if (!finishResponse.ok) { throw new Error( `Failed to complete passkey registration (HTTP ${finishResponse.status}).`, ); } };