--- 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 `