Efren Yevale Varela 3 долоо хоног өмнө
parent
commit
c3d29f3802

+ 12 - 7
AGENTS.md

@@ -20,8 +20,8 @@ the admin surface.
 | PII field codec / blind index | `src/crypto/field.rs` | Versioned AES-256-GCM; never log plaintext or key material. |
 | PII field codec / blind index | `src/crypto/field.rs` | Versioned AES-256-GCM; never log plaintext or key material. |
 | JOSE verification (DPoP, JAR, `private_key_jwt`) | `src/jose/` | Reject fail-closed with the exact error code. |
 | JOSE verification (DPoP, JAR, `private_key_jwt`) | `src/jose/` | Reject fail-closed with the exact error code. |
 | Postgres query or repository | `src/db/` | `query!`/`query_as!` macros; regenerate `.sqlx`. |
 | Postgres query or repository | `src/db/` | `query!`/`query_as!` macros; regenerate `.sqlx`. |
-| Redis record or Lua script | `src/store/` | Keep every key under `{state}`; update [docs/REDIS.md](docs/REDIS.md). |
-| OAuth endpoint or grant | `src/protocol/` | Cite the RFC section in a comment. Update [docs/OAUTH.md](docs/OAUTH.md). A subject-scoped Redis purge lives at `src/protocol/purge.rs` (`purge_subject`), used by `user disable`; keep every key under `{state}` and update docs/REDIS.md. |
+| Redis record or Lua script | `src/store/` | Domain keys under `{state}`, rate-limit counters under `{rl}` (must differ); update [docs/REDIS.md](docs/REDIS.md). |
+| OAuth endpoint or grant | `src/protocol/` | Cite the RFC section in a comment. Update [docs/OAUTH.md](docs/OAUTH.md). A subject-scoped Redis purge lives at `src/protocol/purge.rs` (`purge_subject` for `user disable`, `purge_sibling_sessions` on credential change); domain keys under `{state}`, rate-limit counters under `{rl}` and update docs/REDIS.md. |
 | Login, consent, or device page | `src/ui/` | Keep CSP and CSRF intact. |
 | Login, consent, or device page | `src/ui/` | Keep CSP and CSRF intact. |
 | Cross-cutting HTTP concern | `src/middleware/` | Mind the layer order in `src/routes.rs`. |
 | Cross-cutting HTTP concern | `src/middleware/` | Mind the layer order in `src/routes.rs`. |
 | Endpoint or layer order | `src/routes.rs` | Update [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). |
 | Endpoint or layer order | `src/routes.rs` | Update [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). |
@@ -68,7 +68,7 @@ Before finishing any change, confirm:
 - [ ] A sender-constrained grant re-verifies a fresh proof on every use.
 - [ ] A sender-constrained grant re-verifies a fresh proof on every use.
 - [ ] `private_key_jwt` is treated as authentication only, never as a binding.
 - [ ] `private_key_jwt` is treated as authentication only, never as a binding.
 - [ ] Master keys (pepper, TOTP/PII keys, and retained previous keys) carry at least 32 bytes; shorter material is rejected at load (`WeakKeyMaterial`).
 - [ ] Master keys (pepper, TOTP/PII keys, and retained previous keys) carry at least 32 bytes; shorter material is rejected at load (`WeakKeyMaterial`).
-- [ ] A disabled subject is dead everywhere: introspection reports inactive, refresh rejects with `invalid_grant` before minting, and refresh re-checks current entitlements; `user disable` purges the subject's Redis state (sessions, codes, devices, grants, standalone tokens, lockout) before flipping `users.disabled`, failing closed on a Redis error.
+- [ ] A disabled subject is dead everywhere: introspection reports inactive, refresh rejects with `invalid_grant` before minting, and refresh re-checks current entitlements; `user disable` purges the subject's Redis state (sessions and their meta, codes, devices with their sidecars and `user_code` mappings, grants and their token sidecars, standalone tokens and their sidecars, lockout) before flipping `users.disabled`, failing closed on a Redis error.
 - [ ] The change does not introduce a wildcard scope, redirect URI, or CORS
 - [ ] The change does not introduce a wildcard scope, redirect URI, or CORS
       origin.
       origin.
 
 
@@ -99,14 +99,18 @@ cargo fmt --check
 cargo clippy --all-targets -- -D warnings
 cargo clippy --all-targets -- -D warnings
 cargo nextest run --all-targets
 cargo nextest run --all-targets
 cargo test --doc
 cargo test --doc
+cargo audit
+cargo deny check
 python3 scripts/check_doc_links.py
 python3 scripts/check_doc_links.py
 ```
 ```
 
 
 When a query or migration is added or changed, regenerate and commit the
 When a query or migration is added or changed, regenerate and commit the
-offline cache:
+offline cache. CI runs `cargo sqlx prepare --check --workspace` and fails if
+the committed cache is stale; regenerate it with the plain form:
 
 
 ```bash
 ```bash
-cargo sqlx prepare --workspace
+cargo sqlx prepare --check --workspace   # CI gate
+cargo sqlx prepare --workspace           # regenerate the cache
 ```
 ```
 
 
 ## File-scope discipline
 ## File-scope discipline
@@ -142,5 +146,6 @@ A command in a doc must match the real CLI. Then run the link check:
 python3 scripts/check_doc_links.py
 python3 scripts/check_doc_links.py
 ```
 ```
 
 
-The CI `doc-links` job runs the same check, so a stale file or heading
-reference fails the build.
+The `.github/workflows/docs.yml` `link-check` job (mirrored by the `doc-links`
+job in `.github/workflows/ci.yml`) runs the same check, so a stale file or
+heading reference fails the build.

+ 15 - 6
README.md

@@ -27,7 +27,8 @@ state is in Redis, so replicas are interchangeable.
 ## Prerequisites
 ## Prerequisites
 
 
 - Rust stable, as pinned by `rust-toolchain.toml`.
 - Rust stable, as pinned by `rust-toolchain.toml`.
-- PostgreSQL 18 or newer with the `citext` extension available.
+- PostgreSQL 16 or newer with the `citext` extension available (the shipped
+  `compose.yaml` pins `postgres:18-alpine`).
 - Redis 8 or newer, with persistence enabled and eviction off.
 - Redis 8 or newer, with persistence enabled and eviction off.
 - A TLS certificate and key for the public listener.
 - A TLS certificate and key for the public listener.
 - Docker, for the container image, for the shared test Postgres and Redis in
 - Docker, for the container image, for the shared test Postgres and Redis in
@@ -67,7 +68,8 @@ export D_OAUTH_PII_ENCRYPTION_KEY='replace-with-32-bytes-of-random-00'
 ```
 ```
 
 
 Development relaxes the database TLS requirement. Production requires
 Development relaxes the database TLS requirement. Production requires
-`sslmode=verify-full` and a non-localhost `issuer`.
+`sslmode=verify-full` (unless `database.require_verify_full` is set false) and an
+`issuer` on `https` (no trailing slash, non-empty authority, no whitespace).
 
 
 Validate the configuration, then apply the schema once:
 Validate the configuration, then apply the schema once:
 
 
@@ -180,6 +182,8 @@ Quality gates:
 ```bash
 ```bash
 cargo fmt --check
 cargo fmt --check
 cargo clippy --all-targets -- -D warnings
 cargo clippy --all-targets -- -D warnings
+cargo nextest run --all-targets
+cargo test --doc
 cargo audit
 cargo audit
 cargo deny check
 cargo deny check
 python3 scripts/check_doc_links.py
 python3 scripts/check_doc_links.py
@@ -245,10 +249,12 @@ certificate.
   device records, and lockout counters before flipping `users.disabled`; that
   device records, and lockout counters before flipping `users.disabled`; that
   user's live tokens then introspect inactive and refuse refresh
   user's live tokens then introspect inactive and refuse refresh
   (`invalid_grant`).
   (`invalid_grant`).
-- Production requires `issuer` on `https` and a database URL with
+- Production requires `issuer` on `https` and, unless
+  `database.require_verify_full` is set false, a database URL with
   `sslmode=verify-full`.
   `sslmode=verify-full`.
 - Redis must run with `noeviction` and persistence. The server asserts this at
 - Redis must run with `noeviction` and persistence. The server asserts this at
-  startup and refuses to start otherwise.
+  startup and refuses to start otherwise, unless `redis.noeviction_required` or
+  `redis.aof_required` is set false (both default `true`).
 - When Postgres or Redis is unavailable, security endpoints return `503` rather
 - When Postgres or Redis is unavailable, security endpoints return `503` rather
   than failing open.
   than failing open.
 - CORS is empty by default. Browser clients need an explicit origin allowlist.
 - CORS is empty by default. Browser clients need an explicit origin allowlist.
@@ -260,7 +266,9 @@ certificate.
 - Scope catalog: `scope create|list|enable|disable|delete`. Scopes are
 - Scope catalog: `scope create|list|enable|disable|delete`. Scopes are
   runtime-managed and start empty.
   runtime-managed and start empty.
 - Redirect URIs: `redirect-uri create|list|enable|disable|delete` and
 - Redirect URIs: `redirect-uri create|list|enable|disable|delete` and
-  `client attach-redirect-uri|detach-redirect-uri|redirect-uris`. Each accepts
+  `client attach-redirect-uri|detach-redirect-uri|redirect-uris`.
+  `redirect-uri create|enable|disable|delete` and
+  `client attach-redirect-uri|detach-redirect-uri` accept
   `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
   `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
   the return targets for `POST /logout` (`GET /logout` is read-only).
   the return targets for `POST /logout` (`GET /logout` is read-only).
 - Users and entitlements: `user create|enroll-totp|disable|unlock|grant-scope|revoke-scope|scopes`.
 - Users and entitlements: `user create|enroll-totp|disable|unlock|grant-scope|revoke-scope|scopes`.
@@ -268,7 +276,8 @@ certificate.
 - Declarative apply: `config sync --file` and `config check`.
 - Declarative apply: `config sync --file` and `config check`.
 - PII migration and rotation: `pii backfill`, `pii verify`, and `pii rotate`,
 - PII migration and rotation: `pii backfill`, `pii verify`, and `pii rotate`,
   plus `totp rewrap` for the TOTP key. See [docs/OPERATIONS.md](docs/OPERATIONS.md).
   plus `totp rewrap` for the TOTP key. See [docs/OPERATIONS.md](docs/OPERATIONS.md).
-- DCR initial access: `initial-access-token issue [--show-secret]`.
+- DCR initial access:
+  `initial-access-token issue --uses <n> --ttl <seconds> [--label <text>] [--show-secret]`.
 - Utilities: `keygen` and `healthcheck`.
 - Utilities: `keygen` and `healthcheck`.
 
 
 Full flags are in [docs/OPERATIONS.md](docs/OPERATIONS.md).
 Full flags are in [docs/OPERATIONS.md](docs/OPERATIONS.md).

