Skip to content

Repository files navigation

thecrag-py-auth

Reusable FastAPI authentication for theCrag's processing-server fleet.

  • theCrag OAuth 1.0a as the identity source (existing user accounts work — no new identity system).
  • RS256-signed JWT cookie scoped to .processing.thecrag.com — single sign-on across all our subdomains, never sent to www.thecrag.com or dev-nicky.thecrag.com.
  • Allowlist gate — only a small set of account IDs allowed in. Shared JSON file mounted into every container.
  • JWKS endpoint — auth service publishes its public key; consumer services verify JWTs with the public key only, no shared secrets.

Designed to drop into any FastAPI service in the processing-server fleet (rasterizer, maps, future admin tools). Operational shape matches thecrag-py-altitude-task/SERVICE_TEMPLATE.md.

What's in the box

Module Purpose
thecrag_auth.require_user FastAPI dependency — reads the cookie, verifies the JWT against the JWKS, checks the allowlist, injects the User.
thecrag_auth.User Dataclass with account_id, display_name, services.
thecrag_auth.AuthConfig Env-driven config (loaded once at service startup).
python -m thecrag_auth.server Runnable FastAPI app for auth.processing.thecrag.com — runs the OAuth dance and mints cookies.

Install (consumer service)

# pyproject.toml
dependencies = [
    "thecrag-py-auth @ git+https://github.com/theCrag/thecrag-py-auth.git@<SHA>",
    # ... your other deps
]

For local dev, override with an editable install of the sibling working copy:

pip install -e ../thecrag-py-auth

Usage (consumer service)

from fastapi import FastAPI, Depends
from thecrag_auth import require_user, User, AuthConfig

AuthConfig.from_env()    # read env vars once at startup

app = FastAPI()

@app.get("/")
async def public_endpoint():
    return {"status": "ok"}

@app.get("/cache/cleanup")
async def manual_cache_cleanup(user: User = Depends(require_user)):
    # user.account_id, user.display_name available
    return {"triggered_by": user.display_name}

Unauthenticated requests get a 307 to AUTH_LOGIN_URL (for browsers) or 401 (for API clients), with WWW-Authenticate pointing at the login URL either way.

Consumer-side environment

Var Value
AUTH_JWKS_URL https://auth.processing.thecrag.com/.well-known/jwks.json
AUTH_COOKIE_NAME crag_session (must match auth service)
AUTH_COOKIE_DOMAIN .processing.thecrag.com
AUTH_LOGIN_URL https://auth.processing.thecrag.com/login
AUTH_ALLOWLIST_PATH /opt/thecrag/auth/allowlist.json
AUTH_SERVICE_NAME rasterizer, maps, etc. — used for per-service allowlist filtering

No secrets on the consumer side. The auth service holds the private key; consumers only need the public JWKS URL.

Auth service deployment

Runs as one container in the central processing-server docker-compose.yml:

services:
  auth:
    image: ghcr.io/thecrag/thecrag-py-auth:latest
    container_name: thecrag-auth
    env_file: ./env/auth.env
    restart: unless-stopped
    volumes:
      - /opt/thecrag/auth/allowlist.json:/opt/thecrag/auth/allowlist.json:ro
      - /opt/thecrag/env/auth-private.pem:/opt/thecrag/env/auth-private.pem:ro

Caddyfile entry:

auth.processing.thecrag.com {
    reverse_proxy auth:8080
}

See docker-compose.local.yml for a working local-dev setup using *.processing.localtest.me + mkcert.

Local development

The whole stack runs on a dev laptop with the same DNS hierarchy and cookie semantics as production. The localtest.me wildcard DNS service resolves any subdomain to 127.0.0.1, so auth.processing.localtest.me and <service>.processing.localtest.me all hit the dev machine without /etc/hosts edits.

One-time setup

# 1. mkcert (locally trusted TLS certs)
choco install mkcert
mkcert -install
mkdir local/certs
mkcert -cert-file local/certs/local-cert.pem `
       -key-file  local/certs/local-key.pem `
       "*.processing.localtest.me"

# 2. Generate the auth service's RSA keypair (mirrors /opt/thecrag/env/auth-private.pem in prod)
openssl genrsa -out local/auth-private.pem 2048

# 3. Auth env + allowlist
cp local/auth.env.example local/auth.env          # then fill in dev-nicky OAuth keys
cp local/allowlist.json.example local/allowlist.json  # add your account_id

Bring up the auth stack

docker compose -f docker-compose.local.yml up --build

Verify:

  • https://auth.processing.localtest.me/healthz200 ok
  • https://protected.processing.localtest.me/protected → triggers the OAuth dance, then returns the stub page

Adding another service (e.g. thecrag-rasterizer) to the local stack

