- 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
205 lines
6.4 KiB
JavaScript
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}).`,
|
|
);
|
|
}
|
|
};
|