brewlog/static/js/webauthn.js
Jon Seager 4e917cee75
refactor: move static assets out of templates/ into static/
Move CSS, JS, favicon files to static/ directory to separate
compiled-in assets from Askama templates. Update all include paths,
build.rs, flake.nix, and Tailwind @source directives.
2026-02-05 17:47:32 +00:00

183 lines
6.2 KiB
JavaScript

// Base64url encoding/decoding helpers for WebAuthn
function 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;
}
function 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
function 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(function (cred) {
return Object.assign({}, cred, { id: base64urlToBuffer(cred.id) });
});
}
return options;
}
function prepareRequestOptions(options) {
const publicKey = options.publicKey;
publicKey.challenge = base64urlToBuffer(publicKey.challenge);
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map(function (cred) {
return Object.assign({}, cred, { id: base64urlToBuffer(cred.id) });
});
}
return options;
}
// Serialize credential for sending back to server
function 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),
},
};
}
function 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
async function startPasskeyRegistration(token, displayName, passkeyName) {
// 1. Get challenge from server
const startResponse = await fetch("/api/v1/webauthn/register/start", {
method: "POST",
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",
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
async function startPasskeyAuthentication(queryParams) {
// 1. Get challenge from server
const url = "/api/v1/webauthn/auth/start" + (queryParams || "");
const startResponse = await fetch(url);
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",
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
async function addPasskey(name) {
// 1. Get challenge from server
const startResponse = await fetch("/api/v1/webauthn/passkey/start", {
method: "POST",
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",
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 + ").");
}
}