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

View file

@ -1,11 +1,21 @@
import secrets
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from pathlib import Path
from urllib.parse import urlparse
import aiosqlite
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from fastapi_oidc_op.authn.password import PasswordService
from fastapi_oidc_op.authn.routes import router as authn_router
from fastapi_oidc_op.authn.webauthn import WebAuthnService
from fastapi_oidc_op.config import Settings, StorageBackend
from fastapi_oidc_op.invite.service import MagicLinkService
from fastapi_oidc_op.manage.routes import router as manage_router
from fastapi_oidc_op.store.sqlite.migrations import run_migrations
from fastapi_oidc_op.store.sqlite.repositories import (
SQLiteCredentialRepository,
@ -13,7 +23,8 @@ from fastapi_oidc_op.store.sqlite.repositories import (
SQLiteUserRepository,
)
MIGRATIONS_DIR = Path(__file__).parent / "store" / "sqlite" / "migrations"
PACKAGE_DIR = Path(__file__).parent
MIGRATIONS_DIR = PACKAGE_DIR / "store" / "sqlite" / "migrations"
@asynccontextmanager
@ -30,6 +41,22 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
app.state.user_repo = SQLiteUserRepository(db)
app.state.credential_repo = SQLiteCredentialRepository(db)
app.state.magic_link_repo = SQLiteMagicLinkRepository(db)
# Auth services
app.state.password_service = PasswordService()
rp_id = urlparse(settings.issuer).hostname or "localhost"
app.state.webauthn_service = WebAuthnService(
rp_id=rp_id,
rp_name=app.title,
origin=settings.issuer,
)
app.state.magic_link_service = MagicLinkService(
repo=app.state.magic_link_repo,
ttl=settings.invite_ttl,
)
yield
await db.close()
else:
@ -50,6 +77,20 @@ def create_app(settings: Settings | None = None) -> FastAPI:
app.state.settings = settings
# Session middleware
session_secret = settings.session_secret or secrets.token_hex(32)
app.add_middleware(SessionMiddleware, secret_key=session_secret) # type: ignore[arg-type]
# Templates
app.state.templates = Jinja2Templates(directory=str(PACKAGE_DIR / "templates"))
# Static files
app.mount("/static", StaticFiles(directory=str(PACKAGE_DIR / "static")), name="static")
# Routers
app.include_router(authn_router)
app.include_router(manage_router)
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "ok"}

View file

