feat: add authentication routes with session login, WebAuthn, and credential management

Implement Phase 4 auth routes: password login/logout, WebAuthn
registration and authentication, magic link registration, and
credential management pages with HTMX. Includes session middleware,
Jinja2 templates, vendored HTMX, and last-credential guardrails.

120 tests passing.
This commit is contained in:
Johan Lundberg 2026-02-16 11:39:50 +01:00
parent f7ed2cf54d
commit e15dcc4745
No known key found for this signature in database
GPG key ID: A6C152738D03C7D1
23 changed files with 1440 additions and 2 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,134 @@
:root {
--bg: #fdfdfd;
--fg: #1a1a1a;
--accent: #2563eb;
--accent-fg: #fff;
--border: #d1d5db;
--error-bg: #fef2f2;
--error-fg: #991b1b;
--success-bg: #f0fdf4;
--success-fg: #166534;
--radius: 0.375rem;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111;
--fg: #e5e5e5;
--accent: #60a5fa;
--accent-fg: #111;
--border: #404040;
--error-bg: #450a0a;
--error-fg: #fca5a5;
--success-bg: #052e16;
--success-fg: #86efac;
}
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--fg);
max-width: 40rem;
margin: 2rem auto;
padding: 0 1rem;
line-height: 1.6;
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
background: var(--accent);
color: var(--accent-fg);
padding: 0.5rem 1rem;
z-index: 100;
}
.skip-link:focus {
left: 0;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
label {
display: block;
margin-bottom: 0.25rem;
font-weight: 500;
}
input[type="text"],
input[type="password"],
input[type="email"] {
display: block;
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--fg);
font-size: 1rem;
margin-bottom: 1rem;
}
button {
padding: 0.5rem 1rem;
border: none;
border-radius: var(--radius);
background: var(--accent);
color: var(--accent-fg);
font-size: 1rem;
cursor: pointer;
}
button:hover {
opacity: 0.9;
}
[role="alert"] {
background: var(--error-bg);
color: var(--error-fg);
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
[role="status"] {
background: var(--success-bg);
color: var(--success-fg);
padding: 0.75rem 1rem;
border-radius: var(--radius);
margin-bottom: 1rem;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}

View file

@ -0,0 +1,149 @@
// WebAuthn helper functions for registration and authentication
function base64urlToBytes(s) {
s = s.replace(/-/g, '+').replace(/_/g, '/');
while (s.length % 4) s += '=';
const raw = atob(s);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
return bytes;
}
function bytesToBase64url(bytes) {
const raw = String.fromCharCode(...new Uint8Array(bytes));
return btoa(raw).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
async function beginRegistration() {
const statusEl = document.getElementById('webauthn-status');
try {
// Step 1: Get options from server
const beginRes = await fetch('/manage/credentials/webauthn/begin', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
});
if (!beginRes.ok) {
if (statusEl) statusEl.innerHTML = '<div role="alert">Failed to start registration</div>';
return;
}
const options = await beginRes.json();
// Step 2: Convert base64url fields to ArrayBuffers for WebAuthn API
const publicKey = options.publicKey;
publicKey.challenge = base64urlToBytes(publicKey.challenge);
publicKey.user.id = base64urlToBytes(publicKey.user.id);
if (publicKey.excludeCredentials) {
publicKey.excludeCredentials = publicKey.excludeCredentials.map(function (c) {
return { ...c, id: base64urlToBytes(c.id) };
});
}
// Step 3: Call browser WebAuthn API
const credential = await navigator.credentials.create({ publicKey: publicKey });
// Step 4: Encode response for server
const attestationResponse = credential.response;
const body = {
id: bytesToBase64url(credential.rawId),
rawId: bytesToBase64url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bytesToBase64url(attestationResponse.clientDataJSON),
attestationObject: bytesToBase64url(attestationResponse.attestationObject),
},
};
// Step 5: Send to server
const completeRes = await fetch('/manage/credentials/webauthn/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (completeRes.ok) {
// Reload to show updated credential list
window.location.reload();
} else {
const text = await completeRes.text();
if (statusEl) statusEl.innerHTML = text;
}
} catch (err) {
if (statusEl) statusEl.innerHTML = '<div role="alert">Registration failed: ' + err.message + '</div>';
}
}
async function beginAuthentication(username) {
const statusEl = document.getElementById('webauthn-login-status');
const form = new FormData();
form.append('username', username);
try {
// Step 1: Get options from server
const beginRes = await fetch('/login/webauthn/begin', {
method: 'POST',
body: form,
});
if (!beginRes.ok) {
const text = await beginRes.text();
if (statusEl) statusEl.innerHTML = text;
return;
}
const options = await beginRes.json();
// Step 2: Convert base64url fields to ArrayBuffers
const publicKey = options.publicKey;
publicKey.challenge = base64urlToBytes(publicKey.challenge);
if (publicKey.allowCredentials) {
publicKey.allowCredentials = publicKey.allowCredentials.map(function (c) {
return { ...c, id: base64urlToBytes(c.id) };
});
}
// Step 3: Call browser WebAuthn API
const assertion = await navigator.credentials.get({ publicKey: publicKey });
// Step 4: Encode response for server
const assertionResponse = assertion.response;
const body = {
id: bytesToBase64url(assertion.rawId),
rawId: bytesToBase64url(assertion.rawId),
type: assertion.type,
response: {
clientDataJSON: bytesToBase64url(assertionResponse.clientDataJSON),
authenticatorData: bytesToBase64url(assertionResponse.authenticatorData),
signature: bytesToBase64url(assertionResponse.signature),
userHandle: assertionResponse.userHandle ? bytesToBase64url(assertionResponse.userHandle) : null,
},
};
// Step 5: Send to server
const completeRes = await fetch('/login/webauthn/complete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (completeRes.ok) {
const data = await completeRes.json();
if (data.redirect) {
window.location.href = data.redirect;
} else {
window.location.href = '/manage/credentials';
}
} else {
const text = await completeRes.text();
if (statusEl) statusEl.innerHTML = text;
}
} catch (err) {
if (statusEl) statusEl.innerHTML = '<div role="alert">Authentication failed: ' + err.message + '</div>';
}
}
// Wire up the registration button
document.addEventListener('DOMContentLoaded', function () {
const registerBtn = document.getElementById('webauthn-register-btn');
if (registerBtn) {
registerBtn.addEventListener('click', beginRegistration);
}
});