Browse Source

Updated documentation

Efren Yevale Varela 3 tuần trước cách đây
mục cha
commit
c3d29f3802
20 tập tin đã thay đổi với 333 bổ sung và 128 xóa
  1. 12 7
      AGENTS.md
  2. 15 6
      README.md
  3. 1 1
      RUNBOOK.md
  4. 18 13
      docs/ARCHITECTURE.md
  5. 8 1
      docs/CHANGELOG.md
  6. 19 7
      docs/CONFIGURATION.md
  7. 11 3
      docs/CONTRIBUTING.md
  8. 27 16
      docs/DATABASE.md
  9. 57 27
      docs/DEPLOYMENT.md
  10. 16 6
      docs/OAUTH.md
  11. 20 13
      docs/OPERATIONS.md
  12. 12 7
      docs/REDIS.md
  13. 3 3
      docs/SECURITY.md
  14. 13 3
      src/config/build.rs
  15. 3 0
      src/config/raw.rs
  16. 70 1
      src/config/tests.rs
  17. 2 2
      src/lib.rs
  18. 3 2
      src/middleware/mod.rs
  19. 14 10
      src/routes.rs
  20. 9 0
      src/store/mod.rs

+ 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. |
 | 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`. |
-| 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. |
 | 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). |
@@ -68,7 +68,7 @@ Before finishing any change, confirm:
 - [ ] A sender-constrained grant re-verifies a fresh proof on every use.
 - [ ] `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`).
-- [ ] 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
       origin.
 
@@ -99,14 +99,18 @@ cargo fmt --check
 cargo clippy --all-targets -- -D warnings
 cargo nextest run --all-targets
 cargo test --doc
+cargo audit
+cargo deny check
 python3 scripts/check_doc_links.py
 ```
 
 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
-cargo sqlx prepare --workspace
+cargo sqlx prepare --check --workspace   # CI gate
+cargo sqlx prepare --workspace           # regenerate the cache
 ```
 
 ## 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
 ```
 
-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
 
 - 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.
 - A TLS certificate and key for the public listener.
 - 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
-`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:
 
@@ -180,6 +182,8 @@ Quality gates:
 ```bash
 cargo fmt --check
 cargo clippy --all-targets -- -D warnings
+cargo nextest run --all-targets
+cargo test --doc
 cargo audit
 cargo deny check
 python3 scripts/check_doc_links.py
@@ -245,10 +249,12 @@ certificate.
   device records, and lockout counters before flipping `users.disabled`; that
   user's live tokens then introspect inactive and refuse refresh
   (`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`.
 - 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
   than failing open.
 - 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
   runtime-managed and start empty.
 - 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
   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`.
@@ -268,7 +276,8 @@ certificate.
 - Declarative apply: `config sync --file` and `config check`.
 - 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).
-- 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`.
 
 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).
 - [ ] **6. Verify fail-closed behavior.** Readiness must report ready only with
       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
       [docs/OPERATIONS.md](docs/OPERATIONS.md)).
       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/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/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/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/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. |
@@ -56,14 +56,14 @@ is fixed in `build_router_with_options` in `src/routes.rs`:
    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, 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
    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