@ -0,0 +1,154 @@
from fastapi import APIRouter, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fido2.webauthn import (
AttestedCredentialData,
AuthenticationResponse,
PublicKeyCredentialDescriptor,
PublicKeyCredentialType,
)
from fastapi_oidc_op.models import User
from fastapi_oidc_op.userid import generate_unique_userid
router = APIRouter(tags=["authn"])
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request) -> HTMLResponse:
templates = request.app.state.templates
return templates.TemplateResponse(request, "login.html")
@router.post("/login/password", response_class=HTMLResponse)
async def login_password(
request: Request,
username: str = Form(),
password: str = Form(),
) -> Response:
user_repo = request.app.state.user_repo
cred_repo = request.app.state.credential_repo
password_service = request.app.state.password_service
error_html = '<div role="alert">Invalid username or password</div>'
user = await user_repo.get_by_username(username)
if user is None:
return HTMLResponse(error_html)
credential = await cred_repo.get_password_by_user(user.userid)
if credential is None:
return HTMLResponse(error_html)
if not password_service.verify(credential.password_hash, password):
return HTMLResponse(error_html)
request.session["userid"] = user.userid
request.session["username"] = user.username
response = Response()
response.headers["HX-Redirect"] = "/manage/credentials"
return response
@router.post("/logout")
async def logout(request: Request) -> Response:
request.session.clear()
response = Response()
response.headers["HX-Redirect"] = "/login"
return response
@router.get("/register/{token}")
async def register_magic_link(request: Request, token: str) -> Response:
magic_link_service = request.app.state.magic_link_service
user_repo = request.app.state.user_repo
link = await magic_link_service.validate(token)
if link is None:
return HTMLResponse("<p>Invalid or expired registration link.</p>", status_code=400)
userid = await generate_unique_userid(user_repo)
user = User(userid=userid, username=link.username, groups=["users"])
await user_repo.create(user)
await magic_link_service.mark_used(token)
request.session["userid"] = user.userid
request.session["username"] = user.username
return RedirectResponse("/manage/credentials?setup=1", status_code=303)
@router.post("/login/webauthn/begin")
async def login_webauthn_begin(
request: Request,
username: str = Form(),
) -> Response:
user_repo = request.app.state.user_repo
cred_repo = request.app.state.credential_repo
webauthn_service = request.app.state.webauthn_service
error_html = '<div role="alert">Invalid username or password</div>'
user = await user_repo.get_by_username(username)
if user is None:
return HTMLResponse(error_html)
webauthn_creds = await cred_repo.get_webauthn_by_user(user.userid)
if not webauthn_creds:
return HTMLResponse(error_html)
descriptors = [
PublicKeyCredentialDescriptor(type=PublicKeyCredentialType.PUBLIC_KEY, id=cred.credential_id)
for cred in webauthn_creds
]
options, state = webauthn_service.begin_authentication(credentials=descriptors)
request.session["webauthn_login_state"] = state
request.session["webauthn_login_userid"] = user.userid
return JSONResponse(options)
@router.post("/login/webauthn/complete")
async def login_webauthn_complete(request: Request) -> Response:
webauthn_service = request.app.state.webauthn_service
user_repo = request.app.state.user_repo
cred_repo = request.app.state.credential_repo
state = request.session.pop("webauthn_login_state", None)
userid = request.session.pop("webauthn_login_userid", None)
if state is None or userid is None:
return HTMLResponse('<div role="alert">Authentication session expired</div>', status_code=400)
webauthn_creds = await cred_repo.get_webauthn_by_user(userid)
credentials = [AttestedCredentialData(cred.public_key) for cred in webauthn_creds]
body = await request.json()
try:
webauthn_service.complete_authentication(state, credentials, body)
except Exception:
return HTMLResponse('<div role="alert">Authentication failed</div>')
# Extract sign count from the response and update
auth_response = AuthenticationResponse.from_dict(body)
new_counter = auth_response.response.authenticator_data.counter
matched_credential_id = auth_response.raw_id
stored = await cred_repo.get_webauthn_by_credential_id(matched_credential_id)
if stored is not None:
stored.sign_count = new_counter
await cred_repo.update_webauthn(stored)
user = await user_repo.get_by_userid(userid)
if user is None:
return HTMLResponse('<div role="alert">User not found</div>', status_code=400)
request.session["userid"] = user.userid
request.session["username"] = user.username
response = Response()
response.headers["HX-Redirect"] = "/manage/credentials"
return response

View file

@ -29,6 +29,9 @@ class Settings(BaseSettings):
# Management RP
manage_client_id: str = "manage-app"
# Session
session_secret: str | None = None # If None, a random secret is generated per process
# Magic links
invite_ttl: int = 86400 # seconds

View file

