Files
hold-slayer/.claude/rules/auth-surfaces.md
Robert Helewka 4a3c14d4af
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m53s
docs: add Claude AI assistant rules and configuration
Add comprehensive rule documentation for AI-assisted development covering
authentication surfaces, outbound-call safety invariants, and other project
conventions to guide Claude's understanding of critical system behaviors.
2026-07-28 19:01:38 -04:00

3.8 KiB

description, paths
description paths
Casdoor SSO for the browser + owner-minted PATs for MCP/CLI; owner-only on every surface; one resolver; ?token= fallback; dev-owner on loopback
auth.py
api/auth.py
api/tokens.py
api/deps.py
api/websocket.py
mcp_server/server.py
main.py

Authentication across the four surfaces

Auth is Casdoor SSO for the browser + owner-minted Personal Access Tokens for MCP/CLI, and the gateway is owner-only: exactly one operator (the Casdoor user whose name matches OWNER_NAME) may use any surface; every other identity gets 403. There is one resolver behind all of it — don't add a second auth mechanism, a per-surface token, or a bypass.

  • resolve_bearer(session, raw_token) in auth.py is the single resolver. It turns a bearer string into a User (or None), classifying it as a PAT (hs_pat_ prefix → hash lookup) or a Casdoor JWT (RS256, validated against the endpoint's JWKS). resolve_from_header_or_query wraps it to accept the token from the Authorization header or a ?token= query param. Every surface funnels through these — REST, WebSocket, MCP.

  • Owner gating is is_owner(user) + get_current_owner. is_owner matches user.name == OWNER_NAME (SSO) or the dev-owner sub (dev mode). REST routers carry dependencies=[Depends(get_current_owner)] (aliased _auth in main.py) → 401 if unauthenticated, 403 if not owner. New protected routers get the same dependency. /auth/me is the one exception: it resolves the user without the owner gate so a signed-in non-owner sees is_owner:false (the dashboard's "not authorized" screen) instead of a bare 401.

  • The ?token= query-param fallback is intentional and narrow. Browsers can't set headers on a WebSocket connect or on an <audio src>/<a href> recording download, so the current token (Casdoor JWT, or a PAT) rides as ?token=. It's validated by the same resolver as the header. Don't widen it or remove it without accounting for those two consumers.

  • WebSocket checks ownership itself (api/websocket.py _authorize) rather than via a router dependency, because WS handshakes don't run FastAPI dependencies the same way. It opens a session_scope, resolves the bearer (header or ?token=), and closes with 4401 unless the caller is the owner. Keep the check before websocket.accept().

  • MCP is gated by the ASGI _owner_only_mcp wrapper in main.py, not by FastMCP auth. create_mcp_server builds FastMCP(auth=None); the wrapper reads the ASGI scope's Authorization header, resolves it via the same resolve_from_header_or_query + is_owner, and short-circuits non-owner requests with 401/403 (plus an RFC 9728 WWW-Authenticate header pointing at /.well-known/oauth-protected-resource/mcp). This is why PATs and JWTs both work on /mcp with one code path. The nested mcp_http_app.lifespan still runs — the wrapper is pure middleware around the inner app.

  • Dev mode is the loopback bypass, not a token. CASDOOR_ENABLED=false makes every request resolve to the dev owner — permitted only on a loopback bind. _check_startup_config in main.py exits if SSO is disabled and HOST is off-loopback (the network would see a dev-owner-open gateway), and exits if SSO is enabled but CASDOOR_ENDPOINT/CLIENT_ID/CLIENT_SECRET/ OWNER_NAME is missing. Never weaken these to "warn and continue" — a startup misconfiguration must stop the service.

  • Never log a token. client_secret is a SecretStr; read it via .get_secret_value() only where you hand it to the Casdoor SDK. PAT plaintext is shown once at creation and only its SHA-256 hash is stored — never log the plaintext, a JWT, or a hash.