# 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](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](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](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`, `repeated_values`, 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](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` 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`. `src/protocol/params.rs` captures repeats in wire order while keeping the single-valued map last-wins. For the body endpoints it preserves the prior transport contract (`415` on a non-urlencoded body, `400` on a malformed or non-UTF-8 body, and the documented `invalid_request` mapping for a duplicated known field). `/token` rejects a `resource` on token exchange before any catalog lookup. ## 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:`, 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](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](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://:8081/health/live` returns `200 {"status":"live"}` without touching a dependency. - Readiness: `GET http://: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.