Files
nike/docs/PATTERN_Casdoor_FastAPI.md
Robert Helewka 18710515d8
All checks were successful
CVE Scan & Docker Build / security-scan (push) Successful in 45s
CVE Scan & Docker Build / build-and-push (push) Successful in 1m20s
Add CASDOOR pattern
2026-04-15 21:36:21 -04:00

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_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 <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 when CASDOOR_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 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.
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.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:

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