Once auth is running, any FastAPI consumer that uses require_user can join the same SSO. The pattern is the same one thecrag-rasterizer uses.

1. Create the shared docker network (one-time, outside any compose):

docker network create thecrag-processing-local

This network lets containers from separate compose projects reach each other.

2. Attach the auth-stack containers to it — add to docker-compose.local.yml under both caddy and auth-service:

networks:
  - default
  - thecrag-processing-local

…and declare it as external at the bottom of the file:

networks:
  default:
  thecrag-processing-local:
    external: true

3. Add a Caddy block for the new service in local/Caddyfile. Use the consumer container's container_name, not its compose service key — cross-compose docker DNS resolves only container_name:

rasterizer.processing.localtest.me {
    tls /certs/local-cert.pem /certs/local-key.pem
    reverse_proxy thecrag-rasterizer-dev:8000
}

4. In the consumer service's compose (e.g. thecrag-rasterizer/docker-compose.dev.yml):

services:
  rasterizer:
    container_name: thecrag-rasterizer-dev
    # ...your existing config...
    environment:
      - AUTH_JWKS_URL=http://thecrag-auth-local:8080/.well-known/jwks.json
      - AUTH_COOKIE_NAME=crag_session
      - AUTH_COOKIE_DOMAIN=.processing.localtest.me
      - AUTH_LOGIN_URL=https://auth.processing.localtest.me/login
      - AUTH_SERVICE_NAME=rasterizer
    networks:
      - default
      - thecrag-processing-local

networks:
  default:
    driver: bridge
  thecrag-processing-local:
    external: true

Notes on the env vars:

  • AUTH_JWKS_URL uses container_name (thecrag-auth-local) over plain HTTP on the internal network — fast, no TLS-trust hassle inside the container. This is critical: auth-service is the compose service name and won't resolve from a different compose project.
  • AUTH_LOGIN_URL uses the public subdomain because the browser does the redirect, not the container.

5. Wire up the FastAPI side (example in rasterizer's main.py):

from fastapi import Depends, FastAPI
from thecrag_auth import AuthConfig, User, install_redirect_handler, require_user

app = FastAPI(...)
AuthConfig.from_env()
install_redirect_handler(app)

@app.get("/cache/stats")
async def get_cache_stats(user: User = Depends(require_user)):
    ...

6. Allowlist — add your account to local/allowlist.json with the new service in services (or "*"):

{ "account_id": 190453269, "display_name": "Nicky", "services": ["*"] }

AUTH_ALLOWED_REDIRECT_HOSTS in local/auth.env must also include the new subdomain (rasterizer.processing.localtest.me) so the post-login redirect isn't rejected.

7. Restart Caddy + auth-service to pick up the Caddyfile change and the new network attachment, then start the consumer:

docker compose -f docker-compose.local.yml up -d --force-recreate caddy auth-service
docker compose -f ../thecrag-rasterizer/docker-compose.dev.yml up -d

Visit https://rasterizer.processing.localtest.me/cache/stats — first hit redirects to the OAuth dance, then the cookie unlocks all *.processing.localtest.me services (SSO).

Troubleshooting

Symptom Fix
RuntimeError: Failed to fetch JWKS from http://<service-name>:8080/... You used the compose services: name (auth-service). On cross-compose networks only container_name (thecrag-auth-local) resolves.
{"detail":"Redirect to host 'localhost' is not permitted."} after login redirect You're hitting the service on localhost:8000 (the dev port forward) instead of via Caddy. Use https://<service>.processing.localtest.me/....
Name or service not known on the consumer when reaching auth The container isn't on thecrag-processing-local. Double-check the networks: block on both the consumer and auth-service. Verify with docker network inspect thecrag-processing-local.

Fast iteration without OAuth (dev-only)

Set AUTH_DEV_MODE=true in local/auth.env to unlock GET /dev/login?account_id=<id> on the auth service, which mints a JWT directly and skips OAuth. Gated by the env var so it can't be reached in production builds.

Security model

The cookie is an RS256-signed JWT. Auth signs with a private RSA key held only inside the auth container; consumers verify with the matching public key (fetched from the JWKS endpoint and cached). Even if a consumer is fully compromised, an attacker cannot mint cookies for other users — they only have the public key.

Defence layers:

  1. RS256 signature (private key isolation)
  2. HttpOnly cookie (XSS can't read it)
  3. Secure cookie (HTTPS only)
  4. SameSite=Lax (CSRF baseline)
  5. Domain=.processing.thecrag.com + DNS hierarchy (cookie can't reach prod / dev API hosts)
  6. JWT exp claim (bounded session lifetime)
  7. Allowlist re-checked per request (even leaked key + valid signature still needs an allowlisted account)

See the plan for the full reasoning.

License

MIT © 2026 theCrag.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages