Auth & Access
In one sentence. Auth & Access is WIP's two-layer identity system: a browser-facing OIDC gateway (auth-gateway, backed by Dex) that turns a session cookie into trusted proxy headers, and a pluggable per-service library (wip-auth) that turns those headers — or a directly-presented API key or JWT — into a UserIdentity and enforces namespace-scoped permissions.
Why it exists. Apps and backend services need a uniform way to know who is calling and what they're allowed to touch, without every service re-implementing OIDC or session handling. auth-gateway owns the entire OIDC flow (login, callback, session) so that "apps never touch OIDC — they read identity from headers" (module docstring, auth_gateway/main.py); wip-auth then gives every backend service the same pluggable provider chain, the same UserIdentity shape, and the same namespace-permission check against the Registry, regardless of whether the caller arrived via the gateway's session, a direct API key, or a direct JWT.
The model
Core entities
| Entity | Fields | Defined in |
|---|---|---|
UserIdentity | user_id, username, email, groups: list[str], auth_method: "jwt" | "api_key" | "gateway_oidc" | "none", provider, raw_claims | libs/wip-auth/src/wip_auth/models.py |
APIKeyRecord | name, key_hash, owner, groups, description, created_at, last_used_at, expires_at, enabled, namespaces: list[str] | None | libs/wip-auth/src/wip_auth/models.py |
AuthConfig | mode, jwt_provider, jwt_issuer_url, jwt_jwks_uri, jwt_audience, jwt_groups_claim, jwt_algorithms, jwt_verify_exp, jwt_leeway_seconds, legacy_api_key, api_keys_json, api_keys_file, api_key_header, api_key_hash_salt, trust_proxy_headers, default_groups, admin_groups | libs/wip-auth/src/wip_auth/config.py |
NamespaceFilter | query: dict (MongoDB filter to merge in), namespaces: list[str] | None (None = superadmin/no restriction) | libs/wip-auth/src/wip_auth/permissions.py |
Auth modes → providers assembled
AuthConfig.mode selects which AuthProvider instances create_providers_from_config() builds, tried in this order by AuthMiddleware until one returns an identity:
mode | Providers, in trial order |
|---|---|
none | NoAuthProvider (always returns an anonymous identity) |
api_key_only (default) | TrustedHeaderProvider (only if trust_proxy_headers=True and keys are configured), then APIKeyProvider |
jwt_only | OIDCProvider (raises ValueError at startup if neither jwt_issuer_url nor jwt_jwks_uri is set) |
dual | TrustedHeaderProvider (same condition), then OIDCProvider (if configured), then APIKeyProvider (if keys) |
Source: create_providers_from_config() in libs/wip-auth/src/wip_auth/__init__.py. In dual mode the code comment states JWT is checked before API key "because Bearer token is more explicit" — so a caller presenting both a valid Authorization: Bearer and a valid X-API-Key authenticates as the JWT identity.
Permission levels
libs/wip-auth/src/wip_auth/permissions.py defines a flat hierarchy:
| Level | Rank |
|---|---|
none | 0 |
read | 1 |
write | 2 |
admin | 3 |
permission_sufficient(actual, required) is a simple rank comparison; there are no per-action grants below the level of these four namespace-wide tiers in this library — a higher tier always satisfies a lower requirement.
How it works
1. Browser login (auth-gateway + Dex)
auth-gateway is a separate FastAPI service (components/auth-gateway/src/auth_gateway/) sitting behind Caddy's forward_auth (compose) or nginx-ingress's auth-request (k8s), called on every auth-protected request:
| Route | Behaviour |
|---|---|
GET /health | liveness, {"status": "ok", "service": "auth-gateway"} |
GET /auth/verify | checks the wip_session cookie; 200 with X-WIP-User / X-WIP-Groups / X-API-Key headers if the session has an email and time.time() < exp; otherwise 401 with X-Auth-Redirect: /auth/login?return_to=..., or (in redirect mode, for Accept: text/html requests) a 302 straight to the login URL |
GET /auth/login | stores return_to in the session, builds a PKCE authorization URL against Dex, redirects the browser |
GET /auth/callback | Dex redirects here with code + state; exchanges the code for tokens, populates the session, redirects to the stored return_to |
GET /auth/logout | clears the session, redirects to https://{wip_hostname}:8443/ |
GET /auth/userinfo | returns the current session's identity as JSON, for app UIs |
The session itself is a Starlette SessionMiddleware signed cookie (wip_session), no external session store — session_secret signs it, session_max_age (default 86400s / 24h) bounds it, same_site="lax".
Login (get_auth_url in oidc.py) generates a PKCE code_verifier/code_challenge (S256) pair and a CSRF state, both stashed in the session, and redirects to Dex's discovered authorization_endpoint. Dex (components/dex/wip-component.yaml, vendored image ghcr.io/dexidp/dex:v2.45.0, active only when auth.mode is oidc or hybrid) hosts its own login page at /dex (auth_required: false — Dex handles its own auth) and, on success, redirects to /auth/callback.
Callback (exchange_code in oidc.py) verifies state, exchanges the code at the internal token endpoint (URLs from OIDC discovery are rewritten from the external issuer to the internal one — _rewrite_to_internal), and decodes the returned ID token's JWT payload without verifying its signature (the exchange itself happened over a direct, trusted container-network connection to Dex). The session is populated with email, groups, name, user_id, exp = now + session_max_age, and refresh_token if Dex returned one.
Two issuer URLs exist by design: OIDC_ISSUER (external, what the browser and Dex's issued tokens use) and OIDC_INTERNAL_ISSUER (internal, used for server-side discovery/token exchange to avoid container-to-Caddy TLS issues) — they default to the same value if OIDC_INTERNAL_ISSUER is unset.
2. Backend-service request authorization (wip-auth)
Every backend service wires up wip_auth.setup_auth(app), which builds the provider list above and installs AuthMiddleware. The middleware (libs/wip-auth/src/wip_auth/middleware.py):
- Skips provider iteration entirely for a fixed set of public paths —
/,/health,/ready,/docs,/redoc,/openapi.json, plus any service-specific paths passed tosetup_auth(public_paths=[...])— so a stale or wrong credential header on a health probe never produces a 401. - Otherwise tries each configured provider in order; the first to return a non-
NoneUserIdentitywins. A provider that finds credentials but rejects them raisesHTTPException, which the middleware turns into the JSON error response directly (short-circuiting the remaining providers). - Does not itself reject unauthenticated requests — it stores whatever identity it found (or
None) in a request-scopedcontextvars.ContextVar, via a token-based set/reset so nested in-process calls (e.g. Registry mounted in-process through an ASGI transport) don't clobber the outer identity. Enforcement is left entirely to route-level dependencies.
Route handlers then opt into enforcement via dependencies (libs/wip-auth/src/wip_auth/dependencies.py): require_identity() (401 if unauthenticated), require_groups(groups, require_all=False) / require_admin() (403 if the identity's groups don't match), optional_identity() (no enforcement), and require_namespace_read/write/admin(identity, namespace) (delegates to the permission check below).
Namespace permission resolution (libs/wip-auth/src/wip_auth/permissions.py):
- Superadmin bypass: if the identity has any of
AuthConfig.admin_groups, the permission is alwaysadmin— no Registry call. - Otherwise,
resolve_permission()checks a 30-second in-processTTLCachekeyedf"{user_id}:{namespace}". - On a cache miss, it calls the Registry's
GET /api/registry/my/check-permissionwith the service's ownX-API-Key,namespace/user_id/auth_method/email/usernameas query params, the caller's groups in anX-User-Groupsheader (kept out of query params/logs), and — for API-key identities — the key's own namespace scope inX-Key-Namespaces(so the Registry's synthetic identity sees the same scoping the calling service does). - If Registry is unreachable, the result is
"none"— fails closed. check_namespace_permission()raises404(not403) when the resolved permission is"none", to avoid revealing that the namespace exists to a caller with no access to it; otherwise403if the level is insufficient.
resolve_namespace_filter(identity, namespace) builds a MongoDB query filter for list endpoints: an explicit namespace becomes {"namespace": {"$in": [namespace]}} after a permission check; no namespace resolves the caller's full accessible set via resolve_accessible_namespaces() (same 30s-cache, same Registry-unreachable-fails-closed pattern, hitting GET /api/registry/my/accessible-namespaces) — None back from that call means superadmin (no filter at all), an empty list raises 403 "No accessible namespaces".
Providers, in detail
| Provider | Credential | Notes |
|---|---|---|
NoAuthProvider | none | Always returns an anonymous identity with configured default_groups; for WIP_AUTH_MODE=none. |
APIKeyProvider | X-API-Key header (name configurable) | Validates against bcrypt-hashed APIKeyRecords. A SHA-256-fingerprint cache avoids re-running bcrypt per request for a repeat key. Builds user_id="apikey:<name>", auth_method="api_key", raw_claims={"key_name", "owner", "namespaces"}. |
OIDCProvider | Authorization: Bearer <jwt> | Validates against a JWKS endpoint (cached, 1h TTL, refreshed on an unknown kid). Builds identity from sub/preferred_username/email/the configured groups claim (falls back to roles/role, then default_groups). auth_method="jwt". |
TrustedHeaderProvider | X-WIP-User and a valid X-API-Key | X-WIP-User alone is ignored (logged as a possible spoofing attempt) — a valid API key must accompany it, proving the request passed through a trusted proxy. Groups come from X-WIP-Groups. auth_method="gateway_oidc". This is exactly the shape of the headers auth-gateway's /auth/verify sets on a successful session check. |
Key hashing and lifecycle
Keys are hashed with bcrypt over a SHA-256 pre-hash of f"{salt}:{key}" (hash_api_key(), libs/wip-auth/src/wip_auth/providers/api_key.py) — the pre-hash exists because bcrypt has a 72-byte input limit. Legacy plain hex-SHA-256 hashes are still accepted at verify time (constant-time hmac.compare_digest) but log a deprecation warning on load. AuthConfig.load_api_keys() merges keys from three sources — legacy_api_key (single key, gets admin_groups), api_keys_json, api_keys_file — later sources overriding earlier ones by name; it warns at load time if a non-privileged key (not in wip-admins/wip-services) has no namespaces scope, because such a key ends up with no access at all, not full access.
KeySyncService (libs/wip-auth/src/wip_auth/key_sync.py) is an optional background poller (setup_key_sync(), default interval 30s) that fetches GET /api/registry/api-keys/sync and atomically replaces the APIKeyProvider's runtime keys, leaving config-file-defined key names untouched — so non-Registry services pick up keys created/revoked in the Registry without a restart.
Startup security guards
Two independent, non-shared checks refuse to start a service in WIP_VARIANT=prod if a known-public default credential is still active:
wip_auth.security.check_production_security()— refuses to start if the effective API key equals the published defaultdev_master_key_for_testing.auth_gateway.config.check_production_security()— refuses to start if the session secret equals the published defaultchange-me-in-production. This is a local copy, not an import from wip-auth — the code comment states auth-gateway "deliberately carries no wip-auth dependency (it is the one backend service kept free of it)".
auth-gateway additionally runs check_redirect_mode() at import time, refusing to start on any AUTH_REDIRECT_MODE value other than "401" or "redirect".
Rules & invariants
- Middleware authenticates, dependencies authorize.
AuthMiddlewarenever itself rejects a request for lacking identity — only route-level dependencies (require_identity,require_groups,require_namespace_*) enforce. This is what lets one FastAPI app mix public and protected routes. - Public paths bypass provider iteration entirely, not just enforcement —
/,/health,/ready,/docs,/redoc,/openapi.jsonplus any service-declared additions. A wrong credential on these paths is never evaluated, let alone rejected. - Namespace-permission checks fail closed. Both
resolve_permission()andresolve_accessible_namespaces()return"none"/[]if the Registry call raises any exception (including connection failure) — an unreachable Registry denies access, it never grants it. permission == "none"returns404, not403, for an explicit namespace — the caller with no access can't distinguish "doesn't exist" from "not permitted."- Superadmin (
admin_groupsmembership) bypasses every namespace check — no Registry round-trip, no cache entry. X-WIP-Useris never trusted alone.TrustedHeaderProviderrequires a co-present, independently-validX-API-Key— this is the mechanism by which auth-gateway's identity injection is trustworthy to backend services: a direct caller can sendX-WIP-Userbut can't also produce a service's own API key.- The ID token's signature is not verified during code exchange (
oidc.py::exchange_codedecodes the JWT payload directly). This is safe only because the exchange happens over a direct, trusted container-network HTTP call to Dex, not because the token is otherwise validated — a reader porting this pattern elsewhere should not assume the signature check is happening implicitly. AUTH_REDIRECT_MODEis deployment configuration, not request-derived. The code explicitly notes every proxy forwards the browser'sAcceptheader to the auth subrequest, so the two modes cannot be told apart by sniffing the request; behind nginx'sauth_request,"401"is the only safe value — nginx treats any other non-2xx/401/403 status (a 302 included) from the auth subrequest as an internal error and answers the browser500."redirect"exists solely for proxies (Traefik-styleforwardAuth) that pass the auth response to the client verbatim and have no mechanism of their own to start a login flow.- The session cookie is not marked
Secureat the application layer (SessionMiddleware(..., https_only=False), comment: "Allow HTTP for internal forward_auth subrequests") — TLS enforcement for the browser-facing hop is the deploying proxy's responsibility, not a guarantee auth-gateway makes itself. resolve_namespace_filter()'s query shapes are exactly two:{}(superadmin, unrestricted) or{"namespace": {"$in": [...]}}(scoped) — callers merge this into their MongoDB query and never need to special-case a single namespace.
⚠ GAP: whether any of the above (particularly the redirect-mode / nginx
auth_requestinteraction, or the unsigned-code-exchange pattern) is catalogued as a numbered PoNIF inwip://ponifscan't be confirmed from this doc's source scope — that resource isn't inauth-and-access'ssource_scope. Flagging for the reading-list/manifest owner to cross-check against the PoNIFs doc.
Gotchas
- Two unrelated concepts are both called "identity" inside this same library.
identity.pymanages the request-scoped authenticated caller identity (a contextvar, set byAuthMiddleware).document_identity.pycomputes the hash WIP uses to decide "is this a new document or a new version of an existing one" — completely unrelated to authentication. The module docstring indocument_identity.pycalls this out explicitly ("this module is a sibling ofidentity.py... Two unrelated concepts both called 'identity'"). libs/wip-authis not only an auth library. Alongside authentication/authorization it also hosts the canonical document-identity-hashing algorithm (document_identity.py), a shared Registry HTTP client (registry_client.py), and the canonical bulk-write response models (bulk_models.py) — generic backend-service infrastructure that happens to live in this package. A reader looking here only for auth code will find more than that.- auth-gateway does not depend on wip-auth at all. It reimplements its own
check_production_security()locally rather than importing the (behaviourally near-identical) one from wip-auth. A future change to the production-security check has to be made in both places by hand — nothing enforces they stay in sync. dualmode's provider order means JWT wins over API key when both credentials are present on the same request — not documented as a precedence rule anywhere except the source comment.- An API key with no
namespacesand not in a privileged group silently has zero access, not full/default access — it still loads successfully (with a logged warning), so "the key exists in config" doesn't mean "the key can do anything." RejectUnknownQueryParamsMiddleware(query_validation.py) 422s on any undeclared query parameter for a matched route, but only after route-matching succeeds — an unmatched path (404) or a docs/health path is exempt. An extra debug query param on an otherwise-valid route fails the whole request.
See also
- Identity & the Registry — the Registry-side identity/permission source of truth this doc's namespace checks call into; also home to the other "identity" (document dedup/versioning) this doc explicitly disambiguates from.
- Namespaces & Tenancy — the namespace model that every permission check and
NamespaceFilterresult is scoped against. - Routing & Ingress — the Caddy/nginx layer that actually invokes
/auth/verifyviaforward_auth/auth-requestand turns its 401/302 into a browser redirect. - API Conventions — states the cross-cutting "every read/write sits behind auth-gateway/router" fact that this doc is the target of.
- PoNIFs — candidate non-intuitive behaviours surfaced above (redirect-mode/
auth_requestinteraction, unsigned code-exchange) pending confirmation against the catalogue (see the⚠ GAPabove).
---
