The API supports two complementary authentication paths:
- API key on
/api/*endpoints — always available, governed byENABLE_API_KEY_AUTHandAPI_KEYS. - Optional viewer auth — when
AUTH_MODE != none, browser sessions are established via username/password (basic) or OIDC SSO (oidc, PR2). API keys remain valid in parallel — programmatic clients are unaffected.
Everything outside /api/* (the Pipeline Viewer SPA shell, Swagger UI, OpenAPI spec, ReDoc, health checks, metrics) is publicly accessible regardless of mode.
For the rationale behind the same-origin bypass, stream-token flows, and the layered auth design, see authentication design.
# .env — match the style in .env.example
ENABLE_API_KEY_AUTH=true
API_KEY_HEADER_NAME=X-API-Key
API_KEYS=your-secret-key-hereGenerate a real value via the uic-<uuid> recipe at the bottom of this page, or follow whatever convention your deployment uses. Multiple keys are supported via a comma-separated list — useful for rolling rotations without downtime. Keys are stored as SecretStr internally and compared with secrets.compare_digest() for constant-time comparison. The header name is configurable via API_KEY_HEADER_NAME.
Implementation: src/middleware/api_key_auth.py.
/and every SPA deep link — viewer HTML/docs,/openapi.json,/redoc— API documentation/health,/health/ready— load-balancer health checks/metrics— Prometheus scrape target/api/dev/monitoring/*,/api/dev/minimal/*,/api/dev/pipeline-viewer/*— public whenENVIRONMENT=dev/api/v1/documents/{job_id}/stream?token=...— SSE stream endpoints with a valid short-lived token/lti/*— authenticated via the Canvas LTI JWT flow, not by API key
Browser EventSource connections cannot send custom headers. For SSE, exchange an API key for a short-lived stream token:
POST /api/v1/documents/{job_id}/stream/token(withX-API-Key)- Server returns a single-use token with a 5-minute TTL
- Client opens
GET /api/v1/documents/{job_id}/stream?token={token} - Token is consumed on first use (
GETDELin Redis)
Tokens are job-scoped and deleted after first validation. Implementation: src/services/job_service.py creates and validates; src/middleware/api_key_auth.py recognises the ?token= query parameter as an alternative credential.
/api/v1/approval/* requires both an API key and a valid approval token — see authentication design for the defense-in-depth rationale.
Middleware executes in reverse registration order (last added = first executed):
1. CORS
2. Security Headers
3. Logging
4. Rate Limit
5. Error Handler
6. Session Auth (only when AUTH_MODE != none)
7. API Key Auth
8. Endpoint
SessionAuthMiddleware runs ahead of APIKeyAuthMiddleware so the latter can short-circuit when request.state.identity is set. Both middlewares coexist on purpose — API keys remain a parallel auth path for programmatic clients regardless of AUTH_MODE.
| Variable | Default | Required when | Notes |
|---|---|---|---|
AUTH_MODE |
none |
— | One of none, basic, oidc. none preserves today's behaviour. |
AUTH_SECRET_KEY |
— | AUTH_MODE != none |
HMAC key for signing session and CSRF cookies. >= 32 chars; rotation invalidates all sessions. |
AUTH_SESSION_TTL_SECONDS |
28800 (8h) |
— | Sliding re-issue at half-life. |
AUTH_SESSION_COOKIE_NAME |
reflow_session |
— | CSRF companion cookie is named <this>_csrf. |
AUTH_COOKIE_SECURE |
true |
— | Disable only for local HTTP dev. |
AUTH_BASIC_USERS |
— | AUTH_MODE=basic |
Semicolon-separated username:argon2hash pairs (commas collide with argon2 parameter blocks). Generate hashes with make auth-hash-password. |
AUTH_OIDC_PROVIDERS |
— | AUTH_MODE=oidc |
JSON array of {id, display_name, discovery_url, client_id, client_secret, scopes?} entries. The id field becomes the path segment in /api/v1/auth/callback/{id} — must match what's registered with the IdP. |
AUTH_POST_LOGIN_REDIRECT |
/ |
— | Where to send the browser after login when no ?next= is present. |
| Method & path | When available | Notes |
|---|---|---|
GET /auth/config |
always | Public. SPA reads on mount; reports mode and providers. |
POST /auth/login |
basic | JSON {username, password}. Sets session + CSRF cookies. |
GET /auth/login/{provider_id} |
oidc | 302 to IdP authorisation endpoint with PKCE. Sets the reflow_oauth_tx signed cookie carrying state, nonce, PKCE verifier, and next_path. |
GET /auth/callback/{provider_id} |
oidc | Validates state + tx cookie, exchanges code for tokens, validates ID token (signature, iss, aud, exp, nonce), sets session cookies, 302 to next. |
POST /auth/logout |
basic + oidc | CSRF required (X-CSRF-Token header). Clears cookies. |
GET /auth/me |
basic + oidc | Returns identity or 401. |
reflow_session—HttpOnly,Secure(configurable),SameSite=Lax. Stateless signed cookie carrying{sub, email, name, provider_id, issued_at, expires_at}. The ID token itself is not stored in the cookie.reflow_session_csrf— NOTHttpOnly. HMAC of the session-cookie value. SPA echoes asX-CSRF-Tokenon non-GET requests under/api/v1/auth/*.reflow_oauth_tx—HttpOnly,SameSite=Lax, 10-minute TTL. Set during the OIDC kickoff route; carries the OAuthstate, OIDCnonce, PKCE verifier, and the user's originalnext_path. Cleared by the callback route. Signed with a differentitsdangeroussalt from the session cookie so the two can never be confused.
When auth is on, LoggingMiddleware adds user_sub, user_email, user_provider fields to every Response log line. Auth-state transitions emit a separate structured record with category="auth_event" covering login_success, login_failure, logout, session_expired. Failure reasons are categorical (invalid_password, csrf_mismatch, …) — never the username attempted, never PII.
The API key middleware handles reverse proxy setups (AWS ALB, Nginx, Cloudflare). Priority order:
X-Forwarded-For— take the first IPX-Real-IPrequest.client.host— direct connection
Extracted IPs are included in authentication logs for audit trails.
python3 -c "import uuid; print(f'uic-{uuid.uuid4()}')"Unit tests: tests/unit/middleware/test_api_key_auth.py, tests/unit/services/test_job_service.py::TestStreamTokens
Integration tests: tests/integration/api/test_api_authentication.py, tests/integration/api/test_stream_auth.py
Quick manual check (requires make dev):
# No key → 401
curl http://localhost:8080/api/v1/documents/test-id
# Valid key → 200 (or 404 if job not found)
curl -H "X-API-Key: $API_KEY" http://localhost:8080/api/v1/documents/test-id
# Swagger UI — public, no prompt
open http://localhost:8080/docs
# Health — public
curl http://localhost:8080/health