ARCHITECTURE.md 12 KB

Architecture

The d-OAuth-Server authorization server is a single Rust process with two listeners: a TLS public listener for OAuth traffic and a plaintext health-only listener for probes. All durable domain state lives in PostgreSQL and all ephemeral state lives in Redis. There is no in-process state that a second replica would need, so replicas are interchangeable.

Purpose

This guide explains how the code is laid out, what happens to a request from socket to response, and where the Postgres/Redis boundary sits. Use it to find the module that owns a behavior before you change it.

Module layout

The crate is one library (src/lib.rs) plus a thin binary (src/main.rs). The binary parses the CLI and maps the result to an exit code; all behavior lives in the library.

Module Owns
src/config.rs, src/config/ Layered configuration load from D_OAUTH_* env vars and an optional file, then fail-fast validation. See CONFIGURATION.md.
src/keys.rs The versioned KeyRing: token peppers, the TOTP encryption key, and the PII field-encryption key, each under a key id with an optional retained previous key.
src/crypto/ Constant-time comparison, PKCE, opaque token mint/hash, TOTP, and password/client-secret hashing.
src/crypto/field.rs Versioned AES-256-GCM field codec and keyed-HMAC blind index for the PII columns.
src/jose/ DPoP proof verification, JAR request objects, private_key_jwt client assertions, and JWK parsing.
src/db/ Postgres repositories for the seven domain tables, including resource_repo for the RFC 8707 resource-server catalog. See DATABASE.md.
src/db/resource_repo.rs Exact-match CRUD for the resource_servers catalog: upsert preserves id/enabled, find_enabled, list_enabled, and disable.
src/store/ Redis ephemeral state and the durability barrier. See REDIS.md.
src/protocol/ OAuth endpoints and grants, plus the subject-scoped Redis purges (src/protocol/purge.rs: purge_subject for user disable, purge_sibling_sessions on credential change).
src/protocol/params.rs Shared raw-parameter parsing (parse_pairs, values_of, the form-content-type gate) and fail-closed RFC 8707 audience validation (validate_resources, audience_binds).
src/ui/ Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at /assets/app.css.
src/middleware/ Request id, tracing and redaction, rate limiting, security headers, and audit.
src/resilience.rs Load-shed, timeout, and concurrency-limit guard between CORS and rate limiting.
src/routes.rs Router assembly and the middleware order.
src/serve.rs, src/tls.rs Listener bootstrap and the custom TLS accept loop.
src/state.rs, src/state/ AppState, the topology-aware Redis connection, health endpoints.
src/cli.rs, src/cli/ The operator command tree. See OPERATIONS.md.
src/error.rs AppError: the client-safe OAuth error type and its HTTP mapping.

Every behavior belongs to exactly one module. When a change spans modules, the new logic goes in the one that owns the behavior; the others call into it.

Request lifecycle

A public request enters through the TLS listener and passes through one tower stack before reaching a handler. The order, from outermost to innermost, is fixed in build_router_with_options in src/routes.rs:

  1. Request id: mints or propagates X-Request-Id and opens the redacted http_request tracing span. Tracing and redaction live here.
  2. Security headers: adds Referrer-Policy, X-Content-Type-Options, a CSP with frame-ancestors 'none', HSTS on the TLS listener, and Cache-Control: no-store on every response except /.well-known/… metadata and GET /jwks.json, which carries its own short public, max-age=300 window (a rotated-out key must leave intermediary caches quickly). Sits just inside the request-id layer so short-circuiting layers below (CORS preflights, resilience 503s, rate-limit 429/503s, body-limit 413s) still carry the headers.
  3. CORS: answers preflights from the explicit allowlist. Absent when the allowlist is empty, which is the default.
  4. Resilience: sheds excess load, times out slow handlers, then caps concurrency before they reach rate limiting.
  5. Rate limit: enforces the per-IP and per-client budgets. The per-client bucket is keyed by the (peer_ip, client_id) composite, because client_id is read before client authentication and must never select a global bucket on its own; the per-IP bucket is the primary control. Health, metadata, and /assets have no rule and pass through; those handlers fail closed on their own.
  6. Body limit: caps request bodies at 2 MiB.

A handler that needs a dependency takes AppState and calls a repository or the store. Errors are converted to AppError, which maps to an OAuth error body and the correct HTTP status. A dependency outage on a security path maps to 503 with Retry-After; it never fails open.

Five parameter-carrying endpoints parse the raw query or body instead of using Query<HashMap> or Form<...>, because those extractors consume the raw input and cannot see a repeated resource: GET /authorize, POST /par, POST /token, POST /device_authorization, and POST /introspect. POST /revoke uses the same raw body parser for duplicate-field rejection but is audience-agnostic. /authorize uses RawQuery; the POST handlers use RawForm, which checks the content type before buffering the body, then the shared raw parser. src/protocol/params.rs captures repeats in wire order while keeping the single-valued map last-wins. The body endpoints return 415 on a non-urlencoded body and 400 on a malformed or non-UTF-8 body; a duplicated known field is JSON invalid_request on /token, /introspect, /device_authorization, and /revoke (PAR and authorize keep last-wins absorption). /token rejects a resource on token exchange before any catalog lookup. On /token, /introspect, and /device_authorization the client is authenticated before the catalog lookup, so an unauthenticated caller sees invalid_client rather than a catalog error; the authorization-code grant also validates the presented audience before consuming the code, so a bad resource does not burn it.

