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 towww.thecrag.comordev-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.
| 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. |
# 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-authfrom 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.
| 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.
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:roCaddyfile 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.
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.
# 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_iddocker compose -f docker-compose.local.yml up --buildVerify:
https://auth.processing.localtest.me/healthz→200 okhttps://protected.processing.localtest.me/protected→ triggers the OAuth dance, then returns the stub page
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-localThis 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: true3. 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: trueNotes on the env vars:
AUTH_JWKS_URLuses container_name (thecrag-auth-local) over plain HTTP on the internal network — fast, no TLS-trust hassle inside the container. This is critical:auth-serviceis the compose service name and won't resolve from a different compose project.AUTH_LOGIN_URLuses 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 -dVisit https://rasterizer.processing.localtest.me/cache/stats — first hit redirects to the OAuth dance, then the cookie unlocks all *.processing.localtest.me services (SSO).
| 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. |
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.
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:
- RS256 signature (private key isolation)
HttpOnlycookie (XSS can't read it)Securecookie (HTTPS only)SameSite=Lax(CSRF baseline)Domain=.processing.thecrag.com+ DNS hierarchy (cookie can't reach prod / dev API hosts)- JWT
expclaim (bounded session lifetime) - Allowlist re-checked per request (even leaked key + valid signature still needs an allowlisted account)
See the plan for the full reasoning.
MIT © 2026 theCrag.