@ -1,4 +1,4 @@
from fastapi import Request
from fastapi import HTTPException, Request
from fastapi_oidc_op.store.protocols import (
CredentialRepository,
@ -17,3 +17,24 @@ def get_credential_repo(request: Request) -> CredentialRepository:
def get_magic_link_repo(request: Request) -> MagicLinkRepository:
return request.app.state.magic_link_repo
def get_session_user(request: Request) -> tuple[str, str] | None:
"""Extract (userid, username) from session, or None if not logged in."""
userid = request.session.get("userid")
username = request.session.get("username")
if userid and username:
return (userid, username)
return None
def require_session_user(request: Request) -> tuple[str, str]:
"""Like get_session_user but raises HTTPException(401) if not logged in.
Routes that need a redirect-to-login behavior should catch this or
use get_session_user and redirect manually.
"""
result = get_session_user(request)
if result is None:
raise HTTPException(status_code=401, detail="Not authenticated")
return result

View file

@ -0,0 +1,166 @@
from base64 import urlsafe_b64decode
from fastapi import APIRouter, Form, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
from fido2.webauthn import PublicKeyCredentialDescriptor, PublicKeyCredentialType
from fastapi_oidc_op.dependencies import get_session_user
from fastapi_oidc_op.models import PasswordCredential, WebAuthnCredential
router = APIRouter(prefix="/manage", tags=["manage"])
async def _count_credentials(cred_repo: object, userid: str) -> int:
"""Count total credentials (password + webauthn) for a user."""
webauthn = await cred_repo.get_webauthn_by_user(userid) # type: ignore[union-attr]
password = await cred_repo.get_password_by_user(userid) # type: ignore[union-attr]
return len(webauthn) + (1 if password else 0)
@router.get("/credentials", response_class=HTMLResponse)
async def credentials_page(request: Request) -> Response:
session_user = get_session_user(request)
if session_user is None:
return RedirectResponse("/login", status_code=303)
userid, username = session_user
cred_repo = request.app.state.credential_repo
webauthn_credentials = await cred_repo.get_webauthn_by_user(userid)
password_credential = await cred_repo.get_password_by_user(userid)
setup = request.query_params.get("setup")
templates = request.app.state.templates
return templates.TemplateResponse(
request,
"manage/credentials.html",
{
"username": username,
"webauthn_credentials": webauthn_credentials,
"has_password": password_credential is not None,
"setup": setup,
},
)
@router.post("/credentials/password", response_class=HTMLResponse)
async def set_password(
request: Request,
password: str = Form(),
confirm: str = Form(),
) -> Response:
session_user = get_session_user(request)
if session_user is None:
return RedirectResponse("/login", status_code=303)
userid, _username = session_user
cred_repo = request.app.state.credential_repo
password_service = request.app.state.password_service
if password != confirm:
return HTMLResponse('<div role="alert">Passwords do not match</div>')
if len(password) < 8:
return HTMLResponse('<div role="alert">Password must be at least 8 characters</div>')
password_hash = password_service.hash(password)
existing = await cred_repo.get_password_by_user(userid)
if existing is not None:
await cred_repo.delete_password(userid)
await cred_repo.create_password(PasswordCredential(user_id=userid, password_hash=password_hash))
return HTMLResponse('<div role="status">Password updated successfully</div>')
@router.delete("/credentials/password", response_class=HTMLResponse)
async def delete_password(request: Request) -> Response:
session_user = get_session_user(request)
if session_user is None:
return RedirectResponse("/login", status_code=303)
userid, _username = session_user
cred_repo = request.app.state.credential_repo
count = await _count_credentials(cred_repo, userid)
if count <= 1:
return HTMLResponse('<div role="alert">Cannot remove your last credential</div>')
await cred_repo.delete_password(userid)
return HTMLResponse('<div role="status">Password removed</div>')
@router.post("/credentials/webauthn/begin")
async def webauthn_begin(request: Request) -> Response:
session_user = get_session_user(request)
if session_user is None:
return RedirectResponse("/login", status_code=303)
userid, username = session_user
cred_repo = request.app.state.credential_repo
webauthn_service = request.app.state.webauthn_service
# Build exclude list from existing credentials
existing = await cred_repo.get_webauthn_by_user(userid)
descriptors = [
PublicKeyCredentialDescriptor(type=PublicKeyCredentialType.PUBLIC_KEY, id=cred.credential_id)
for cred in existing
]
options, state = webauthn_service.begin_registration(
user_id=userid.encode(),
username=username,
existing_credentials=descriptors,
)
request.session["webauthn_register_state"] = state
return JSONResponse(options)
@router.post("/credentials/webauthn/complete")
async def webauthn_complete(request: Request) -> Response:
session_user = get_session_user(request)
if session_user is None:
return RedirectResponse("/login", status_code=303)
userid, _username = session_user
cred_repo = request.app.state.credential_repo
webauthn_service = request.app.state.webauthn_service
state = request.session.pop("webauthn_register_state", None)
if state is None:
return HTMLResponse('<div role="alert">Registration session expired</div>', status_code=400)
body = await request.json()
result = webauthn_service.complete_registration(state, body)
cred = WebAuthnCredential(
user_id=userid,
credential_id=result.credential_data.credential_id,
public_key=bytes(result.credential_data),
)
await cred_repo.create_webauthn(cred)
return HTMLResponse('<div role="status">Security key added</div>')
@router.delete("/credentials/webauthn/{credential_id_b64}")
async def delete_webauthn(request: Request, credential_id_b64: str) -> Response:
session_user = get_session_user(request)
if session_user is None:
return RedirectResponse("/login", status_code=303)
userid, _username = session_user
cred_repo = request.app.state.credential_repo
# Decode base64url credential_id (add padding if needed)
padded = credential_id_b64 + "=" * (-len(credential_id_b64) % 4)
credential_id = urlsafe_b64decode(padded)
count = await _count_credentials(cred_repo, userid)
if count <= 1:
return HTMLResponse('<div role="alert">Cannot remove your last credential</div>')
await cred_repo.delete_webauthn(userid, credential_id)
return HTMLResponse('<div role="status">Security key removed</div>')

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);
}
});