The two listeners

  • The public listener binds bind_addr (default 0.0.0.0:8080) and speaks TLS only. It is driven by a custom hyper-util plus tokio-rustls accept loop in src/tls.rs, not axum::serve, because the loop must inject the verified client-certificate chain into request extensions for mTLS.
  • The health listener binds health_addr (default 0.0.0.0:8081) in plaintext and serves exactly two routes, /health/live and /health/ready. Every OAuth route returns 404 there by construction. It is not exposed through the Service or an ingress; only the pod network probes it.

The health address must not be loopback. Kubelet probes originate from the node network namespace and cannot reach pod-loopback, so serve refuses a loopback health_addr at startup.

Postgres and Redis split

The split is strict and drives the whole design:

  • PostgreSQL holds durable domain state only, in seven tables: oauth_clients, users, scopes, user_scopes, redirect_uris, client_redirect_uris, and resource_servers, plus sqlx's _sqlx_migrations. Nothing is written to Postgres per request. A failed login does not touch Postgres; the counter lives in Redis. Introspection and refresh do re-read Postgres on every use: a non-empty subject resolving to a disabled user yields inactive (introspection) or invalid_grant (refresh) without minting, and refresh re-reads the user's current entitlements; an empty or unknown subject stays live. user disable purges the user's Redis state before flipping users.disabled, failing closed on a Redis error.
  • Redis holds every short-lived value: authorization codes, access and refresh tokens, grant chains, sessions, PAR request URIs, pending-auth records (pending:<id>, 300 s), device codes plus their user_code mappings (ttl.device, 600 s), DCR initial-access tokens, DPoP and private_key_jwt replay markers, TOTP replay markers (totp_replay:*, 180 s), rotation idempotency mappings (rotation:*, min(900 s, refresh TTL)), login-failure counters (loginfail:*, 15 min), and rate-limit counters.

The consequence is that a Redis flush loses ephemeral state but not clients, users, scopes, or redirect URIs. Registration access tokens are the one token kind stored as a hash in Postgres, so client management survives a Redis loss.

Topology layer

AppState.redis is the RedisConn enum (src/state/redis.rs), not a concrete connection type. One enum lets single-node, Sentinel, and Cluster share every call site:

  • Single: one redis::aio::ConnectionManager.
  • Sentinel: a hand-built master re-resolution loop, because the driver's Sentinel support does not follow a promoted master across a failover.
  • Cluster: native slot refresh, so MOVED and ASK redirects recover without a restart.

Atomic multi-key operations use Lua scripts keyed under the {state} hash tag, so they stay on a single slot. See REDIS.md.

Security properties

  • The public listener is TLS-only; issuer is validated as absolute https.
  • Secrets stay in secrecy/zeroize types and never reach a Debug output, a log, or an error body.
  • Dependency failure on a security path fails closed with 503.
  • Middleware order puts request identity and redaction before any handler.
  • The health listener cannot reach OAuth handlers, so a probe port cannot become a second, unauthenticated API surface.

Edge cases

  • A mTLS client certificate terminates at an L7 ingress that strips it. In that case in-process verification fails; use L4/TCP passthrough. See DEPLOYMENT.md.
  • GET /.well-known/oauth-authorization-server (discovery document) reads the enabled scope catalog from Postgres on every request, so a CLI catalog mutation is visible immediately on every replica; when Postgres is down it returns 503 rather than advertising an empty or unknown scope list.
  • GET /jwks.json (public JWT access-token verification keys) serves a JWK Set built from the configured signing ring: the current key first, then every retained verify-only key. It is the single cacheable path outside /.well-known/: keys are public by definition, and the shorter 300-second window bounds how long a rotated-out key stays cached. With jwt_access_tokens_enabled off it returns {"keys":[]} deterministically rather than a cacheable 404.
  • Migrations are applied out of band. A replica whose schema version is behind the binary reports not ready and is pulled from service.

Operational runbook

  • Liveness: GET http://<pod-ip>:8081/health/live returns 200 {"status":"live"} without touching a dependency.
  • Readiness: GET http://<pod-ip>:8081/health/ready returns 200 {"status":"ready"} only when Postgres and Redis answer and the schema version matches the binary. Any other outcome is 503 {"status":"not_ready"}.
  • On SIGTERM the process flips readiness to 503 first, waits a one second propagation grace, drains in-flight requests under a 30 second deadline, then closes the pools and exits 0.
  • Tracing is JSON to stdout. Use the X-Request-Id value to correlate a request with its logs.

File and directory references

  • src/lib.rs for the authoritative module list.
  • src/routes.rs for endpoint inventory and middleware order.
  • src/serve.rs for listener bootstrap and shutdown semantics.
  • src/state.rs, src/state/health.rs, src/state/pg.rs, src/state/redis.rs, src/state/sentinel.rs for state, health, the Postgres pool, and topology.
  • src/tls.rs for the TLS accept loop.