+ 1 - 1
RUNBOOK.md

@@ -168,7 +168,7 @@ correlation. No step below writes a secret to a log.
       (the raw token is printed once).
       (the raw token is printed once).
 - [ ] **6. Verify fail-closed behavior.** Readiness must report ready only with
 - [ ] **6. Verify fail-closed behavior.** Readiness must report ready only with
       both dependencies up:
       both dependencies up:
-      `d-oauth-server healthcheck --url http://127.0.0.1:8081/health/ready`
+      `d-oauth-server healthcheck [--url http://127.0.0.1:8081/health/ready]`
       (readiness; liveness is `/health/live` as in
       (readiness; liveness is `/health/live` as in
       [docs/OPERATIONS.md](docs/OPERATIONS.md)).
       [docs/OPERATIONS.md](docs/OPERATIONS.md)).
       A dependency outage during recovery must yield `503`, never a fail-open
       A dependency outage during recovery must yield `503`, never a fail-open

+ 18 - 13
docs/ARCHITECTURE.md

@@ -27,10 +27,10 @@ the library.
 | `src/jose/` | DPoP proof verification, JAR request objects, `private_key_jwt` client assertions, and JWK parsing. |
 | `src/jose/` | DPoP proof verification, JAR request objects, `private_key_jwt` client assertions, and JWK parsing. |
 | `src/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
 | `src/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
 | `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
 | `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 purge (`src/protocol/purge.rs`, `purge_subject`, used by `user disable`). |
+| `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/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `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/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
-| `src/resilience.rs` | Load-shed, concurrency-limit, and timeout guard between CORS and rate limiting. |
+| `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/routes.rs` | Router assembly and the middleware order. |
 | `src/serve.rs`, `src/tls.rs` | Listener bootstrap and the custom TLS accept loop. |
 | `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/state.rs`, `src/state/` | `AppState`, the topology-aware Redis connection, health endpoints. |
@@ -56,14 +56,14 @@ is fixed in `build_router_with_options` in `src/routes.rs`:
    still carry the headers.
    still carry the headers.
 3. CORS: answers preflights from the explicit allowlist. Absent when the
 3. CORS: answers preflights from the explicit allowlist. Absent when the
    allowlist is empty, which is the default.
    allowlist is empty, which is the default.
-4. Resilience: sheds excess load, caps concurrency, and times out slow
-   handlers before they reach rate limiting.
+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
 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
    bucket is keyed by the `(peer_ip, client_id)` composite, because
    `client_id` is read before client authentication and must never select a
    `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
-   and metadata have no rule and pass through; those handlers fail closed on
-   their own.
+   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.
 6. Body limit: caps request bodies at 2 MiB.
 
 
 A handler that needs a dependency takes `AppState` and calls a repository or the
 A handler that needs a dependency takes `AppState` and calls a repository or the
@@ -100,9 +100,13 @@ The split is strict and drives the whole design:
   unknown subject stays live. `user disable` purges the user's Redis state
   unknown subject stays live. `user disable` purges the user's Redis state
   before flipping `users.disabled`, failing closed on a Redis error.
   before flipping `users.disabled`, failing closed on a Redis error.
 - Redis holds every short-lived value: authorization codes, access and refresh
 - Redis holds every short-lived value: authorization codes, access and refresh
-  tokens, grant chains, sessions, PAR request URIs, device codes, DCR
-  initial-access tokens, DPoP and `private_key_jwt` replay markers, and
-  rate-limit counters.
+  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,
 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
 users, scopes, or redirect URIs. Registration access tokens are the one token
@@ -138,7 +142,7 @@ so they stay on a single slot. See [REDIS.md](REDIS.md).
 - A mTLS client certificate terminates at an L7 ingress that strips it. In that
 - A mTLS client certificate terminates at an L7 ingress that strips it. In that
   case in-process verification fails; use L4/TCP passthrough. See
   case in-process verification fails; use L4/TCP passthrough. See
   [DEPLOYMENT.md](DEPLOYMENT.md).
   [DEPLOYMENT.md](DEPLOYMENT.md).
-- `/metadata` reads the enabled scope catalog from Postgres on every request, so
+- `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
   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
   is down it returns `503` rather than advertising an empty or unknown scope
   list.
   list.
@@ -163,6 +167,7 @@ so they stay on a single slot. See [REDIS.md](REDIS.md).
 - `src/lib.rs` for the authoritative module list.
 - `src/lib.rs` for the authoritative module list.
 - `src/routes.rs` for endpoint inventory and middleware order.
 - `src/routes.rs` for endpoint inventory and middleware order.
 - `src/serve.rs` for listener bootstrap and shutdown semantics.
 - `src/serve.rs` for listener bootstrap and shutdown semantics.
-- `src/state.rs`, `src/state/health.rs`, `src/state/redis.rs`,
-  `src/state/sentinel.rs` for state, health, and topology.
+- `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.
 - `src/tls.rs` for the TLS accept loop.

+ 8 - 1
docs/CHANGELOG.md

@@ -62,7 +62,9 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
 - Cluster hardening in `deploy/k8s`: the token pepper and TOTP key mount as
 - Cluster hardening in `deploy/k8s`: the token pepper and TOTP key mount as
   `0400` tmpfs files under `/run/secrets` (read through
   `0400` tmpfs files under `/run/secrets` (read through
   `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, mirrored
   `D_OAUTH_TOKEN_PEPPER_FILE` / `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE`, mirrored
-  in `compose.yaml`), the image reference is digest-pinned, a NetworkPolicy
+  in `compose.yaml`), the image reference defaults to the mutable `:latest`
+  tag (a digest pin is available but not the default; see
+  `docs/DEPLOYMENT.md`), a NetworkPolicy
   default-denies pod egress except DNS plus Postgres/Redis scoped to their
   default-denies pod egress except DNS plus Postgres/Redis scoped to their
   namespace (no internet-wide `0.0.0.0/0` peer; `scripts/check_manifests.py`
   namespace (no internet-wide `0.0.0.0/0` peer; `scripts/check_manifests.py`
   rejects one), and `automountServiceAccountToken` is `false`. Remote JAR
   rejects one), and `automountServiceAccountToken` is `false`. Remote JAR
@@ -80,6 +82,11 @@ While the version stays below `1.0.0`, a minor bump may carry a breaking change.
   introspection endpoints do; a missing or unregistered chain fails closed as
   introspection endpoints do; a missing or unregistered chain fails closed as
   `invalid_client`. Client certificates are expected to be short-lived, so
   `invalid_client`. Client certificates are expected to be short-lived, so
   expiry bounds exposure without a revocation list.
   expiry bounds exposure without a revocation list.
+- The token-pepper key ring can retain a previous pepper, matching TOTP and
+  PII: `token_pepper_previous_key` / `token_pepper_previous_key_file` with an
+  explicit `token_pepper_previous_key_id` lets a rotated pepper keep verifying
+  hashes issued under the old one, so rotation no longer invalidates live
+  opaque tokens.
 
 
 ### Changed
 ### Changed
 
 

+ 19 - 7
docs/CONFIGURATION.md

@@ -151,7 +151,10 @@ connection directly, so it may contain any bytes (no URL encoding required).
 | `token_pepper` | none | Inline token-pepper secret. Required unless `token_pepper_file` is set. |
 | `token_pepper` | none | Inline token-pepper secret. Required unless `token_pepper_file` is set. |
 | `token_pepper_file` | none | Mounted token-pepper secret. |
 | `token_pepper_file` | none | Mounted token-pepper secret. |
 | `token_pepper_id` | `v1` | Version id stored with the hash so peppers can rotate. |
 | `token_pepper_id` | `v1` | Version id stored with the hash so peppers can rotate. |
-| `totp_encryption_key` | none | Inline AES-256-GCM key for TOTP secrets. Required to enroll TOTP. |
+| `token_pepper_previous_key` | none | Optional retained previous token-pepper secret for rotation. Required unless `token_pepper_previous_key_file` is set. |
+| `token_pepper_previous_key_file` | none | Mounted previous token-pepper secret. |
+| `token_pepper_previous_key_id` | none | Required when a previous pepper key is set; must differ from `token_pepper_id`. |
+| `totp_encryption_key` | none | Inline AES-256-GCM key for TOTP secrets. Required; the server refuses to start without it (or `totp_encryption_key_file`). |
 | `totp_encryption_key_file` | none | Mounted TOTP key. |
 | `totp_encryption_key_file` | none | Mounted TOTP key. |
 | `totp_key_id` | `v1` | Version id carried in the encrypted TOTP blob. |
 | `totp_key_id` | `v1` | Version id carried in the encrypted TOTP blob. |
 | `totp_previous_key` | none | Optional retained previous TOTP key for rotation. |
 | `totp_previous_key` | none | Optional retained previous TOTP key for rotation. |
@@ -172,6 +175,9 @@ The exact environment spellings:
 | `token_pepper` | `D_OAUTH_TOKEN_PEPPER` |
 | `token_pepper` | `D_OAUTH_TOKEN_PEPPER` |
 | `token_pepper_file` | `D_OAUTH_TOKEN_PEPPER_FILE` |
 | `token_pepper_file` | `D_OAUTH_TOKEN_PEPPER_FILE` |
 | `token_pepper_id` | `D_OAUTH_TOKEN_PEPPER_ID` |
 | `token_pepper_id` | `D_OAUTH_TOKEN_PEPPER_ID` |
+| `token_pepper_previous_key` | `D_OAUTH_TOKEN_PEPPER_PREVIOUS_KEY` |
+| `token_pepper_previous_key_file` | `D_OAUTH_TOKEN_PEPPER_PREVIOUS_KEY_FILE` |
+| `token_pepper_previous_key_id` | `D_OAUTH_TOKEN_PEPPER_PREVIOUS_KEY_ID` |
 | `totp_encryption_key` | `D_OAUTH_TOTP_ENCRYPTION_KEY` |
 | `totp_encryption_key` | `D_OAUTH_TOTP_ENCRYPTION_KEY` |
 | `totp_encryption_key_file` | `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` |
 | `totp_encryption_key_file` | `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` |
 | `totp_key_id` | `D_OAUTH_TOTP_KEY_ID` |
 | `totp_key_id` | `D_OAUTH_TOTP_KEY_ID` |
@@ -186,9 +192,12 @@ The exact environment spellings:
 | `pii_previous_key_id` | `D_OAUTH_PII_PREVIOUS_KEY_ID` |
 | `pii_previous_key_id` | `D_OAUTH_PII_PREVIOUS_KEY_ID` |
 | `pii_dual_write` | `D_OAUTH_PII_DUAL_WRITE` |
 | `pii_dual_write` | `D_OAUTH_PII_DUAL_WRITE` |
 
 
