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.
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 |
|
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 aUser(orNone), 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_querywraps it to accept the token from theAuthorizationheader or a?token=query param. Every surface funnels through these — REST, WebSocket, MCP. -
Owner gating is
is_owner(user)+get_current_owner.is_ownermatchesuser.name == OWNER_NAME(SSO) or the dev-owner sub (dev mode). REST routers carrydependencies=[Depends(get_current_owner)](aliased_authin main.py) → 401 if unauthenticated, 403 if not owner. New protected routers get the same dependency./auth/meis the one exception: it resolves the user without the owner gate so a signed-in non-owner seesis_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 asession_scope, resolves the bearer (header or?token=), and closes with 4401 unless the caller is the owner. Keep the check beforewebsocket.accept(). -
MCP is gated by the ASGI
_owner_only_mcpwrapper in main.py, not by FastMCP auth.create_mcp_serverbuildsFastMCP(auth=None); the wrapper reads the ASGI scope'sAuthorizationheader, resolves it via the sameresolve_from_header_or_query+is_owner, and short-circuits non-owner requests with 401/403 (plus an RFC 9728WWW-Authenticateheader pointing at/.well-known/oauth-protected-resource/mcp). This is why PATs and JWTs both work on/mcpwith one code path. The nestedmcp_http_app.lifespanstill runs — the wrapper is pure middleware around the inner app. -
Dev mode is the loopback bypass, not a token.
CASDOOR_ENABLED=falsemakes every request resolve to the dev owner — permitted only on a loopback bind._check_startup_configin main.py exits if SSO is disabled andHOSTis off-loopback (the network would see a dev-owner-open gateway), and exits if SSO is enabled butCASDOOR_ENDPOINT/CLIENT_ID/CLIENT_SECRET/OWNER_NAMEis missing. Never weaken these to "warn and continue" — a startup misconfiguration must stop the service. -
Never log a token.
client_secretis aSecretStr; 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.