← Back to Library

Auth & Access

concept ·  operators, app devs
authoidcgatewaypermissionsdex

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

EntityFieldsDefined in
UserIdentityuser_id, username, email, groups: list[str], auth_method: "jwt" | "api_key" | "gateway_oidc" | "none", provider, raw_claimslibs/wip-auth/src/wip_auth/models.py
APIKeyRecordname, key_hash, owner, groups, description, created_at, last_used_at, expires_at, enabled, namespaces: list[str] | Nonelibs/wip-auth/src/wip_auth/models.py
AuthConfigmode, 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_groupslibs/wip-auth/src/wip_auth/config.py
NamespaceFilterquery: 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:

modeProviders, in trial order
noneNoAuthProvider (always returns an anonymous identity)
api_key_only (default)TrustedHeaderProvider (only if trust_proxy_headers=True and keys are configured), then APIKeyProvider
jwt_onlyOIDCProvider (raises ValueError at startup if neither jwt_issuer_url nor jwt_jwks_uri is set)
dualTrustedHeaderProvider (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:

LevelRank
none0
read1
write2
admin3

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:

RouteBehaviour
GET /healthliveness, {"status": "ok", "service": "auth-gateway"}
GET /auth/verifychecks 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/loginstores return_to in the session, builds a PKCE authorization URL against Dex, redirects the browser
GET /auth/callbackDex redirects here with code + state; exchanges the code for tokens, populates the session, redirects to the stored return_to
GET /auth/logoutclears the session, redirects to https://{wip_hostname}:8443/
GET /auth/userinforeturns 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):

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):

  1. Superadmin bypass: if the identity has any of AuthConfig.admin_groups, the permission is always admin — no Registry call.
  2. Otherwise, resolve_permission() checks a 30-second in-process TTLCache keyed f"{user_id}:{namespace}".
  3. On a cache miss, it calls the Registry's GET /api/registry/my/check-permission with the service's own X-API-Key, namespace/user_id/auth_method/email/username as query params, the caller's groups in an X-User-Groups header (kept out of query params/logs), and — for API-key identities — the key's own namespace scope in X-Key-Namespaces (so the Registry's synthetic identity sees the same scoping the calling service does).
  4. If Registry is unreachable, the result is "none"fails closed.
  5. check_namespace_permission() raises 404 (not 403) when the resolved permission is "none", to avoid revealing that the namespace exists to a caller with no access to it; otherwise 403 if 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

ProviderCredentialNotes
NoAuthProvidernoneAlways returns an anonymous identity with configured default_groups; for WIP_AUTH_MODE=none.
APIKeyProviderX-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"}.
OIDCProviderAuthorization: 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".
TrustedHeaderProviderX-WIP-User and a valid X-API-KeyX-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:

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

⚠ GAP: whether any of the above (particularly the redirect-mode / nginx auth_request interaction, or the unsigned-code-exchange pattern) is catalogued as a numbered PoNIF in wip://ponifs can't be confirmed from this doc's source scope — that resource isn't in auth-and-access's source_scope. Flagging for the reading-list/manifest owner to cross-check against the PoNIFs doc.

Gotchas

See also


---
Generated from real source code, not hand-written — see the WIP Technical Library.