23 KiB
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_userresolution 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 <JWT> |
| 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.
# 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.
# 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:
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 whenCASDOOR_ENABLED=true.
| Priority | Method | Source | When Used |
|---|---|---|---|
| 1 | Bearer JWT | Authorization: Bearer <token> |
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):
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 viaAPPNAME_OWNER_NAME.email— The user's email address.casdoor_sub— The Casdoorsubclaim (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.
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:
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:
{
"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.jsononly 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:
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:
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:
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
# 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
# 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()
# 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
# 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). 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)
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
@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
@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
@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.j2with bothredirectUris - Add
redirectUrisin Casdoor admin UI (for existing deployments) - Create
config.pywithpydantic-settingsandAPPNAME_prefix - Create
auth.pywith SDK singleton andget_current_userdependency - Create
routers/auth.pywith/login,/callback,/me,/logout - Create
Usermodel withcasdoor_subcolumn - 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.j2with 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_NAMEto the Casdoor username of the app owner