Replace username/password authentication with FIDO2/WebAuthn passkey-based auth using webauthn-rs. Sessions and bearer tokens are unchanged — only the way they are created changes. - Add webauthn-rs, uuid, open, url deps; remove argon2, rpassword - Add passkey_credentials and registration_tokens tables (migrations 17-18) - Add domain entities, typed IDs, and repository traits for passkeys/tokens - Add SQL repository implementations for passkeys and registration tokens - Add ChallengeStore for in-memory WebAuthn ceremony state - Add WebAuthn route handlers (register/auth start+finish ceremonies) - Add CLI browser handoff for token creation (opens browser, local callback) - Replace login form with "Sign in with Passkey" button - Add registration page for first-user bootstrap via one-time token - Replace BREWLOG_ADMIN_USERNAME/PASSWORD with BREWLOG_RP_ID/RP_ORIGIN - Change default BREWLOG_URL from 127.0.0.1 to localhost (WebAuthn requires it)
147 lines
5 KiB
JavaScript
147 lines
5 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) {
|
|
// 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 }),
|
|
});
|
|
|
|
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,
|
|
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();
|
|
}
|