# Casdoor SSO Authentication for FastAPI Pattern v1.0.0 Standardises how FastAPI applications integrate with Casdoor for Single Sign-On authentication, supporting both browser-based OIDC (via OAuth2-Proxy) and programmatic JWT access (via Casdoor Python SDK). ## 🐾 Red Panda Approvalβ„’ This pattern follows Red Panda Approval standards. --- ## Why a Pattern, Not a Shared Library FastAPI applications have varied authentication needs that make a single shared auth library impractical: - A **location manager** (Periplus) needs owner/guest roles with collection-level sharing - A **task manager** (Kairos) needs Django-allauth with OIDC, not a FastAPI SDK - A **CMS** (Angelia) needs Django-native OIDC callbacks - An **MCP server** needs headless JWT-only auth without browser sessions Instead, this pattern defines: - **Required settings** β€” every FastAPI app must configure for Casdoor - **Required auth dependency** β€” the `get_current_user` resolution chain - **Required infrastructure** β€” OAuth2-Proxy sidecar + Casdoor app registration - **Standard callback URL rules** β€” for interoperability - **Anti-patterns** β€” hard-won lessons from production debugging --- ## Architecture FastAPI applications use **dual-layer authentication**: ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ Browser │────▢│ HAProxy │────▢│ OAuth2-Proxy │────▢│ FastAPI App β”‚ β”‚ β”‚ β”‚ (TLS) β”‚ β”‚ (sidecar) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ └────▢│ Casdoor β”‚β—€β”€β”€β”€β”€β”€β”˜ β”‚ (OIDC Provider) β”‚ JWT validation β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` | Layer | Handles | Method | |-------|---------|--------| | **OAuth2-Proxy** (sidecar) | Browser sessions | OIDC cookie β†’ `X-Forwarded-*` headers | | **Casdoor SDK** (in-app) | API/MCP clients | `Authorization: Bearer ` | | **Dev mode** | Local development | No auth required | The FastAPI app is **never directly exposed** β€” it only accepts connections from OAuth2-Proxy within the Docker compose network. --- ## Required Settings Every FastAPI app using Casdoor must define these settings via `pydantic-settings`. Use the app-specific `env_prefix` to namespace variables. ```python # config.py from pydantic_settings import BaseSettings class Settings(BaseSettings): # ── Casdoor SSO ────────────────────────────────────────── # Set APPNAME_CASDOOR_ENABLED=false for local dev (no auth required) casdoor_enabled: bool = False casdoor_endpoint: str = "https://sso.example.com" casdoor_client_id: str = "" casdoor_client_secret: str = "" # PEM-encoded X.509 certificate from Casdoor β†’ Certs β†’ your cert casdoor_certificate: str = "" casdoor_org_name: str = "" casdoor_app_name: str = "" # Owner identity (Casdoor username for the app owner) owner_name: str = "" model_config = {"env_prefix": "APPNAME_", "env_file": ".env"} settings = Settings() ``` ### Environment Variables (Production) | Variable | Description | Example | |----------|-------------|---------| | `APPNAME_CASDOOR_ENABLED` | Enable/disable SSO | `true` | | `APPNAME_CASDOOR_ENDPOINT` | Casdoor public URL | `https://id.d.helu.ca` | | `APPNAME_CASDOOR_CLIENT_ID` | OAuth2 client ID | From OCI Vault | | `APPNAME_CASDOOR_CLIENT_SECRET` | OAuth2 client secret | From OCI Vault | | `APPNAME_CASDOOR_CERTIFICATE` | PEM X.509 cert for JWT validation | From Casdoor admin | | `APPNAME_CASDOOR_ORG_NAME` | Casdoor organization | `heluca` | | `APPNAME_CASDOOR_APP_NAME` | Casdoor application name | `periplus` | | `APPNAME_OWNER_NAME` | Casdoor username for the app owner | `r@helu.ca` | --- ## Casdoor SDK Singleton Initialise the `AsyncCasdoorSDK` once and reuse it across requests. ```python # auth.py from casdoor import AsyncCasdoorSDK _sdk: AsyncCasdoorSDK | None = None def _get_sdk() -> AsyncCasdoorSDK: global _sdk if _sdk is None: if not settings.casdoor_certificate: raise RuntimeError( "APPNAME_CASDOOR_CERTIFICATE is not configured. " "Set it to the PEM certificate from your Casdoor application." ) _sdk = AsyncCasdoorSDK( endpoint=settings.casdoor_endpoint, client_id=settings.casdoor_client_id, client_secret=settings.casdoor_client_secret, certificate=settings.casdoor_certificate.encode() if isinstance(settings.casdoor_certificate, str) else settings.casdoor_certificate, org_name=settings.casdoor_org_name, application_name=settings.casdoor_app_name, ) return _sdk def get_sdk() -> AsyncCasdoorSDK: """FastAPI dependency β€” returns the configured Casdoor SDK.""" return _get_sdk() ``` --- ## Required Auth Dependency: `get_current_user` The core dependency resolves the authenticated user by checking methods in priority order: ```python from typing import Annotated from fastapi import Depends, HTTPException, Request from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer _bearer = HTTPBearer(auto_error=False) async def get_current_user( request: Request, credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer)] = None, session: AsyncSession = Depends(get_session), ) -> User: if not settings.casdoor_enabled: return await _get_or_create_dev_owner(session) # Method 1: Bearer JWT (API/MCP clients) if credentials is not None: token = credentials.credentials try: sdk = _get_sdk() claims = sdk.parse_jwt_token(token) except Exception as exc: raise HTTPException(status_code=401, detail=f"Invalid token: {exc}") from exc sub = claims.get("sub") or claims.get("name") or "" if not sub: raise HTTPException(status_code=401, detail="Token missing subject claim") name = claims.get("displayName") or claims.get("name") or sub email = claims.get("email") or None return await _find_or_create_user(session, sub, name, email) # Method 2: OAuth2-Proxy forwarded headers (browser users) forwarded_email = request.headers.get("X-Forwarded-Email") forwarded_user = request.headers.get("X-Forwarded-User") forwarded_name = request.headers.get("X-Forwarded-Preferred-Username") if forwarded_email or forwarded_user: sub = forwarded_user or forwarded_email or "" name = forwarded_name or forwarded_user or forwarded_email or sub email = forwarded_email or None return await _find_or_create_user(session, sub, name, email) raise HTTPException(status_code=401, detail="Not authenticated") async def _get_or_create_dev_owner(session: AsyncSession) -> User: """Return the dev-mode owner user row, creating it if it doesn't exist.""" result = await session.execute( select(User).where(User.casdoor_sub == _DEV_OWNER_SUB) ) user = result.scalar_one_or_none() if user is None: user = User(name="Owner", casdoor_sub=_DEV_OWNER_SUB) session.add(user) await session.commit() await session.refresh(user) return user async def _find_or_create_user( session: AsyncSession, casdoor_sub: str, name: str, email: str | None, ) -> User: """Look up a user by casdoor_sub; create a new row if first login.""" result = await session.execute( select(User).where(User.casdoor_sub == casdoor_sub) ) user = result.scalar_one_or_none() if user is not None: return user # First-time login β€” provision a new user row user = User(name=name, email=email, casdoor_sub=casdoor_sub) session.add(user) await session.commit() await session.refresh(user) return user # Type alias for use in route signatures CurrentUser = Annotated[User, Depends(get_current_user)] ``` ### Authentication Priority Chain > **Note on code execution order:** Dev mode is checked *first* in `get_current_user` (before JWT and headers) and short-circuits immediately. The table below shows conceptual priority for production use when `CASDOOR_ENABLED=true`. | Priority | Method | Source | When Used | |----------|--------|--------|-----------| | 1 | Bearer JWT | `Authorization: Bearer ` | API clients, MCP | | 2 | OAuth2-Proxy headers | `X-Forwarded-Email`, `X-Forwarded-User` | Browser sessions | | β€” | Dev mode | No auth (short-circuits first) | `CASDOOR_ENABLED=false` | --- ## Auth Router Endpoints Four standard endpoints for the direct OIDC flow (used by API/MCP clients that need tokens): ```python from fastapi import APIRouter, HTTPException, Query, Request from fastapi.responses import JSONResponse, RedirectResponse router = APIRouter(prefix="/auth", tags=["auth"]) @router.get("/login") async def login(request: Request, redirect_uri: str = Query(None)): """Redirect the browser to the Casdoor authorization page.""" if not settings.casdoor_enabled: raise HTTPException(400, "Casdoor SSO is not enabled") sdk = get_sdk() # ⚠️ CRITICAL: derive callback from request.base_url, NOT casdoor_endpoint callback = redirect_uri or f"{request.base_url}auth/callback" auth_url = await sdk.get_auth_link( redirect_uri=callback, response_type="code", scope="openid profile email", ) return RedirectResponse(url=auth_url) @router.get("/callback") async def callback( code: str = Query(...), state: str = Query(None), redirect_uri: str = Query(None), ): """Exchange authorization code for tokens.""" if not settings.casdoor_enabled: raise HTTPException(400, "Casdoor SSO is not enabled") sdk = get_sdk() try: # ⚠️ CRITICAL: method is get_oauth_token(), NOT async_get_oauth_token() token = await sdk.get_oauth_token(code=code) except Exception as exc: raise HTTPException(400, f"Token exchange failed: {exc}") from exc access_token = token.get("access_token", "") return RedirectResponse(url=f"/#token={access_token}") @router.get("/me") async def me(user: CurrentUser): """Return current authenticated user's profile.""" return JSONResponse({ "id": str(user.id), "name": user.name, "email": user.email, "is_owner": is_owner(user), }) @router.get("/logout") async def logout(): """Redirect to Casdoor logout.""" if not settings.casdoor_enabled: return RedirectResponse(url="/") logout_url = ( f"{settings.casdoor_endpoint}/login/oauth/logout" f"?client_id={settings.casdoor_client_id}" f"&post_logout_redirect_uri=/" ) return RedirectResponse(url=logout_url) ``` --- ## User Model The user model stores three identity fields from Casdoor: - **`name`** β€” The Casdoor username (stable, human-readable). Used for **owner matching** via `APPNAME_OWNER_NAME`. - **`email`** β€” The user's email address. - **`casdoor_sub`** β€” The Casdoor `sub` claim (an opaque UUID). Used as the lookup key for `_find_or_create_user()` to de-duplicate logins. **Not used for owner matching** β€” it is arbitrary and may change on Casdoor redeploy. ```python from sqlalchemy import Column, DateTime, String, text from sqlalchemy.dialects.postgresql import UUID class User(Base): __tablename__ = "users" id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) name = Column(String, nullable=False) # ← owner matching key email = Column(String, unique=True) casdoor_sub = Column(String, unique=True, nullable=True) # Casdoor subject claim β€” used to map SSO identity to local user row created_at = Column(DateTime(timezone=True), server_default=text("now()")) updated_at = Column(DateTime(timezone=True), server_default=text("now()")) ``` Users are auto-provisioned on first login via `_find_or_create_user()`. --- ## OAuth2-Proxy Sidecar (Docker Compose) The production `docker-compose.yml.j2` template for the OAuth2-Proxy sidecar: ```yaml services: myapp: image: git.helu.ca/r/myapp:latest expose: - "8000" # Internal only β€” NOT published to host restart: unless-stopped networks: - internal oauth2-proxy: image: quay.io/oauth2-proxy/oauth2-proxy:v7.6.0 environment: OAUTH2_PROXY_PROVIDER: oidc OAUTH2_PROXY_OIDC_ISSUER_URL: "https://id.d.helu.ca" OAUTH2_PROXY_CLIENT_ID: "{{ app_casdoor_client_id }}" OAUTH2_PROXY_CLIENT_SECRET: "{{ app_casdoor_client_secret }}" OAUTH2_PROXY_COOKIE_SECRET: "{{ app_oauth2_cookie_secret }}" OAUTH2_PROXY_COOKIE_SECURE: "true" OAUTH2_PROXY_COOKIE_NAME: _myapp_session OAUTH2_PROXY_HTTP_ADDRESS: 0.0.0.0:4180 # ⚠️ MUST use PLURAL form β€” see Anti-Patterns OAUTH2_PROXY_UPSTREAMS: http://myapp:8000 OAUTH2_PROXY_REDIRECT_URL: "https://myapp.d.helu.ca/oauth2/callback" OAUTH2_PROXY_EMAIL_DOMAINS: "*" OAUTH2_PROXY_SKIP_PROVIDER_BUTTON: "true" OAUTH2_PROXY_SET_XAUTHREQUEST: "true" OAUTH2_PROXY_REVERSE_PROXY: "true" OAUTH2_PROXY_REAL_CLIENT_IP_HEADER: X-Forwarded-For OAUTH2_PROXY_SKIP_AUTH_ROUTES: "^/live/$,^/ready/$" OAUTH2_PROXY_INSECURE_OIDC_ALLOW_UNVERIFIED_EMAIL: "true" ports: - "{{ app_web_port }}:4180" depends_on: - myapp restart: unless-stopped networks: - internal networks: internal: ``` --- ## Casdoor Application Registration Register the application in `ansible/casdoor/init_data.json.j2`: ```json { "owner": "admin", "name": "myapp", "displayName": "My Application", "organization": "heluca", "cert": "cert-heluca", "clientId": "{{ myapp_casdoor_client_id }}", "clientSecret": "{{ myapp_casdoor_client_secret }}", "grantTypes": ["authorization_code", "refresh_token"], "redirectUris": [ "https://myapp.d.helu.ca/oauth2/callback", "https://myapp.d.helu.ca/auth/callback" ], "tokenFormat": "JWT", "expireInHours": 168 } ``` ### Redirect URIs (Both Required) | URI | Purpose | |-----|---------| | `https://myapp.d.helu.ca/oauth2/callback` | OAuth2-Proxy browser flow | | `https://myapp.d.helu.ca/auth/callback` | Direct SDK flow (API/MCP token acquisition) | Both URIs must be registered in Casdoor's `redirectUris` array. Missing either one causes a 404 or Casdoor redirect error. > **Note:** `init_data.json` only applies during **initial Casdoor setup**. For existing deployments, also add the redirect URI via the Casdoor admin UI: Applications β†’ myapp β†’ Redirect URIs. --- ## Uvicorn Proxy Headers The Dockerfile **must** include `--proxy-headers` and `--forwarded-allow-ips=*` so that `request.base_url` resolves to the external URL (e.g., `https://myapp.d.helu.ca/`) rather than the internal Docker URL: ```dockerfile CMD ["sh", "-c", "alembic upgrade head && uvicorn myapp.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=*"] ``` This works because HAProxy sets `X-Forwarded-Proto: https` and `X-Forwarded-Host`, and Uvicorn with `--proxy-headers` honours these. --- ## Database Migrations at Startup Include `alembic upgrade head` in the container entrypoint so the database schema is always current: ```dockerfile CMD ["sh", "-c", "alembic upgrade head && uvicorn myapp.main:app --host 0.0.0.0 --port 8000 --proxy-headers --forwarded-allow-ips=*"] ``` Without this, a fresh database deployment fails with `relation "users" does not exist`. --- ## Role Helpers Standard owner/guest role pattern: ```python def is_owner(user: User) -> bool: """Check if the user is the owner.""" if not settings.casdoor_enabled: return user.casdoor_sub == _DEV_OWNER_SUB return bool(settings.owner_name and user.name == settings.owner_name) async def require_owner(user: CurrentUser) -> User: """Dependency β€” 403 unless the caller is the owner.""" if not is_owner(user): raise HTTPException(status_code=403, detail="Owner access required") return user OwnerUser = Annotated[User, Depends(require_owner)] ``` Use `CurrentUser` for any-authenticated-user routes, `OwnerUser` for owner-only routes. --- ## Anti-Patterns ### ❌ Don't match owner by `casdoor_sub` ```python # WRONG β€” casdoor_sub is an opaque UUID that may change on Casdoor redeploy return user.casdoor_sub == settings.owner_sub # CORRECT β€” match on user.name (the stable Casdoor username) return user.name == settings.owner_name ``` The `casdoor_sub` (OIDC `sub` claim) is an arbitrary UUID assigned by Casdoor. It can change if the Casdoor database is recreated. The `name` field is the Casdoor username β€” stable, human-readable, and what operators naturally configure. ### ❌ Don't use `casdoor_endpoint` for callback URLs ```python # WRONG β€” redirects to Casdoor, which returns 404 callback = f"{settings.casdoor_endpoint}/auth/callback" # CORRECT β€” redirects back to the application itself callback = f"{request.base_url}auth/callback" ``` The callback URL must point to your **application**, not to Casdoor. After the user authenticates on Casdoor, Casdoor redirects the browser to the registered `redirect_uri` with an authorization code. That URI must be a route your app handles. ### ❌ Don't use `async_get_oauth_token()` ```python # WRONG β€” method does not exist on AsyncCasdoorSDK token = await sdk.async_get_oauth_token(code=code, redirect_uri=url) # CORRECT β€” the async SDK uses the same method name as the sync SDK token = await sdk.get_oauth_token(code=code) ``` The Casdoor Python SDK's `AsyncCasdoorSDK` uses `get_oauth_token()`, not a prefixed async variant. The SDK handles the async internally. ### ❌ Don't use singular oauth2-proxy env var names ```python # WRONG β€” silently ignored, causes 404 or crash OAUTH2_PROXY_EMAIL_DOMAIN: "*" # ← singular OAUTH2_PROXY_UPSTREAM: http://app # ← singular # CORRECT β€” must use plural form OAUTH2_PROXY_EMAIL_DOMAINS: "*" # ← plural OAUTH2_PROXY_UPSTREAMS: http://app # ← plural ``` This is a [known oauth2-proxy bug (#253)](https://github.com/oauth2-proxy/oauth2-proxy/issues/253). The singular form is silently ignored, causing `EMAIL_DOMAIN` to crash with "missing email validation" and `UPSTREAM` to return 404 for all requests. ### ❌ Don't skip database migrations in the container entrypoint Without `alembic upgrade head` in the startup command, the first deployment to a fresh database will fail with `relation "users" does not exist`. ### ❌ Don't expose the app container port to the host The FastAPI app should only use `expose` (Docker-internal), never `ports` (host-published). All external traffic must flow through OAuth2-Proxy. ### ❌ Don't hardcode `state` parameter values If you see `state=appname` in the callback URL (instead of a random cryptographic nonce), your application code is likely constructing the authorization URL incorrectly. The `state` parameter should be generated by OAuth2-Proxy or the SDK, never hardcoded. --- ## Terraform Secrets Standard secrets to provision in `terraform/oci/secrets.tf` for each new FastAPI app: | Resource | Secret Name | Description | |----------|-------------|-------------| | `random_id` | `env-appname-casdoor-client-id` | OAuth2 client ID (hex) | | `random_password` | `env-appname-casdoor-client-secret` | OAuth2 client secret | | `random_password` | `env-appname-oauth2-cookie-secret` | Cookie encryption (32 chars) | | `random_password` | `env-appname-db-password` | Database password | | Placeholder | `env-appname-casdoor-certificate` | Updated manually after Casdoor deploy | | Placeholder | `env-appname-owner-name` | Casdoor username of the app owner | --- ## Testing ### Dev Mode (No Auth) ```python import pytest from httpx import AsyncClient @pytest.mark.asyncio async def test_dev_mode_no_auth_required(client: AsyncClient): """When casdoor_enabled=false, requests succeed without authentication.""" response = await client.get("/api/v1/collections") assert response.status_code == 200 ``` ### Bearer JWT Validation ```python @pytest.mark.asyncio async def test_invalid_bearer_returns_401(client: AsyncClient): """Invalid JWT token returns 401.""" response = await client.get( "/api/v1/collections", headers={"Authorization": "Bearer invalid-token"}, ) assert response.status_code == 401 ``` ### OAuth2-Proxy Header Auth ```python @pytest.mark.asyncio async def test_proxy_headers_create_user(client: AsyncClient): """X-Forwarded-Email/User headers auto-provision a user.""" response = await client.get( "/api/v1/collections", headers={ "X-Forwarded-Email": "test@example.com", "X-Forwarded-User": "test-user-sub", }, ) assert response.status_code == 200 ``` ### Callback URL Derivation ```python @pytest.mark.asyncio async def test_login_callback_uses_app_url(client: AsyncClient): """The /auth/login endpoint must redirect with the app's URL, not Casdoor's.""" response = await client.get("/auth/login", follow_redirects=False) assert response.status_code == 307 location = response.headers["location"] # The redirect_uri parameter must contain the APP's domain assert "myapp.d.helu.ca/auth/callback" in location # Must NOT contain the Casdoor domain as callback assert "id.d.helu.ca/auth/callback" not in location ``` --- ## Checklist for New FastAPI + Casdoor Application - [ ] Add Terraform secrets (`client-id`, `client-secret`, `cookie-secret`, `certificate`, `owner-name`, `db-password`) - [ ] Add application entry in `ansible/casdoor/init_data.json.j2` with both `redirectUris` - [ ] Add `redirectUris` in Casdoor admin UI (for existing deployments) - [ ] Create `config.py` with `pydantic-settings` and `APPNAME_` prefix - [ ] Create `auth.py` with SDK singleton and `get_current_user` dependency - [ ] Create `routers/auth.py` with `/login`, `/callback`, `/me`, `/logout` - [ ] Create `User` model with `casdoor_sub` column - [ ] Create Alembic migration for users table - [ ] Configure Dockerfile with `alembic upgrade head &&` in CMD - [ ] Configure `--proxy-headers --forwarded-allow-ips=*` in uvicorn - [ ] Create `docker-compose.yml.j2` with OAuth2-Proxy sidecar (use **plural** env vars) - [ ] Add HAProxy backend in `hippocamp.helu.ca.yml` - [ ] Add Ansible deploy/remove playbooks - [ ] Add OIDC skip-auth routes for health endpoints (`/live/`, `/ready/`) - [ ] Test: dev mode works without auth - [ ] Test: callback URL points to app, not Casdoor - [ ] Update Casdoor certificate secret after first deploy - [ ] Set `APPNAME_OWNER_NAME` to the Casdoor username of the app owner