-The token pepper and the PII field-encryption key are required: without either
-the key ring cannot be built and the server refuses to start with
-`MissingPiiKey` (or `MissingPepper`). Key ids (`token_pepper_id`,
+The token pepper, the TOTP encryption key, and the PII field-encryption key are
+required: without any of them the key ring cannot be built and the server
+refuses to start with `ConfigError::KeyRing(KeyRingError::MissingTokenPepper)`
+for the token pepper, `ConfigError::KeyRing(KeyRingError::MissingTotpKey)` for
+the TOTP key, or `ConfigError::KeyRing(KeyRingError::MissingPiiKey)` for the
+PII key. Key ids (`token_pepper_id`,
 `totp_key_id`, `pii_key_id`, and any `*_previous_key_id`) are stored verbatim:
 `totp_key_id`, `pii_key_id`, and any `*_previous_key_id`) are stored verbatim:
 token hashes as `<kid>.<digest>` (split on the first `.`) and PII blind
 token hashes as `<kid>.<digest>` (split on the first `.`) and PII blind
 indexes as `<kid>:<digest>`, so an id must contain neither `.` (token split)
 indexes as `<kid>:<digest>`, so an id must contain neither `.` (token split)
@@ -238,7 +247,9 @@ The security endpoints are `authorize`, `token`, `introspect`, `revoke`, `par`,
 | `argon2.parallelism` | `1` |
 | `argon2.parallelism` | `1` |
 
 
 Every TTL must be non-zero, and the access-token TTL must not exceed the
 Every TTL must be non-zero, and the access-token TTL must not exceed the
-refresh-token TTL. Argon2 memory must be at least 8192 KiB, with a non-zero
+refresh-token TTL. The authorization-code lifetime is additionally capped at
+60 s (`CODE_TTL_CAP` in `src/protocol/code.rs`), so a `ttl.code_secs` value
+above 60 is effectively clamped. Argon2 memory must be at least 8192 KiB, with a non-zero
 iteration count and parallelism.
 iteration count and parallelism.
 
 
 ## Rate-limit keys
 ## Rate-limit keys
@@ -318,8 +329,9 @@ the statement.
 - Forwarded client-cert and client-IP headers are never trusted: the listener
 - Forwarded client-cert and client-IP headers are never trusted: the listener
   uses only the TLS peer address and the certificate chain it verified itself.
   uses only the TLS peer address and the certificate chain it verified itself.
 - CORS and open registration are off by default.
 - CORS and open registration are off by default.
-- File-backed pepper/TOTP/PII keys are read from a `0400` tmpfs file, never
-  from the process environment.
+- File-backed pepper/TOTP/PII keys are read from a `0400` tmpfs file, so the
+  material avoids the process environment; the inline forms are supported and
+  win when both are set.
 
 
 ## Edge cases
 ## Edge cases
 
 

+ 11 - 3
docs/CONTRIBUTING.md

@@ -35,15 +35,20 @@ cargo build --locked --release  # release (thin LTO, stripped)
 The build is offline-safe for SQL: with `SQLX_OFFLINE=true` the `query!` macros
 The build is offline-safe for SQL: with `SQLX_OFFLINE=true` the `query!` macros
 compile against the committed `.sqlx/` cache, so no database is needed at build
 compile against the committed `.sqlx/` cache, so no database is needed at build
 time. The Docker build relies on exactly this. When a query or a migration
 time. The Docker build relies on exactly this. When a query or a migration
-changes, regenerate and commit the cache:
+changes, regenerate and commit the cache. CI runs the check form and fails if
+the committed cache is stale:
 
 
 ```bash
 ```bash
-cargo sqlx prepare --workspace
+cargo sqlx prepare --check --workspace   # CI gate
+cargo sqlx prepare --workspace           # regenerate the cache
 ```
 ```
 
 
 ## Quality gates
 ## Quality gates
 
 
-Run all of these before opening a change. CI runs the same set.
+Run all of these before opening a change. CI runs this set plus additional
+gates: `cargo sqlx prepare --check --workspace`, the fuzz targets
+(`cargo fuzz build`), coverage, `cargo mutants`, `miri`, and the separate
+`.github/workflows/docs.yml` link check.
 
 
 ```bash
 ```bash
 cargo fmt --check
 cargo fmt --check
@@ -175,8 +180,11 @@ Every module has a single responsibility:
 | Protocol handlers | `src/protocol/` | OAuth endpoints and grants. |
 | Protocol handlers | `src/protocol/` | OAuth endpoints and grants. |
 | Server-rendered UI | `src/ui/` | Login, consent, and device pages. |
 | Server-rendered UI | `src/ui/` | Login, consent, and device pages. |
 | Middleware | `src/middleware/` | Request id, tracing/redaction, rate limit, security headers, audit. |
 | Middleware | `src/middleware/` | Request id, tracing/redaction, rate limit, security headers, audit. |
+| Resilience | `src/resilience.rs` | Load-shed, timeout, and concurrency-limit guard. |
 | Routing and serving | `src/routes.rs`, `src/serve.rs`, `src/tls.rs` | Router assembly and the two listeners. |
 | Routing and serving | `src/routes.rs`, `src/serve.rs`, `src/tls.rs` | Router assembly and the two listeners. |
+| Application state | `src/state.rs`, `src/state/` | `AppState`, the topology-aware Redis connection, health endpoints (health/pg/redis/sentinel). |
 | Operator CLI | `src/cli.rs`, `src/cli/` | Every administrative command. |
 | Operator CLI | `src/cli.rs`, `src/cli/` | Every administrative command. |
+| Errors | `src/error.rs` | `AppError`: the client-safe OAuth error type and its HTTP mapping. |
 
 
 Rules that apply everywhere:
 Rules that apply everywhere:
 
 

+ 27 - 16
docs/DATABASE.md

@@ -35,10 +35,10 @@ One row per registered client (RFC 7591 / RFC 7592).
 | `jwks` | `jsonb` | Optional inline JWKS. Public by design; never sealed. |
 | `jwks` | `jsonb` | Optional inline JWKS. Public by design; never sealed. |
 | `jwks_uri` | `text` | Optional JWKS URL (fetched with the SSRF guard). |
 | `jwks_uri` | `text` | Optional JWKS URL (fetched with the SSRF guard). |
 | `jwks_uri_enc` | `bytea` | Sealed JWKS URL. |
 | `jwks_uri_enc` | `bytea` | Sealed JWKS URL. |
-| `tls_client_auth_subject_dn` | `text` | RFC 8705 subject DN binding. Nullable after the expansion migration. |
+| `tls_client_auth_subject_dn` | `text` | RFC 8705 subject DN binding. Nullable in the base table. |
 | `tls_client_auth_subject_dn_enc` | `bytea` | Sealed subject DN. |
 | `tls_client_auth_subject_dn_enc` | `bytea` | Sealed subject DN. |
 | `tls_client_auth_subject_dn_index` | `text` | Keyed blind index over the subject DN. |
 | `tls_client_auth_subject_dn_index` | `text` | Keyed blind index over the subject DN. |
-| `tls_client_auth_cert_thumbprint` | `text` | RFC 8705 `x5t#S256` binding. Nullable after the expansion migration. |
+| `tls_client_auth_cert_thumbprint` | `text` | RFC 8705 `x5t#S256` binding. Nullable in the base table. |
 | `tls_client_auth_cert_thumbprint_enc` | `bytea` | Sealed certificate thumbprint. |
 | `tls_client_auth_cert_thumbprint_enc` | `bytea` | Sealed certificate thumbprint. |
 | `tls_client_auth_cert_thumbprint_index` | `text` | Keyed blind index over the thumbprint. |
 | `tls_client_auth_cert_thumbprint_index` | `text` | Keyed blind index over the thumbprint. |
 | `request_uris` | `jsonb` | Pre-registered JAR request URIs. |
 | `request_uris` | `jsonb` | Pre-registered JAR request URIs. |
@@ -142,7 +142,9 @@ is unique per `(uri, kind)`.
 `ux_redirect_uris_uri_index_kind` is a partial unique index on
 `ux_redirect_uris_uri_index_kind` is a partial unique index on
 `(uri_index, kind)` where `uri_index` is not null; the legacy
 `(uri_index, kind)` where `uri_index` is not null; the legacy
 `ux_redirect_uris_uri_kind UNIQUE (uri, kind)` constraint is kept and goes inert
 `ux_redirect_uris_uri_kind UNIQUE (uri, kind)` constraint is kept and goes inert
-once every `uri` is NULL.
+once every `uri` is NULL. `ck_redirect_uris_kind` is a
+`CHECK (kind IN ('authorize', 'post_logout'))` constraint that keeps the column
+to those two values.
 
 
 `kind` separates the two browser landings: `authorize` URIs are valid
 `kind` separates the two browser landings: `authorize` URIs are valid
 authorization-response callbacks (they carry `code`/`state`); `post_logout` URIs
 authorization-response callbacks (they carry `code`/`state`); `post_logout` URIs
@@ -174,16 +176,21 @@ them, or an operator must install the extension out of band before migrating.
 
 
 ## PII encryption at rest
 ## PII encryption at rest
 
 
