brewlog/static/js/webauthn.js
Jon Seager 5820c162f6
fix: address static assets code review findings
- Remove dead CSS color property in .tab-mobile
- Fix design token violations (text-red-500, fallback colors)
- Add disconnectedCallback to chip-scroll, image-upload, searchable-select
- Replace innerHTML with safe DOM APIs in world-map screen reader table
- Add credentials: same-origin to WebAuthn fetch calls
- Move page-specific scripts (donut-chart, location) out of base.html
- Add client-side image resizing (1920px max dimension)
- Resize app-icon-512.png from 2048x2048 to 512x512
- Document Datastar unsafe-eval CSP requirement
- Add static asset serving tests (16 routes)
- Add e2e tests for world-map, donut-chart, chip-scroll presence
- Add cache-busting query params to all static asset URLs
2026-02-13 17:13:02 +00:00

205 lines
6.4 KiB
JavaScript

// 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}).`,
);
}
};