-   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.
 
 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
   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, 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,
 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
   case in-process verification fails; use L4/TCP passthrough. See
   [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
   is down it returns `503` rather than advertising an empty or unknown scope
   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/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/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.

+ 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
   `0400` tmpfs files under `/run/secrets` (read through
   `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
   namespace (no internet-wide `0.0.0.0/0` peer; `scripts/check_manifests.py`
   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
   `invalid_client`. Client certificates are expected to be short-lived, so
   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
 

+ 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_file` | none | Mounted token-pepper secret. |
 | `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_key_id` | `v1` | Version id carried in the encrypted TOTP blob. |
 | `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_file` | `D_OAUTH_TOKEN_PEPPER_FILE` |
 | `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_file` | `D_OAUTH_TOTP_ENCRYPTION_KEY_FILE` |
 | `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_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:
 token hashes as `<kid>.<digest>` (split on the first `.`) and PII blind
 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` |
 
 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.
 
 ## Rate-limit keys
@@ -318,8 +329,9 @@ the statement.
 - 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.
 - 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
 

+ 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
 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
-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
-cargo sqlx prepare --workspace
+cargo sqlx prepare --check --workspace   # CI gate
+cargo sqlx prepare --workspace           # regenerate the cache
 ```
 
 ## 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
 cargo fmt --check
@@ -175,8 +180,11 @@ Every module has a single responsibility:
 | Protocol handlers | `src/protocol/` | OAuth endpoints and grants. |
 | Server-rendered UI | `src/ui/` | Login, consent, and device pages. |
 | 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. |
+| 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. |
+| Errors | `src/error.rs` | `AppError`: the client-safe OAuth error type and its HTTP mapping. |
 
 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_uri` | `text` | Optional JWKS URL (fetched with the SSRF guard). |
 | `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_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_index` | `text` | Keyed blind index over the thumbprint. |
 | `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
 `(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
-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
 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
 
-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
   `{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
@@ -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
   until every embedded migration version is present in `_sqlx_migrations`.
 - 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.
 
@@ -247,7 +256,9 @@ The PII encryption rollout follows this lifecycle exactly:
    blanked plaintext PII column still holds a value.
 6. **Contract (later release).** Dropping the now-inert plaintext columns and
    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`
 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:
 
 ```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
@@ -275,8 +278,9 @@ A correct Deployment has these properties:
   environment or baked into the image.
 - A `NetworkPolicy` that default-denies pod egress except DNS, Postgres, and
   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
   immutable pin.
 - 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
 ```
 
+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
 (`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
@@ -425,8 +433,13 @@ spec:
 
 ### 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
 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.
 - **Encrypt the backups and their keys.** `pg_dump`, `pg_basebackup`, and
   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
   [SECURITY.md](SECURITY.md#master-key-custody)).
 - **Consider Postgres-level encryption.** Disk or volume encryption (cloud
   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
-  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
   alternative, not the current design, and it moves key custody into the SQL
   path.
@@ -568,7 +587,10 @@ cosign verify \
 kustomize build deploy/k8s
 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 -k deploy/k8s
 ```
@@ -646,29 +668,37 @@ tarball.
   the process.
 - A CNI that does not enforce NetworkPolicy leaves pod egress unrestricted;
   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
 
 - 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
 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
   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
   file-mounted and replaced by the secret manager), `service.yaml` (public TLS
   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),
   `migrate-job.yaml` (exactly-once schema migration, never per replica),
   `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),
-  `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.
 - `compose.yaml` for the local 3-replica proof (one Postgres + one Redis,
   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 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 |
-| 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 |
-| 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 |
 | `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 |
@@ -61,12 +61,18 @@ mounts only `/health/live` and `/health/ready`.
 | POST | `/device_authorization` | RFC 8628 §3.1 | Wired |
 | GET, POST | `/device` | RFC 8628 §3.3 | 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 | `/consent` | RFC 9700 §4.7.1 | Wired |
 | POST | `/introspect` | RFC 7662 §2.1 | Wired |
 | POST | `/revoke` | RFC 7009 §2.1 | Wired |
 | POST | `/register` | RFC 7591 §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
 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_redirect_uri` | 400 | DCR redirect URI not in the catalog or otherwise ineligible. |
 | `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. |
 | `slow_down` | 400 | Device polling faster than the interval. |
 | `expired_token` | 400 | Device code or code expired. |
 | `temporarily_unavailable` | 503 with `Retry-After: 1` | Postgres or Redis is unavailable on a security path. |
 | `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
 `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
 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`).
-`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
 

+ 20 - 13
docs/OPERATIONS.md

@@ -43,7 +43,7 @@ d-oauth-server config check
 
 ```bash
 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 unlock --email <email>
 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>
 ```
 
-`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`
 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
 
@@ -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
 by `POST /logout` to send the browser back to the client after the session ends
 (`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
 
@@ -169,14 +173,15 @@ when `allow_open_registration` is false.
 ### Key generation and health
 
 ```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
 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
 
@@ -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.
 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:
 
@@ -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
 `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`,
 `scope.delete`, `redirect_uri.create`, `redirect_uri.enable`,
 `redirect_uri.disable`, `redirect_uri.delete`, `client.attach_redirect_uri`,
 `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`,
 `consent`, `token.issue`, `token.revoke`, `token.refresh_reuse_detected`, and

+ 12 - 7
docs/REDIS.md

@@ -20,7 +20,7 @@ differ.
 
 | 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>:act` | Token-exchange `act` claim sidecar. | Lives with the access record |
 | `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}: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}: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}: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: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}: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) |
 
 `<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`.
 - 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
 its tag.
@@ -163,7 +164,9 @@ rotation mapping or the record instead of replaying the write.
 ## Security properties
 
 - 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
   a token.
 - 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`.
 
 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
 

+ 3 - 3
docs/SECURITY.md

@@ -88,9 +88,9 @@ fallback.
   Postgres, Redis, logs, an error body, or an evidence file.
 - Log redaction masks `authorization`, `cookie`, `dpop`, `code`, `token`,
   `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.
 
 ## 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",
   )?;
+  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 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 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(
     totp.map(|secret| (totp_id, secret)),
     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_file: Option<PathBuf>,
   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_file: Option<PathBuf>,
   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"
     );
   }
-  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();
     map.insert(
       key.to_owned(),
@@ -441,6 +445,35 @@ fn config_builds_pii_chain() -> Result<(), Box<dyn Error>> {
   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]
 fn config_rejects_previous_key_without_id() {
   let mut map = base();
@@ -470,6 +503,14 @@ fn config_rejects_previous_key_without_id() {
   );
   let result = try_load(&totp);
   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]
@@ -503,6 +544,21 @@ fn config_rejects_previous_key_without_active() {
     result,
     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]
@@ -573,6 +629,19 @@ fn config_rejects_dotted_key_id() {
     try_load(&previous),
     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]

+ 2 - 2
src/lib.rs

@@ -2,8 +2,8 @@
 //!
 //! Module layout (authoritative; see `.omo/plans/rust-oauth21-server.md`):
 //! `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.
 
 #![forbid(unsafe_code)]

+ 3 - 2
src/middleware/mod.rs

@@ -1,7 +1,8 @@
 //! 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
 //! `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`]
 //! whose execution order (outer-to-inner) is exactly {request-id → security
 //! 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
 //!    `X-Request-Id` and opens the redacted `http_request` tracing span
 //!    (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
 //!    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
 //!    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
 //!    configured `rate_limit.trusted_proxies` entry, in which case the
 //!    rightmost-untrusted `X-Forwarded-For` address is attributed instead
 //!    (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`].
-//! 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`
 //! ([`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}: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}:grant:<id>` — grant-chain records.
 //! - `oauth:{state}:grant:<id>:tokens` — per-grant token-member sets.
 //! - `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}:pending:<id>` — validated pending-auth JSON 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}:dcr_iat:<hash>` — DCR initial-access-token records.
 //! - `oauth:{state}:dpop_jti:<hash>` — DPoP `jti` replay markers.
 //! - `oauth:{state}:client_assertion_jti:<hash>` — `private_key_jwt` replay markers.
 //! - `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).
 //!
 //! Registration access tokens are NOT here: their hash lives only in Postgres