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:
parent
f7ed2cf54d
commit
e15dcc4745
23 changed files with 1440 additions and 2 deletions
1
src/fastapi_oidc_op/static/htmx.min.js
vendored
Normal file
1
src/fastapi_oidc_op/static/htmx.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
134
src/fastapi_oidc_op/static/style.css
Normal file
134
src/fastapi_oidc_op/static/style.css
Normal 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;
|
||||
}
|
||||
}
|
||||
149
src/fastapi_oidc_op/static/webauthn.js
Normal file
149
src/fastapi_oidc_op/static/webauthn.js
Normal 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);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue