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.
70 lines
3.8 KiB
Markdown
70 lines
3.8 KiB
Markdown
---
|
|
description: Casdoor SSO for the browser + owner-minted PATs for MCP/CLI; owner-only on every surface; one resolver; ?token= fallback; dev-owner on loopback
|
|
paths:
|
|
- "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](../../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](../../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](../../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](../../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.
|