View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}FastAPI OIDC OP{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<main id="main" tabindex="-1">
{% block content %}{% endblock %}
</main>
<div aria-live="polite" aria-atomic="true" class="sr-only" id="live"></div>
<script src="/static/htmx.min.js" defer></script>
{% block scripts %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Login — FastAPI OIDC OP{% endblock %}
{% block content %}
<h1>Sign in</h1>
<div id="login-error"></div>
<section>
<h2>Password</h2>
<form hx-post="/login/password" hx-target="#login-error" hx-swap="innerHTML">
<div>
<label for="username">Username</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<button type="submit">Sign in</button>
</form>
</section>
<section>
<h2>Security key</h2>
<form id="webauthn-login-form">
<div>
<label for="webauthn-username">Username</label>
<input type="text" id="webauthn-username" name="username" required autocomplete="username">
</div>
<button type="button" id="webauthn-login-btn">Sign in with security key</button>
</form>
</section>
{% endblock %}

View file

@ -0,0 +1,58 @@
{% extends "base.html" %}
{% block title %}Credentials — FastAPI OIDC OP{% endblock %}
{% block content %}
<h1>Credentials</h1>
{% if setup %}
<div role="status">
<p><strong>Welcome, {{ username }}!</strong> Set up your credentials to secure your account.</p>
</div>
{% endif %}
<section>
<h2>Security keys</h2>
<div id="webauthn-list">
{% if webauthn_credentials %}
<ul>
{% for cred in webauthn_credentials %}
<li>
{{ cred.device_name or "Security key" }}
<small>(added {{ cred.created_at.strftime('%Y-%m-%d') }})</small>
</li>
{% endfor %}
</ul>
{% else %}
<p>No security keys registered.</p>
{% endif %}
</div>
<button type="button" id="webauthn-register-btn">Add security key</button>
</section>
<section>
<h2>Password</h2>
<div id="password-section">
{% if has_password %}
<p>Password is set.</p>
{% else %}
<p>No password set.</p>
{% endif %}
<form hx-post="/manage/credentials/password" hx-target="#password-section" hx-swap="innerHTML">
<div>
<label for="password">{{ "New password" if has_password else "Set password" }}</label>
<input type="password" id="password" name="password" required minlength="8" autocomplete="new-password">
</div>
<div>
<label for="confirm">Confirm password</label>
<input type="password" id="confirm" name="confirm" required minlength="8" autocomplete="new-password">
</div>
<button type="submit">{{ "Change password" if has_password else "Set password" }}</button>
</form>
</div>
</section>
{% endblock %}
{% block scripts %}
<script src="/static/webauthn.js" defer></script>
{% endblock %}