-The plaintext identifier columns stay in the schema, but once the migration
-completes they are inert: NULL for every row, kept only so version N-1 replicas
-can still read them during the rollout. The live values move into two parallel
-columns per field:
-
-- **`_enc` (`bytea`)** holds a versioned AES-256-GCM blob. The blob carries the
-  key id (`kid`) of the PII key that wrote it, then a random 96-bit nonce, then
-  the ciphertext and tag. The key is derived from the master PII key with
-  HKDF-SHA256 (info `pii-field-gcm-v1`), so the ciphertext is not key-stable:
-  rotating the master key re-encrypts every blob.
+The plaintext identifier columns stay in the schema. The PII migration only adds
+the `_enc`/`_index` columns and drops `NOT NULL`; it blanks nothing. The
+plaintext is nulled later by the `pii backfill` cutover (`pii_dual_write=false`,
+app tier scaled to zero), and only then are the columns inert: NULL for every
+row, kept only so version N-1 replicas can still read them during the rollout.
+While `pii_dual_write=true`, writers still store the plaintext alongside the
+sealed value. The live values move into two parallel columns per field:
+
+- **`_enc` (`bytea`)** holds a versioned AES-256-GCM blob with the framing
+  `version(1)=1 || kid_len(1) || kid || nonce(12) || ciphertext+tag`: the format
+  version byte, a one-byte key-id length, the key id (`kid`) of the PII key that
+  wrote it, a random 96-bit nonce, then the ciphertext and tag. The key is
+  derived from the master PII key with HKDF-SHA256 (info `pii-field-gcm-v1`), so
+  the ciphertext is not key-stable: rotating the master key re-encrypts every
+  blob.
 - **`_index` (`text`)** holds a keyed-HMAC blind index in the form
 - **`_index` (`text`)** holds a keyed-HMAC blind index in the form
   `{kid}:{hex}`. Encrypted bytes cannot be compared or made unique directly, so
   `{kid}:{hex}`. Encrypted bytes cannot be compared or made unique directly, so
   the columns that are looked up or must stay unique (`email_index`, the two
   the columns that are looked up or must stay unique (`email_index`, the two
@@ -213,8 +220,10 @@ zero) while `pii rotate` re-encrypts and recomputes every index; see
 - `serve` never migrates. Startup only reads the schema. Readiness returns 503
 - `serve` never migrates. Startup only reads the schema. Readiness returns 503
   until every embedded migration version is present in `_sqlx_migrations`.
   until every embedded migration version is present in `_sqlx_migrations`.
 - A checksum mismatch or a dirty migration fails fast rather than guessing.
 - A checksum mismatch or a dirty migration fails fast rather than guessing.
-- Each file is deterministic with no down path. There is no `DROP` and no
-  rollback.
+- Each file is deterministic with no down path. There is no rollback and no
+  `DROP TABLE` or `DROP COLUMN`; the release only adds columns, apart from
+  dropping the legacy `ux_redirect_uris_uri` constraint and the `NOT NULL`s on
+  the plaintext columns the backfill blanks.
 
 
 In a cluster, run migrations from a dedicated Job, never from every replica.
 In a cluster, run migrations from a dedicated Job, never from every replica.
 
 
@@ -247,7 +256,9 @@ The PII encryption rollout follows this lifecycle exactly:
    blanked plaintext PII column still holds a value.
    blanked plaintext PII column still holds a value.
 6. **Contract (later release).** Dropping the now-inert plaintext columns and
 6. **Contract (later release).** Dropping the now-inert plaintext columns and
    legacy constraints is a deliberate follow-up release, not part of this
    legacy constraints is a deliberate follow-up release, not part of this
-   change. This release drops and renames nothing.
+   change. This release renames no column and drops no table or column, though
+   it does drop the legacy `ux_redirect_uris_uri` constraint and the `NOT NULL`s
+   on the plaintext columns the backfill blanks.
 
 
 **Maintenance window.** `ALTER TABLE ... DROP NOT NULL` takes `ACCESS EXCLUSIVE`
 **Maintenance window.** `ALTER TABLE ... DROP NOT NULL` takes `ACCESS EXCLUSIVE`
 on `users`, `redirect_uris`, and `oauth_clients`, blocking reads and writes for
 on `users`, `redirect_uris`, and `oauth_clients`, blocking reads and writes for

+ 57 - 27
docs/DEPLOYMENT.md

@@ -84,7 +84,10 @@ noticeably slower than the amd64 build.
 Then run the image:
 Then run the image:
 
 
 ```bash
 ```bash
-docker run --rm -p 8080:8080 --read-only d-oauth-server:latest
+docker run --rm -p 8080:8080 --read-only \
+  --env-file ./oauth.env \
+  -v /path/to/tls:/certs:ro \
+  d-oauth-server:latest
 ```
 ```
 
 
 The container needs its configuration from the environment, its TLS material
 The container needs its configuration from the environment, its TLS material
@@ -275,8 +278,9 @@ A correct Deployment has these properties:
   environment or baked into the image.
   environment or baked into the image.
 - A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
 - A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
   Redis, and admits ingress only on the two pod ports (see below).
   Redis, and admits ingress only on the two pod ports (see below).
-- The `ghcr.io/syavne/d-oauth-server:latest` image reference that
-  `kustomization.yaml` applies. `:latest` is mutable, so a rollout is only as
+- The `ghcr.io/syavne/d-oauth-server:latest` image reference carried in
+  `deployment.yaml` and `migrate-job.yaml`; `kustomization.yaml` applies no
+  `images:` override. `:latest` is mutable, so a rollout is only as
   fresh as the last pull; that is a deliberate convenience tradeoff, not an
   fresh as the last pull; that is a deliberate convenience tradeoff, not an
   immutable pin.
   immutable pin.
 - Client-CA material mounted read-only if in-process mTLS is used. The Postgres
 - Client-CA material mounted read-only if in-process mTLS is used. The Postgres
@@ -309,6 +313,10 @@ D_OAUTH_DATABASE__PASSWORD_FILE=/run/secrets/database-password
 D_OAUTH_REDIS__PASSWORD_FILE=/run/secrets/redis-password
 D_OAUTH_REDIS__PASSWORD_FILE=/run/secrets/redis-password
 ```
 ```
 
 
+The two password variables also accept the unprefixed
+`DATABASE_PASSWORD_FILE` / `REDIS_PASSWORD_FILE` alias, but the shipped
+manifests and `scripts/check_manifests.py` use the prefixed `D_OAUTH_*` forms.
+
 The passwordless DSNs are non-secret and live in the ConfigMap
 The passwordless DSNs are non-secret and live in the ConfigMap
 (`D_OAUTH_DATABASE__URL` with `sslmode=verify-full` and `D_OAUTH_REDIS__URL`);
 (`D_OAUTH_DATABASE__URL` with `sslmode=verify-full` and `D_OAUTH_REDIS__URL`);
 only the passwords come from the Secret files. Only the runtime user/group can
 only the passwords come from the Secret files. Only the runtime user/group can
@@ -425,8 +433,13 @@ spec:
 
 
 ### Database access and backups
 ### Database access and backups
 
 
-The schema is plaintext PII plus hashed or encrypted secrets, so the database
-and every copy of it are in scope for the full-disclosure threat model. The
+The schema stores sealed PII (`_enc` AES-256-GCM ciphertext plus
+keyed-HMAC blind indexes) alongside hashed or encrypted secrets; the PII
+migration only adds the `_enc` columns and drops `NOT NULL`, and the legacy
+plaintext identifier columns are blanked to NULL by the `pii backfill` cutover,
+kept only so version N-1 replicas can still read them during the rollout. The
+database and every copy of it are in scope for the full-disclosure threat
+model. The
 server opens one DSN as one role; it does not create roles, audit rows, or
 server opens one DSN as one role; it does not create roles, audit rows, or
 encrypted backups, so those controls live with the operator. Apply at least:
 encrypted backups, so those controls live with the operator. Apply at least:
 
 
@@ -447,14 +460,20 @@ encrypted backups, so those controls live with the operator. Apply at least:
   permission changes. A read-only dump otherwise leaves no trace.
   permission changes. A read-only dump otherwise leaves no trace.
 - **Encrypt the backups and their keys.** `pg_dump`, `pg_basebackup`, and
 - **Encrypt the backups and their keys.** `pg_dump`, `pg_basebackup`, and
   pgBackRest output must be encrypted at rest, stored off-site, and restore
   pgBackRest output must be encrypted at rest, stored off-site, and restore
-  tested. The backup is a full copy of the plaintext PII columns, so treat it
-  exactly like a live dump; the backup key is custodied separately and never
+  tested. The backup is a point-in-time copy of the `_enc` ciphertext and the
+  blind indexes, plus the inert NULL plaintext columns, so treat it
+  exactly like a live dump; after the PII key is rotated, an old backup is
+  readable only with the retained old PII key, so custody of the key store
+  still governs backup disclosure. The backup key is custodied separately and
+  never
   with the database (see
   with the database (see
   [SECURITY.md](SECURITY.md#master-key-custody)).
   [SECURITY.md](SECURITY.md#master-key-custody)).
 - **Consider Postgres-level encryption.** Disk or volume encryption (cloud
 - **Consider Postgres-level encryption.** Disk or volume encryption (cloud
   EBS/PD or a filesystem layer) protects stolen media; `pg_tde` or an engine's
   EBS/PD or a filesystem layer) protects stolen media; `pg_tde` or an engine's
   TDE protects the data files; `pgcrypto` can encrypt selected PII columns at
   TDE protects the data files; `pgcrypto` can encrypt selected PII columns at
-  the SQL layer. The application already encrypts TOTP secrets with AES-256-GCM
+  the SQL layer. The application already seals directory PII in `_enc`
+  AES-256-GCM columns with keyed-HMAC blind indexes and encrypts TOTP secrets
+  with AES-256-GCM
   and hashes passwords and client secrets with Argon2id, so `pgcrypto` is an
   and hashes passwords and client secrets with Argon2id, so `pgcrypto` is an
   alternative, not the current design, and it moves key custody into the SQL
   alternative, not the current design, and it moves key custody into the SQL
   path.
   path.
@@ -568,7 +587,10 @@ cosign verify \
 kustomize build deploy/k8s
 kustomize build deploy/k8s
 python3 scripts/check_manifests.py
 python3 scripts/check_manifests.py
 
 
-# 3. Roll out (migration Job first; the Deployment readiness gate waits for it).
+# 3. Roll out. A single `kubectl apply -k` creates the migration Job and the
+#    Deployment together; the Deployment readiness gate (`/health/ready`
+#    against `_sqlx_migrations`) keeps new replicas out of traffic until the
+#    Job has applied the schema, so it sequences itself.
 kubectl apply --dry-run=server -k deploy/k8s
 kubectl apply --dry-run=server -k deploy/k8s
 kubectl apply -k deploy/k8s
 kubectl apply -k deploy/k8s
 ```
 ```
@@ -646,29 +668,37 @@ tarball.
   the process.
   the process.
 - A CNI that does not enforce NetworkPolicy leaves pod egress unrestricted;
 - A CNI that does not enforce NetworkPolicy leaves pod egress unrestricted;
   verify the cluster CNI applies the policy before relying on it.
   verify the cluster CNI applies the policy before relying on it.
-- Re-applying the manifests after a new push rolls forward: `:latest` is
-  mutable, so an unchanged manifest can still pull a newer build than the one
-  you verified.
+- Re-applying the manifests after a new push does not roll forward on its own:
+  both workloads use `imagePullPolicy: IfNotPresent` with a mutable `:latest`,
+  so an unchanged pod template triggers no rollout and the cached image is
+  reused. Pin an explicit tag or digest, or force a rollout (`kubectl rollout
+  restart` or a pod-template annotation change), to pull a new build. `:latest`
+  is mutable, so even a fresh pull can differ from the one you verified.
 
 
 ## Operational runbook
 ## Operational runbook
 
 
 - Roll out a new image by re-applying the manifests; they track
 - Roll out a new image by re-applying the manifests; they track
-  `ghcr.io/syavne/d-oauth-server:latest`, and the migration Job runs first. To
-  roll a specific build instead, point the `images` entry at a release tag such
-  as `<X.Y.Z>`.
-- Probe commands inside the pod network:
-
-```bash
-wget -qO- http://<pod-ip>:8081/health/live
-wget -qO- http://<pod-ip>:8081/health/ready
-```
-
-- The container healthcheck, usable without a shell:
+  `ghcr.io/syavne/d-oauth-server:latest` directly in `deployment.yaml` and
+  `migrate-job.yaml` (`kustomization.yaml` applies no `images:` override), and
+  the migration Job runs first because the Deployment readiness gate waits for
+  it. Re-applying alone does not roll forward: both workloads use
+  `imagePullPolicy: IfNotPresent`, so an unchanged pod template triggers no
+  rollout and a cached `:latest` is reused. To roll a specific build, pin an
+  explicit tag or digest in those files' `image:` (or add an `images:` override
+  to `kustomization.yaml`), or force a rollout with `kubectl rollout restart`
+  or a pod-template annotation change.
+- Probe commands inside the pod, using the in-image `healthcheck`. The runtime
+  image has no shell, `wget`, or `curl`, so a `wget` variant needs a debug or
+  sidecar container that carries those tools:
 
 
 ```bash
 ```bash
 d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
 d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
+d-oauth-server healthcheck --url http://127.0.0.1:8081/health/ready
 ```
 ```
 
 
+- The same `healthcheck` subcommand is the image `HEALTHCHECK`, usable without
+  a shell.
+
 - On a rolling upgrade, watch that the PDB keeps at least two replicas ready
 - On a rolling upgrade, watch that the PDB keeps at least two replicas ready
   and that readiness recovers after the drain.
   and that readiness recovers after the drain.
 
 
@@ -692,14 +722,14 @@ d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
   passwords — all
   passwords — all
   file-mounted and replaced by the secret manager), `service.yaml` (public TLS
   file-mounted and replaced by the secret manager), `service.yaml` (public TLS
   port only, never the health port), `deployment.yaml` (3 stateless replicas,
   port only, never the health port), `deployment.yaml` (3 stateless replicas,
-  probes on the plaintext health listener, PDB + topology spread,
+  probes on the plaintext health listener, topology spread,
   `automountServiceAccountToken: false`, every secret file-mounted),
   `automountServiceAccountToken: false`, every secret file-mounted),
   `migrate-job.yaml` (exactly-once schema migration, never per replica),
   `migrate-job.yaml` (exactly-once schema migration, never per replica),
   `networkpolicy.yaml` (default-deny egress except DNS + Postgres/Redis scoped
   `networkpolicy.yaml` (default-deny egress except DNS + Postgres/Redis scoped
   to their namespace, no `0.0.0.0/0` peer; ingress to the two pod ports),
   to their namespace, no `0.0.0.0/0` peer; ingress to the two pod ports),
-  `pdb.yaml` (`minAvailable: 2`), `kustomization.yaml`
-  (an `images` entry pointing at `ghcr.io/syavne/d-oauth-server:latest`), and
-  `tls-secret.example.yaml` (how to provision `/certs`, never applied). No
+  `pdb.yaml` (`minAvailable: 2`), `kustomization.yaml` (no `images:` override;
+  the mutable `:latest` tag lives in `deployment.yaml` / `migrate-job.yaml`),
+  and `tls-secret.example.yaml` (how to provision `/certs`, never applied). No
   Ingress is shipped: mTLS requires L4/TCP passthrough.
   Ingress is shipped: mTLS requires L4/TCP passthrough.
 - `compose.yaml` for the local 3-replica proof (one Postgres + one Redis,
 - `compose.yaml` for the local 3-replica proof (one Postgres + one Redis,
   one-shot `migrate`, file-mounted dev secrets under `/run/secrets`, random
   one-shot `migrate`, file-mounted dev secrets under `/run/secrets`, random

+ 16 - 6
docs/OAUTH.md

@@ -40,9 +40,9 @@ practice in RFC 9700. This server enforces every one of these:
 | The `plain` PKCE method is removed | `code_challenge_method=plain` is rejected. | RFC 7636 §4.3, RFC 9700 §2.1.1 |
 | The `plain` PKCE method is removed | `code_challenge_method=plain` is rejected. | RFC 7636 §4.3, RFC 9700 §2.1.1 |
 | The implicit grant is removed | Only `response_type=code` exists; there is no token response. | draft-ietf-oauth-v2-1-16, RFC 9700 |
 | The implicit grant is removed | Only `response_type=code` exists; there is no token response. | draft-ietf-oauth-v2-1-16, RFC 9700 |
 | The resource-owner-password grant is removed | There is no `grant_type=password` code path. | draft-ietf-oauth-v2-1-16 |
 | The resource-owner-password grant is removed | There is no `grant_type=password` code path. | draft-ietf-oauth-v2-1-16 |
-| Bearer credentials in the URI query are rejected | Access tokens are presented in the `Authorization` header. | RFC 6750 §2 |
+| Bearer credentials in the URI query are rejected | Access tokens are presented in the `Authorization` header. | draft-ietf-oauth-v2-1-16 §5.1 |
 | Redirect URIs are matched exactly | No prefix or wildcard match; one loopback exception. | RFC 9700 §2.1, RFC 3986 §6.2.1 |
 | Redirect URIs are matched exactly | No prefix or wildcard match; one loopback exception. | RFC 9700 §2.1, RFC 3986 §6.2.1 |
-| Public-client refresh tokens rotate | Rotation on every use, with tombstone reuse detection. | RFC 9700 §2.2.2, §4.14 |
+| All refresh tokens rotate on every use | Rotation with tombstone reuse detection applies to every refresh grant. | RFC 9700 §2.2.2, §4.14 |
 | Authorization codes are single-use and short-lived | A code is consumed atomically and capped at 60 seconds. | RFC 9700 §4.5.3 |
 | Authorization codes are single-use and short-lived | A code is consumed atomically and capped at 60 seconds. | RFC 9700 §4.5.3 |
 | `state` is supported and echoed | The value is returned on the success and error redirects. | RFC 6749 §4.1.2.1 |
 | `state` is supported and echoed | The value is returned on the success and error redirects. | RFC 6749 §4.1.2.1 |
 | No open redirect | The redirect URI must be registered, attached, and enabled. | RFC 9700 §4.1.3 |
 | No open redirect | The redirect URI must be registered, attached, and enabled. | RFC 9700 §4.1.3 |
@@ -61,12 +61,18 @@ mounts only `/health/live` and `/health/ready`.
 | POST | `/device_authorization` | RFC 8628 §3.1 | Wired |
 | POST | `/device_authorization` | RFC 8628 §3.1 | Wired |
 | GET, POST | `/device` | RFC 8628 §3.3 | Wired |
 | GET, POST | `/device` | RFC 8628 §3.3 | Wired |
 | GET, POST | `/login` | RFC 9700 §4.7.1 | Wired |
 | GET, POST | `/login` | RFC 9700 §4.7.1 | Wired |
+| GET | `/login/success` | server-specific | Wired |
+| GET, POST | `/login/change-password` | server-specific | Wired |
+| GET | `/login/totp/setup` | server-specific | Wired |
+| GET, POST | `/login/totp/confirm` | server-specific | Wired |
 | GET, POST | `/logout` | server-specific | Wired |
 | GET, POST | `/logout` | server-specific | Wired |
 | GET, POST | `/consent` | RFC 9700 §4.7.1 | Wired |
 | GET, POST | `/consent` | RFC 9700 §4.7.1 | Wired |
 | POST | `/introspect` | RFC 7662 §2.1 | Wired |
 | POST | `/introspect` | RFC 7662 §2.1 | Wired |
 | POST | `/revoke` | RFC 7009 §2.1 | Wired |
 | POST | `/revoke` | RFC 7009 §2.1 | Wired |
 | POST | `/register` | RFC 7591 §2 | Wired |
 | POST | `/register` | RFC 7591 §2 | Wired |
 | GET, PUT, DELETE | `/register/{client_id}` | RFC 7592 §2 | Wired |
 | GET, PUT, DELETE | `/register/{client_id}` | RFC 7592 §2 | Wired |
+| GET | `/assets/app.css` | server-specific | Wired |
+| GET | `/health/live`, `/health/ready` | server-specific (canonical: ARCHITECTURE.md) | Wired |
 
 
 `GET /logout` and `POST /logout` are a server-specific session endpoint, not a
 `GET /logout` and `POST /logout` are a server-specific session endpoint, not a
 feature defined by OAuth 2.1 or OIDC. `GET /logout` is read-only: it renders
 feature defined by OAuth 2.1 or OIDC. `GET /logout` is read-only: it renders
@@ -258,13 +264,17 @@ Errors use the RFC 6749 §5.2 body `{error, error_description}`.
 | `invalid_dpop_proof` | 400 | Missing or invalid DPoP proof; replayed `jti`. |
 | `invalid_dpop_proof` | 400 | Missing or invalid DPoP proof; replayed `jti`. |
 | `invalid_redirect_uri` | 400 | DCR redirect URI not in the catalog or otherwise ineligible. |
 | `invalid_redirect_uri` | 400 | DCR redirect URI not in the catalog or otherwise ineligible. |
 | `invalid_client_metadata` | 400 | DCR metadata that violates the rules. |
 | `invalid_client_metadata` | 400 | DCR metadata that violates the rules. |
-| `access_denied` | 400 | Resource-owner denial; RFC 7592 token mismatch. |
+| `access_denied` | 400 (403 on DCR manage token mismatch) | Resource-owner denial; RFC 7592 token mismatch. |
 | `authorization_pending` | 400 | Device flow before approval. |
 | `authorization_pending` | 400 | Device flow before approval. |
 | `slow_down` | 400 | Device polling faster than the interval. |
 | `slow_down` | 400 | Device polling faster than the interval. |
 | `expired_token` | 400 | Device code or code expired. |
 | `expired_token` | 400 | Device code or code expired. |
 | `temporarily_unavailable` | 503 with `Retry-After: 1` | Postgres or Redis is unavailable on a security path. |
 | `temporarily_unavailable` | 503 with `Retry-After: 1` | Postgres or Redis is unavailable on a security path. |
 | `server_error` | 500 | A masked internal failure. |
 | `server_error` | 500 | A masked internal failure. |
 
 
+The device polling codes `authorization_pending`, `slow_down`, and
+`expired_token` live in `src/protocol/token/grants/device_code/error.rs`
+(`DevicePollError`) and have no `AppError` counterpart.
+
 The server never fails open on a security path: a dependency outage yields
 The server never fails open on a security path: a dependency outage yields
 `503`, never a silent success.
 `503`, never a silent success.
 
 
@@ -283,9 +293,9 @@ never satisfies sender constraint. For token exchange, the proof must match the
 subject token's confirmation. See [SECURITY.md](SECURITY.md) for the full matrix
 subject token's confirmation. See [SECURITY.md](SECURITY.md) for the full matrix
 and the confirmation propagation rule. `private_key_jwt` accepts `RS256`, `PS256`, `ES256`, and
 and the confirmation propagation rule. `private_key_jwt` accepts `RS256`, `PS256`, `ES256`, and
 `EdDSA` only (`none` and HMAC are rejected); DPoP proofs accept `ES256`, `EdDSA`, and `RS256` only (no `PS256`).
 `EdDSA` only (`none` and HMAC are rejected); DPoP proofs accept `ES256`, `EdDSA`, and `RS256` only (no `PS256`).
-`exp` may lie at most 300 s past `now` with 30 s clock-skew leeway on each side
-(360 s bound), and the single-use `jti` marker lives for the
-assertion's remaining validity, clamped to `[1 s, 360 s]`.
+The `exp` acceptance bound is 330 s: `exp` may lie at most 300 s past `now` plus
+30 s clock-skew leeway. The single-use `jti` marker lives for the assertion's
+remaining validity, clamped to `[1 s, 360 s]`.
 
 
 ## Metadata
 ## Metadata
 
 

+ 20 - 13
docs/OPERATIONS.md

@@ -43,7 +43,7 @@ d-oauth-server config check
 
 
 ```bash
 ```bash
 d-oauth-server user create --email <email> [--admin] [--password-stdin] [--password-file <path>]
 d-oauth-server user create --email <email> [--admin] [--password-stdin] [--password-file <path>]
-d-oauth-server user enroll-totp --email <email>
+d-oauth-server user enroll-totp --email <email> [--show-secret]
 d-oauth-server user disable --email <email>
 d-oauth-server user disable --email <email>
 d-oauth-server user unlock --email <email>
 d-oauth-server user unlock --email <email>
 d-oauth-server user grant-scope --email <email> --scope <scope>
 d-oauth-server user grant-scope --email <email> --scope <scope>
@@ -89,10 +89,11 @@ d-oauth-server scope disable <name>
 d-oauth-server scope delete <name>
 d-oauth-server scope delete <name>
 ```
 ```
 
 
-`create` upserts: it creates a scope or updates its description. `delete`
+`create` upserts: it creates a scope, updates its description, or re-enables a
+disabled one. `delete`
 deletes a scope that nothing references, and disables it when `user_scopes`
 deletes a scope that nothing references, and disables it when `user_scopes`
 references it, so entitlements are not silently destroyed. The enabled scope
 references it, so entitlements are not silently destroyed. The enabled scope
-catalog is what `/metadata` advertises as `scopes_supported`.
+catalog is what `GET /.well-known/oauth-authorization-server` (discovery document) advertises as `scopes_supported`.
 
 
 ### Redirect URIs and client attachments
 ### Redirect URIs and client attachments
 
 
@@ -126,8 +127,11 @@ disable or delete the legacy row first, then re-run the sync.
 authorization-response callback. `post_logout` is a bare return target used only
 authorization-response callback. `post_logout` is a bare return target used only
 by `POST /logout` to send the browser back to the client after the session ends
 by `POST /logout` to send the browser back to the client after the session ends
 (`GET /logout` is read-only and issues no redirect).
 (`GET /logout` is read-only and issues no redirect).
-A client may register at most one enabled `post_logout` URI; `POST /logout`
-redirects only to it, and only while the URI is enabled and the client active.
+`POST /logout` redirects only while exactly one enabled `post_logout` URI is
+registered for the client and the client is active. Zero registered enabled URIs
+or more than one, and `POST /logout` performs no redirect: the target must be
+unambiguous or nothing happens. Registering a second enabled `post_logout` URI
+is a configuration error, not a silent last-writer-wins.
 
 
 ### Declarative config
 ### Declarative config
 
 
@@ -169,14 +173,15 @@ when `allow_open_registration` is false.
 ### Key generation and health
 ### Key generation and health
 
 
 ```bash
 ```bash
-d-oauth-server keygen --alg es256
-d-oauth-server healthcheck --url http://127.0.0.1:8081/health/live
+d-oauth-server keygen [--alg es256]
+d-oauth-server healthcheck [--url <url>]
 ```
 ```
 
 
 `keygen` emits an ES256 keypair for test fixtures and client-side DPoP keys. It
 `keygen` emits an ES256 keypair for test fixtures and client-side DPoP keys. It
 is never an authorization-server signing key; the server publishes no
 is never an authorization-server signing key; the server publishes no
-`jwks_uri`. `healthcheck` probes the health listener over raw HTTP and exits 0
-or 1; it is the container `HEALTHCHECK`.
+`jwks_uri`. `--alg` defaults to `es256` (the only algorithm). `healthcheck` probes the health listener over raw HTTP and exits 0
+or 1; it is the container `HEALTHCHECK`. `--url` defaults to
+`http://127.0.0.1:8081/health/live` (liveness).
 
 
 ### PII and TOTP migration commands
 ### PII and TOTP migration commands
 
 
@@ -255,7 +260,7 @@ The file is JSON. Every section is optional and defaults to empty.
 
 
 Scope names must match the RFC 6749 scope-token grammar. `openid` is reserved.
 Scope names must match the RFC 6749 scope-token grammar. `openid` is reserved.
 Redirect URIs must be absolute and are matched exactly at `/authorize` (or, for
 Redirect URIs must be absolute and are matched exactly at `/authorize` (or, for
-`kind: post_logout`, at `GET /logout`). `kind` defaults to `authorize`.
+`kind: post_logout`, at `POST /logout`). `kind` defaults to `authorize`.
 
 
 Apply and re-apply:
 Apply and re-apply:
 
 
@@ -271,13 +276,15 @@ Upserts use `IS DISTINCT FROM`, so an unchanged row does not bump `updated_at`.
 
 
 CLI mutations go to the `audit` target with `event = "cli.mutation"`, an
 CLI mutations go to the `audit` target with `event = "cli.mutation"`, an
 `operation`, and a `subject`. The operations are `migrate`, `config.sync`,
 `operation`, and a `subject`. The operations are `migrate`, `config.sync`,
-`user.create`, `user.enroll_totp`, `user.disable`, `user.grant_scope`,
+`user.create`, `user.enroll_totp`, `user.disable`, `user.unlock`,
+`user.grant_scope`,
 `user.revoke_scope`, `scope.create`, `scope.enable`, `scope.disable`,
 `user.revoke_scope`, `scope.create`, `scope.enable`, `scope.disable`,
 `scope.delete`, `redirect_uri.create`, `redirect_uri.enable`,
 `scope.delete`, `redirect_uri.create`, `redirect_uri.enable`,
 `redirect_uri.disable`, `redirect_uri.delete`, `client.attach_redirect_uri`,
 `redirect_uri.disable`, `redirect_uri.delete`, `client.attach_redirect_uri`,
 `client.detach_redirect_uri`, `initial_access_token.issue`,
 `client.detach_redirect_uri`, `initial_access_token.issue`,
-`pii.backfill`, `pii.rotate`, `totp.rewrap`, and the `admin.bootstrap.*`
-family. `pii verify` is read-only and emits no mutation event.
+`pii.backfill`, `pii.rotate`, `totp.rewrap`, `admin.count.changed`, and the
+`admin.bootstrap.*` family. `pii verify` and `user scopes` are read-only and
+emit no mutation event.
 
 
 HTTP events on the same target include `login.success`, `login.failure`,
 HTTP events on the same target include `login.success`, `login.failure`,
 `consent`, `token.issue`, `token.revoke`, `token.refresh_reuse_detected`, and
 `consent`, `token.issue`, `token.revoke`, `token.refresh_reuse_detected`, and

+ 12 - 7
docs/REDIS.md

@@ -20,7 +20,7 @@ differ.
 
 
 | Key | Contents | TTL |
 | Key | Contents | TTL |
 | --- | --- | --- |
 | --- | --- | --- |
-| `oauth:{state}:code:<hash>` | Single-use authorization code, PKCE-bound. | `ttl.code` (60s) |
+| `oauth:{state}:code:<hash>` | Single-use authorization code, PKCE-bound. | `ttl.code` (60s), clamped to 60 s (`CODE_TTL_CAP`) |
 | `oauth:{state}:access:<hash>` | Access-token record. | `ttl.access` (300s) |
 | `oauth:{state}:access:<hash>` | Access-token record. | `ttl.access` (300s) |
 | `oauth:{state}:access:<hash>:act` | Token-exchange `act` claim sidecar. | Lives with the access record |
 | `oauth:{state}:access:<hash>:act` | Token-exchange `act` claim sidecar. | Lives with the access record |
 | `oauth:{state}:refresh:<hash>` | Refresh-token record or tombstone. | `ttl.refresh` (30d) |
 | `oauth:{state}:refresh:<hash>` | Refresh-token record or tombstone. | `ttl.refresh` (30d) |
@@ -35,13 +35,13 @@ differ.
 | `oauth:{state}:device:<hash>:poll` | Device poll-throttle marker. | Lives with the device record |
 | `oauth:{state}:device:<hash>:poll` | Device poll-throttle marker. | Lives with the device record |
 | `oauth:{state}:user_code:<code-hash>` | User code to device-hash mapping. | `ttl.device` |
 | `oauth:{state}:user_code:<code-hash>` | User code to device-hash mapping. | `ttl.device` |
 | `oauth:{state}:dcr_iat:<hash>` | DCR initial-access token budget. | CLI `--ttl` |
 | `oauth:{state}:dcr_iat:<hash>` | DCR initial-access token budget. | CLI `--ttl` |
-| `oauth:{state}:dpop_jti:<hash>` | DPoP `jti` replay marker. | Proof freshness window |
+| `oauth:{state}:dpop_jti:<hash>` | DPoP `jti` replay marker. | 300 s (`DPOP_JTI_TTL`; fixed, independent of proof `iat`) |
 | `oauth:{state}:client_assertion_jti:<hash>` | `private_key_jwt` `jti` replay marker. | Remaining assertion validity (`exp + 30s skew - now`), clamped `[1s, 360s]` (300s max lifetime + 2×30s skew) |
 | `oauth:{state}:client_assertion_jti:<hash>` | `private_key_jwt` `jti` replay marker. | Remaining assertion validity (`exp + 30s skew - now`), clamped `[1s, 360s]` (300s max lifetime + 2×30s skew) |
 | `oauth:{state}:rotation:<request-id>` | Rotation idempotency mapping. | `min(900s, refresh TTL)` |
 | `oauth:{state}:rotation:<request-id>` | Rotation idempotency mapping. | `min(900s, refresh TTL)` |
 | `oauth:{state}:loginfail:<sha256>` | Failed-login counter (hashed label; the global per-account cap). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:<sha256>` | Failed-login counter (hashed label; the global per-account cap). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:ip:<sha256>:<sha256>` | Composite failed-login counter for one source IP and one account (both halves hashed; locks that IP only). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:ip:<sha256>:<sha256>` | Composite failed-login counter for one source IP and one account (both halves hashed; locks that IP only). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:unknown:<sha256>` | Unknown-label bucket for one source IP (hashed IP only; no per-label keys, so label enumeration cannot spray the keyspace). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:unknown:<sha256>` | Unknown-label bucket for one source IP (hashed IP only; no per-label keys, so label enumeration cannot spray the keyspace). | `ui::login::LOCKOUT_WINDOW` (15 min) |
-| `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. The fingerprint is an HMAC under the token pepper (never the code itself), so a dump does not reveal the code. | Time step window |
+| `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. The fingerprint is an HMAC under the token pepper (never the code itself), so a dump does not reveal the code. | 180 s (`USED_CODE_TTL`; fixed, 6× the 30 s step) |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 
 
 `<hash>` is always a keyed hash. Tokens are opaque, so the server can look a
 `<hash>` is always a keyed hash. Tokens are opaque, so the server can look a
@@ -57,7 +57,8 @@ token state. This is what keeps two promises:
 
 
 - A multi-key token operation never fails with `CROSSSLOT`.
 - A multi-key token operation never fails with `CROSSSLOT`.
 - Counters can never evict tokens, because the two keyspaces are separate and
 - Counters can never evict tokens, because the two keyspaces are separate and
-  Redis eviction is off entirely (`noeviction`).
+  Redis eviction is off by default (`noeviction`, required unless
+  `redis.noeviction_required` is set false).
 
 
 The server asserts both properties at startup and refuses a prefix that lacks
 The server asserts both properties at startup and refuses a prefix that lacks
 its tag.
 its tag.
@@ -163,7 +164,9 @@ rotation mapping or the record instead of replaying the write.
 ## Security properties
 ## Security properties
 
 
 - Only keyed hashes are stored. A Redis dump does not reveal a usable token.
 - Only keyed hashes are stored. A Redis dump does not reveal a usable token.
-- `noeviction` and AOF are required and asserted at startup.
+- `noeviction` and AOF are required by default and asserted at startup unless
+  the matching opt-out flag (`redis.noeviction_required` or `redis.aof_required`)
+  is set false.
 - Token state and rate-limit counters are separated, so a counter cannot evict
 - Token state and rate-limit counters are separated, so a counter cannot evict
   a token.
   a token.
 - Replay markers are written atomically with `SET NX`, so a replayed `jti` is
 - Replay markers are written atomically with `SET NX`, so a replayed `jti` is
@@ -234,8 +237,10 @@ For a single node without replicas, use `appendfsync always` or Redis 7.2+ so
 `WAITAOF` is available, and set `redis.durability = waitaof`.
 `WAITAOF` is available, and set `redis.durability = waitaof`.
 
 
 On startup the server runs `PING`, checks `maxmemory-policy`, and checks
 On startup the server runs `PING`, checks `maxmemory-policy`, and checks
-`appendonly`. A violation is a startup failure, not a warning. To inspect a key
-family, use `SCAN` with the prefix; never use `KEYS` in a request path.
+`appendonly`. A violation is a startup failure, not a warning, unless the
+matching opt-out flag (`redis.noeviction_required` or `redis.aof_required`) is
+set false. To inspect a key family, use `SCAN` with the prefix; never use `KEYS`
+in a request path.
 
 
 ## File and directory references
 ## File and directory references
 
 

+ 3 - 3
docs/SECURITY.md

@@ -88,9 +88,9 @@ fallback.
   Postgres, Redis, logs, an error body, or an evidence file.
   Postgres, Redis, logs, an error body, or an evidence file.
 - Log redaction masks `authorization`, `cookie`, `dpop`, `code`, `token`,
 - Log redaction masks `authorization`, `cookie`, `dpop`, `code`, `token`,
   `client_secret`, `code_verifier`, `password`, `user_code`, `device_code`, and
   `client_secret`, `code_verifier`, `password`, `user_code`, `device_code`, and
-  `request_uri`, plus any `*_token`/`*_secret`/`*_password`/`*_code` key, in
-  headers, JSON, form bodies, and query strings; `code_challenge` and
-  `request_id` stay visible.
+  `request_uri`, plus any key whose name contains `secret`, and any
+  `*_token`/`*_secret`/`*_password`/`*_code` key, in headers, JSON, form bodies,
+  and query strings; `code_challenge` and `request_id` stay visible.
 - Secret comparisons are constant time.
 - Secret comparisons are constant time.
 
 
 ## Browser and intermediary caching
 ## Browser and intermediary caching

+ 13 - 3
src/config/build.rs

@@ -183,12 +183,22 @@ fn build_key_ring(raw: &RawConfig) -> Result<KeyRing, ConfigError> {
     )?),
     )?),
     "pii_previous_key",
     "pii_previous_key",
   )?;
   )?;
+  let pepper_previous = require_min_key_bytes(
+    non_blank(resolve_secret(
+      raw.token_pepper_previous_key.as_deref(),
+      raw.token_pepper_previous_key_file.as_deref(),
+    )?),
+    "token_pepper_previous_key",
+  )?;
   let pepper_id = key_id_or_default(raw.token_pepper_id.as_deref())?;
   let pepper_id = key_id_or_default(raw.token_pepper_id.as_deref())?;
   let totp_id = key_id_or_default(raw.totp_key_id.as_deref())?;
   let totp_id = key_id_or_default(raw.totp_key_id.as_deref())?;
   let pii_id = key_id_or_default(raw.pii_key_id.as_deref())?;
   let pii_id = key_id_or_default(raw.pii_key_id.as_deref())?;
-  let peppers = pepper
-    .map(|secret| vec![(pepper_id, secret)])
-    .unwrap_or_default();
+  let peppers = active_first_chain(
+    pepper.map(|secret| (pepper_id, secret)),
+    raw.token_pepper_previous_key_id.as_deref(),
+    pepper_previous,
+    KeyRingError::MissingTokenPepper,
+  )?;
   let totp_keys = active_first_chain(
   let totp_keys = active_first_chain(
     totp.map(|secret| (totp_id, secret)),
     totp.map(|secret| (totp_id, secret)),
     raw.totp_previous_key_id.as_deref(),
     raw.totp_previous_key_id.as_deref(),

+ 3 - 0
src/config/raw.rs

@@ -38,6 +38,9 @@ pub(crate) struct RawConfig {
   pub token_pepper: Option<String>,
   pub token_pepper: Option<String>,
   pub token_pepper_file: Option<PathBuf>,
   pub token_pepper_file: Option<PathBuf>,
   pub token_pepper_id: Option<String>,
   pub token_pepper_id: Option<String>,
+  pub token_pepper_previous_key: Option<String>,
+  pub token_pepper_previous_key_file: Option<PathBuf>,
+  pub token_pepper_previous_key_id: Option<String>,
   pub totp_encryption_key: Option<String>,
   pub totp_encryption_key: Option<String>,
   pub totp_encryption_key_file: Option<PathBuf>,
   pub totp_encryption_key_file: Option<PathBuf>,
   pub totp_key_id: Option<String>,
   pub totp_key_id: Option<String>,

+ 70 - 1
src/config/tests.rs

@@ -371,7 +371,11 @@ fn config_rejects_weak_key_material() {
       "{key} shorter than 32 bytes must be rejected"
       "{key} shorter than 32 bytes must be rejected"
     );
     );
   }
   }
-  for key in ["totp_previous_key", "pii_previous_key"] {
+  for key in [
+    "totp_previous_key",
+    "pii_previous_key",
+    "token_pepper_previous_key",
+  ] {
     let mut map = base();
     let mut map = base();
     map.insert(
     map.insert(
       key.to_owned(),
       key.to_owned(),
@@ -441,6 +445,35 @@ fn config_builds_pii_chain() -> Result<(), Box<dyn Error>> {
   Ok(())
   Ok(())
 }
 }
 
 
+#[test]
+fn config_builds_pepper_chain() -> Result<(), Box<dyn Error>> {
+  let mut map = base();
+  map.insert(
+    "token_pepper_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_PEPPER_PREVIOUS-0123456789ab".to_owned()),
+  );
+  map.insert(
+    "token_pepper_previous_key_id".to_owned(),
+    Value::String("v0".to_owned()),
+  );
+  let cfg = try_load(&map)?;
+  let active = cfg.keys.active_pepper().ok_or("missing active pepper")?;
+  assert_eq!(active.0.as_str(), "v1");
+  let retained = cfg
+    .keys
+    .pepper_by_id("v0")
+    .ok_or("missing retained pepper")?;
+  assert!(!cfg.keys.pepper_by_id("v9").is_some());
+  let _ = retained;
+  let ids: Vec<&str> = cfg
+    .keys
+    .peppers_newest_first()
+    .map(|(id, _)| id.as_str())
+    .collect();
+  assert_eq!(ids, vec!["v1", "v0"]);
+  Ok(())
+}
+
 #[test]
 #[test]
 fn config_rejects_previous_key_without_id() {
 fn config_rejects_previous_key_without_id() {
   let mut map = base();
   let mut map = base();
@@ -470,6 +503,14 @@ fn config_rejects_previous_key_without_id() {
   );
   );
   let result = try_load(&totp);
   let result = try_load(&totp);
   assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
   assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
+
+  let mut pepper = base();
+  pepper.insert(
+    "token_pepper_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_PEPPER_PREVIOUS-0123456789ab".to_owned()),
+  );
+  let result = try_load(&pepper);
+  assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
 }
 }
 
 
 #[test]
 #[test]
@@ -503,6 +544,21 @@ fn config_rejects_previous_key_without_active() {
     result,
     result,
     Err(ConfigError::KeyRing(KeyRingError::MissingTotpKey))
     Err(ConfigError::KeyRing(KeyRingError::MissingTotpKey))
   ));
   ));
+
+  let mut pepper = without("token_pepper");
+  pepper.insert(
+    "token_pepper_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_PEPPER_PREVIOUS-0123456789ab".to_owned()),
+  );
+  pepper.insert(
+    "token_pepper_previous_key_id".to_owned(),
+    Value::String("v0".to_owned()),
+  );
+  let result = try_load(&pepper);
+  assert!(matches!(
+    result,
+    Err(ConfigError::KeyRing(KeyRingError::MissingTokenPepper))
+  ));
 }
 }
 
 
 #[test]
 #[test]
@@ -573,6 +629,19 @@ fn config_rejects_dotted_key_id() {
     try_load(&previous),
     try_load(&previous),
     Err(ConfigError::InvalidKeyId { .. })
     Err(ConfigError::InvalidKeyId { .. })
   ));
   ));
+  let mut pepper_previous = base();
+  pepper_previous.insert(
+    "token_pepper_previous_key".to_owned(),
+    Value::String("SUPER_SECRET_PEPPER_PREVIOUS-0123456789ab".to_owned()),
+  );
+  pepper_previous.insert(
+    "token_pepper_previous_key_id".to_owned(),
+    Value::String("v0.evil".to_owned()),
+  );
+  assert!(matches!(
+    try_load(&pepper_previous),
+    Err(ConfigError::InvalidKeyId { .. })
+  ));
 }
 }
 
 
 #[test]
 #[test]

+ 2 - 2
src/lib.rs

@@ -2,8 +2,8 @@
 //!
 //!
 //! Module layout (authoritative; see `.omo/plans/rust-oauth21-server.md`):
 //! Module layout (authoritative; see `.omo/plans/rust-oauth21-server.md`):
 //! `config`, `keys`, `error`, `state`, `crypto::*`, `jose::*`, `db::*`,
 //! `config`, `keys`, `error`, `state`, `crypto::*`, `jose::*`, `db::*`,
-//! `store::*`, `protocol::*`, `ui::*`, `middleware::*`, `routes`, `serve`,
-//! `cli`.
+//! `store::*`, `protocol::*`, `ui::*`, `middleware::*`, `resilience`, `routes`,
+//! `serve`, `tls`, `cli`.
 //! Every later todo places new code in exactly one of these modules.
 //! Every later todo places new code in exactly one of these modules.
 
 
 #![forbid(unsafe_code)]
 #![forbid(unsafe_code)]

+ 3 - 2
src/middleware/mod.rs

@@ -1,7 +1,8 @@
 //! HTTP middleware: observability, rate limiting, request ids, security headers.
 //! HTTP middleware: observability, rate limiting, request ids, security headers.
 //!
 //!
-//! Ordered in `routes.rs`: request-id → security headers → rate limit →
-//! trace. Nothing here performs authentication or authorization.
+//! Ordered in `routes.rs`: request-id → security headers → CORS (when
+//! configured) → resilience (load-shed → timeout → concurrency-limit) → rate
+//! limit → body limit. Nothing here performs authentication or authorization.
 //!
 //!
 //! `observability` installs the JSON log subscriber; `request_id` attaches the
 //! `observability` installs the JSON log subscriber; `request_id` attaches the
 //! `X-Request-Id` span; `redaction` is the shared secret-scrubbing policy and
 //! `X-Request-Id` span; `redaction` is the shared secret-scrubbing policy and

+ 14 - 10
src/routes.rs

@@ -3,29 +3,33 @@
 //! [`build_router`] mounts every endpoint behind one [`tower::ServiceBuilder`]
 //! [`build_router`] mounts every endpoint behind one [`tower::ServiceBuilder`]
 //! whose execution order (outer-to-inner) is exactly {request-id → security
 //! whose execution order (outer-to-inner) is exactly {request-id → security
 //! headers → CORS (when configured) → resilience (load-shed →
 //! headers → CORS (when configured) → resilience (load-shed →
-//! concurrency-limit → timeout) → rate-limit → body limit}:
+//! timeout → concurrency-limit) → rate-limit → body limit}:
 //!
 //!
 //! 1. [`middleware::request_id::RequestIdLayer`] mints/propagates
 //! 1. [`middleware::request_id::RequestIdLayer`] mints/propagates
 //!    `X-Request-Id` and opens the redacted `http_request` tracing span
 //!    `X-Request-Id` and opens the redacted `http_request` tracing span
 //!    (tracing and redaction live in this layer).
 //!    (tracing and redaction live in this layer).
-//! 2. [`middleware::security_headers::cors_layer`] answers CORS preflights
-//!    from the explicit allowlist before the limiter can bill them — mounted
+//! 2. [`middleware::security_headers::SecurityHeadersLayer`] decorates
+//!    responses with `Referrer-Policy`, `X-Content-Type-Options`, a CSP with
+//!    `frame-ancestors 'none'` (plus a strict script policy on the
+//!    login/consent/device pages), and HSTS on the TLS listener. Every response
+//!    except `/.well-known/…` metadata is also `Cache-Control: no-store`.
+//! 3. [`middleware::security_headers::cors_layer`] answers CORS preflights
+//!    from the explicit allowlist before the limiter can bill them; mounted
 //!    only when `cors_allowed_origins` is non-empty (default empty: no CORS
 //!    only when `cors_allowed_origins` is non-empty (default empty: no CORS
 //!    headers, browser clients unsupported).
 //!    headers, browser clients unsupported).
-//! 3. [`middleware::ratelimit::RateLimitLayer`] enforces per-IP/per-client
+//! 4. [`ResilienceLayer`] sheds excess load, then times out slow handlers,
+//!    then caps concurrency, so overload returns a `503` before it can queue
+//!    into `PoolTimedOut`; it sits outside the rate limiter and inside the
+//!    request-id span.
+//! 5. [`middleware::ratelimit::RateLimitLayer`] enforces per-IP/per-client
 //!    budgets on every endpoint except health and metadata (those paths have
 //!    budgets on every endpoint except health and metadata (those paths have
 //!    no rule and pass through to handlers that fail closed on their own).
 //!    no rule and pass through to handlers that fail closed on their own).
 //!    The IP bucket uses the verified TCP peer unless the peer is a
 //!    The IP bucket uses the verified TCP peer unless the peer is a
 //!    configured `rate_limit.trusted_proxies` entry, in which case the
 //!    configured `rate_limit.trusted_proxies` entry, in which case the
 //!    rightmost-untrusted `X-Forwarded-For` address is attributed instead
 //!    rightmost-untrusted `X-Forwarded-For` address is attributed instead
 //!    (rate-limit bucket selection only; never client authentication).
 //!    (rate-limit bucket selection only; never client authentication).
-//! 4. [`tower_http::limit::RequestBodyLimitLayer`] caps form bodies at
+//! 6. [`tower_http::limit::RequestBodyLimitLayer`] caps form bodies at
 //!    [`MAX_BODY_BYTES`].
 //!    [`MAX_BODY_BYTES`].
-//! 5. [`middleware::security_headers::SecurityHeadersLayer`] decorates
-//!    responses with `Referrer-Policy`, `X-Content-Type-Options`, a CSP with
-//!    `frame-ancestors 'none'` (plus a strict script policy on the
-//!    login/consent/device pages), and HSTS on the TLS listener. Every response
-//!    except `/.well-known/…` metadata is also `Cache-Control: no-store`.
 //!
 //!
 //! Endpoint inventory: `GET /.well-known/oauth-authorization-server`
 //! Endpoint inventory: `GET /.well-known/oauth-authorization-server`
 //! ([`protocol::metadata`]), `GET /authorize` ([`authorize_entry`] —
 //! ([`protocol::metadata`]), `GET /authorize` ([`authorize_entry`] —

+ 9 - 0
src/store/mod.rs

@@ -10,18 +10,27 @@
 //!
 //!
 //! - `oauth:{state}:code:<hash>` — single-use authorization codes.
 //! - `oauth:{state}:code:<hash>` — single-use authorization codes.
 //! - `oauth:{state}:access:<hash>` — access-token records.
 //! - `oauth:{state}:access:<hash>` — access-token records.
+//! - `oauth:{state}:access:<hash>:act` — token-exchange `act` claim sidecar.
 //! - `oauth:{state}:refresh:<hash>` — refresh-token records and tombstones.
 //! - `oauth:{state}:refresh:<hash>` — refresh-token records and tombstones.
 //! - `oauth:{state}:grant:<id>` — grant-chain records.
 //! - `oauth:{state}:grant:<id>` — grant-chain records.
 //! - `oauth:{state}:grant:<id>:tokens` — per-grant token-member sets.
 //! - `oauth:{state}:grant:<id>:tokens` — per-grant token-member sets.
 //! - `oauth:{state}:session:<id>` — server-side login sessions.
 //! - `oauth:{state}:session:<id>` — server-side login sessions.
+//! - `oauth:{state}:session:<id>:meta` — session sidecar: CSRF secret, bound
+//!   `request_id`, and post-logout client/URI (see `purge_subject`).
 //! - `oauth:{state}:par:<id>` — PAR `request_uri` records.
 //! - `oauth:{state}:par:<id>` — PAR `request_uri` records.
 //! - `oauth:{state}:pending:<id>` — validated pending-auth JSON records.
 //! - `oauth:{state}:pending:<id>` — validated pending-auth JSON records.
 //! - `oauth:{state}:device:<hash>` — device-code records.
 //! - `oauth:{state}:device:<hash>` — device-code records.
+//! - `oauth:{state}:device:<hash>:cnf` — device sender-binding sidecar.
+//! - `oauth:{state}:device:<hash>:poll` — device poll-throttle marker.
 //! - `oauth:{state}:user_code:<code-hash>` — user-code to device mapping.
 //! - `oauth:{state}:user_code:<code-hash>` — user-code to device mapping.
 //! - `oauth:{state}:dcr_iat:<hash>` — DCR initial-access-token records.
 //! - `oauth:{state}:dcr_iat:<hash>` — DCR initial-access-token records.
 //! - `oauth:{state}:dpop_jti:<hash>` — DPoP `jti` replay markers.
 //! - `oauth:{state}:dpop_jti:<hash>` — DPoP `jti` replay markers.
 //! - `oauth:{state}:client_assertion_jti:<hash>` — `private_key_jwt` replay markers.
 //! - `oauth:{state}:client_assertion_jti:<hash>` — `private_key_jwt` replay markers.
 //! - `oauth:{state}:rotation:<request-id>` — rotation idempotency mappings.
 //! - `oauth:{state}:rotation:<request-id>` — rotation idempotency mappings.
+//! - `oauth:{state}:loginfail:<sha256>` — per-account failed-login counter.
+//! - `oauth:{state}:loginfail:ip:<sha256>:<sha256>` — composite per-IP counter.
+//! - `oauth:{state}:loginfail:unknown:<sha256>` — unknown-label IP bucket.
+//! - `oauth:{state}:totp_replay:<fingerprint>` — TOTP used-code replay marker.
 //! - `oauth:{rl}:ratelimit:<bucket-hash>` — rate-limit counters (own keyspace).
 //! - `oauth:{rl}:ratelimit:<bucket-hash>` — rate-limit counters (own keyspace).
 //!
 //!
 //! Registration access tokens are NOT here: their hash lives only in Postgres
 //! Registration access tokens are NOT here: their hash lives only in Postgres