Jelajahi Sumber

Security fixes

Efren Yevale Varela 3 minggu lalu
induk
melakukan
bf931f10f7
58 mengubah file dengan 3554 tambahan dan 765 penghapusan
  1. 29 0
      .sqlx/query-c3d1b3b177daf4a4b62eede8e75d810f56f6d976f07acf9fc4a1f3846d2e1c24.json
  2. 28 0
      .sqlx/query-cade514beae19dcf0ce69cf6abc2ff28244505a0beb0b12fce2163898cdd9cbb.json
  3. 3 3
      README.md
  4. 3 3
      deploy/k8s/secret.yaml
  5. 8 12
      docs/ARCHITECTURE.md
  6. 13 7
      docs/CONFIGURATION.md
  7. 7 13
      docs/DEPLOYMENT.md
  8. 3 1
      docs/OAUTH.md
  9. 7 0
      docs/OPERATIONS.md
  10. 25 0
      docs/REDIS.md
  11. 16 11
      docs/SECURITY.md
  12. 4 2
      scripts/check_manifests.py
  13. 209 0
      src/cli/tests.rs
  14. 22 6
      src/cli/user.rs
  15. 7 11
      src/config.rs
  16. 48 34
      src/config/build.rs
  17. 0 1
      src/config/raw.rs
  18. 67 13
      src/config/tests.rs
  19. 110 0
      src/db/user_repo.rs
  20. 104 0
      src/db/user_repo/tests.rs
  21. 6 5
      src/jose/jar.rs
  22. 42 195
      src/jose/jar/fetch.rs
  23. 39 20
      src/middleware/ratelimit.rs
  24. 7 7
      src/middleware/ratelimit/tests.rs
  25. 88 3
      src/middleware/redaction.rs
  26. 2 57
      src/middleware/security_headers.rs
  27. 45 13
      src/protocol/client_auth/assertion.rs
  28. 170 1
      src/protocol/client_auth/tests.rs
  29. 36 13
      src/protocol/dcr.rs
  30. 115 1
      src/protocol/dcr/tests.rs
  31. 13 1
      src/protocol/introspect.rs
  32. 192 1
      src/protocol/introspect/tests.rs
  33. 10 5
      src/protocol/metadata.rs
  34. 2 1
      src/protocol/metadata/snapshots/metadata_document.snap
  35. 21 0
      src/protocol/metadata/tests.rs
  36. 1 0
      src/protocol/mod.rs
  37. 2 6
      src/protocol/mtls.rs
  38. 0 79
      src/protocol/mtls/source.rs
  39. 558 0
      src/protocol/purge.rs
  40. 16 2
      src/protocol/scope.rs
  41. 38 27
      src/protocol/ssrf.rs
  42. 47 0
      src/protocol/ssrf/tests.rs
  43. 13 1
      src/protocol/token/dispatch/grants.rs
  44. 104 1
      src/protocol/token/grants/refresh_token.rs
  45. 9 0
      src/protocol/token/grants/refresh_token/tests.rs
  46. 335 0
      src/protocol/token/grants/refresh_token/tests/disabled_subject.rs
  47. 405 0
      src/protocol/token/grants/refresh_token/tests/entitlements.rs
  48. 61 88
      src/routes.rs
  49. 67 14
      src/store/mod.rs
  50. 91 4
      src/store/tests_store.rs
  51. 2 3
      src/tls.rs
  52. 21 3
      src/ui/login.rs
  53. 205 5
      src/ui/login/tests/enroll.rs
  54. 2 3
      tests/bootstrap.rs
  55. 1 1
      tests/e2e_abuse.rs
  56. 4 78
      tests/mtls.rs
  57. 0 7
      tests/mtls/certs.rs
  58. 71 3
      tests/scope_engine.rs

+ 29 - 0
.sqlx/query-c3d1b3b177daf4a4b62eede8e75d810f56f6d976f07acf9fc4a1f3846d2e1c24.json

@@ -0,0 +1,29 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT disabled FROM users\n         WHERE email_index = ANY($1)\n            OR (email_enc IS NULL AND email = $2::text::citext)",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "disabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "disabled"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "TextArray",
+        "Text"
+      ]
+    },
+    "nullable": [
+      false
+    ]
+  },
+  "hash": "c3d1b3b177daf4a4b62eede8e75d810f56f6d976f07acf9fc4a1f3846d2e1c24"
+}

+ 28 - 0
.sqlx/query-cade514beae19dcf0ce69cf6abc2ff28244505a0beb0b12fce2163898cdd9cbb.json

@@ -0,0 +1,28 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT disabled FROM users WHERE id = $1",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "disabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "users",
+            "name": "disabled"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid"
+      ]
+    },
+    "nullable": [
+      false
+    ]
+  },
+  "hash": "cade514beae19dcf0ce69cf6abc2ff28244505a0beb0b12fce2163898cdd9cbb"
+}

+ 3 - 3
README.md

@@ -58,9 +58,9 @@ export D_OAUTH_TLS__KEY_PATH=/path/to/tls.key
 export D_OAUTH_DATABASE__URL='postgres://oauth:password@127.0.0.1:5432/oauth?sslmode=disable'
 export D_OAUTH_REDIS__URL=redis://127.0.0.1:6379
 export D_OAUTH_REDIS__DURABILITY=waitaof
-export D_OAUTH_TOKEN_PEPPER='replace-with-32-bytes-of-random'
-export D_OAUTH_TOTP_ENCRYPTION_KEY='replace-with-32-bytes-of-random'
-export D_OAUTH_PII_ENCRYPTION_KEY='replace-with-32-bytes-of-random'
+export D_OAUTH_TOKEN_PEPPER='replace-with-32-bytes-of-random-00'
+export D_OAUTH_TOTP_ENCRYPTION_KEY='replace-with-32-bytes-of-random-00'
+export D_OAUTH_PII_ENCRYPTION_KEY='replace-with-32-bytes-of-random-00'
 ```
 
 Development relaxes the database TLS requirement. Production requires

+ 3 - 3
deploy/k8s/secret.yaml

@@ -56,10 +56,10 @@ stringData:
   redis-password: "REPLACE_ME__redis-password"
   # 256-bit server-side pepper for opaque-token hashing. Losing it invalidates
   # every live token; rotate per docs/OPERATIONS.md.
-  token-pepper: "REPLACE_ME__32-bytes-of-random"
+  token-pepper: "REPLACE_ME__at-least-32-bytes-of-random"
   # 256-bit key encrypting TOTP secrets at rest.
-  totp-encryption-key: "REPLACE_ME__32-bytes-of-random"
+  totp-encryption-key: "REPLACE_ME__at-least-32-bytes-of-random"
   # 256-bit master key encrypting PII fields at rest (mandatory). Losing it
   # makes every sealed PII value unreadable; rotate per docs/OPERATIONS.md
   # (retaining the previous key until `pii rotate` completes).
-  pii-encryption-key: "REPLACE_ME__32-bytes-of-random"
+  pii-encryption-key: "REPLACE_ME__at-least-32-bytes-of-random"

+ 8 - 12
docs/ARCHITECTURE.md

@@ -48,17 +48,14 @@ is fixed in `build_router_with_options` in `src/routes.rs`:
 
 1. Request id: mints or propagates `X-Request-Id` and opens the redacted
    `http_request` tracing span. Tracing and redaction live here.
-2. Proxy-header stripping: when no `trusted_proxy` is configured, forged
-   `x-forwarded-tls-client-*` headers are removed before any handler can read
-   them. Without a configured proxy, proxy evidence is never trusted.
-3. CORS: answers preflights from the explicit allowlist. Absent when the
+2. 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
+3. Resilience: sheds excess load, caps concurrency, and times out slow
    handlers before they reach rate limiting.
-5. Rate limit: enforces the per-IP and per-client budgets. Health and metadata
+4. Rate limit: enforces the per-IP and per-client budgets. Health and metadata
    have no rule and pass through; those handlers fail closed on their own.
-6. Body limit: caps request bodies at 2 MiB.
-7. Security headers: adds `Referrer-Policy`, `X-Content-Type-Options`, a CSP
+5. Body limit: caps request bodies at 2 MiB.
+6. Security headers: adds `Referrer-Policy`, `X-Content-Type-Options`, a CSP
    with `frame-ancestors 'none'`, HSTS on the TLS listener, and
    `Cache-Control: no-store` on every response except `/.well-known/…` metadata.
 
@@ -120,16 +117,15 @@ so they stay on a single slot. See [REDIS.md](REDIS.md).
 - Secrets stay in `secrecy`/`zeroize` types and never reach a `Debug` output,
   a log, or an error body.
 - Dependency failure on a security path fails closed with `503`.
-- Middleware order puts request identity and redaction before any handler, and
-  strips untrusted proxy headers before they could be read.
+- Middleware order puts request identity and redaction before any handler.
 - The health listener cannot reach OAuth handlers, so a probe port cannot
   become a second, unauthenticated API surface.
 
 ## Edge cases
 
 - A mTLS client certificate terminates at an L7 ingress that strips it. In that
-  case in-process verification fails; use L4/TCP passthrough or the explicit
-  trusted-proxy mode. See [DEPLOYMENT.md](DEPLOYMENT.md).
+  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
   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

+ 13 - 7
docs/CONFIGURATION.md

@@ -46,6 +46,10 @@ mount them `0400` on a tmpfs (the shipped manifests project them at
 both are set, so an existing environment-based deployment still starts. Each
 purpose also has an optional retained previous key (`*_previous_key` and its
 `*_previous_key_file` form) so a rotation can keep old ciphertext readable.
+Every resolved key — `token_pepper`, `totp_encryption_key`,
+`pii_encryption_key`, and each retained `*_previous_key` — must be at least 32
+bytes (256 bits). A shorter key is rejected at load with `WeakKeyMaterial`,
+which names the offending key.
 
 ## Top-level keys
 
@@ -57,7 +61,6 @@ purpose also has an optional retained previous key (`*_previous_key` and its
 | `environment` | `production` | `production` or `development`. Development relaxes the database TLS requirement. |
 | `cookie_domain` | none (host-only) | Optional cookie domain. |
 | `cookie_secure` | `true` | Adds the `Secure` attribute. Set `false` only for localhost testing. |
-| `trusted_proxy` | none | An IP. When set, that proxy's forwarded client-cert and client-IP headers are honored. When unset, those headers are stripped. |
 | `allow_open_registration` | `false` | When false, DCR requires a valid initial-access token. |
 | `dcr_redirect_policy` | `catalog` | `catalog`, `catalog_loopback`, or `open`. See below. |
 | `cors_allowed_origins` | empty | An explicit origin allowlist. Empty means no CORS layer. A lone `*` is silently dropped (no CORS layer, no error). |
@@ -265,8 +268,9 @@ the statement.
 - `catalog`: only redirect URIs present in the runtime catalog may be
   registered. This is the default.
 - `catalog_loopback`: catalog URIs plus dynamic loopback URIs (RFC 8252).
-- `open`: any redirect URI may be registered. This is an explicit opt-in and is
-  not the default.
+- `open`: any absolute `https` redirect URI, or an RFC 8252 loopback URI, may
+  be registered. This is an explicit opt-in and is not the default; other
+  schemes and plaintext non-loopback URIs are still rejected.
 
 ## Security properties
 
@@ -275,8 +279,10 @@ the statement.
 - A production database URL without `sslmode=verify-full` is refused.
 - A Redis topology that does not satisfy the durability contract is refused at
   load and re-checked live at startup.
-- `trusted_proxy` is unset by default, so forwarded client-cert headers from an
-  untrusted hop are stripped before any handler reads them.
+- A pepper/TOTP/PII key shorter than 32 bytes is refused at load
+  (`WeakKeyMaterial`).
+- 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.
@@ -298,8 +304,8 @@ the statement.
 - `scope delete` and `redirect-uri delete` disable a referenced row instead of
   deleting it. See [OPERATIONS.md](OPERATIONS.md).
 - A deliberate mismatch of `dcr_redirect_policy = open` and mTLS is a deployment
-  error, not a load error: open registration lets a caller register an arbitrary
-  redirect URI.
+  error, not a load error: open registration lets a caller register any
+  `https`/loopback redirect URI without pre-registration.
 
 ## Operational runbook
 

+ 7 - 13
docs/DEPLOYMENT.md

@@ -88,8 +88,8 @@ than disabling it, trust a private CA so the proxy verifies both the backend
 chain and its hostname.
 
 This L7 model cannot carry a client certificate, so it does not support
-in-process client-certificate mTLS. For that, use L4/TCP passthrough or the
-explicit trusted-proxy mode described below. Everything else, including DPoP,
+in-process client-certificate mTLS. For that, use L4/TCP passthrough (see the
+mTLS section below). Everything else, including DPoP,
 PAR, and JAR, works unchanged.
 
 ### Generate a private CA and a server certificate
@@ -453,16 +453,10 @@ in-flight requests to be cut off. Keep it above the budget.
 In-process client-certificate verification needs the peer certificate to reach
 the application. That requires L4/TCP passthrough at the load balancer
 (a network load balancer gateway or a `TLSRoute`). An L7 ingress that terminates
-TLS strips the client certificate, so in-process mTLS cannot work behind it.
-
-Two correct options:
-
-- Use L4/TCP passthrough so the server terminates TLS and sees the client
-  certificate.
-- Use the explicit trusted-proxy mode: terminate at a proxy you control, have it
-  forward a verified client-cert representation, and set `trusted_proxy` to that
-  proxy's address. When `trusted_proxy` is unset, forwarded client-cert headers
-  are stripped.
+TLS strips the client certificate, so in-process mTLS cannot work behind it;
+the only correct option is L4/TCP passthrough, where the server terminates TLS
+and sees the client certificate itself. Forwarded client-cert headers from an
+intermediary are never trusted.
 
 This constraint is documented here and is not claimed to be exercised end to end
 in CI. CI validates the manifests for the forbidden pairing of an L7 ingress
@@ -508,7 +502,7 @@ The image pipeline is gated on:
 
 - A root or privileged pod violates the policy and must be rejected.
 - An L7-terminated ingress with client-cert auth cannot carry the cert; use
-  passthrough or trusted-proxy mode.
+  L4/TCP passthrough.
 - Booting three replicas without the migration Job lets readiness block them
   until the schema matches.
 - A replica that has no shared state is a bug: token state is in Redis, not in

+ 3 - 1
docs/OAUTH.md

@@ -158,7 +158,9 @@ registered for the refresh grant.
 Reference: RFC 6749 §6, RFC 9700 §2.2.2 and §4.14, RFC 9449 §5.
 
 Parameters: `client_id`, `refresh_token`, optional `requested_scope` (which may
-narrow but never widen), and a freshly verified sender proof.
+narrow but never widen), and a freshly verified sender proof. The effective
+scope is re-checked against the client's current registration and the user's
+current entitlements, so a revoked scope or entitlement drops on refresh.
 
 Behavior: rotation on every use. The presented token is tombstoned with a
 `rotated_to` member. Presenting a rotated token again is reuse: the whole grant

+ 7 - 0
docs/OPERATIONS.md

@@ -55,6 +55,13 @@ d-oauth-server user scopes --email <email>
   user always starts with `must_change_password = true`.
 - `enroll-totp` generates an encrypted TOTP secret and prints the secret and the
   `otpauth://` URI exactly once. Register the secret in an authenticator now.
+- `disable` first purges all of the user's Redis ephemeral state (login
+  sessions and their `:meta` sidecars, access/refresh tokens and their grants,
+  authorization codes, device records with their `:cnf`/`:poll` sidecars and
+  `user_code` mappings, and the lockout counter), then flips the Postgres
+  `disabled` flag. If Redis is unavailable the command fails and the user
+  stays enabled (fail closed). Disabling alone does not touch PAR records,
+  replay markers, or rate-limit counters, which carry no subject.
 - `grant-scope` requires the scope to exist in the catalog.
 - `revoke-scope` is idempotent.
 

+ 25 - 0
docs/REDIS.md

@@ -159,6 +159,30 @@ rotation mapping or the record instead of replaying the write.
 - A `CROSSSLOT` error fails closed. It is never worked around by splitting an
   atomic operation across slots.
 
+## Subject purge (`user disable`)
+
+`d-oauth-server user disable --email <email>` revokes the user's ephemeral
+state before the Postgres `disabled` flag flips (fail closed: a Redis outage
+leaves the user enabled). The purge deletes every record whose `subject`
+field is the canonical email or the user-id string:
+
+- `session:<id>` records plus their `:meta` sidecars.
+- The `loginfail:<sha256>` lockout counter for the normalized email.
+- `code:<hash>` authorization-code records.
+- `device:<hash>` records plus their `:cnf`/`:poll` sidecars and the
+  `user_code:<code-hash>` mappings that point at them.
+- `grant:<id>` families via the grant cascade (member token keys, the
+  `:tokens` set, the record, and member `:act` sidecars), plus standalone
+  `access:<hash>` (with `:act`) and `refresh:<hash>` records.
+
+Records without a `subject` field are never touched: `par:`, `pending:`,
+`dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the
+`{rl}` rate-limit counters.
+
+Like the DCR `DELETE` revocation, the purge scans with `SCAN MATCH <prefix>*
+COUNT 100` and filters on the raw hash field. On Cluster topologies a scan
+covers the contacted node only (Single and Sentinel scan fully).
+
 ## Operational runbook
 
 Configure Redis with persistence and eviction off. A minimal single-node
@@ -185,5 +209,6 @@ family, use `SCAN` with the prefix; never use `KEYS` in a request path.
 - `src/store/tokens.rs`, `src/store/grants.rs`, `src/store/codes.rs`,
   `src/store/sessions.rs`, `src/store/par.rs`, `src/store/device.rs`,
   `src/store/dcr.rs`, `src/store/ratelimit.rs` for the per-family records.
+- `src/protocol/purge.rs` for the `user disable` subject purge.
 - `src/config/redis.rs` for topology, durability, and prefix validation.
 - `src/state/redis.rs`, `src/state/sentinel.rs` for connection management.

+ 16 - 11
docs/SECURITY.md

@@ -34,16 +34,14 @@ https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1).
 - A stolen token used from another key or certificate.
 - An operator or job that could leak a secret through logs, argv, or an error
   body.
-- A forged reverse-proxy header claiming a client certificate.
 
 ### Trust boundaries
 
 - The TLS public listener is the only route to OAuth handlers.
 - The plaintext health listener carries no OAuth routes.
 - Postgres holds durable domain state; Redis holds ephemeral state.
-- Forwarded client-cert and client-IP headers are trusted only from the
-  configured `trusted_proxy`; otherwise they are stripped before any handler
-  reads them.
+- 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.
 
 ## Attack surface
 
@@ -139,7 +137,11 @@ Every sender-constrained grant requires a freshly verified proof on every use.
 - For token exchange, the proof must match the subject token's confirmation,
   not merely the caller's.
 - `private_key_jwt` is client authentication only. It produces no confirmation
-  and never satisfies sender constraint. This is deliberate.
+  and never satisfies sender constraint. This is deliberate. Its `exp` may lie
+  at most 300 s past `now` (plus clock skew), and the `jti` replay marker is
+  held for the assertion's full remaining validity, so a long-lived assertion
+  cannot be replayed once its marker expires. `RS256`, `PS256`, `ES256`, and
+  `EdDSA` are accepted; `none` and HMAC are rejected.
 - mTLS identity is a client certificate subject DN (`tls_client_auth`) or a
   certificate thumbprint (`self_signed_tls_client_auth`), each unique per the
   partial indexes in the schema.
@@ -162,7 +164,9 @@ Reference: RFC 9126, RFC 9101.
   with the query string are rejected.
 - A `request_uri` is one-time and client-bound, or exactly pre-registered, and
   is fetched only through the SSRF guard (https only, no redirects, global IPs
-  only, size and time capped).
+  only, size and time capped). The connection is pinned to the addresses
+  validated before the fetch, so a rebinding hostname cannot redirect it to a
+  blocked address.
 - PAR credentials are read from the header and body only, never the query.
 
 ## Key ring and rotation
@@ -288,7 +292,8 @@ configure one as fail-open is a load-time error.
 - A PAR `request_uri` is consumed atomically.
 - A device code is consumed atomically on approval.
 - DPoP and `private_key_jwt` `jti` values are recorded with `SET NX`, so a
-  replayed proof or assertion is rejected.
+  replayed proof or assertion is rejected. A `private_key_jwt` marker lives for
+  the assertion's full remaining validity, bounded by the 300 s lifetime cap.
 - A TOTP code is recorded in a replay cache for its time-step window.
 
 ## Input and output safety
@@ -297,7 +302,9 @@ configure one as fail-open is a load-time error.
   There are no wildcards and no prefix matches.
 - PKCE is `S256` only. A verifier cannot be omitted to downgrade.
 - Scope is intersected: `requested ∩ client-registered ∩ user-entitled ∩
-  enabled`, and refresh can only narrow.
+  enabled`. Refresh can only narrow and re-checks the current client-registered
+  and user-entitled sets (a fresh Postgres read), so revoking a scope or an
+  entitlement drops it on the next refresh.
 - The login, consent, and device pages set a strict CSP with
   `frame-ancestors 'none'`. They use server-side sessions, CSRF protection, and
   no open redirect.
@@ -451,9 +458,7 @@ service up mid-rotation. The operator checklist with concrete commands is in
   redirect URIs. DCR client management survives because its token hash is in
   Postgres.
 - An L7 ingress that terminates TLS strips the client certificate; in-process
-  mTLS then fails. Use L4 passthrough or trusted-proxy mode.
-- A trusted proxy that is misconfigured to a broad address trusts forged
-  headers. Keep `trusted_proxy` as narrow as possible.
+  mTLS then fails. Use L4 passthrough.
 - A public client that loses its DPoP key cannot use its tokens; there is no
   bearer fallback for a sender-constrained grant.
 

+ 4 - 2
scripts/check_manifests.py

@@ -39,7 +39,8 @@ contract WITHOUT a live cluster:
 - L7-vs-L4 mTLS constraint: an L7 Ingress that terminates TLS while
   in-process client-cert auth is enabled (`D_OAUTH_TLS__CLIENT_CA_PATH`
   set, or client-cert Ingress annotations) FAILS. mTLS needs L4/TCP
-  passthrough (NLB gateway / TLSRoute) or explicit trusted-proxy mode.
+  passthrough (NLB gateway / TLSRoute); forwarded client-cert headers are
+  never trusted.
   This constraint is DOCUMENTED (docs/DEPLOYMENT.md) and lint-checked
   here; it is explicitly NOT exercised end-to-end in CI.
 
@@ -508,7 +509,8 @@ def check_l7_mtls(docs: list[dict], fails: Failures) -> None:
       fails.add(
         f"ingress/{name}: L7 TLS termination strips the client certificate "
         "while in-process client-cert auth is enabled — use L4/TCP "
-        "passthrough (NLB gateway / TLSRoute) or explicit trusted-proxy mode"
+        "passthrough (NLB gateway / TLSRoute); forwarded client-cert "
+        "headers are never trusted"
       )
 
 

+ 209 - 0
src/cli/tests.rs

@@ -1262,3 +1262,212 @@ async fn open_pool_uses_mounted_database_password() -> Result<(), TestError> {
   let _ = std::fs::remove_file(&pw_path);
   Ok(())
 }
+
+/// `user disable` purges the user's Redis ephemeral state before flipping the
+/// Postgres `disabled` flag; unrelated subjects' keys survive.
+#[tokio::test]
+async fn user_disable_purges_ephemeral_state() -> Result<(), TestError> {
+  let (_harness, context) = test_support::context().await?;
+  let email = test_support::unique_email("disable-purge");
+  user::create(&context, &email, false, PASSWORD).await?;
+  let record = user_repo::find_by_email(context.pool(), &context.config().keys, &email)
+    .await?
+    .ok_or(TestError::Missing("created user"))?;
+  let canonical = record.email.clone();
+  let user_id = record.id.to_string();
+  let tag = uuid::Uuid::new_v4().to_string();
+
+  let store = crate::store::Store::from_config(&context.config().redis);
+  let mut conn = AppState::connect(context.config().clone())
+    .await?
+    .redis
+    .clone();
+
+  async fn hset(conn: &mut RedisConn, key: &str, pairs: &[(&str, &str)]) -> Result<(), TestError> {
+    let mut cmd = redis::cmd("HSET");
+    cmd.arg(key);
+    for (field, value) in pairs {
+      cmd.arg(field).arg(value);
+    }
+    cmd.query_async::<()>(conn).await?;
+    redis::cmd("PEXPIRE")
+      .arg(key)
+      .arg(60_000)
+      .query_async::<()>(conn)
+      .await?;
+    Ok(())
+  }
+
+  let session_key = store.session_key(&format!("disable-purge-session-{tag}"));
+  hset(
+    &mut conn,
+    &session_key,
+    &[
+      ("subject", &canonical),
+      ("mfa_verified", "true"),
+      ("restricted", "false"),
+    ],
+  )
+  .await?;
+  let session_meta = format!("{session_key}:meta");
+  hset(
+    &mut conn,
+    &session_meta,
+    &[("auth_time", "1700000000"), ("csrf", "csrf-secret")],
+  )
+  .await?;
+
+  let code_key = store.code_key(&format!("disable-purge-code-{tag}"));
+  hset(
+    &mut conn,
+    &code_key,
+    &[
+      ("client_id", "client-a"),
+      ("redirect_uri", "https://client.example.test/cb"),
+      ("scope", "orders:read"),
+      (
+        "code_challenge",
+        "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+      ),
+      ("subject", &canonical),
+      ("grant_id", "grant-code"),
+      ("cnf", ""),
+    ],
+  )
+  .await?;
+
+  let grant_id = format!("disable-purge-grant-{tag}");
+  let grant_key = store.grant_key(&grant_id);
+  hset(
+    &mut conn,
+    &grant_key,
+    &[
+      ("grant_id", &grant_id),
+      ("client_id", "client-a"),
+      ("subject", &canonical),
+      ("scope", "orders:read"),
+    ],
+  )
+  .await?;
+  let access_hash = format!("disable-purge-access-{tag}");
+  let refresh_hash = format!("disable-purge-refresh-{tag}");
+  let access_key = store.access_key(&access_hash);
+  let refresh_key = store.refresh_key(&refresh_hash);
+  for (key, hash) in [(&access_key, &access_hash), (&refresh_key, &refresh_hash)] {
+    hset(
+      &mut conn,
+      key,
+      &[
+        ("client_id", "client-a"),
+        ("grant_id", &grant_id),
+        ("scope", "orders:read"),
+        ("subject", &canonical),
+        ("cnf", ""),
+      ],
+    )
+    .await?;
+    redis::cmd("SADD")
+      .arg(store.grant_tokens_key(&grant_id))
+      .arg(hash)
+      .query_async::<()>(&mut conn)
+      .await?;
+  }
+  let access_act = format!("{access_key}:act");
+  redis::cmd("SET")
+    .arg(&access_act)
+    .arg("jkt")
+    .arg("PX")
+    .arg(60_000)
+    .query_async::<()>(&mut conn)
+    .await?;
+
+  let device_hash = format!("disable-purge-device-{tag}");
+  let device_key = store.device_key(&device_hash);
+  hset(
+    &mut conn,
+    &device_key,
+    &[
+      ("client_id", "client-a"),
+      ("scope", "orders:read"),
+      ("status", "approved"),
+      ("subject", &user_id),
+      ("grant_id", ""),
+    ],
+  )
+  .await?;
+  let device_cnf = format!("{device_key}:cnf");
+  let device_poll = format!("{device_key}:poll");
+  for key in [&device_cnf, &device_poll] {
+    redis::cmd("SET")
+      .arg(key)
+      .arg("sidecar")
+      .arg("PX")
+      .arg(60_000)
+      .query_async::<()>(&mut conn)
+      .await?;
+  }
+  let user_code_key = store.user_code_key(&format!("disable-purge-usercode-{tag}"));
+  redis::cmd("SET")
+    .arg(&user_code_key)
+    .arg(&device_hash)
+    .arg("PX")
+    .arg(60_000)
+    .query_async::<()>(&mut conn)
+    .await?;
+
+  let fail_key = store.login_fail_key(&crate::store::sha256_hex(
+    crate::db::user_repo::normalize_email(&canonical).as_bytes(),
+  ));
+  redis::cmd("INCR")
+    .arg(&fail_key)
+    .query_async::<()>(&mut conn)
+    .await?;
+
+  let decoy_email = test_support::unique_email("disable-purge-decoy");
+  let decoy_session_key = store.session_key(&format!("disable-purge-decoy-{tag}"));
+  hset(
+    &mut conn,
+    &decoy_session_key,
+    &[
+      ("subject", &decoy_email),
+      ("mfa_verified", "true"),
+      ("restricted", "false"),
+    ],
+  )
+  .await?;
+
+  user::disable(&context, &email).await?;
+
+  let after = user_repo::find_by_email(context.pool(), &context.config().keys, &email)
+    .await?
+    .ok_or(TestError::Missing("disabled user"))?;
+  assert!(after.disabled, "user disable must set the disabled flag");
+
+  for key in [
+    session_key,
+    session_meta,
+    code_key,
+    grant_key,
+    store.grant_tokens_key(&grant_id),
+    access_key,
+    access_act,
+    refresh_key,
+    device_key,
+    device_cnf,
+    device_poll,
+    user_code_key,
+    fail_key,
+  ] {
+    let present: i64 = redis::cmd("EXISTS")
+      .arg(&key)
+      .query_async(&mut conn)
+      .await?;
+    assert_eq!(present, 0, "disable must purge {key}");
+  }
+  let decoy_present: i64 = redis::cmd("EXISTS")
+    .arg(&decoy_session_key)
+    .query_async(&mut conn)
+    .await?;
+  assert_eq!(decoy_present, 1, "disable must keep unrelated subjects");
+  Ok(())
+}

+ 22 - 6
src/cli/user.rs

@@ -83,19 +83,35 @@ pub async fn enroll_totp(context: &Context, email: &str) -> Result<Enrollment, C
 
 /// Disable a user account.
 ///
+/// The user's Redis ephemeral state (sessions, tokens, grants, codes, device
+/// records, and the lockout counter) is purged BEFORE the Postgres `disabled`
+/// flag flips, mirroring the DCR `DELETE` ordering. A Redis or connect failure
+/// fails closed: the command errors and the user stays enabled.
+///
 /// # Errors
-/// Returns [`CliError`] when the user is missing or the write fails.
+/// Returns [`CliError`] when the user is missing, when Redis is unavailable,
+/// or when a write fails.
 pub async fn disable(context: &Context, email: &str) -> Result<(), CliError> {
-  let email = normalized_email(email)?;
-  let id = find_user_id(context, &email).await?;
-  let updated = user_repo::disable_user(context.pool(), id)
+  let email = email.trim();
+  require_nonempty(email, "email")?;
+  let record = user_repo::find_by_email(context.pool(), &context.config().keys, email)
+    .await
+    .map_err(map_user_repo_error)?
+    .ok_or_else(|| CliError::NotFound("user not found".to_owned()))?;
+  let state = crate::state::AppState::connect(context.config().clone()).await?;
+  let store = crate::store::Store::from_config(&context.config().redis);
+  let mut conn = state.redis.clone();
+  crate::protocol::purge::purge_subject(&mut conn, &store, &record.email, &record.id.to_string())
+    .await
+    .map_err(CliError::from)?;
+  let updated = user_repo::disable_user(context.pool(), record.id)
     .await
     .map_err(map_user_repo_error)?;
   if !updated {
     return Err(CliError::NotFound("user not found".to_owned()));
   }
-  audit("user.disable", &email);
-  println!("disabled user {email}");
+  audit("user.disable", &record.email);
+  println!("disabled user {}", record.email);
   Ok(())
 }
 

+ 7 - 11
src/config.rs

@@ -2,7 +2,7 @@
 //!
 //! Loads env + optional YAML via the `config` crate: issuer, bind/health
 //! addresses, TLS and CA paths, Postgres/Redis connection blocks, failure
-//! policy, versioned key ids, TTLs, Argon2 params, cookie/proxy/CORS knobs,
+//! policy, versioned key ids, TTLs, Argon2 params, cookie/CORS knobs,
 //! rate-limit knobs, and the optional declarative `scope_registry`.
 //!
 //! Validation is fail-fast: an insecure issuer, a missing token pepper, a
@@ -17,7 +17,7 @@
 
 use std::{
   fmt,
-  net::{IpAddr, SocketAddr},
+  net::SocketAddr,
   path::{Path, PathBuf},
 };
 
@@ -93,9 +93,6 @@ pub struct Config {
   pub cookie_domain: Option<String>,
   /// Whether cookies carry the `Secure` attribute (default true).
   pub cookie_secure: bool,
-  /// Trusted reverse proxy whose forwarded client-cert/client-IP headers are
-  /// honored (default none: headers are ignored).
-  pub trusted_proxy: Option<IpAddr>,
   /// Whether unauthenticated dynamic registration is allowed (default false).
   pub allow_open_registration: bool,
   /// DCR redirect-URI policy (default [`DcrRedirectPolicy::Catalog`]).
@@ -128,7 +125,6 @@ impl fmt::Debug for Config {
       .field("rate_limit", &self.rate_limit)
       .field("cookie_domain", &self.cookie_domain)
       .field("cookie_secure", &self.cookie_secure)
-      .field("trusted_proxy", &self.trusted_proxy)
       .field("allow_open_registration", &self.allow_open_registration)
       .field("dcr_redirect_policy", &self.dcr_redirect_policy)
       .field("cors_allowed_origins", &self.cors_allowed_origins)
@@ -292,11 +288,11 @@ pub enum ConfigError {
     /// The offending endpoint.
     endpoint: String,
   },
-  /// The trusted-proxy address was not a valid IP.
-  #[error("invalid trusted proxy address: {value}")]
-  InvalidTrustedProxy {
-    /// The rejected value.
-    value: String,
+  /// A resolved master key was shorter than the required 32 bytes.
+  #[error("key material for {key} must be at least 32 bytes")]
+  WeakKeyMaterial {
+    /// The configuration key that carried the weak material.
+    key: &'static str,
   },
   /// Argon2 parameters were outside the safe range.
   #[error("invalid argon2 parameters")]

+ 48 - 34
src/config/build.rs

@@ -3,11 +3,7 @@
 //! Kept in a submodule so the root `config.rs` stays focused on the public
 //! types and loader surface.
 
-use std::{
-  net::{IpAddr, SocketAddr},
-  path::Path,
-  str::FromStr,
-};
+use std::{net::SocketAddr, path::Path, str::FromStr};
 
 use secrecy::{ExposeSecret, SecretString};
 
@@ -47,7 +43,6 @@ impl Config {
     let ttl = TtlConfig::from_raw(raw.ttl)?;
     let argon2 = Argon2Params::from_raw(raw.argon2)?;
     let rate_limit = RateLimitConfig::from_raw(raw.rate_limit)?;
-    let trusted_proxy = parse_trusted_proxy(raw.trusted_proxy.as_deref())?;
     let scope_registry = raw
       .scope_registry
       .into_iter()
@@ -74,7 +69,6 @@ impl Config {
       rate_limit,
       cookie_domain: raw.cookie_domain,
       cookie_secure: raw.cookie_secure.unwrap_or(true),
-      trusted_proxy,
       allow_open_registration: raw.allow_open_registration,
       pii_dual_write: raw.pii_dual_write,
       dcr_redirect_policy: raw.dcr_redirect_policy,
@@ -130,40 +124,60 @@ where
   SocketAddr::from_str(value).map_err(|_| invalid(value.to_owned()))
 }
 
-fn parse_trusted_proxy(raw: Option<&str>) -> Result<Option<IpAddr>, ConfigError> {
-  match raw.map(str::trim).filter(|value| !value.is_empty()) {
-    None => Ok(None),
-    Some(value) => {
-      IpAddr::from_str(value)
-        .map(Some)
-        .map_err(|_| ConfigError::InvalidTrustedProxy {
-          value: value.to_owned(),
-        })
+/// Master keys must carry at least 256 bits of material.
+const MIN_KEY_BYTES: usize = 32;
+
+/// Reject a resolved key shorter than [`MIN_KEY_BYTES`]. `None` (no key
+/// configured) passes through: the key-ring chain builder reports a missing
+/// active key as its own error.
+fn require_min_key_bytes(
+  secret: Option<SecretString>,
+  key: &'static str,
+) -> Result<Option<SecretString>, ConfigError> {
+  match secret {
+    Some(secret) if secret.expose_secret().len() < MIN_KEY_BYTES => {
+      Err(ConfigError::WeakKeyMaterial { key })
     }
+    other => Ok(other),
   }
 }
 
 fn build_key_ring(raw: &RawConfig) -> Result<KeyRing, ConfigError> {
-  let pepper = resolve_secret(
-    raw.token_pepper.as_deref(),
-    raw.token_pepper_file.as_deref(),
+  let pepper = require_min_key_bytes(
+    resolve_secret(
+      raw.token_pepper.as_deref(),
+      raw.token_pepper_file.as_deref(),
+    )?,
+    "token_pepper",
+  )?;
+  let totp = require_min_key_bytes(
+    resolve_secret(
+      raw.totp_encryption_key.as_deref(),
+      raw.totp_encryption_key_file.as_deref(),
+    )?,
+    "totp_encryption_key",
+  )?;
+  let pii = require_min_key_bytes(
+    non_blank(resolve_secret(
+      raw.pii_encryption_key.as_deref(),
+      raw.pii_encryption_key_file.as_deref(),
+    )?),
+    "pii_encryption_key",
+  )?;
+  let totp_previous = require_min_key_bytes(
+    non_blank(resolve_secret(
+      raw.totp_previous_key.as_deref(),
+      raw.totp_previous_key_file.as_deref(),
+    )?),
+    "totp_previous_key",
   )?;
-  let totp = resolve_secret(
-    raw.totp_encryption_key.as_deref(),
-    raw.totp_encryption_key_file.as_deref(),
+  let pii_previous = require_min_key_bytes(
+    non_blank(resolve_secret(
+      raw.pii_previous_key.as_deref(),
+      raw.pii_previous_key_file.as_deref(),
+    )?),
+    "pii_previous_key",
   )?;
-  let pii = non_blank(resolve_secret(
-    raw.pii_encryption_key.as_deref(),
-    raw.pii_encryption_key_file.as_deref(),
-  )?);
-  let totp_previous = non_blank(resolve_secret(
-    raw.totp_previous_key.as_deref(),
-    raw.totp_previous_key_file.as_deref(),
-  )?);
-  let pii_previous = non_blank(resolve_secret(
-    raw.pii_previous_key.as_deref(),
-    raw.pii_previous_key_file.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 pii_id = key_id_or_default(raw.pii_key_id.as_deref());

+ 0 - 1
src/config/raw.rs

@@ -57,7 +57,6 @@ pub(crate) struct RawConfig {
   pub rate_limit: RawRateLimit,
   pub cookie_domain: Option<String>,
   pub cookie_secure: Option<bool>,
-  pub trusted_proxy: Option<String>,
   pub allow_open_registration: bool,
   pub dcr_redirect_policy: DcrRedirectPolicy,
   pub cors_allowed_origins: Vec<String>,

+ 67 - 13
src/config/tests.rs

@@ -28,11 +28,11 @@ fn base() -> Map<String, Value> {
       "durability": "waitaof",
       "root_cert_path": "/etc/d-oauth/redis-ca.pem"
     },
-    "token_pepper": "SUPER_SECRET_PEPPER_VALUE",
+    "token_pepper": "SUPER_SECRET_PEPPER_VALUE-0123456789abcdef",
     "token_pepper_id": "v1",
-    "totp_encryption_key": "SUPER_SECRET_TOTP_KEY",
+    "totp_encryption_key": "SUPER_SECRET_TOTP_KEY-0123456789abcdef",
     "totp_key_id": "v1",
-    "pii_encryption_key": "SUPER_SECRET_PII_KEY",
+    "pii_encryption_key": "SUPER_SECRET_PII_KEY-0123456789abcdef",
     "pii_key_id": "v1",
     "cookie_secure": true,
     "allow_open_registration": false,
@@ -96,7 +96,6 @@ fn config_loads_with_empty_scope_registry() -> Result<(), Box<dyn Error>> {
   assert!(cfg.cookie_secure);
   assert_eq!(cfg.dcr_redirect_policy, DcrRedirectPolicy::Catalog);
   assert!(!cfg.allow_open_registration);
-  assert!(cfg.trusted_proxy.is_none());
   assert!(cfg.cors_allowed_origins.is_empty());
   Ok(())
 }
@@ -272,9 +271,9 @@ fn config_redis_startup_assertions_are_callable() -> Result<(), Box<dyn Error>>
 fn config_debug_redacts_secret_values() -> Result<(), Box<dyn Error>> {
   let cfg = try_load(&base())?;
   let rendered = format!("{cfg:?}");
-  assert!(!rendered.contains("SUPER_SECRET_PEPPER_VALUE"));
-  assert!(!rendered.contains("SUPER_SECRET_TOTP_KEY"));
-  assert!(!rendered.contains("SUPER_SECRET_PII_KEY"));
+  assert!(!rendered.contains("SUPER_SECRET_PEPPER_VALUE-0123456789abcdef"));
+  assert!(!rendered.contains("SUPER_SECRET_TOTP_KEY-0123456789abcdef"));
+  assert!(!rendered.contains("SUPER_SECRET_PII_KEY-0123456789abcdef"));
   assert!(!rendered.contains("DB_PASSWORD_XYZ"));
   assert!(rendered.contains("REDACTED"));
   Ok(())
@@ -353,12 +352,67 @@ fn config_rejects_empty_active_pii_key() {
   }
 }
 
+/// A resolved pepper/TOTP/PII key shorter than 32 bytes is rejected with the
+/// offending key named; retained previous keys are checked too.
+#[test]
+fn config_rejects_weak_key_material() {
+  for (key, value) in [
+    ("token_pepper", "short-pepper-material"),
+    ("totp_encryption_key", "short-totp-key-material"),
+    ("pii_encryption_key", "short-pii-key-material"),
+  ] {
+    let mut map = base();
+    map.insert(key.to_owned(), Value::String(value.to_owned()));
+    assert!(
+      matches!(
+        try_load(&map),
+        Err(ConfigError::WeakKeyMaterial { key: rejected }) if rejected == key
+      ),
+      "{key} shorter than 32 bytes must be rejected"
+    );
+  }
+  for key in ["totp_previous_key", "pii_previous_key"] {
+    let mut map = base();
+    map.insert(
+      key.to_owned(),
+      Value::String("short-previous-key".to_owned()),
+    );
+    map.insert(format!("{key}_id"), Value::String("v0".to_owned()));
+    assert!(
+      matches!(
+        try_load(&map),
+        Err(ConfigError::WeakKeyMaterial { key: rejected }) if rejected == key
+      ),
+      "previous key {key} shorter than 32 bytes must be rejected"
+    );
+  }
+}
+
+/// The key-length boundary is exact: 31 bytes is rejected, 32 accepted.
+#[test]
+fn config_key_length_boundary_is_32_bytes() -> Result<(), Box<dyn Error>> {
+  let mut exact = base();
+  exact.insert("token_pepper".to_owned(), Value::String("p".repeat(32)));
+  let cfg = try_load(&exact)?;
+  assert!(cfg.keys.active_pepper().is_some());
+
+  let mut short = base();
+  short.insert("token_pepper".to_owned(), Value::String("p".repeat(31)));
+  assert!(matches!(
+    try_load(&short),
+    Err(ConfigError::WeakKeyMaterial {
+      key: "token_pepper"
+    })
+  ));
+  Ok(())
+}
+
 #[test]
 fn config_builds_pii_chain() -> Result<(), Box<dyn Error>> {
   let mut map = base();
   map.insert(
     "pii_previous_key".to_owned(),
-    Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
+    Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
   );
   map.insert(
     "pii_previous_key_id".to_owned(),
@@ -392,7 +446,7 @@ fn config_rejects_previous_key_without_id() {
   let mut map = base();
   map.insert(
     "pii_previous_key".to_owned(),
-    Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
+    Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
   );
   let result = try_load(&map);
   assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
@@ -400,7 +454,7 @@ fn config_rejects_previous_key_without_id() {
   let mut same_id = base();
   same_id.insert(
     "pii_previous_key".to_owned(),
-    Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
+    Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
   );
   same_id.insert(
     "pii_previous_key_id".to_owned(),
@@ -412,7 +466,7 @@ fn config_rejects_previous_key_without_id() {
   let mut totp = base();
   totp.insert(
     "totp_previous_key".to_owned(),
-    Value::String("SUPER_SECRET_TOTP_PREVIOUS".to_owned()),
+    Value::String("SUPER_SECRET_TOTP_PREVIOUS-0123456789abcdef".to_owned()),
   );
   let result = try_load(&totp);
   assert!(matches!(result, Err(ConfigError::MissingPreviousKeyId)));
@@ -423,7 +477,7 @@ fn config_rejects_previous_key_without_active() {
   let mut pii = without("pii_encryption_key");
   pii.insert(
     "pii_previous_key".to_owned(),
-    Value::String("SUPER_SECRET_PII_PREVIOUS".to_owned()),
+    Value::String("SUPER_SECRET_PII_PREVIOUS-0123456789abcdef".to_owned()),
   );
   pii.insert(
     "pii_previous_key_id".to_owned(),
@@ -438,7 +492,7 @@ fn config_rejects_previous_key_without_active() {
   let mut totp = without("totp_encryption_key");
   totp.insert(
     "totp_previous_key".to_owned(),
-    Value::String("SUPER_SECRET_TOTP_PREVIOUS".to_owned()),
+    Value::String("SUPER_SECRET_TOTP_PREVIOUS-0123456789abcdef".to_owned()),
   );
   totp.insert(
     "totp_previous_key_id".to_owned(),

+ 110 - 0
src/db/user_repo.rs

@@ -17,6 +17,9 @@
 //! `lockout` section); no lockout state is ever written to Postgres, which is
 //! what makes the counters safe under horizontal scaling.
 
+#[cfg(test)]
+mod tests;
+
 use std::fmt;
 
 use time::OffsetDateTime;
@@ -206,6 +209,113 @@ pub async fn find_by_email(
     .transpose()
 }
 
+/// Use-time account status of a token subject.
+///
+/// Token subjects are inconsistent by origin: authorization-code and
+/// refresh-token records carry the session subject (the user email), while
+/// device-code records carry the user UUID string, and client-credentials
+/// records carry an empty subject. This enum lets callers treat "resolves to
+/// a disabled user" as fatal while leaving unmapped (synthetic or
+/// client-credentials) subjects live.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SubjectAccountStatus {
+  /// The subject maps to no user; the token stays live.
+  Unmapped,
+  /// The subject maps to an enabled user; the token stays live.
+  Enabled,
+  /// The subject maps to a disabled user; the token must go inactive.
+  Disabled,
+}
+
+/// Resolve a token subject to its account status without decrypting PII.
+///
+/// An empty subject (client-credentials tokens) short-circuits to
+/// [`SubjectAccountStatus::Unmapped`]. A subject that parses as a `Uuid` is
+/// looked up by primary key; an email-shaped subject is normalized and matched
+/// through the email blind index, mirroring [`find_by_email`]. Only the
+/// `disabled` flag is read, never the sealed email. A subject that matches no
+/// row reports [`SubjectAccountStatus::Unmapped`].
+///
+/// # Errors
+/// Returns a masked error on any database failure; callers must fail closed
+/// (treat the token as unusable) rather than defaulting to active.
+pub async fn subject_account_status(
+  pool: &sqlx::PgPool,
+  keys: &KeyRing,
+  subject: &str,
+) -> Result<SubjectAccountStatus, AppError> {
+  const fn map(disabled: bool) -> SubjectAccountStatus {
+    if disabled {
+      SubjectAccountStatus::Disabled
+    } else {
+      SubjectAccountStatus::Enabled
+    }
+  }
+  let subject = subject.trim();
+  if subject.is_empty() {
+    return Ok(SubjectAccountStatus::Unmapped);
+  }
+  if let Ok(id) = subject.parse::<Uuid>() {
+    let row = sqlx::query!(r#"SELECT disabled FROM users WHERE id = $1"#, id)
+      .fetch_optional(pool)
+      .await?;
+    return Ok(row.map_or(SubjectAccountStatus::Unmapped, |row| map(row.disabled)));
+  }
+  // A user identifier is a UUID or an email; a value that is neither cannot
+  // match a row, so skip the query (this keeps synthetic subjects off the
+  // database hot path).
+  if !subject.contains('@') {
+    return Ok(SubjectAccountStatus::Unmapped);
+  }
+  let canonical = normalize_email(subject);
+  let candidates = blind_index_candidates(keys, &canonical);
+  let row = sqlx::query!(
+    r#"SELECT disabled FROM users
+         WHERE email_index = ANY($1)
+            OR (email_enc IS NULL AND email = $2::text::citext)"#,
+    &candidates,
+    canonical,
+  )
+  .fetch_optional(pool)
+  .await?;
+  Ok(row.map_or(SubjectAccountStatus::Unmapped, |row| map(row.disabled)))
+}
+
+/// Resolve a token subject to its user id, or `None` when it maps to no user.
+///
+/// Mirrors [`subject_account_status`] without decrypting PII: an empty subject
+/// (client-credentials tokens) and a non-email, non-UUID subject short-circuit
+/// to `None`; a UUID subject resolves by primary key; an email-shaped subject
+/// is normalized and matched through the email blind index like
+/// [`find_by_email`]. Callers treat `None` as "no user term".
+///
+/// # Errors
+/// Returns a masked error on any database failure.
+pub async fn subject_user_id(
+  pool: &sqlx::PgPool,
+  keys: &KeyRing,
+  subject: &str,
+) -> Result<Option<Uuid>, AppError> {
+  let subject = subject.trim();
+  if subject.is_empty() {
+    return Ok(None);
+  }
+  if let Ok(id) = subject.parse::<Uuid>() {
+    return match subject_account_status(pool, keys, subject).await? {
+      SubjectAccountStatus::Unmapped => Ok(None),
+      SubjectAccountStatus::Enabled | SubjectAccountStatus::Disabled => Ok(Some(id)),
+    };
+  }
+  if !subject.contains('@') {
+    return Ok(None);
+  }
+  Ok(
+    find_by_email(pool, keys, subject)
+      .await?
+      .map(|user| user.id),
+  )
+}
+
 /// Verify a password against the stored Argon2id hash.
 ///
 /// Uses the same dual predicate as [`find_by_email`], never the legacy

+ 104 - 0
src/db/user_repo/tests.rs

@@ -0,0 +1,104 @@
+//! `test(user_repo_subject_status)` — use-time disabled-user lookup.
+//!
+//! Covers the fail-closed helper behind token introspection and the
+//! refresh-token grant: an email subject (any casing) or a UUID subject that
+//! resolves to a disabled user reports `Disabled`; enabled users report
+//! `Enabled`; empty, unknown, and synthetic subjects report `Unmapped` and
+//! must never fail a live token. Every test gets a fresh migrated database
+//! from [`crate::db::test_support`].
+
+use uuid::Uuid;
+
+use super::{NewUser, SubjectAccountStatus, create_user, disable_user, subject_account_status};
+use crate::crypto::secret::{Argon2Params, hash_secret};
+use crate::db::test_support::{TestError, migrated_pool};
+use crate::keys::{KeyId, KeyRing};
+use secrecy::SecretString;
+use sqlx::PgPool;
+
+/// Test PII key ring (the blind-index candidates must derive from it).
+fn test_keys() -> Result<KeyRing, TestError> {
+  Ok(KeyRing::from_parts(
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("user-repo-test-pepper"),
+    )],
+    vec![(KeyId::new("v1"), SecretString::from("user-repo-test-totp"))],
+    vec![(KeyId::new("v1"), SecretString::from("user-repo-test-pii"))],
+  )?)
+}
+
+/// Provision one enabled user; returns its id.
+async fn provision(pool: &PgPool, keys: &KeyRing, email: &str) -> Result<Uuid, TestError> {
+  let params = Argon2Params::new(8, 1, 1, 32)?;
+  let record = create_user(
+    pool,
+    keys,
+    false,
+    &NewUser {
+      email: email.to_owned(),
+      password_hash: hash_secret("correct-horse-01", &params)?,
+      is_admin: false,
+      must_change_password: false,
+    },
+  )
+  .await?;
+  Ok(record.id)
+}
+
+#[tokio::test]
+async fn user_repo_subject_status_empty_is_unmapped() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  for subject in ["", "   "] {
+    let status = subject_account_status(&pool, &keys, subject).await?;
+    assert_eq!(
+      status,
+      SubjectAccountStatus::Unmapped,
+      "an empty subject is a client-credentials token and must never resolve"
+    );
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn user_repo_subject_status_email_enabled_then_disabled() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let id = provision(&pool, &keys, "status-user@example.test").await?;
+  // Any casing normalizes to the same blind-index candidates.
+  let status = subject_account_status(&pool, &keys, "  Status-User@Example.Test ").await?;
+  assert_eq!(status, SubjectAccountStatus::Enabled);
+  assert!(disable_user(&pool, id).await?);
+  let status = subject_account_status(&pool, &keys, "status-user@example.test").await?;
+  assert_eq!(status, SubjectAccountStatus::Disabled);
+  Ok(())
+}
+
+#[tokio::test]
+async fn user_repo_subject_status_uuid_enabled_then_disabled() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let id = provision(&pool, &keys, "uuid-user@example.test").await?;
+  let subject = id.to_string();
+  let status = subject_account_status(&pool, &keys, &subject).await?;
+  assert_eq!(status, SubjectAccountStatus::Enabled);
+  assert!(disable_user(&pool, id).await?);
+  let status = subject_account_status(&pool, &keys, &subject).await?;
+  assert_eq!(status, SubjectAccountStatus::Disabled);
+  Ok(())
+}
+
+#[tokio::test]
+async fn user_repo_subject_status_unknown_is_unmapped() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  // Synthetic subjects (never provisioned) must stay live.
+  let status = subject_account_status(&pool, &keys, "synthetic-subject-1").await?;
+  assert_eq!(status, SubjectAccountStatus::Unmapped);
+  let status = subject_account_status(&pool, &keys, &Uuid::new_v4().to_string()).await?;
+  assert_eq!(status, SubjectAccountStatus::Unmapped);
+  let status = subject_account_status(&pool, &keys, "ghost@example.test").await?;
+  assert_eq!(status, SubjectAccountStatus::Unmapped);
+  Ok(())
+}

+ 6 - 5
src/jose/jar.rs

@@ -10,10 +10,10 @@
 //! and client-bound from the PAR store) or an exact pre-registered client
 //! URI fetched behind the SSRF guard — never an arbitrary fetch.
 //!
-//! SEAM(task 18): `fetch` implements the SSRF policy inline (https-only, no
-//! redirects, 64 KiB / 5 s caps, deny non-global IPs after a
-//! pre-resolution check). Once `protocol::ssrf` exposes a guarded fetch,
-//! that module must delegate to it instead of duplicating the policy.
+//! `fetch` delegates to the shared `protocol::ssrf` guarded fetch
+//! (`https`-only, no redirects, DNS-validated and connection-pinned
+//! targets, 5 s / 256 KiB caps) and then enforces the 64 KiB
+//! request-object cap with a UTF-8 requirement here.
 //!
 //! INTEGRATION(task 23): `protocol::authorize` must call [`resolve`]. The
 //! JAR/PAR-mandatory rule for confidential clients is enforced here by
@@ -36,7 +36,8 @@ use verify::verify_request_object;
 pub const PAR_REQUEST_URI_PREFIX: &str = "urn:ietf:params:oauth:request_uri:";
 /// Maximum fetched request-object body (SSRF size cap).
 pub const MAX_REQUEST_OBJECT_BYTES: usize = 64 * 1024;
-/// Outbound `request_uri` fetch timeout (SSRF time cap).
+/// Outbound `request_uri` fetch timeout, matching the shared SSRF guard's
+/// budget; the effective timeout is enforced by `protocol::ssrf`.
 pub const FETCH_TIMEOUT: Duration = Duration::from_secs(5);
 
 /// Authorize-time JAR input: at most one of `request` / `request_uri`.

+ 42 - 195
src/jose/jar/fetch.rs

@@ -1,21 +1,16 @@
 //! SSRF-guarded fetch for pre-registered `request_uri` values.
 //!
-//! Policy: absolute `https` only, no userinfo, no redirects, every
-//! resolved address must be publicly routable (checked before connecting),
-//! success statuses only, body capped at [`MAX_REQUEST_OBJECT_BYTES`]
-//! within [`FETCH_TIMEOUT`].
-//!
-//! SEAM(task 18): this duplicates the policy `protocol::ssrf` will own.
-//! Delegate to it once its guarded fetch lands; until then this module is
-//! the single choke point, so the swap is one call site. Residual risk:
-//! resolution happens twice (guard, then the connector), so a hostile DNS
-//! flip between the two needs task 18's pinned-connector hardening.
-
-use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
+//! Delegates to the shared [`crate::protocol::ssrf`] guard (`https`-only,
+//! no redirects, DNS-validated and connection-pinned targets, success
+//! statuses only) and then enforces the request-object body cap of
+//! [`MAX_REQUEST_OBJECT_BYTES`] with a UTF-8 requirement.
 
-use crate::error::AppError;
+use crate::{
+  error::AppError,
+  protocol::ssrf::{self, SsrfError},
+};
 
-use super::{FETCH_TIMEOUT, MAX_REQUEST_OBJECT_BYTES};
+use super::MAX_REQUEST_OBJECT_BYTES;
 
 /// Fetch a pre-registered request object behind the SSRF guard.
 ///
@@ -24,200 +19,52 @@ use super::{FETCH_TIMEOUT, MAX_REQUEST_OBJECT_BYTES};
 /// Returns `invalid_request_object` for any policy violation or fetch
 /// failure; never surfaces transport detail.
 pub(crate) async fn fetch_request_object(uri: &str) -> Result<String, AppError> {
-  let (host, port) = parse_https_authority(uri)?;
-  deny_non_global_host(&host, port).await?;
-  let agent = reqwest::Client::builder()
-    .redirect(reqwest::redirect::Policy::none())
-    .timeout(FETCH_TIMEOUT)
-    .build()
-    .map_err(AppError::internal)?;
-  let mut response = agent
-    .get(uri)
-    .send()
+  let bytes = ssrf::fetch_guarded_bytes(uri)
     .await
-    .map_err(|_| AppError::invalid_request_object("request_uri fetch failed"))?;
-  if !response.status().is_success() {
-    return Err(AppError::invalid_request_object(
-      "request_uri did not return a request object",
-    ));
-  }
-  if response
-    .content_length()
-    .is_some_and(|len| len > MAX_REQUEST_OBJECT_BYTES as u64)
-  {
-    return Err(AppError::invalid_request_object(
-      "request_uri response is too large",
-    ));
-  }
-  let mut body: Vec<u8> = Vec::new();
-  loop {
-    let chunk = response
-      .chunk()
-      .await
-      .map_err(|_| AppError::invalid_request_object("request_uri fetch failed"))?;
-    let Some(bytes) = chunk else {
-      break;
-    };
-    body.extend_from_slice(&bytes);
-    if body.len() > MAX_REQUEST_OBJECT_BYTES {
-      return Err(AppError::invalid_request_object(
-        "request_uri response is too large",
-      ));
-    }
-  }
-  String::from_utf8(body)
-    .map_err(|_| AppError::invalid_request_object("request_uri body is not UTF-8"))
+    .map_err(map_ssrf_error)?;
+  decode_request_object(&bytes)
 }
 
-/// Split an absolute `https` URI into its `(host, port)`, rejecting any
-/// other scheme and any userinfo.
-fn parse_https_authority(uri: &str) -> Result<(String, u16), AppError> {
-  let after_scheme = uri
-    .strip_prefix("https://")
-    .ok_or_else(|| AppError::invalid_request_object("request_uri must be an absolute https URI"))?;
-  let end = after_scheme
-    .find(['/', '?', '#'])
-    .unwrap_or(after_scheme.len());
-  let authority = &after_scheme[..end];
-  if authority.is_empty() || authority.contains('@') {
-    return Err(AppError::invalid_request_object(
-      "request_uri authority is invalid",
-    ));
-  }
-  if let Some(bracketed) = authority.strip_prefix('[') {
-    return parse_bracketed_authority(bracketed);
-  }
-  match authority.rsplit_once(':') {
-    Some((host, digits)) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => {
-      if host.is_empty() {
-        return Err(AppError::invalid_request_object(
-          "request_uri authority is invalid",
-        ));
-      }
-      Ok((host.to_owned(), port_from_str(digits)?))
-    }
-    _ => Ok((authority.to_owned(), 443)),
+/// Map a shared-guard failure to `invalid_request_object` without leaking
+/// transport detail; an over-limit body keeps the too-large message.
+fn map_ssrf_error(error: SsrfError) -> AppError {
+  match error {
+    SsrfError::TooLarge => AppError::invalid_request_object("request_uri response is too large"),
+    _ => AppError::invalid_request_object("request_uri fetch failed"),
   }
 }
 
-/// Split a `[v6-literal](:port)?` authority into its `(host, port)`.
-fn parse_bracketed_authority(bracketed: &str) -> Result<(String, u16), AppError> {
-  let invalid = || AppError::invalid_request_object("request_uri authority is invalid");
-  let close = bracketed.find(']').ok_or_else(invalid)?;
-  let host = &bracketed[..close];
-  let tail = &bracketed[close + 1..];
-  if host.is_empty() {
-    return Err(invalid());
-  }
-  if tail.is_empty() {
-    return Ok((host.to_owned(), 443));
+/// Enforce the 64 KiB request-object cap and the UTF-8 requirement on an
+/// already-guarded response body.
+fn decode_request_object(bytes: &[u8]) -> Result<String, AppError> {
+  if bytes.len() > MAX_REQUEST_OBJECT_BYTES {
+    return Err(AppError::invalid_request_object(
+      "request_uri response is too large",
+    ));
   }
-  let digits = tail.strip_prefix(':').ok_or_else(invalid)?;
-  Ok((host.to_owned(), port_from_str(digits)?))
+  String::from_utf8(bytes.to_vec())
+    .map_err(|_| AppError::invalid_request_object("request_uri body is not UTF-8"))
 }
 
-/// Parse an explicit port, rejecting out-of-range values.
-fn port_from_str(digits: &str) -> Result<u16, AppError> {
-  digits
-    .parse::<u16>()
-    .map_err(|_| AppError::invalid_request_object("request_uri port is invalid"))
-}
+#[cfg(test)]
+mod tests {
+  use super::decode_request_object;
+  use crate::jose::jar::MAX_REQUEST_OBJECT_BYTES;
 
-/// Deny any host that does not resolve exclusively to publicly routable
-/// addresses: literal IPs are checked directly, names are resolved and
-/// every address is checked (loopback, private, link-local — including the
-/// 169.254.169.254 cloud-metadata address — multicast, and other
-/// non-global ranges all fail closed).
-async fn deny_non_global_host(host: &str, port: u16) -> Result<(), AppError> {
-  if let Ok(literal) = host.parse::<IpAddr>() {
-    return check_global(&literal);
-  }
-  let mut seen = false;
-  let resolved = tokio::net::lookup_host((host, port))
-    .await
-    .map_err(|_| AppError::invalid_request_object("request_uri host does not resolve"))?;
-  for next in resolved {
-    seen = true;
-    check_global(&next.ip())?;
+  #[test]
+  fn jar_fetch_decode_rejects_body_over_64kib() {
+    let oversize = vec![b'a'; MAX_REQUEST_OBJECT_BYTES + 1];
+    assert!(decode_request_object(&oversize).is_err());
   }
-  if seen {
-    Ok(())
-  } else {
-    Err(AppError::invalid_request_object(
-      "request_uri host does not resolve",
-    ))
-  }
-}
 
-/// Accept only globally routable unicast addresses: loopback, private,
-/// link-local (including the 169.254.169.254 cloud-metadata address),
-/// multicast, documentation, benchmarking, shared, and reserved ranges all
-/// fail closed.
-fn check_global(ip: &IpAddr) -> Result<(), AppError> {
-  let routable = match ip {
-    IpAddr::V4(addr) => ipv4_routable(addr),
-    IpAddr::V6(addr) => ipv6_routable(addr),
-  };
-  if routable {
-    Ok(())
-  } else {
-    Err(AppError::invalid_request_object(
-      "request_uri target is not publicly routable",
-    ))
+  #[test]
+  fn jar_fetch_decode_rejects_non_utf8_body() {
+    assert!(decode_request_object(&[0xff, 0xfe, 0x00]).is_err());
   }
-}
 
-/// Whether an IPv4 address is publicly routable.
-fn ipv4_routable(addr: &Ipv4Addr) -> bool {
-  if addr.is_unspecified()
-    || addr.is_loopback()
-    || addr.is_private()
-    || addr.is_link_local()
-    || addr.is_broadcast()
-    || addr.is_documentation()
-    || addr.is_multicast()
-  {
-    return false;
-  }
-  let [first, second, ..] = addr.octets();
-  if first == 100 && (64..128).contains(&second) {
-    return false;
-  }
-  if first == 198 && (18..20).contains(&second) {
-    return false;
-  }
-  if first >= 240 {
-    return false;
-  }
-  true
-}
-
-/// Whether an IPv6 address is publicly routable; IPv4-mapped addresses are
-/// judged by the embedded IPv4 address.
-fn ipv6_routable(addr: &Ipv6Addr) -> bool {
-  if addr.is_unspecified()
-    || addr.is_loopback()
-    || addr.is_multicast()
-    || addr.is_unique_local()
-    || addr.is_unicast_link_local()
-  {
-    return false;
-  }
-  let segments = addr.segments();
-  if segments[0] == 0x2001 && segments[1] == 0x0db8 {
-    return false;
-  }
-  if segments[0] == 0
-    && segments[1] == 0
-    && segments[2] == 0
-    && segments[3] == 0
-    && segments[4] == 0
-    && segments[5] == 0xffff
-  {
-    let octets = addr.octets();
-    return ipv4_routable(&Ipv4Addr::new(
-      octets[12], octets[13], octets[14], octets[15],
-    ));
+  #[test]
+  fn jar_fetch_decode_accepts_valid_body() {
+    let body = "header.payload.signature";
+    assert!(decode_request_object(body.as_bytes()).is_ok());
   }
-  true
 }

+ 39 - 20
src/middleware/ratelimit.rs

@@ -77,7 +77,7 @@ static SCRIPT: LazyLock<Script> = LazyLock::new(|| Script::new(LUA));
 /// The buckets a protected endpoint enforces. `per_client` is `None` for
 /// endpoints that have no client identity (login, registration).
 #[derive(Debug, Clone, Copy)]
-struct Rule {
+pub(crate) struct Rule {
   /// Per-IP request budget per window.
   per_ip: u32,
   /// Per-client request budget per window, when a client is identifiable.
@@ -85,36 +85,55 @@ struct Rule {
 }
 
 impl Rule {
-  /// Classify a path by its last segment so both `/token` and `/oauth/token`
-  /// (and similar prefixes) match.
-  fn for_path(limits: RateLimitConfig, path: &str) -> Option<Self> {
-    let last = path
-      .trim_end_matches('/')
-      .rsplit('/')
+  /// Classify a request path against the mounted route table. The match is on
+  /// the full path (query stripped, trailing slash normalized) so a dynamic
+  /// segment such as `/register/{client_id}` is classified by its prefix and
+  /// a sensitive route can never fall through to an unrated handler.
+  /// `/health`, `/.well-known`, and `/assets` are deliberately unrated.
+  pub(crate) fn for_path(limits: RateLimitConfig, path: &str) -> Option<Self> {
+    let normalized = path
+      .split('?')
       .next()
-      .unwrap_or_default();
-    match last {
-      "token" => Some(Self {
+      .unwrap_or_default()
+      .trim_end_matches('/');
+    if normalized.starts_with("/health/")
+      || normalized.starts_with("/.well-known/")
+      || normalized.starts_with("/assets/")
+    {
+      return None;
+    }
+    match normalized {
+      "/token" => Some(Self {
         per_ip: limits.per_ip,
         per_client: Some(limits.token_per_client),
       }),
-      "login" => Some(Self {
+      "/authorize"
+      | "/consent"
+      | "/device"
+      | "/device_authorization"
+      | "/device_verification"
+      | "/introspect"
+      | "/revoke"
+      | "/par" => Some(Self {
+        per_ip: limits.per_ip,
+        per_client: Some(limits.per_client),
+      }),
+      "/login"
+      | "/login/success"
+      | "/login/change-password"
+      | "/login/totp/setup"
+      | "/login/totp/confirm"
+      | "/logout" => Some(Self {
         per_ip: limits.login_per_ip,
         per_client: None,
       }),
-      "register" => Some(Self {
+      "/register" => Some(Self {
         per_ip: limits.per_ip,
         per_client: None,
       }),
-      "authorize"
-      | "consent"
-      | "device"
-      | "device_authorization"
-      | "device_verification"
-      | "introspect"
-      | "revoke" => Some(Self {
+      _ if normalized.starts_with("/register/") => Some(Self {
         per_ip: limits.per_ip,
-        per_client: Some(limits.per_client),
+        per_client: None,
       }),
       _ => None,
     }

+ 7 - 7
src/middleware/ratelimit/tests.rs

@@ -211,11 +211,11 @@ async fn ratelimit_leaves_unlisted_paths_untouched_on_store_outage() -> TestResu
   Ok(())
 }
 
-/// Without a `trusted_proxy`, a forged `X-Forwarded-For` is never an identity:
-/// the limiter keys off the recorded peer, so rotating spoofed addresses
-/// cannot mint a fresh bucket and evade the limit.
+/// A forged `X-Forwarded-For` is never an identity: the limiter keys off the
+/// recorded peer, so rotating spoofed addresses cannot mint a fresh bucket and
+/// evade the limit.
 #[tokio::test]
-async fn ratelimit_ignores_spoofed_x_forwarded_for_without_trusted_proxy() -> TestResult {
+async fn ratelimit_ignores_spoofed_x_forwarded_for() -> TestResult {
   let (_redis, url) = redis_container().await?;
   let config = test_config(
     &url,
@@ -270,10 +270,10 @@ async fn ratelimit_ignores_spoofed_x_forwarded_for_without_trusted_proxy() -> Te
   Ok(())
 }
 
-/// The same rule for RFC 7239 `Forwarded`: with no `trusted_proxy` it can
-/// never select a bucket different from the recorded peer.
+/// The same rule for RFC 7239 `Forwarded`: it can never select a bucket
+/// different from the recorded peer.
 #[tokio::test]
-async fn ratelimit_ignores_spoofed_forwarded_header_without_trusted_proxy() -> TestResult {
+async fn ratelimit_ignores_spoofed_forwarded_header() -> TestResult {
   let (_redis, url) = redis_container().await?;
   let config = test_config(
     &url,

+ 88 - 3
src/middleware/redaction.rs

@@ -25,14 +25,18 @@ const SENSITIVE_FIELDS: &[&str] = &[
   "client_secret",
   "code_verifier",
   "password",
+  "user_code",
+  "device_code",
+  "request_uri",
 ];
 
 /// Whether a header/body/query key holds secret material.
 ///
 /// The match is on the normalized (trimmed, lowercased, `-`→`_`) key: an exact
-/// member of [`SENSITIVE_FIELDS`], or any key ending in `_token`, `_secret`, or
-/// `_password`. `code_challenge` deliberately stays visible — RFC 9449 makes it
-/// public, unlike `code_verifier`.
+/// member of [`SENSITIVE_FIELDS`], or any key ending in `_token`, `_secret`,
+/// `_password`, or `_code`. `code_challenge` deliberately stays visible — RFC
+/// 9449 makes it public, unlike `code_verifier`; it does not end in `_code`.
+/// `request_id` stays visible because it is a correlation id, not a credential.
 #[must_use]
 pub fn is_sensitive(key: &str) -> bool {
   let normalized = key.trim().to_ascii_lowercase().replace('-', "_");
@@ -43,6 +47,7 @@ pub fn is_sensitive(key: &str) -> bool {
     || normalized.ends_with("_token")
     || normalized.ends_with("_secret")
     || normalized.ends_with("_password")
+    || normalized.ends_with("_code")
     || normalized.contains("secret")
 }
 
@@ -215,8 +220,88 @@ mod redaction_tests {
     assert!(is_sensitive("code-verifier"));
     assert!(is_sensitive("access_token"));
     assert!(is_sensitive("REFRESH_SECRET"));
+    assert!(is_sensitive("user_code"));
+    assert!(is_sensitive("device_code"));
+    assert!(is_sensitive("request_uri"));
+    assert!(is_sensitive("USER-CODE"));
     assert!(!is_sensitive("code_challenge"));
+    assert!(!is_sensitive("request_id"));
     assert!(!is_sensitive("client_id"));
+    assert!(!is_sensitive("state"));
     assert!(!is_sensitive(""));
   }
+
+  #[test]
+  fn redaction_header_map_masks_device_flow_fields() {
+    let mut headers = HeaderMap::new();
+    headers.insert(
+      HeaderName::from_static("user-code"),
+      HeaderValue::from_static("ABCD-1234"),
+    );
+    headers.insert(
+      HeaderName::from_static("device_code"),
+      HeaderValue::from_static("device-secret"),
+    );
+    headers.insert(
+      HeaderName::from_static("request-uri"),
+      HeaderValue::from_static("urn:ietf:params:oauth:request_uri:abc"),
+    );
+    headers.insert(
+      HeaderName::from_static("request-id"),
+      HeaderValue::from_static("req-1"),
+    );
+
+    let redacted = redact_headers(&headers);
+    let map: std::collections::HashMap<_, _> = redacted.into_iter().collect();
+
+    assert_eq!(map.get("user-code").map(String::as_str), Some(REDACTED));
+    assert_eq!(map.get("device_code").map(String::as_str), Some(REDACTED));
+    assert_eq!(map.get("request-uri").map(String::as_str), Some(REDACTED));
+    assert_eq!(map.get("request-id").map(String::as_str), Some("req-1"));
+  }
+
+  #[test]
+  fn redaction_json_masks_device_flow_fields_but_keeps_public_ids() {
+    let mut value = json!({
+      "user_code": "ABCD-1234",
+      "device_code": "device-secret",
+      "request_uri": "urn:ietf:params:oauth:request_uri:abc",
+      "code_challenge": "public-challenge",
+      "request_id": "req-1",
+      "client_id": "public-client",
+      "state": "s1",
+      "nested": { "user_code": "EFGH-5678" }
+    });
+
+    redact_json(&mut value);
+
+    assert_eq!(value["user_code"], json!(REDACTED));
+    assert_eq!(value["device_code"], json!(REDACTED));
+    assert_eq!(value["request_uri"], json!(REDACTED));
+    assert_eq!(value["nested"]["user_code"], json!(REDACTED));
+    assert_eq!(value["code_challenge"], json!("public-challenge"));
+    assert_eq!(value["request_id"], json!("req-1"));
+    assert_eq!(value["client_id"], json!("public-client"));
+    assert_eq!(value["state"], json!("s1"));
+  }
+
+  #[test]
+  fn redaction_form_and_query_mask_device_flow_fields() {
+    assert_eq!(
+      redact_form(
+        "user_code=ABCD-1234&device_code=device-secret&request_uri=urn%3Aabc&client_id=web"
+      ),
+      format!("user_code={REDACTED}&device_code={REDACTED}&request_uri={REDACTED}&client_id=web")
+    );
+
+    let uri: Uri = "/device?user_code=ABCD-1234&device_code=device-secret&request_uri=urn%3Aabc&code_challenge=public&request_id=req-1&state=s1"
+      .parse()
+      .unwrap_or_else(|_| Uri::from_static("/"));
+    assert_eq!(
+      redact_uri(&uri),
+      format!(
+        "/device?user_code={REDACTED}&device_code={REDACTED}&request_uri={REDACTED}&code_challenge=public&request_id=req-1&state=s1"
+      )
+    );
+  }
 }

+ 2 - 57
src/middleware/security_headers.rs

@@ -1,4 +1,4 @@
-//! Security headers, CORS allowlist, and proxy-header stripping.
+//! Security headers and CORS allowlist.
 //!
 //! [`SecurityHeadersLayer`] decorates every response that reaches the router
 //! with `X-Content-Type-Options`, `Referrer-Policy`, and a
@@ -19,10 +19,7 @@
 //! [`cors_layer`] builds the explicit browser-facing CORS policy from
 //! `config.cors_allowed_origins`: an empty allowlist yields `None` (no CORS
 //! headers at all — browser clients are unsupported by default), and the
-//! wildcard `*` is never honored. [`StripProxyHeadersLayer`] removes
-//! forwarded client-cert headers on the direct TLS listener so a forged
-//! header can never reach [`crate::protocol::mtls`]; it is mounted only when
-//! no `trusted_proxy` is configured.
+//! wildcard `*` is never honored.
 
 use std::future::Future;
 use std::pin::Pin;
@@ -222,58 +219,6 @@ pub fn cors_layer(allowed_origins: &[String]) -> Option<CorsLayer> {
   )
 }
 
-/// Tower layer stripping forwarded client-cert headers on the direct listener.
-///
-/// Mounted only when no `trusted_proxy` is configured, so a forged
-/// `x-forwarded-tls-client-cert` / `x-forwarded-tls-client-verify` header can
-/// never be mistaken for proxy evidence downstream. When a trusted proxy IS
-/// configured this layer is omitted and
-/// [`crate::protocol::mtls::presented_leaf`] applies its own
-/// `trusted_proxy`-gated fallback.
-#[derive(Debug, Clone, Copy, Default)]
-pub struct StripProxyHeadersLayer;
-
-impl<S> Layer<S> for StripProxyHeadersLayer {
-  type Service = StripProxyHeadersService<S>;
-
-  fn layer(&self, inner: S) -> Self::Service {
-    StripProxyHeadersService::new(inner)
-  }
-}
-
-/// Service produced by [`StripProxyHeadersLayer`].
-#[derive(Debug, Clone, Copy)]
-pub struct StripProxyHeadersService<S> {
-  inner: S,
-}
-
-impl<S> StripProxyHeadersService<S> {
-  /// Wrap an inner service.
-  pub const fn new(inner: S) -> Self {
-    Self { inner }
-  }
-}
-
-impl<S, B> Service<Request<B>> for StripProxyHeadersService<S>
-where
-  S: Service<Request<B>> + Clone + Send + 'static,
-  S::Future: Send + 'static,
-  B: Send + 'static,
-{
-  type Response = S::Response;
-  type Error = S::Error;
-  type Future = S::Future;
-
-  fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
-    self.inner.poll_ready(cx)
-  }
-
-  fn call(&mut self, mut request: Request<B>) -> Self::Future {
-    crate::protocol::mtls::strip_proxy_headers(request.headers_mut());
-    self.inner.call(request)
-  }
-}
-
 #[cfg(test)]
 mod tests {
   use axum::http::header;

+ 45 - 13
src/protocol/client_auth/assertion.rs

@@ -2,10 +2,13 @@
 //!
 //! The assertion is verified against the client's inline `jwks`, or against
 //! `jwks_uri` fetched ONLY through the SSRF guard. Enforced: `alg`
-//! allowlist (`RS256`/`ES256`/`EdDSA`; never `none`/HMAC), `typ`, `iss` and
-//! `sub` equal to `client_id`, `aud` equal to the token endpoint, `exp`
-//! with clock-skew leeway, and single-use `jti` via the
-//! `oauth:client_assertion_jti:<hash>` replay store.
+//! allowlist (`RS256`/`PS256`/`ES256`/`EdDSA`; never `none`/HMAC), `typ`,
+//! `iss` and `sub` equal to `client_id`, `aud` equal to the token endpoint,
+//! `exp` with clock-skew leeway and a bounded lifetime, `exp >= iat` (with
+//! skew) when `iat` is present, and single-use `jti` via the
+//! `oauth:client_assertion_jti:<hash>` replay store, with the marker TTL
+//! derived from the assertion's remaining validity so the marker always
+//! outlives the assertion.
 
 use std::time::Duration;
 
@@ -23,13 +26,20 @@ use crate::{
 
 /// Allowed client-assertion signing algorithms (matches the metadata
 /// `token_endpoint_auth_signing_alg_values_supported` allowlist).
-const ALLOWED_ALGS: [Algorithm; 3] = [Algorithm::RS256, Algorithm::ES256, Algorithm::EdDSA];
+pub(crate) const ALLOWED_ALGS: [Algorithm; 4] = [
+  Algorithm::RS256,
+  Algorithm::PS256,
+  Algorithm::ES256,
+  Algorithm::EdDSA,
+];
 
 /// Clock-skew leeway applied to `exp`/`nbf`/`iat` (seconds).
 const CLOCK_SKEW_SECS: u64 = 30;
 
-/// How long a consumed assertion `jti` stays reserved (bounded TTL).
-const JTI_TTL: Duration = Duration::from_secs(300);
+/// Maximum assertion lifetime past `now` (seconds), before skew. The
+/// single-use `jti` marker TTL is derived from the remaining validity, so an
+/// assertion claiming to live longer than this cannot be covered by it.
+const MAX_ASSERTION_LIFETIME_SECS: u64 = 300;
 
 /// Maximum assertion compact-serialization length accepted.
 const MAX_ASSERTION_BYTES: usize = 16 * 1024;
@@ -79,6 +89,9 @@ struct AssertionClaims {
 pub struct ValidatedAssertion {
   /// Hex hash of the `jti`, the replay-store key suffix.
   pub jti_hash: String,
+  /// Lifetime for the `jti` replay marker: the assertion's remaining validity
+  /// (`exp + skew - now`), clamped to `[1s, 300s + 2*skew]`.
+  pub jti_ttl: Duration,
 }
 
 /// Read `iss` from an assertion WITHOUT verifying the signature.
@@ -189,13 +202,28 @@ pub fn validate_assertion(
   if claims.exp.saturating_add(skew) < now_secs {
     return Err(invalid());
   }
-  if let Some(iat) = claims.iat
-    && iat > now_secs.saturating_add(skew)
-  {
+  let max_lifetime = i64::try_from(MAX_ASSERTION_LIFETIME_SECS).unwrap_or(i64::MAX);
+  if claims.exp > now_secs.saturating_add(max_lifetime.saturating_add(skew)) {
     return Err(invalid());
   }
+  if let Some(iat) = claims.iat {
+    if iat > now_secs.saturating_add(skew) {
+      return Err(invalid());
+    }
+    if claims.exp.saturating_add(skew) < iat {
+      return Err(invalid());
+    }
+  }
+  let max_ttl = i64::try_from(MAX_ASSERTION_LIFETIME_SECS.saturating_add(2 * CLOCK_SKEW_SECS))
+    .unwrap_or(i64::MAX);
+  let remaining_secs = claims
+    .exp
+    .saturating_add(skew)
+    .saturating_sub(now_secs)
+    .clamp(1, max_ttl);
   Ok(ValidatedAssertion {
     jti_hash: store::sha256_hex(claims.jti.as_bytes()),
+    jti_ttl: Duration::from_secs(u64::try_from(remaining_secs).unwrap_or(1)),
   })
 }
 
@@ -221,9 +249,13 @@ pub async fn authenticate(
     ctx.token_endpoint,
     ctx.now,
   )?;
-  let claimed =
-    dcr::claim_client_assertion_jti(stores.conn, stores.store, &validated.jti_hash, JTI_TTL)
-      .await?;
+  let claimed = dcr::claim_client_assertion_jti(
+    stores.conn,
+    stores.store,
+    &validated.jti_hash,
+    validated.jti_ttl,
+  )
+  .await?;
   if !claimed {
     return Err(AppError::invalid_client_non_basic());
   }

+ 170 - 1
src/protocol/client_auth/tests.rs

@@ -1,6 +1,8 @@
 //! `test(client_auth)` pure suite: header parsing, expiry, assertion
 //! verification, and the SSRF policy on the `jwks_uri` path (offline).
 
+use std::time::Duration;
+
 use base64::Engine as _;
 use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
 use ring::rand::SystemRandom;
@@ -9,7 +11,7 @@ use secrecy::ExposeSecret as _;
 use serde::{Deserialize, Serialize};
 use time::OffsetDateTime;
 
-use super::assertion::{unverified_iss, validate_assertion};
+use super::assertion::{ALLOWED_ALGS, unverified_iss, validate_assertion};
 use super::{is_secret_expired, parse_basic_header, token_endpoint};
 use crate::protocol::ssrf;
 
@@ -89,6 +91,27 @@ fn mint(
     .map_err(|error| TestError::Crypto(format!("mint failed: {error}")))
 }
 
+/// Mint a signed assertion with explicit `exp`/`iat` Unix seconds.
+fn mint_at(
+  key: &TestKey,
+  client_id: &str,
+  audience: &str,
+  header: Header,
+  exp: i64,
+  iat: i64,
+) -> Result<String, TestError> {
+  let claims = TestClaims {
+    iss: client_id.to_owned(),
+    sub: client_id.to_owned(),
+    aud: TestAud::One(audience.to_owned()),
+    jti: format!("jti-at-{exp}"),
+    exp,
+    iat,
+  };
+  encode(&header, &claims, &key.encoding)
+    .map_err(|error| TestError::Crypto(format!("mint failed: {error}")))
+}
+
 #[test]
 fn client_auth_basic_header_parses_valid() -> TestResult {
   let raw = base64::engine::general_purpose::STANDARD.encode("my-client:s3cret:with:colons");
@@ -290,6 +313,152 @@ fn client_auth_assertion_expired_rejected() -> TestResult {
   Ok(())
 }
 
+#[test]
+fn client_auth_assertion_long_exp_rejected() -> TestResult {
+  let key = TestKey::generate("k1")?;
+  let client_id = "client-auth-long-exp";
+  let aud = token_endpoint("https://auth.example.com");
+  let now = OffsetDateTime::now_utc();
+  let now_secs = now.unix_timestamp();
+  let token = mint_at(
+    &key,
+    client_id,
+    &aud,
+    Header::new(Algorithm::EdDSA),
+    now_secs + 3600,
+    now_secs,
+  )?;
+  if validate_assertion(&token, &key.jwks, client_id, &aud, now).is_ok() {
+    return Err(TestError::Unexpected("far-future exp accepted".to_owned()));
+  }
+  Ok(())
+}
+
+#[test]
+fn client_auth_assertion_exp_before_iat_rejected() -> TestResult {
+  let key = TestKey::generate("k1")?;
+  let client_id = "client-auth-neg-lifetime";
+  let aud = token_endpoint("https://auth.example.com");
+  let now = OffsetDateTime::now_utc();
+  let now_secs = now.unix_timestamp();
+  let token = mint_at(
+    &key,
+    client_id,
+    &aud,
+    Header::new(Algorithm::EdDSA),
+    now_secs - 10,
+    now_secs + 25,
+  )?;
+  if validate_assertion(&token, &key.jwks, client_id, &aud, now).is_ok() {
+    return Err(TestError::Unexpected("exp before iat accepted".to_owned()));
+  }
+  Ok(())
+}
+
+#[test]
+fn client_auth_assertion_short_exp_accepted() -> TestResult {
+  let key = TestKey::generate("k1")?;
+  let client_id = "client-auth-short-exp";
+  let aud = token_endpoint("https://auth.example.com");
+  let now = OffsetDateTime::now_utc();
+  let now_secs = now.unix_timestamp();
+  let token = mint_at(
+    &key,
+    client_id,
+    &aud,
+    Header::new(Algorithm::EdDSA),
+    now_secs + 60,
+    now_secs,
+  )?;
+  validate_assertion(&token, &key.jwks, client_id, &aud, now)
+    .map_err(|error| TestError::Unexpected(format!("short-lived assertion rejected: {error}")))?;
+  Ok(())
+}
+
+#[test]
+fn client_auth_assertion_jti_ttl_matches_remaining_validity() -> TestResult {
+  let key = TestKey::generate("k1")?;
+  let client_id = "client-auth-ttl";
+  let aud = token_endpoint("https://auth.example.com");
+  let now_secs = OffsetDateTime::now_utc().unix_timestamp();
+  let now = OffsetDateTime::from_unix_timestamp(now_secs)
+    .map_err(|error| TestError::Unexpected(format!("bad test timestamp: {error}")))?;
+  let token = mint_at(
+    &key,
+    client_id,
+    &aud,
+    Header::new(Algorithm::EdDSA),
+    now_secs + 60,
+    now_secs,
+  )?;
+  let validated = validate_assertion(&token, &key.jwks, client_id, &aud, now)
+    .map_err(|error| TestError::Unexpected(format!("assertion rejected: {error}")))?;
+  if validated.jti_ttl != Duration::from_secs(90) {
+    return Err(TestError::Unexpected(format!(
+      "jti_ttl {}s != 90s (60s remaining + 30s skew)",
+      validated.jti_ttl.as_secs()
+    )));
+  }
+  Ok(())
+}
+
+#[test]
+fn client_auth_assertion_jti_ttl_bounded_by_cap() -> TestResult {
+  let key = TestKey::generate("k1")?;
+  let client_id = "client-auth-ttl-cap";
+  let aud = token_endpoint("https://auth.example.com");
+  let now_secs = OffsetDateTime::now_utc().unix_timestamp();
+  let now = OffsetDateTime::from_unix_timestamp(now_secs)
+    .map_err(|error| TestError::Unexpected(format!("bad test timestamp: {error}")))?;
+  let token = mint_at(
+    &key,
+    client_id,
+    &aud,
+    Header::new(Algorithm::EdDSA),
+    now_secs + 300,
+    now_secs,
+  )?;
+  let validated = validate_assertion(&token, &key.jwks, client_id, &aud, now)
+    .map_err(|error| TestError::Unexpected(format!("assertion rejected: {error}")))?;
+  if validated.jti_ttl != Duration::from_secs(330) {
+    return Err(TestError::Unexpected(format!(
+      "jti_ttl {}s != 330s (300s cap + 30s skew)",
+      validated.jti_ttl.as_secs()
+    )));
+  }
+  if validated.jti_ttl > Duration::from_secs(360) {
+    return Err(TestError::Unexpected(
+      "jti_ttl exceeds the 300s + 2*30s clamp".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+#[test]
+fn client_auth_allowed_algs_include_ps256() -> TestResult {
+  if !ALLOWED_ALGS.contains(&Algorithm::PS256) {
+    return Err(TestError::Unexpected("PS256 not allowed".to_owned()));
+  }
+  Ok(())
+}
+
+#[test]
+fn client_auth_allowed_algs_match_metadata_advertisement() -> TestResult {
+  let mut advertised: Vec<String> = crate::protocol::metadata::CLIENT_ASSERTION_SIGNING_ALG_VALUES
+    .iter()
+    .map(|alg| (*alg).to_owned())
+    .collect();
+  advertised.sort();
+  let mut allowed: Vec<String> = ALLOWED_ALGS.iter().map(|alg| format!("{alg:?}")).collect();
+  allowed.sort();
+  if allowed != advertised {
+    return Err(TestError::Unexpected(format!(
+      "allowlist {allowed:?} != metadata {advertised:?}"
+    )));
+  }
+  Ok(())
+}
+
 #[test]
 fn client_auth_jwks_uri_private_ip_rejected_by_guard() -> TestResult {
   if ssrf::validate_fetch_url("https://127.0.0.1:8443/jwks.json").is_err() {

+ 36 - 13
src/protocol/dcr.rs

@@ -510,9 +510,9 @@ async fn resolve_redirects(
         }
       }
       DcrRedirectPolicy::Open => {
-        if !has_valid_scheme(uri) {
+        if !is_open_redirect_uri(uri) {
           return Err(AppError::invalid_redirect_uri(
-            "redirect URI is not a valid absolute URI",
+            "redirect URI must be an absolute https URI or a loopback URI",
           ));
         }
       }
@@ -589,21 +589,44 @@ fn is_loopback_uri(uri: &str) -> bool {
   }
 }
 
-/// Whether `uri` is absolute with a valid RFC 3986 §3.1 scheme and a
-/// non-empty remainder without whitespace.
-fn has_valid_scheme(uri: &str) -> bool {
-  let Some((scheme, rest)) = uri.split_once(':') else {
+/// Whether `uri` may be registered under the `open` DCR redirect policy: an
+/// RFC 8252 loopback URI, or an absolute `https` URI with a non-empty host
+/// and no userinfo, fragment, or whitespace. Accepting an arbitrary absolute
+/// URI here would turn the opt-in `open` policy into an open-redirect
+/// factory.
+fn is_open_redirect_uri(uri: &str) -> bool {
+  if is_loopback_uri(uri) {
+    return true;
+  }
+  if uri.contains('#') || uri.chars().any(char::is_whitespace) {
     return false;
-  };
-  let mut chars = scheme.chars();
-  match chars.next() {
-    Some(first) if first.is_ascii_alphabetic() => {}
-    _ => return false,
   }
-  if !chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.')) {
+  let Some(rest) = strip_scheme_prefix(uri, "https") else {
+    return false;
+  };
+  let Some(after_slashes) = rest.strip_prefix("//") else {
+    return false;
+  };
+  let authority = match after_slashes.find(['/', '?']) {
+    Some(index) => &after_slashes[..index],
+    None => after_slashes,
+  };
+  if authority.contains('@') {
     return false;
   }
-  !rest.is_empty() && !rest.chars().any(char::is_whitespace)
+  if let Some(inner) = authority.strip_prefix('[') {
+    match inner.find(']') {
+      Some(close) => {
+        close > 0 && (inner.len() == close + 1 || inner.as_bytes().get(close + 1) == Some(&b':'))
+      }
+      None => false,
+    }
+  } else {
+    match authority.split_once(':') {
+      Some((host, _)) => !host.is_empty(),
+      None => !authority.is_empty(),
+    }
+  }
 }
 
 /// Strip a case-insensitive `scheme:` prefix without ever slicing at a

+ 115 - 1
src/protocol/dcr/tests.rs

@@ -13,7 +13,10 @@ use testcontainers::{
 use time::OffsetDateTime;
 use uuid::Uuid;
 
-use super::{RegisterContext, RegisterRequest, RegisterStores, is_loopback_uri, register_client};
+use super::{
+  RegisterContext, RegisterRequest, RegisterStores, is_loopback_uri, is_open_redirect_uri,
+  register_client,
+};
 use crate::{
   config::{Config, DurabilityMode},
   crypto::{secret as secret_crypto, tokens as tokens_crypto},
@@ -666,6 +669,94 @@ async fn dcr_register_open_anonymous_201() -> TestResult {
   Ok(())
 }
 
+#[tokio::test]
+async fn dcr_register_open_policy_rejects_plaintext_non_loopback() -> TestResult {
+  let (_guard, mut harness) = harness(false, "open").await?;
+  let iat = issue_iat(&mut harness, 3).await?;
+  let request = dpop_request("http://evil.example.com/cb", None);
+  let (mut stores, ctx) = parts_of(&mut harness);
+  match register_client(&mut stores, &ctx, Some(&iat), &request).await {
+    Ok(_) => Err(TestError::Unexpected(
+      "plaintext non-loopback redirect accepted under open".to_owned(),
+    )),
+    Err(error) => expect_error(error, OAuthErrorCode::InvalidRedirectUri, 400),
+  }
+}
+
+#[tokio::test]
+async fn dcr_register_open_policy_rejects_custom_scheme() -> TestResult {
+  let (_guard, mut harness) = harness(false, "open").await?;
+  let iat = issue_iat(&mut harness, 3).await?;
+  let request = dpop_request("myapp://cb", None);
+  let (mut stores, ctx) = parts_of(&mut harness);
+  match register_client(&mut stores, &ctx, Some(&iat), &request).await {
+    Ok(_) => Err(TestError::Unexpected(
+      "custom-scheme redirect accepted under open".to_owned(),
+    )),
+    Err(error) => expect_error(error, OAuthErrorCode::InvalidRedirectUri, 400),
+  }
+}
+
+#[tokio::test]
+async fn dcr_register_open_policy_rejects_fragment() -> TestResult {
+  let (_guard, mut harness) = harness(false, "open").await?;
+  let iat = issue_iat(&mut harness, 3).await?;
+  let request = dpop_request("https://app.example.com/cb?x=1#frag", None);
+  let (mut stores, ctx) = parts_of(&mut harness);
+  match register_client(&mut stores, &ctx, Some(&iat), &request).await {
+    Ok(_) => Err(TestError::Unexpected(
+      "fragment redirect accepted under open".to_owned(),
+    )),
+    Err(error) => expect_error(error, OAuthErrorCode::InvalidRedirectUri, 400),
+  }
+}
+
+#[tokio::test]
+async fn dcr_register_open_policy_accepts_https() -> TestResult {
+  let (_guard, mut harness) = harness(false, "open").await?;
+  let iat = issue_iat(&mut harness, 3).await?;
+  let request = dpop_request("https://app.example.com/cb", None);
+  let response = {
+    let (mut stores, ctx) = parts_of(&mut harness);
+    register_client(&mut stores, &ctx, Some(&iat), &request)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("https register failed: {error}")))?
+  };
+  let effective =
+    redirect_repo::effective_for_client(&harness.pool, &harness.config.keys, &response.client_id)
+      .await?;
+  if effective.len() != 1 || effective[0].uri != "https://app.example.com/cb" {
+    return Err(TestError::Unexpected(format!(
+      "https URI not attached: {:?}",
+      effective.iter().map(|row| &row.uri).collect::<Vec<_>>()
+    )));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn dcr_register_open_policy_accepts_loopback() -> TestResult {
+  let (_guard, mut harness) = harness(false, "open").await?;
+  let iat = issue_iat(&mut harness, 3).await?;
+  let request = dpop_request("http://127.0.0.1:1234/cb", None);
+  let response = {
+    let (mut stores, ctx) = parts_of(&mut harness);
+    register_client(&mut stores, &ctx, Some(&iat), &request)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("loopback register failed: {error}")))?
+  };
+  let effective =
+    redirect_repo::effective_for_client(&harness.pool, &harness.config.keys, &response.client_id)
+      .await?;
+  if effective.len() != 1 || effective[0].uri != "http://127.0.0.1:1234/cb" {
+    return Err(TestError::Unexpected(format!(
+      "loopback URI not attached: {:?}",
+      effective.iter().map(|row| &row.uri).collect::<Vec<_>>()
+    )));
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn dcr_register_mtls_client_201() -> TestResult {
   let (_guard, mut harness) = harness(false, "catalog").await?;
@@ -751,6 +842,29 @@ fn dcr_register_loopback_shapes() {
   assert!(!is_loopback_uri("not a uri"));
 }
 
+#[test]
+fn dcr_register_open_redirect_shapes() {
+  assert!(is_open_redirect_uri("https://app.example.com/cb"));
+  assert!(is_open_redirect_uri("https://app.example.com/cb?x=1"));
+  assert!(is_open_redirect_uri("https://app.example.com:8443/cb"));
+  assert!(is_open_redirect_uri("HTTPS://APP.EXAMPLE.COM/cb"));
+  assert!(is_open_redirect_uri("https://[2001:db8::1]/cb"));
+  assert!(is_open_redirect_uri("http://127.0.0.1:1234/cb"));
+  assert!(is_open_redirect_uri("http://[::1]:1234/cb"));
+  assert!(!is_open_redirect_uri("http://evil.example.com/cb"));
+  assert!(!is_open_redirect_uri("myapp://cb"));
+  assert!(!is_open_redirect_uri("//app.example.com/cb"));
+  assert!(!is_open_redirect_uri("https://app.example.com/cb?x=1#frag"));
+  assert!(!is_open_redirect_uri("https://app.example.com/cb#frag"));
+  assert!(!is_open_redirect_uri("https://user@app.example.com/cb"));
+  assert!(!is_open_redirect_uri("https:///cb"));
+  assert!(!is_open_redirect_uri("https://"));
+  assert!(!is_open_redirect_uri("https:app.example.com/cb"));
+  assert!(!is_open_redirect_uri("https://[]/cb"));
+  assert!(!is_open_redirect_uri("https://app.example.com/cb "));
+  assert!(!is_open_redirect_uri("not a uri"));
+}
+
 #[test]
 fn dcr_register_bearer_exposes_nothing_secret() {
   let exposed = format!("{:?}", AppError::invalid_client_bearer());

+ 13 - 1
src/protocol/introspect.rs

@@ -32,7 +32,7 @@ use super::{
   token::{Cnf, TokenService, TokenServiceError},
 };
 use crate::{
-  db::{Client, TokenEndpointAuthMethod},
+  db::{Client, TokenEndpointAuthMethod, user_repo},
   error::{AppError, OAuthErrorCode},
   state::{AppState, RedisConn},
   store::{Store, StoreError},
@@ -229,6 +229,18 @@ pub async fn introspect(
   {
     return Ok(IntrospectionResponse::inactive());
   }
+  // Use-time disabled-user re-check: a token whose non-empty subject resolves
+  // to a disabled user reports the same inactive shape as an unknown token.
+  // Unmapped subjects (client-credentials empties, synthetic principals) stay
+  // live; a datastore failure propagates as an error, never as active.
+  if !located.subject.trim().is_empty()
+    && matches!(
+      user_repo::subject_account_status(stores.pool, stores.keys, &located.subject).await?,
+      user_repo::SubjectAccountStatus::Disabled
+    )
+  {
+    return Ok(IntrospectionResponse::inactive());
+  }
   active_response(stores, ctx, located).await
 }
 

+ 192 - 1
src/protocol/introspect/tests.rs

@@ -39,7 +39,11 @@ use super::{
 use crate::{
   config::Config,
   crypto::secret::{Argon2Params, hash_secret},
-  db::{NewClient, TokenEndpointAuthMethod, client_repo, test_support::migrated_pool},
+  db::{
+    NewClient, TokenEndpointAuthMethod, client_repo,
+    test_support::migrated_pool,
+    user_repo::{self, NewUser, UserRecord},
+  },
   protocol::{
     client_auth::ClientAuthRequest,
     revoke::{RevokeContext, RevokeOutcome, RevokeRequest, RevokeStores, revoke_tokens},
@@ -834,3 +838,190 @@ async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
   assert_eq!(response, IntrospectionResponse::inactive());
   Ok(())
 }
+
+/// Mint one token pair owned by `client_id` with an explicit subject.
+async fn mint_subject(
+  h: &mut Harness,
+  client_id: &str,
+  subject: &str,
+) -> TestResult<crate::protocol::token::MintedTokens> {
+  let service = token_service(h)?;
+  Ok(
+    service
+      .mint(
+        &mut h.conn,
+        &h.store,
+        &MintRequest {
+          client_id: client_id.to_owned(),
+          client_kind: ClientKind::Confidential,
+          subject: subject.to_owned(),
+          scope: "orders:read".to_owned(),
+          grant_id: unique("grant"),
+          proof: SenderProof::none(),
+        },
+        now_unix(),
+      )
+      .await?,
+  )
+}
+
+/// Provision one enabled user with a fast test password hash.
+async fn provision_user(h: &Harness, email: &str) -> TestResult<UserRecord> {
+  let params = Argon2Params::new(8, 1, 1, 32).map_err(|error| format!("{error:?}"))?;
+  Ok(
+    user_repo::create_user(
+      &h.pool,
+      &h.config.keys,
+      false,
+      &NewUser {
+        email: email.to_owned(),
+        password_hash: hash_secret("correct-horse-01", &params)?,
+        is_admin: false,
+        must_change_password: false,
+      },
+    )
+    .await?,
+  )
+}
+
+async fn introspect_client(h: &mut Harness, label: &str, secret: &str) -> TestResult<String> {
+  let client_id = unique(label);
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretPost,
+    Some(secret),
+  )
+  .await?;
+  Ok(client_id)
+}
+
+#[tokio::test]
+async fn test_introspect_disabled_email_subject_is_inactive() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = introspect_client(&mut h, "disabled-email", "secret-de").await?;
+  let user = provision_user(&h, "disabled-introspect@example.test").await?;
+  let minted = mint_subject(&mut h, &client_id, &user.email).await?;
+  assert!(user_repo::disable_user(&h.pool, user.id).await?);
+
+  let response = run_core(
+    &mut h,
+    &minted.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-de",
+  )
+  .await?;
+  assert!(!response.active, "a disabled user's token must be inactive");
+  assert_eq!(response, IntrospectionResponse::inactive());
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_disabled_uuid_subject_is_inactive() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = introspect_client(&mut h, "disabled-uuid", "secret-du").await?;
+  let user = provision_user(&h, "disabled-uuid@example.test").await?;
+  // Device-code tokens carry the user UUID string as subject.
+  let minted = mint_subject(&mut h, &client_id, &user.id.to_string()).await?;
+  assert!(user_repo::disable_user(&h.pool, user.id).await?);
+
+  let response = run_core(
+    &mut h,
+    &minted.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-du",
+  )
+  .await?;
+  assert!(!response.active, "a disabled user's token must be inactive");
+  assert_eq!(response, IntrospectionResponse::inactive());
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_enabled_email_subject_stays_active() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = introspect_client(&mut h, "enabled-email", "secret-ee").await?;
+  let user = provision_user(&h, "enabled-introspect@example.test").await?;
+  let minted = mint_subject(&mut h, &client_id, &user.email).await?;
+
+  let response = run_core(
+    &mut h,
+    &minted.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-ee",
+  )
+  .await?;
+  assert!(response.active, "an enabled user's token must stay active");
+  assert_eq!(response.sub.as_deref(), Some(user.email.as_str()));
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_reenabled_subject_is_active_again() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = introspect_client(&mut h, "reenabled", "secret-re").await?;
+  let user = provision_user(&h, "reenabled-introspect@example.test").await?;
+  let minted = mint_subject(&mut h, &client_id, &user.email).await?;
+  assert!(user_repo::disable_user(&h.pool, user.id).await?);
+  let disabled = run_core(
+    &mut h,
+    &minted.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-re",
+  )
+  .await?;
+  assert!(!disabled.active, "a disabled user's token must be inactive");
+
+  sqlx::query("UPDATE users SET disabled = false, updated_at = now() WHERE id = $1")
+    .bind(user.id)
+    .execute(&h.pool)
+    .await?;
+  let response = run_core(
+    &mut h,
+    &minted.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-re",
+  )
+  .await?;
+  assert!(
+    response.active,
+    "a re-enabled user's token must be active again"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_empty_and_unknown_subjects_stay_active() -> TestResult {
+  let (_container, mut h) = harness().await?;
+  let client_id = introspect_client(&mut h, "non-user-sub", "secret-nu").await?;
+  // Client-credentials tokens carry an empty subject.
+  let empty = mint_subject(&mut h, &client_id, "").await?;
+  let response = run_core(
+    &mut h,
+    &empty.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-nu",
+  )
+  .await?;
+  assert!(response.active, "an empty subject must stay active");
+  // Synthetic subjects resolve to no user and must stay live.
+  let synthetic = mint_subject(&mut h, &client_id, "synthetic-subject-9").await?;
+  let response = run_core(
+    &mut h,
+    &synthetic.access_token,
+    Some("access_token"),
+    &client_id,
+    "secret-nu",
+  )
+  .await?;
+  assert!(response.active, "an unmapped subject must stay active");
+  assert_eq!(response.sub.as_deref(), Some("synthetic-subject-9"));
+  Ok(())
+}

+ 10 - 5
src/protocol/metadata.rs

@@ -54,8 +54,13 @@ pub const INTROSPECTION_AUTH_METHODS: [&str; 5] = [
   "self_signed_tls_client_auth",
 ];
 
-/// JWS algorithms accepted for `private_key_jwt` assertions and DPoP proofs.
-pub const SIGNING_ALG_VALUES: [&str; 3] = ["ES256", "EdDSA", "RS256"];
+/// JWS algorithms accepted for `private_key_jwt` client assertions
+/// (`token_endpoint_auth_signing_alg_values_supported`); kept in sync with
+/// `protocol::client_auth::assertion::ALLOWED_ALGS`.
+pub const CLIENT_ASSERTION_SIGNING_ALG_VALUES: [&str; 4] = ["ES256", "EdDSA", "RS256", "PS256"];
+
+/// JWS algorithms accepted for DPoP proofs (`dpop_signing_alg_values_supported`).
+pub const DPOP_SIGNING_ALG_VALUES: [&str; 3] = ["ES256", "EdDSA", "RS256"];
 
 /// RFC 8414 authorization-server metadata document.
 #[derive(Debug, Serialize)]
@@ -83,7 +88,7 @@ pub struct AuthorizationServerMetadata {
   /// Token-endpoint client-authentication methods.
   pub token_endpoint_auth_methods_supported: [&'static str; 6],
   /// Signing algorithms for `private_key_jwt` client assertions.
-  pub token_endpoint_auth_signing_alg_values_supported: [&'static str; 3],
+  pub token_endpoint_auth_signing_alg_values_supported: [&'static str; 4],
   /// Introspection-endpoint client-authentication methods.
   pub introspection_endpoint_auth_methods_supported: [&'static str; 5],
   /// Revocation-endpoint client-authentication methods.
@@ -119,11 +124,11 @@ impl AuthorizationServerMetadata {
       response_types_supported: ["code"],
       grant_types_supported: GRANT_TYPES_SUPPORTED,
       token_endpoint_auth_methods_supported: TOKEN_AUTH_METHODS,
-      token_endpoint_auth_signing_alg_values_supported: SIGNING_ALG_VALUES,
+      token_endpoint_auth_signing_alg_values_supported: CLIENT_ASSERTION_SIGNING_ALG_VALUES,
       introspection_endpoint_auth_methods_supported: INTROSPECTION_AUTH_METHODS,
       revocation_endpoint_auth_methods_supported: REVOCATION_AUTH_METHODS,
       code_challenge_methods_supported: ["S256"],
-      dpop_signing_alg_values_supported: SIGNING_ALG_VALUES,
+      dpop_signing_alg_values_supported: DPOP_SIGNING_ALG_VALUES,
       tls_client_certificate_bound_access_tokens: true,
       authorization_response_iss_parameter_supported: true,
       scopes_supported,

+ 2 - 1
src/protocol/metadata/snapshots/metadata_document.snap

@@ -32,7 +32,8 @@ expression: rendered
   "token_endpoint_auth_signing_alg_values_supported": [
     "ES256",
     "EdDSA",
-    "RS256"
+    "RS256",
+    "PS256"
   ],
   "introspection_endpoint_auth_methods_supported": [
     "client_secret_basic",

+ 21 - 0
src/protocol/metadata/tests.rs

@@ -147,6 +147,27 @@ fn metadata_never_advertises_plain_or_jwks_uri() {
   );
 }
 
+#[test]
+fn metadata_advertises_ps256_for_client_assertions() {
+  let doc = AuthorizationServerMetadata::for_issuer(ISSUER, Vec::new());
+  let json = serde_json::to_value(&doc).unwrap_or(Value::Null);
+  assert_eq!(
+    json.get("token_endpoint_auth_signing_alg_values_supported"),
+    Some(&serde_json::json!(
+      super::CLIENT_ASSERTION_SIGNING_ALG_VALUES
+    )),
+    "client-assertion allowlist must include PS256"
+  );
+  let dpop_has_ps256 = json
+    .get("dpop_signing_alg_values_supported")
+    .and_then(Value::as_array)
+    .is_some_and(|algs| algs.iter().any(|alg| alg.as_str() == Some("PS256")));
+  assert!(
+    !dpop_has_ps256,
+    "DPoP allowlist is unchanged by assertion PS256"
+  );
+}
+
 #[tokio::test]
 async fn metadata_postgres_down_fails_closed() -> TestResult {
   let pool = sqlx::postgres::PgPoolOptions::new()

+ 1 - 0
src/protocol/mod.rs

@@ -15,6 +15,7 @@ pub mod introspect;
 pub mod metadata;
 pub mod mtls;
 pub mod par;
+pub mod purge;
 pub mod redirect_repo;
 pub mod revoke;
 pub mod scope;

+ 2 - 6
src/protocol/mtls.rs

@@ -5,19 +5,15 @@
 //! (PKI / subject-DN over the client-CA bundle). Both verifiers offer but
 //! never require a certificate at the TLS layer; [`select::authenticate`]
 //! decides per registered client. The leaf arrives via
-//! [`crate::tls::PeerCertificates`], with a `trusted_proxy` header fallback
-//! (see [`source`]).
+//! [`crate::tls::PeerCertificates`], injected per request by the custom
+//! accept loop.
 
 mod cert;
 mod select;
-mod source;
 mod verifier;
 
 pub use cert::{subject_dn, thumbprint_b64url};
 pub use select::{authenticate, authenticate_by_id, select_by_subject_dn, select_by_thumbprint};
-pub use source::{
-  PROXY_CERT_HEADER, PROXY_VERIFY_HEADER, PROXY_VERIFY_SUCCESS, presented_leaf, strip_proxy_headers,
-};
 pub use verifier::{pki_verifier, pki_verifier_from_file, self_signed_verifier, server_config};
 
 use crate::error::{AppError, OAuthErrorCode};

+ 0 - 79
src/protocol/mtls/source.rs

@@ -1,79 +0,0 @@
-//! Where the leaf comes from: direct TLS evidence or proxy assertion.
-//!
-//! The direct-TLS certificate ([`crate::tls::PeerCertificates`], injected
-//! per request by the custom accept loop) always wins. The proxy headers
-//! are a fallback honored only when `trusted_proxy` is set and the proxy
-//! signals `SUCCESS`; without the signal the headers are ignored, and a
-//! `SUCCESS`-signed header that does not decode to an X.509 leaf fails
-//! closed. The direct listener calls [`strip_proxy_headers`] on inbound
-//! requests so a forged header can never reach this module.
-
-use std::net::IpAddr;
-
-use base64::{Engine as _, engine::general_purpose::STANDARD};
-use rustls::pki_types::CertificateDer;
-use x509_parser::prelude::{FromDer, X509Certificate};
-
-use super::MtlsError;
-use crate::tls::PeerCertificates;
-
-/// Proxy header carrying the base64 (standard alphabet) DER of the leaf the
-/// terminating proxy verified.
-pub const PROXY_CERT_HEADER: &str = "x-forwarded-tls-client-cert";
-/// Proxy header carrying the proxy's verification verdict.
-pub const PROXY_VERIFY_HEADER: &str = "x-forwarded-tls-client-verify";
-/// The only [`PROXY_VERIFY_HEADER`] value that authorizes trusting
-/// [`PROXY_CERT_HEADER`].
-pub const PROXY_VERIFY_SUCCESS: &str = "SUCCESS";
-
-/// Resolve the presented leaf for this request: the direct-TLS certificate
-/// first, the trusted-proxy headers only as a fallback.
-pub fn presented_leaf(
-  parts: &axum::http::request::Parts,
-  trusted_proxy: Option<IpAddr>,
-) -> Result<Option<CertificateDer<'static>>, MtlsError> {
-  if let Some(peer) = parts.extensions.get::<PeerCertificates>() {
-    let leaf = peer.chain().first().ok_or(MtlsError::MissingCertificate)?;
-    return Ok(Some(leaf.clone()));
-  }
-  proxy_leaf(&parts.headers, trusted_proxy)
-}
-
-/// Remove inbound proxy client-cert headers. The direct listener applies
-/// this before routing so a forged header can never be mistaken for proxy
-/// evidence downstream.
-pub fn strip_proxy_headers(headers: &mut axum::http::HeaderMap) {
-  headers.remove(PROXY_CERT_HEADER);
-  headers.remove(PROXY_VERIFY_HEADER);
-}
-
-/// Read the proxy-asserted leaf. Returns `None` unless `trusted_proxy` is
-/// set *and* the proxy signals `SUCCESS` *and* a header is present; a
-/// `SUCCESS`-signed header that does not decode to an X.509 leaf fails
-/// closed.
-fn proxy_leaf(
-  headers: &axum::http::HeaderMap,
-  trusted_proxy: Option<IpAddr>,
-) -> Result<Option<CertificateDer<'static>>, MtlsError> {
-  if trusted_proxy.is_none() {
-    return Ok(None);
-  }
-  let verified = headers
-    .get(PROXY_VERIFY_HEADER)
-    .and_then(|value| value.to_str().ok())
-    .is_some_and(|value| value == PROXY_VERIFY_SUCCESS);
-  if !verified {
-    return Ok(None);
-  }
-  let Some(raw) = headers
-    .get(PROXY_CERT_HEADER)
-    .and_then(|value| value.to_str().ok())
-  else {
-    return Ok(None);
-  };
-  let der = STANDARD
-    .decode(raw.trim())
-    .map_err(|_| MtlsError::MalformedCertificate)?;
-  X509Certificate::from_der(&der).map_err(|_| MtlsError::MalformedCertificate)?;
-  Ok(Some(CertificateDer::from(der)))
-}

+ 558 - 0
src/protocol/purge.rs

@@ -0,0 +1,558 @@
+//! Subject-scoped ephemeral-state purge for `user disable`.
+//!
+//! [`purge_subject`] deletes every Redis record whose `subject` field names
+//! the user (canonical email or user-id string): login sessions (plus their
+//! `:meta` sidecars), the lockout counter, authorization codes, device records
+//! (plus `:cnf`/`:poll` sidecars and `user_code` mappings), and token grants
+//! (cascade-revoked through [`grants::revoke`] so member token keys, the
+//! member set, and the barrier stay correct, plus standalone access/refresh
+//! records and `:act` sidecars).
+//!
+//! Records without a `subject` field are never touched: `par:`, `pending:`,
+//! `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the
+//! `{rl}` rate-limit counters carry no subject, so they cannot name a user.
+//!
+//! Like the DCR `DELETE` revocation this scans the keyspaces with `SCAN MATCH
+//! <prefix>* COUNT 100` and filters on the raw `subject` hash field. `user
+//! disable` is a rare administrative call, so a bounded linear scan is
+//! proportionate; on Cluster topologies the scan covers the contacted node
+//! only (documented limit — Single and Sentinel scan fully).
+
+use std::collections::{HashMap, HashSet};
+
+use crate::db::user_repo::normalize_email;
+use crate::error::AppError;
+use crate::state::RedisConn;
+use crate::store::{Store, StoreError, grants, sha256_hex};
+
+/// How many records one subject purge removed, per key kind.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct PurgedSubject {
+  /// Login session records deleted (each also removed its `:meta` sidecar).
+  pub sessions: u64,
+  /// Lockout counters deleted (at most one: the email's `loginfail` key).
+  pub lockouts: u64,
+  /// Authorization-code records deleted.
+  pub codes: u64,
+  /// Device-code records deleted (each also removed `:cnf`/`:poll` sidecars).
+  pub devices: u64,
+  /// `user_code` mappings deleted.
+  pub user_codes: u64,
+  /// Grant families cascade-revoked.
+  pub grants: u64,
+  /// Standalone access-token records deleted (each also removed `:act`).
+  pub access: u64,
+  /// Standalone refresh-token records deleted.
+  pub refresh: u64,
+}
+
+/// Delete every Redis record whose `subject` is `email` or `user_id`.
+///
+/// `email` must be the canonical (lowercase) address from
+/// [`user_repo::find_by_email`](crate::db::user_repo::find_by_email); the
+/// lockout key is derived from it with the same normalization the login flow
+/// uses. Refresh-owned families cascade through [`grants::revoke`]; the
+/// grant's member `:act` sidecars are removed alongside because the revoke
+/// script deletes member keys only.
+///
+/// # Errors
+///
+/// Returns a fail-closed store failure when Redis is unavailable; callers
+/// (notably `user disable`) must leave the Postgres `disabled` flag untouched
+/// on error.
+pub async fn purge_subject(
+  conn: &mut RedisConn,
+  store: &Store,
+  email: &str,
+  user_id: &str,
+) -> Result<PurgedSubject, AppError> {
+  let mut summary = PurgedSubject::default();
+
+  let session_prefix = store.session_key_prefix();
+  for key in scan_keys(conn, &session_prefix).await? {
+    if key.ends_with(":meta") {
+      continue;
+    }
+    if is_subject(&read_fields(conn, &key).await?, email, user_id) {
+      del_key(conn, &key).await?;
+      del_key(conn, &format!("{key}:meta")).await?;
+      summary.sessions += 1;
+    }
+  }
+
+  let fail_key = store.login_fail_key(&sha256_hex(normalize_email(email).as_bytes()));
+  let removed: i64 = redis::cmd("DEL")
+    .arg(&fail_key)
+    .query_async(conn)
+    .await
+    .map_err(store_mapped)?;
+  if removed > 0 {
+    summary.lockouts += 1;
+  }
+
+  for key in scan_keys(conn, &store.code_key_prefix()).await? {
+    if is_subject(&read_fields(conn, &key).await?, email, user_id) {
+      del_key(conn, &key).await?;
+      summary.codes += 1;
+    }
+  }
+
+  let device_prefix = store.device_key_prefix();
+  let mut device_hashes: HashSet<String> = HashSet::new();
+  for key in scan_keys(conn, &device_prefix).await? {
+    if key.ends_with(":cnf") || key.ends_with(":poll") {
+      continue;
+    }
+    if is_subject(&read_fields(conn, &key).await?, email, user_id) {
+      del_key(conn, &key).await?;
+      del_key(conn, &format!("{key}:cnf")).await?;
+      del_key(conn, &format!("{key}:poll")).await?;
+      if let Some(hash) = key.strip_prefix(&device_prefix) {
+        device_hashes.insert(hash.to_owned());
+      }
+      summary.devices += 1;
+    }
+  }
+  if !device_hashes.is_empty() {
+    for key in scan_keys(conn, &store.user_code_key_prefix()).await? {
+      let target: Option<String> = redis::cmd("GET")
+        .arg(&key)
+        .query_async(conn)
+        .await
+        .map_err(store_mapped)?;
+      if target.is_some_and(|hash| device_hashes.contains(&hash)) {
+        del_key(conn, &key).await?;
+        summary.user_codes += 1;
+      }
+    }
+  }
+
+  let grant_prefix = store.grant_key_prefix();
+  let access_prefix = store.access_key_prefix();
+  let mut grant_ids: Vec<String> = Vec::new();
+  for key in scan_keys(conn, &grant_prefix).await? {
+    if key.ends_with(":tokens") {
+      continue;
+    }
+    if is_subject(&read_fields(conn, &key).await?, email, user_id)
+      && let Some(id) = key.strip_prefix(&grant_prefix)
+    {
+      grant_ids.push(id.to_owned());
+    }
+  }
+  for grant_id in &grant_ids {
+    let members: Vec<String> = redis::cmd("SMEMBERS")
+      .arg(store.grant_tokens_key(grant_id))
+      .query_async(conn)
+      .await
+      .map_err(store_mapped)?;
+    grants::revoke(conn, store, grant_id)
+      .await
+      .map_err(AppError::from)?;
+    for member in &members {
+      del_key(conn, &format!("{access_prefix}{member}:act")).await?;
+    }
+    summary.grants += 1;
+  }
+
+  for key in scan_keys(conn, &access_prefix).await? {
+    if key.ends_with(":act") {
+      continue;
+    }
+    if is_subject(&read_fields(conn, &key).await?, email, user_id) {
+      del_key(conn, &key).await?;
+      del_key(conn, &format!("{key}:act")).await?;
+      summary.access += 1;
+    }
+  }
+
+  for key in scan_keys(conn, &store.refresh_key_prefix()).await? {
+    if is_subject(&read_fields(conn, &key).await?, email, user_id) {
+      del_key(conn, &key).await?;
+      summary.refresh += 1;
+    }
+  }
+
+  Ok(summary)
+}
+
+/// Whether a record's `subject` field names this user by email or id.
+fn is_subject(fields: &HashMap<String, String>, email: &str, user_id: &str) -> bool {
+  fields
+    .get("subject")
+    .is_some_and(|subject| subject == email || subject == user_id)
+}
+
+/// List every key under `prefix` with a bounded `SCAN` loop.
+async fn scan_keys(conn: &mut RedisConn, prefix: &str) -> Result<Vec<String>, AppError> {
+  let pattern = format!("{prefix}*");
+  let mut cursor = "0".to_owned();
+  let mut out = Vec::new();
+  loop {
+    let (next, batch): (String, Vec<String>) = redis::cmd("SCAN")
+      .arg(&cursor)
+      .arg("MATCH")
+      .arg(&pattern)
+      .arg("COUNT")
+      .arg(100)
+      .query_async(conn)
+      .await
+      .map_err(store_mapped)?;
+    out.extend(batch);
+    if next == "0" {
+      break;
+    }
+    cursor = next;
+  }
+  Ok(out)
+}
+
+/// Read one record's hash fields; a missing key reads as empty.
+async fn read_fields(conn: &mut RedisConn, key: &str) -> Result<HashMap<String, String>, AppError> {
+  redis::cmd("HGETALL")
+    .arg(key)
+    .query_async(conn)
+    .await
+    .map_err(store_mapped)
+}
+
+/// Delete one key.
+async fn del_key(conn: &mut RedisConn, key: &str) -> Result<(), AppError> {
+  let _: () = redis::cmd("DEL")
+    .arg(key)
+    .query_async(conn)
+    .await
+    .map_err(store_mapped)?;
+  Ok(())
+}
+
+/// Map a Redis failure onto the fail-closed store policy (transient outages
+/// render `503`, everything else is masked).
+fn store_mapped(error: redis::RedisError) -> AppError {
+  AppError::from(StoreError::Redis(error))
+}
+
+#[cfg(test)]
+mod tests {
+  use std::{
+    sync::atomic::{AtomicU64, Ordering},
+    time::Duration,
+  };
+
+  use testcontainers::{
+    GenericImage, ImageExt,
+    core::{IntoContainerPort, WaitFor},
+    runners::AsyncRunner,
+  };
+
+  use super::{PurgedSubject, purge_subject};
+  use crate::{config::DurabilityMode, state::RedisConn, store::Store, test_guard::ContainerGuard};
+
+  #[derive(Debug, thiserror::Error)]
+  enum TestError {
+    #[error("container error: {0}")]
+    Containers(#[from] testcontainers::TestcontainersError),
+    #[error("redis error: {0}")]
+    Redis(#[from] redis::RedisError),
+    #[error("store error: {0}")]
+    Store(#[from] crate::store::StoreError),
+    #[error("app error: {0}")]
+    App(#[from] crate::error::AppError),
+    #[error("unexpected test value: {0}")]
+    Unexpected(String),
+  }
+
+  type TestResult = Result<(), TestError>;
+
+  static NONCE: AtomicU64 = AtomicU64::new(1);
+
+  async fn harness() -> Result<(ContainerGuard, RedisConn, Store), TestError> {
+    let n = NONCE.fetch_add(1, Ordering::Relaxed);
+    let container = GenericImage::new("redis", "7.2-alpine")
+      .with_exposed_port(6379.tcp())
+      .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
+      .with_cmd(["redis-server", "--appendonly", "yes"])
+      .start()
+      .await?;
+    let guarded = ContainerGuard::new(container);
+    let port = guarded.get_host_port_ipv4(6379).await?;
+    let url = format!("redis://127.0.0.1:{port}");
+    let client = redis::Client::open(url.as_str())?;
+    let manager_config = redis::aio::ConnectionManagerConfig::new()
+      .set_response_timeout(Some(Duration::from_secs(10)))
+      .set_connection_timeout(Some(Duration::from_secs(5)));
+    let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
+    let store = Store::from_parts(
+      format!("purge-test-{n}:{{state}}:"),
+      "oauth:{rl}:".to_owned(),
+      DurabilityMode::WaitAof,
+    );
+    Ok((guarded, RedisConn::Single(manager), store))
+  }
+
+  async fn hset(conn: &mut RedisConn, key: &str, pairs: &[(&str, &str)]) -> TestResult {
+    let mut cmd = redis::cmd("HSET");
+    cmd.arg(key);
+    for (field, value) in pairs {
+      cmd.arg(field).arg(value);
+    }
+    cmd.query_async::<()>(conn).await?;
+    redis::cmd("PEXPIRE")
+      .arg(key)
+      .arg(60_000)
+      .query_async::<()>(conn)
+      .await?;
+    Ok(())
+  }
+
+  async fn set(conn: &mut RedisConn, key: &str, value: &str) -> TestResult {
+    redis::cmd("SET")
+      .arg(key)
+      .arg(value)
+      .arg("PX")
+      .arg(60_000)
+      .query_async::<()>(conn)
+      .await?;
+    Ok(())
+  }
+
+  async fn exists(conn: &mut RedisConn, key: &str) -> Result<bool, TestError> {
+    let count: i64 = redis::cmd("EXISTS").arg(key).query_async(conn).await?;
+    Ok(count > 0)
+  }
+
+  async fn require_gone(conn: &mut RedisConn, key: &str) -> TestResult {
+    if exists(conn, key).await? {
+      return Err(TestError::Unexpected(format!("key survived purge: {key}")));
+    }
+    Ok(())
+  }
+
+  async fn require_present(conn: &mut RedisConn, key: &str) -> TestResult {
+    if !exists(conn, key).await? {
+      return Err(TestError::Unexpected(format!("key missing: {key}")));
+    }
+    Ok(())
+  }
+
+  #[tokio::test]
+  async fn purge_subject_deletes_all_subject_state() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let email = "alice@example.test";
+    let user_id = uuid::Uuid::new_v4().to_string();
+    let decoy = "bob@example.test";
+
+    let session = "sess-alice";
+    let session_key = store.session_key(session);
+    hset(
+      &mut conn,
+      &session_key,
+      &[
+        ("subject", email),
+        ("mfa_verified", "true"),
+        ("restricted", "false"),
+      ],
+    )
+    .await?;
+    hset(
+      &mut conn,
+      &format!("{session_key}:meta"),
+      &[
+        ("auth_time", "1700000000"),
+        ("csrf", "csrf-secret"),
+        ("request_id", "req-1"),
+      ],
+    )
+    .await?;
+
+    let fail_key = store.login_fail_key(&crate::store::sha256_hex(email.as_bytes()));
+    redis::cmd("INCR")
+      .arg(&fail_key)
+      .query_async::<()>(&mut conn)
+      .await?;
+
+    let code_key = store.code_key("codehashalice");
+    hset(
+      &mut conn,
+      &code_key,
+      &[
+        ("client_id", "client-a"),
+        ("redirect_uri", "https://client.example.test/cb"),
+        ("scope", "orders:read"),
+        (
+          "code_challenge",
+          "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
+        ),
+        ("subject", email),
+        ("grant_id", "grant-code"),
+        ("cnf", ""),
+      ],
+    )
+    .await?;
+
+    let device_hash = "devicehashalice";
+    let device_key = store.device_key(device_hash);
+    hset(
+      &mut conn,
+      &device_key,
+      &[
+        ("client_id", "client-a"),
+        ("scope", "orders:read"),
+        ("status", "approved"),
+        ("subject", user_id.as_str()),
+        ("grant_id", ""),
+      ],
+    )
+    .await?;
+    set(&mut conn, &format!("{device_key}:cnf"), "binding").await?;
+    set(&mut conn, &format!("{device_key}:poll"), "1700000000").await?;
+    let user_code_hash = "usercodehashalice";
+    let user_code_key = store.user_code_key(user_code_hash);
+    set(&mut conn, &user_code_key, device_hash).await?;
+
+    let grant_id = "grant-alice";
+    let grant_key = store.grant_key(grant_id);
+    hset(
+      &mut conn,
+      &grant_key,
+      &[
+        ("grant_id", grant_id),
+        ("client_id", "client-a"),
+        ("subject", email),
+        ("scope", "orders:read"),
+      ],
+    )
+    .await?;
+    let access_hash = "accesshashalice";
+    let refresh_hash = "refreshhashalice";
+    let access_key = store.access_key(access_hash);
+    let refresh_key = store.refresh_key(refresh_hash);
+    for (key, hash) in [(&access_key, access_hash), (&refresh_key, refresh_hash)] {
+      hset(
+        &mut conn,
+        key,
+        &[
+          ("client_id", "client-a"),
+          ("grant_id", grant_id),
+          ("scope", "orders:read"),
+          ("subject", email),
+          ("cnf", ""),
+        ],
+      )
+      .await?;
+      redis::cmd("SADD")
+        .arg(store.grant_tokens_key(grant_id))
+        .arg(hash)
+        .query_async::<()>(&mut conn)
+        .await?;
+    }
+    set(&mut conn, &format!("{access_key}:act"), "jkt").await?;
+
+    let lone_access_key = store.access_key("loneaccessalice");
+    hset(
+      &mut conn,
+      &lone_access_key,
+      &[
+        ("client_id", "client-a"),
+        ("grant_id", "orphan"),
+        ("scope", "orders:read"),
+        ("subject", email),
+        ("cnf", ""),
+      ],
+    )
+    .await?;
+    set(&mut conn, &format!("{lone_access_key}:act"), "jkt").await?;
+    let lone_refresh_key = store.refresh_key("lonerefreshalice");
+    hset(
+      &mut conn,
+      &lone_refresh_key,
+      &[
+        ("client_id", "client-a"),
+        ("grant_id", "orphan"),
+        ("scope", "orders:read"),
+        ("subject", email),
+        ("cnf", ""),
+      ],
+    )
+    .await?;
+
+    let decoy_session_key = store.session_key("sess-bob");
+    hset(
+      &mut conn,
+      &decoy_session_key,
+      &[
+        ("subject", decoy),
+        ("mfa_verified", "true"),
+        ("restricted", "false"),
+      ],
+    )
+    .await?;
+    let decoy_access_key = store.access_key("accesshashbob");
+    hset(
+      &mut conn,
+      &decoy_access_key,
+      &[
+        ("client_id", "client-b"),
+        ("grant_id", "grant-bob"),
+        ("scope", "orders:read"),
+        ("subject", decoy),
+        ("cnf", ""),
+      ],
+    )
+    .await?;
+
+    let summary = purge_subject(&mut conn, &store, email, &user_id).await?;
+    let expected = PurgedSubject {
+      sessions: 1,
+      lockouts: 1,
+      codes: 1,
+      devices: 1,
+      user_codes: 1,
+      grants: 1,
+      access: 1,
+      refresh: 1,
+    };
+    if summary != expected {
+      return Err(TestError::Unexpected(format!(
+        "wrong purge summary: {summary:?} (expected {expected:?})"
+      )));
+    }
+
+    for key in [
+      session_key.clone(),
+      format!("{session_key}:meta"),
+      fail_key,
+      code_key,
+      device_key.clone(),
+      format!("{device_key}:cnf"),
+      format!("{device_key}:poll"),
+      user_code_key,
+      grant_key,
+      store.grant_tokens_key(grant_id),
+      access_key.clone(),
+      format!("{access_key}:act"),
+      refresh_key,
+      lone_access_key.clone(),
+      format!("{lone_access_key}:act"),
+      lone_refresh_key,
+    ] {
+      require_gone(&mut conn, &key).await?;
+    }
+
+    require_present(&mut conn, &decoy_session_key).await?;
+    require_present(&mut conn, &decoy_access_key).await?;
+    Ok(())
+  }
+
+  #[tokio::test]
+  async fn purge_subject_with_no_matching_state_returns_zero() -> TestResult {
+    let (_guard, mut conn, store) = harness().await?;
+    let summary = purge_subject(&mut conn, &store, "nobody@example.test", "no-id").await?;
+    if summary != PurgedSubject::default() {
+      return Err(TestError::Unexpected(format!(
+        "expected zero summary, got {summary:?}"
+      )));
+    }
+    Ok(())
+  }
+}

+ 16 - 2
src/protocol/scope.rs

@@ -155,21 +155,35 @@ pub fn effective_scope(input: &ScopeInput<'_>) -> Result<ScopeSet, AppError> {
 }
 
 /// Refresh may narrow but never widen: `None` keeps the previous grant
-/// (re-checked against the current catalog), `Some` must be a subset of it.
+/// (re-checked against the current catalog, then silently narrowed to the
+/// still-registered and still-entitled intersection, which may be empty),
+/// `Some` must be a subset of the previous grant and of every current set.
 pub fn narrow_on_refresh(
   previous: &ScopeSet,
   requested: Option<&str>,
   enabled: &ScopeSet,
+  client_registered: &ScopeSet,
+  user_entitled: &ScopeSet,
 ) -> Result<ScopeSet, AppError> {
   match requested {
     None => {
       require_subset(previous, enabled, "granted scope no longer enabled")?;
-      Ok(previous.clone())
+      Ok(
+        previous
+          .intersect(client_registered)
+          .intersect(user_entitled),
+      )
     }
     Some(raw) => {
       let candidate = ScopeSet::parse(raw)?;
       require_subset(&candidate, enabled, "unknown or disabled scope")?;
       require_subset(&candidate, previous, "refresh must not widen scope")?;
+      require_subset(
+        &candidate,
+        client_registered,
+        "scope exceeds client registration",
+      )?;
+      require_subset(&candidate, user_entitled, "scope not granted to user")?;
       Ok(candidate)
     }
   }

+ 38 - 27
src/protocol/ssrf.rs

@@ -7,15 +7,15 @@
 //! socket is opened. A denial is fail-closed and never carries the fetched
 //! content.
 //!
-//! Residual risk: DNS is re-resolved by the HTTP client at connect time, so
-//! a rebinding hostname could resolve differently between the pre-flight
-//! check and the connection. The redirect ban and the `https`-only rule
-//! bound the blast radius; IP pinning is deferred to task 22 if needed.
+//! DNS-rebinding hardening: the pre-flight check returns the validated
+//! addresses and the fetch pins the HTTP client to exactly those addresses
+//! (`resolve_to_addrs`), so a hostname that flips to a blocked address
+//! between validation and connect time cannot steer the connection.
 
 #[cfg(test)]
 mod tests;
 
-use std::net::IpAddr;
+use std::net::{IpAddr, SocketAddr};
 use std::time::Duration;
 
 use reqwest::redirect::Policy;
@@ -127,36 +127,43 @@ pub fn validate_fetch_url(raw: &str) -> Result<reqwest::Url, SsrfError> {
 
 /// Resolve `host` and deny the fetch if any resolved address is blocked.
 ///
-/// IP literals are checked without touching DNS.
-pub async fn check_fetch_host(host: &str, port: u16) -> Result<(), SsrfError> {
+/// Returns the validated addresses so the caller can pin the connection to
+/// exactly these addresses. IP literals are checked without touching DNS
+/// and pin to themselves.
+///
+/// A DNS-rebinding hostname that resolves to a mix of public and blocked
+/// addresses is denied outright: any blocked address fails the whole host.
+pub async fn check_fetch_host(host: &str, port: u16) -> Result<Vec<SocketAddr>, SsrfError> {
   if let Ok(literal) = host.parse::<IpAddr>() {
-    return if is_blocked_ip(&literal) {
-      Err(SsrfError::BlockedTarget)
-    } else {
-      Ok(())
-    };
+    if is_blocked_ip(&literal) {
+      return Err(SsrfError::BlockedTarget);
+    }
+    return Ok(vec![SocketAddr::new(literal, port)]);
   }
-  let addrs = tokio::net::lookup_host((host, port))
+  let addrs: Vec<SocketAddr> = tokio::net::lookup_host((host, port))
     .await
-    .map_err(|_| SsrfError::DnsFailed)?;
-  let mut resolved_any = false;
-  for addr in addrs {
-    resolved_any = true;
+    .map_err(|_| SsrfError::DnsFailed)?
+    .collect();
+  if addrs.is_empty() {
+    return Err(SsrfError::DnsFailed);
+  }
+  for addr in &addrs {
     if is_blocked_ip(&addr.ip()) {
       return Err(SsrfError::BlockedTarget);
     }
   }
-  if resolved_any {
-    Ok(())
-  } else {
-    Err(SsrfError::DnsFailed)
-  }
+  Ok(addrs)
 }
 
 /// Fetch `raw` through the full SSRF guard: URL policy, DNS target check,
 /// then an `https` GET with redirects disabled, a total timeout of
 /// [`SSRF_TIMEOUT`], and a body cap of [`SSRF_MAX_BYTES`].
 ///
+/// The connection is pinned to the addresses validated by the DNS check
+/// (`resolve_to_addrs` for hostnames; IP literals need no DNS and connect
+/// directly to the validated literal), closing the DNS-rebinding window
+/// between validation and connect time.
+///
 /// # Errors
 ///
 /// Returns [`SsrfError`] when the URL policy, DNS check, fetch, status, or
@@ -165,14 +172,18 @@ pub async fn fetch_guarded_bytes(raw: &str) -> Result<Vec<u8>, SsrfError> {
   let url = validate_fetch_url(raw)?;
   let host = url.host_str().unwrap_or_default().to_owned();
   let port = url.port_or_known_default().unwrap_or(443);
-  check_fetch_host(&host, port).await?;
+  let addrs = check_fetch_host(&host, port).await?;
 
-  let client = reqwest::Client::builder()
+  let builder = reqwest::Client::builder()
     .redirect(Policy::none())
     .timeout(SSRF_TIMEOUT)
-    .no_proxy()
-    .build()
-    .map_err(|_| SsrfError::FetchFailed)?;
+    .no_proxy();
+  let builder = if host.parse::<IpAddr>().is_ok() {
+    builder
+  } else {
+    builder.resolve_to_addrs(&host, &addrs)
+  };
+  let client = builder.build().map_err(|_| SsrfError::FetchFailed)?;
   let mut response = client
     .get(url)
     .send()

+ 47 - 0
src/protocol/ssrf/tests.rs

@@ -147,3 +147,50 @@ async fn ssrf_guarded_fetch_rejects_http_without_network() -> TestResult {
     )),
   }
 }
+
+#[tokio::test]
+async fn ssrf_check_returns_pinned_addrs_for_public_literal() -> TestResult {
+  match check_fetch_host("8.8.8.8", 443).await {
+    Ok(addrs) => {
+      let expected: IpAddr = "8.8.8.8"
+        .parse()
+        .map_err(|_| TestError::Unexpected("fixture literal must parse".to_owned()))?;
+      if addrs
+        .iter()
+        .any(|addr| addr.ip() == expected && addr.port() == 443)
+      {
+        Ok(())
+      } else {
+        Err(TestError::Unexpected(
+          "validated addresses must contain the pinned literal".to_owned(),
+        ))
+      }
+    }
+    Err(other) => Err(TestError::Unexpected(format!("wrong error: {other}"))),
+  }
+}
+
+#[tokio::test]
+async fn ssrf_check_denies_blocked_literal_with_blocked_target() -> TestResult {
+  match check_fetch_host("127.0.0.1", 443).await {
+    Err(super::SsrfError::BlockedTarget) => Ok(()),
+    Err(other) => Err(TestError::Unexpected(format!("wrong error: {other}"))),
+    Ok(_) => Err(TestError::Unexpected(
+      "loopback literal must never validate".to_owned(),
+    )),
+  }
+}
+
+#[tokio::test]
+async fn ssrf_check_denies_hostname_resolving_to_blocked() -> TestResult {
+  // `localhost` resolves via the local hosts file (no external DNS), so
+  // this stays offline while proving a hostname whose addresses are all
+  // blocked is denied with the validated addresses withheld.
+  match check_fetch_host("localhost", 443).await {
+    Err(super::SsrfError::BlockedTarget) => Ok(()),
+    Err(other) => Err(TestError::Unexpected(format!("wrong error: {other}"))),
+    Ok(_) => Err(TestError::Unexpected(
+      "localhost must never validate".to_owned(),
+    )),
+  }
+}

+ 13 - 1
src/protocol/token/dispatch/grants.rs

@@ -100,10 +100,20 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
   require_dpop_binding(&client, &proof)?;
   let enabled = enabled_scopes(&d.state.pool).await?;
   let service = token_service(d.state)?;
-  let refreshed = refresh_token::refresh(
+  let users = refresh_token::RefreshUserStores {
+    pool: &d.state.pool,
+    keys: &d.state.keys,
+  };
+  let registered = ScopeSet::parse(&client.scope).map_err(|_| AppError::server_error())?;
+  // The user term is resolved fresh inside `refresh_with_subject_check` from
+  // the presented token's subject; this placeholder only flows through on the
+  // unknown-token path, where scope is never minted.
+  let no_user_term = ScopeSet::empty();
+  let refreshed = refresh_token::refresh_with_subject_check(
     &mut *d.conn,
     d.store,
     &service,
+    &users,
     &RefreshInput {
       client_id: &client.client_id,
       client_kind: kind,
@@ -111,6 +121,8 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
       requested_scope: d.form.scope.as_deref(),
       proof: &proof,
       enabled: &enabled,
+      client_registered: &registered,
+      user_entitled: &no_user_term,
       now_unix: d.now_unix,
     },
   )

+ 104 - 1
src/protocol/token/grants/refresh_token.rs

@@ -21,7 +21,12 @@ mod tests;
 
 use super::super::{ClientKind, Cnf, MintRequest, MintedTokens, SenderProof, TokenService};
 use crate::{
+  db::{
+    user_repo::{self, SubjectAccountStatus},
+    user_scope_repo,
+  },
   error::AppError,
+  keys::KeyRing,
   protocol::scope::{ScopeSet, narrow_on_refresh},
   state::RedisConn,
   store::{
@@ -48,6 +53,13 @@ pub struct RefreshInput<'a> {
   pub proof: &'a SenderProof,
   /// Enabled catalog rows, freshly read at issuance.
   pub enabled: &'a ScopeSet,
+  /// Current scopes registered to the authenticated client (fresh parse of
+  /// the client's `scope` at issuance).
+  pub client_registered: &'a ScopeSet,
+  /// Current user entitlements. [`refresh_with_subject_check`] replaces this
+  /// with a fresh Postgres read for real users; direct [`refresh`] callers
+  /// (Redis-only tests) supply the term themselves.
+  pub user_entitled: &'a ScopeSet,
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
 }
@@ -69,6 +81,83 @@ pub struct RefreshedTokens {
   pub grant_id: String,
 }
 
+/// Datastore handles for the use-time disabled-user re-check in
+/// [`refresh_with_subject_check`]. `refresh` itself stays Redis-only so its
+/// existing callers keep compiling; the dispatcher must prefer the checked
+/// entry point (see the note there).
+pub struct RefreshUserStores<'a> {
+  /// Postgres pool for the `users.disabled` lookup.
+  pub pool: &'a sqlx::PgPool,
+  /// Versioned PII keys for the email blind-index lookup.
+  pub keys: &'a KeyRing,
+}
+
+/// Rotate with a fail-closed use-time disabled-user re-check and fresh
+/// entitlement enforcement.
+///
+/// The presented refresh token is located first; when its non-empty subject
+/// resolves to a disabled user the grant is rejected with `invalid_grant`
+/// BEFORE anything is minted or rotated, so the presented token stays live
+/// for a retry after re-enable. Otherwise the current user entitlements are
+/// read fresh from Postgres (a subject that maps to no user — empty
+/// client-credentials subjects, synthetic principals — carries no user term)
+/// and the rotation narrows against the current `client_registered`,
+/// `user_entitled`, and `enabled` sets, so a revoked entitlement or a removed
+/// client scope drops out on refresh instead of surviving for the token's
+/// whole lifetime. The disabled check preserves unknown-token,
+/// tombstone-cascade, and proof-check behavior. A datastore failure propagates
+/// as an error.
+///
+/// # Errors
+///
+/// Returns `invalid_grant` for a disabled subject, plus every error
+/// [`refresh`] can return.
+pub async fn refresh_with_subject_check(
+  conn: &mut RedisConn,
+  store: &Store,
+  service: &TokenService,
+  users: &RefreshUserStores<'_>,
+  input: &RefreshInput<'_>,
+) -> Result<RefreshedTokens, AppError> {
+  let raw = input.refresh_token.trim();
+  let found = if raw.is_empty() {
+    None
+  } else {
+    service
+      .find_refresh(conn, store, raw)
+      .await
+      .map_err(AppError::from)?
+  };
+  let Some(found) = found else {
+    return refresh(conn, store, service, input).await;
+  };
+  if !found.record.subject.trim().is_empty()
+    && matches!(
+      user_repo::subject_account_status(users.pool, users.keys, &found.record.subject).await?,
+      SubjectAccountStatus::Disabled
+    )
+  {
+    return Err(AppError::invalid_grant("account is disabled"));
+  }
+  let entitled =
+    match user_repo::subject_user_id(users.pool, users.keys, &found.record.subject).await? {
+      Some(id) => ScopeSet::from_names(&user_scope_repo::list_for_user(users.pool, id).await?)?,
+      None => ScopeSet::parse(&found.record.scope).map_err(|_| AppError::server_error())?,
+    };
+  let fresh = RefreshInput {
+    client_id: input.client_id,
+    client_kind: input.client_kind,
+    refresh_token: input.refresh_token,
+    requested_scope: input.requested_scope,
+    proof: input.proof,
+    enabled: input.enabled,
+    client_registered: input.client_registered,
+    user_entitled: &entitled,
+    now_unix: input.now_unix,
+  };
+  refresh(conn, store, service, &fresh).await
+}
+
 /// Rotate a refresh token: fresh proof-of-possession, scope narrowing, then
 /// mint-first atomic rotation with tombstone reuse detection.
 ///
@@ -111,7 +200,21 @@ pub async fn refresh(
   }
   check_proofs(input.client_kind, &found.record.cnf, input.proof)?;
   let previous = ScopeSet::parse(&found.record.scope).map_err(|_| AppError::server_error())?;
-  let narrowed = narrow_on_refresh(&previous, input.requested_scope, input.enabled)?;
+  // An empty subject is a client-credentials-style principal with no user
+  // term; the previous grant itself stands in so only the client and catalog
+  // sets narrow.
+  let user_term = if found.record.subject.trim().is_empty() {
+    &previous
+  } else {
+    input.user_entitled
+  };
+  let narrowed = narrow_on_refresh(
+    &previous,
+    input.requested_scope,
+    input.enabled,
+    input.client_registered,
+    user_term,
+  )?;
   let minted = service
     .mint(
       conn,

+ 9 - 0
src/protocol/token/grants/refresh_token/tests.rs

@@ -4,6 +4,8 @@
 //! uses unique client/grant labels so the shared keyspace never collides.
 
 mod concurrent;
+mod disabled_subject;
+mod entitlements;
 mod failover;
 mod proofs;
 mod recovery;
@@ -183,6 +185,11 @@ async fn mint_grant(
 }
 
 /// Baseline refresh inputs; tests override what they probe.
+///
+/// Redis-only suites carry no Postgres user, so the client registration and
+/// the user entitlement both default to the enabled catalog (fully entitled):
+/// scope assertions there probe narrowing/widening only. Suites with a real
+/// user build [`RefreshInput`] literally with the current sets instead.
 fn input<'a>(
   client: &'a str,
   kind: ClientKind,
@@ -198,6 +205,8 @@ fn input<'a>(
     requested_scope: scope,
     proof,
     enabled,
+    client_registered: enabled,
+    user_entitled: enabled,
     now_unix: now_unix(),
   }
 }

+ 335 - 0
src/protocol/token/grants/refresh_token/tests/disabled_subject.rs

@@ -0,0 +1,335 @@
+//! Disabled-user use-time re-check: `invalid_grant` without rotation.
+//!
+//! A refresh record whose non-empty subject resolves to a disabled user
+//! (by email or by UUID string) is rejected before minting or rotating, so
+//! the presented token stays live for a later retry after re-enable.
+//! Client-credentials (empty) and synthetic (unmapped) subjects still rotate.
+
+use sqlx::PgPool;
+use uuid::Uuid;
+
+use super::{
+  TestError, TestResult, dpop_proof, input, is_invalid_grant, now_unix, ring, service, test_redis,
+  unique,
+};
+use crate::{
+  crypto::secret::{Argon2Params, hash_secret},
+  db::{
+    scope_repo,
+    test_support::migrated_pool,
+    user_repo::{self, NewUser},
+    user_scope_repo,
+  },
+  keys::KeyRing,
+  protocol::{
+    scope::ScopeSet,
+    token::{
+      ClientKind, MintRequest, MintedTokens, SenderProof, TokenService,
+      grants::refresh_token::{RefreshUserStores, refresh_with_subject_check},
+    },
+  },
+  state::RedisConn,
+  store::Store,
+  test_guard::Lease,
+};
+
+/// Postgres pool from the shared test server; errors as a test failure.
+async fn pg_pool() -> Result<(Lease<crate::db::test_support::Db>, PgPool), TestError> {
+  migrated_pool()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("pg harness: {error}")))
+}
+
+/// Provision one enabled user; returns its id.
+async fn provision(pool: &PgPool, keys: &KeyRing, email: &str) -> Result<Uuid, TestError> {
+  let params =
+    Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::Unexpected(format!("{error:?}")))?;
+  let record = user_repo::create_user(
+    pool,
+    keys,
+    false,
+    &NewUser {
+      email: email.to_owned(),
+      password_hash: hash_secret("correct-horse-01", &params)
+        .map_err(|error| TestError::Unexpected(format!("{error:?}")))?,
+      is_admin: false,
+      must_change_password: false,
+    },
+  )
+  .await?;
+  Ok(record.id)
+}
+
+/// Mint a refreshable grant with an explicit subject.
+async fn mint_subject(
+  service: &TokenService,
+  conn: &mut RedisConn,
+  store: &Store,
+  client: &str,
+  subject: &str,
+  proof: SenderProof,
+) -> Result<MintedTokens, TestError> {
+  Ok(
+    service
+      .mint(
+        conn,
+        store,
+        &MintRequest {
+          client_id: client.to_owned(),
+          client_kind: ClientKind::Public,
+          subject: subject.to_owned(),
+          scope: "read".to_owned(),
+          grant_id: unique("grant"),
+          proof,
+        },
+        now_unix(),
+      )
+      .await?,
+  )
+}
+
+fn pg_error(error: sqlx::Error) -> TestError {
+  TestError::Unexpected(format!("pg op: {error}"))
+}
+
+#[tokio::test]
+async fn grant_refresh_disabled_email_subject_is_invalid_grant_without_rotation() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = pg_pool().await?;
+  let keys = ring()?;
+  let service = service()?;
+  let client = unique("disabled-client");
+  let proof = dpop_proof(&unique("disabled-jkt"));
+  let email = format!("{}@example.test", unique("disabled-user"));
+  let user_id = provision(&pool, &keys, &email).await?;
+  let minted = mint_subject(&service, &mut conn, &store, &client, &email, proof.clone()).await?;
+  if !user_repo::disable_user(&pool, user_id).await? {
+    return Err(TestError::Unexpected(
+      "disable_user updated no rows".to_owned(),
+    ));
+  }
+  let enabled = ScopeSet::parse("read")?;
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+
+  let error = match refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &input(
+      &client,
+      ClientKind::Public,
+      &minted.refresh_token,
+      None,
+      &proof,
+      &enabled,
+    ),
+  )
+  .await
+  {
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a disabled subject rotated".to_owned(),
+      ));
+    }
+    Err(error) => error,
+  };
+  if !is_invalid_grant(&error) {
+    return Err(TestError::Unexpected(format!(
+      "expected invalid_grant, got {}",
+      error.code().as_str()
+    )));
+  }
+  // No rotation and no mint: the presented token is still live.
+  let live = service
+    .find_refresh(&mut conn, &store, &minted.refresh_token)
+    .await?;
+  if live.is_none() {
+    return Err(TestError::Unexpected(
+      "the presented token was rotated or revoked".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn grant_refresh_disabled_uuid_subject_is_invalid_grant() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = pg_pool().await?;
+  let keys = ring()?;
+  let service = service()?;
+  let client = unique("disabled-uuid-client");
+  let proof = dpop_proof(&unique("disabled-uuid-jkt"));
+  let email = format!("{}@example.test", unique("disabled-uuid-user"));
+  let user_id = provision(&pool, &keys, &email).await?;
+  // Device-code tokens carry the user UUID string as subject.
+  let minted = mint_subject(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    &user_id.to_string(),
+    proof.clone(),
+  )
+  .await?;
+  if !user_repo::disable_user(&pool, user_id).await? {
+    return Err(TestError::Unexpected(
+      "disable_user updated no rows".to_owned(),
+    ));
+  }
+  let enabled = ScopeSet::parse("read")?;
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+
+  let error = match refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &input(
+      &client,
+      ClientKind::Public,
+      &minted.refresh_token,
+      None,
+      &proof,
+      &enabled,
+    ),
+  )
+  .await
+  {
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a disabled UUID subject rotated".to_owned(),
+      ));
+    }
+    Err(error) => error,
+  };
+  if !is_invalid_grant(&error) {
+    return Err(TestError::Unexpected(format!(
+      "expected invalid_grant, got {}",
+      error.code().as_str()
+    )));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn grant_refresh_reenabled_subject_rotates_again() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = pg_pool().await?;
+  let keys = ring()?;
+  let service = service()?;
+  let client = unique("reenabled-client");
+  let proof = dpop_proof(&unique("reenabled-jkt"));
+  let email = format!("{}@example.test", unique("reenabled-user"));
+  let user_id = provision(&pool, &keys, &email).await?;
+  // The re-enabled rotation must keep `read`, so the user holds a current
+  // entitlement for it (refresh now enforces live entitlements).
+  scope_repo::upsert(&pool, "read", "reenable test scope").await?;
+  user_scope_repo::entitle(&pool, user_id, "read", None).await?;
+  let minted = mint_subject(&service, &mut conn, &store, &client, &email, proof.clone()).await?;
+  if !user_repo::disable_user(&pool, user_id).await? {
+    return Err(TestError::Unexpected(
+      "disable_user updated no rows".to_owned(),
+    ));
+  }
+  let enabled = ScopeSet::parse("read")?;
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+  let refused = refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &input(
+      &client,
+      ClientKind::Public,
+      &minted.refresh_token,
+      None,
+      &proof,
+      &enabled,
+    ),
+  )
+  .await;
+  if refused.is_ok() {
+    return Err(TestError::Unexpected(
+      "a disabled subject rotated".to_owned(),
+    ));
+  }
+
+  sqlx::query("UPDATE users SET disabled = false, updated_at = now() WHERE id = $1")
+    .bind(user_id)
+    .execute(&pool)
+    .await
+    .map_err(pg_error)?;
+  let rotated = refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &input(
+      &client,
+      ClientKind::Public,
+      &minted.refresh_token,
+      None,
+      &proof,
+      &enabled,
+    ),
+  )
+  .await
+  .map_err(TestError::from)?;
+  if rotated.scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "scope changed: {}",
+      rotated.scope
+    )));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn grant_refresh_empty_and_unknown_subjects_still_rotate() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = pg_pool().await?;
+  let keys = ring()?;
+  let service = service()?;
+  let client = unique("non-user-client");
+  let proof = dpop_proof(&unique("non-user-jkt"));
+  let enabled = ScopeSet::parse("read")?;
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+  // Client-credentials-style empty subjects and synthetic subjects resolve to
+  // no user and must keep rotating.
+  for subject in ["", "synthetic-subject-7"] {
+    let minted = mint_subject(&service, &mut conn, &store, &client, subject, proof.clone()).await?;
+    let rotated = refresh_with_subject_check(
+      &mut conn,
+      &store,
+      &service,
+      &users,
+      &input(
+        &client,
+        ClientKind::Public,
+        &minted.refresh_token,
+        None,
+        &proof,
+        &enabled,
+      ),
+    )
+    .await
+    .map_err(TestError::from)?;
+    if rotated.refresh_token == minted.refresh_token {
+      return Err(TestError::Unexpected("rotation did not rotate".to_owned()));
+    }
+  }
+  Ok(())
+}

+ 405 - 0
src/protocol/token/grants/refresh_token/tests/entitlements.rs

@@ -0,0 +1,405 @@
+//! Current-entitlement enforcement on refresh: a revoked user entitlement or
+//! a scope removed from the client registration drops out on the next
+//! rotation, and explicitly requesting a no-longer-held scope is
+//! `invalid_scope`.
+
+use sqlx::PgPool;
+use uuid::Uuid;
+
+use super::{
+  TestError, TestResult, dpop_proof, is_invalid_scope, now_unix, ring, service, test_redis, unique,
+};
+use crate::{
+  crypto::secret::{Argon2Params, hash_secret},
+  db::{
+    scope_repo,
+    test_support::migrated_pool,
+    user_repo::{self, NewUser},
+    user_scope_repo,
+  },
+  keys::KeyRing,
+  protocol::{
+    scope::ScopeSet,
+    token::{
+      ClientKind, MintRequest, MintedTokens, SenderProof, TokenService,
+      grants::refresh_token::{RefreshInput, RefreshUserStores, refresh_with_subject_check},
+    },
+  },
+  state::RedisConn,
+  store::Store,
+  test_guard::Lease,
+};
+
+/// Postgres pool from the shared test server; errors as a test failure.
+async fn pg_pool() -> Result<(Lease<crate::db::test_support::Db>, PgPool), TestError> {
+  migrated_pool()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("pg harness: {error}")))
+}
+
+/// Provision one enabled user entitled to every scope in `scopes` (creating
+/// the catalog rows first, since entitlements reference them); returns its id.
+async fn provision_entitled(
+  pool: &PgPool,
+  keys: &KeyRing,
+  email: &str,
+  scopes: &[&str],
+) -> Result<Uuid, TestError> {
+  let params =
+    Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::Unexpected(format!("{error:?}")))?;
+  let record = user_repo::create_user(
+    pool,
+    keys,
+    false,
+    &NewUser {
+      email: email.to_owned(),
+      password_hash: hash_secret("correct-horse-01", &params)
+        .map_err(|error| TestError::Unexpected(format!("{error:?}")))?,
+      is_admin: false,
+      must_change_password: false,
+    },
+  )
+  .await?;
+  for scope in scopes {
+    scope_repo::upsert(pool, scope, "entitlement test scope").await?;
+    user_scope_repo::entitle(pool, record.id, scope, None).await?;
+  }
+  Ok(record.id)
+}
+
+/// Mint a refreshable grant with an explicit subject and scope.
+async fn mint_subject(
+  service: &TokenService,
+  conn: &mut RedisConn,
+  store: &Store,
+  client: &str,
+  subject: &str,
+  scope: &str,
+  proof: SenderProof,
+) -> Result<MintedTokens, TestError> {
+  Ok(
+    service
+      .mint(
+        conn,
+        store,
+        &MintRequest {
+          client_id: client.to_owned(),
+          client_kind: ClientKind::Public,
+          subject: subject.to_owned(),
+          scope: scope.to_owned(),
+          grant_id: unique("grant"),
+          proof,
+        },
+        now_unix(),
+      )
+      .await?,
+  )
+}
+
+/// Refresh inputs carrying the CURRENT client registration alongside the
+/// enabled catalog; the user term is always re-read fresh from Postgres, so
+/// the caller-supplied entitlement here is only a placeholder.
+fn entitled_input<'a>(
+  client: &'a str,
+  token: &'a str,
+  scope: Option<&'a str>,
+  proof: &'a SenderProof,
+  enabled: &'a ScopeSet,
+  registered: &'a ScopeSet,
+  entitled: &'a ScopeSet,
+) -> RefreshInput<'a> {
+  RefreshInput {
+    client_id: client,
+    client_kind: ClientKind::Public,
+    refresh_token: token,
+    requested_scope: scope,
+    proof,
+    enabled,
+    client_registered: registered,
+    user_entitled: entitled,
+    now_unix: now_unix(),
+  }
+}
+
+#[tokio::test]
+async fn grant_refresh_revoked_entitlement_drops_on_omitted_scope() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = pg_pool().await?;
+  let keys = ring()?;
+  let service = service()?;
+  let client = unique("revoked-client");
+  let proof = dpop_proof(&unique("revoked-jkt"));
+  let email = format!("{}@example.test", unique("revoked-user"));
+  let user_id = provision_entitled(&pool, &keys, &email, &["read", "write"]).await?;
+  let minted = mint_subject(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    &email,
+    "read write",
+    proof.clone(),
+  )
+  .await?;
+  if !user_scope_repo::revoke(&pool, user_id, "write").await? {
+    return Err(TestError::Unexpected("revoke removed no rows".to_owned()));
+  }
+  let enabled = ScopeSet::parse("read write")?;
+  let registered = ScopeSet::parse("read write")?;
+  let placeholder = ScopeSet::empty();
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+
+  let rotated = refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &entitled_input(
+      &client,
+      &minted.refresh_token,
+      None,
+      &proof,
+      &enabled,
+      &registered,
+      &placeholder,
+    ),
+  )
+  .await
+  .map_err(TestError::from)?;
+  if rotated.scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "revoked scope survived refresh: {}",
+      rotated.scope
+    )));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn grant_refresh_revoked_entitlement_explicit_request_is_invalid_scope() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = pg_pool().await?;
+  let keys = ring()?;
+  let service = service()?;
+  let client = unique("revoked-explicit-client");
+  let proof = dpop_proof(&unique("revoked-explicit-jkt"));
+  let email = format!("{}@example.test", unique("revoked-explicit-user"));
+  let user_id = provision_entitled(&pool, &keys, &email, &["read", "write"]).await?;
+  let minted = mint_subject(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    &email,
+    "read write",
+    proof.clone(),
+  )
+  .await?;
+  if !user_scope_repo::revoke(&pool, user_id, "write").await? {
+    return Err(TestError::Unexpected("revoke removed no rows".to_owned()));
+  }
+  let enabled = ScopeSet::parse("read write")?;
+  let registered = ScopeSet::parse("read write")?;
+  let placeholder = ScopeSet::empty();
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+
+  let error = match refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &entitled_input(
+      &client,
+      &minted.refresh_token,
+      Some("read write"),
+      &proof,
+      &enabled,
+      &registered,
+      &placeholder,
+    ),
+  )
+  .await
+  {
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a de-entitled scope rotated".to_owned(),
+      ));
+    }
+    Err(error) => error,
+  };
+  if !is_invalid_scope(&error) {
+    return Err(TestError::Unexpected(format!(
+      "expected invalid_scope, got {}",
+      error.code().as_str()
+    )));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn grant_refresh_removed_client_scope_drops_on_omitted_scope() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = pg_pool().await?;
+  let keys = ring()?;
+  let service = service()?;
+  let client = unique("deregistered-client");
+  let proof = dpop_proof(&unique("deregistered-jkt"));
+  let email = format!("{}@example.test", unique("deregistered-user"));
+  provision_entitled(&pool, &keys, &email, &["read", "write"]).await?;
+  let minted = mint_subject(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    &email,
+    "read write",
+    proof.clone(),
+  )
+  .await?;
+  let enabled = ScopeSet::parse("read write")?;
+  // The client's current registration no longer carries `write`.
+  let registered = ScopeSet::parse("read")?;
+  let placeholder = ScopeSet::empty();
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+
+  let rotated = refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &entitled_input(
+      &client,
+      &minted.refresh_token,
+      None,
+      &proof,
+      &enabled,
+      &registered,
+      &placeholder,
+    ),
+  )
+  .await
+  .map_err(TestError::from)?;
+  if rotated.scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "de-registered scope survived refresh: {}",
+      rotated.scope
+    )));
+  }
+  let error = match refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &entitled_input(
+      &client,
+      &rotated.refresh_token,
+      Some("read write"),
+      &proof,
+      &enabled,
+      &registered,
+      &placeholder,
+    ),
+  )
+  .await
+  {
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a de-registered scope rotated".to_owned(),
+      ));
+    }
+    Err(error) => error,
+  };
+  if !is_invalid_scope(&error) {
+    return Err(TestError::Unexpected(format!(
+      "expected invalid_scope, got {}",
+      error.code().as_str()
+    )));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn grant_refresh_still_entitled_scope_is_kept() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let (_db, pool) = pg_pool().await?;
+  let keys = ring()?;
+  let service = service()?;
+  let client = unique("kept-client");
+  let proof = dpop_proof(&unique("kept-jkt"));
+  let email = format!("{}@example.test", unique("kept-user"));
+  provision_entitled(&pool, &keys, &email, &["read", "write"]).await?;
+  let minted = mint_subject(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    &email,
+    "read write",
+    proof.clone(),
+  )
+  .await?;
+  let enabled = ScopeSet::parse("read write")?;
+  let registered = ScopeSet::parse("read write")?;
+  let placeholder = ScopeSet::empty();
+  let users = RefreshUserStores {
+    pool: &pool,
+    keys: &keys,
+  };
+
+  let rotated = refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &entitled_input(
+      &client,
+      &minted.refresh_token,
+      None,
+      &proof,
+      &enabled,
+      &registered,
+      &placeholder,
+    ),
+  )
+  .await
+  .map_err(TestError::from)?;
+  if rotated.scope != "read write" {
+    return Err(TestError::Unexpected(format!(
+      "entitled scope changed: {}",
+      rotated.scope
+    )));
+  }
+  let narrowed = refresh_with_subject_check(
+    &mut conn,
+    &store,
+    &service,
+    &users,
+    &entitled_input(
+      &client,
+      &rotated.refresh_token,
+      Some("read"),
+      &proof,
+      &enabled,
+      &registered,
+      &placeholder,
+    ),
+  )
+  .await
+  .map_err(TestError::from)?;
+  if narrowed.scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "explicit narrow failed: {}",
+      narrowed.scope
+    )));
+  }
+  Ok(())
+}

+ 61 - 88
src/routes.rs

@@ -7,19 +7,16 @@
 //! 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::StripProxyHeadersLayer`] removes forged
-//!    forwarded client-cert headers — mounted only when no `trusted_proxy`
-//!    is configured, so proxy evidence is never trusted by default.
-//! 3. [`middleware::security_headers::cors_layer`] answers CORS preflights
+//! 2. [`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).
-//! 4. [`middleware::ratelimit::RateLimitLayer`] enforces per-IP/per-client
+//! 3. [`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).
-//! 5. [`tower_http::limit::RequestBodyLimitLayer`] caps form bodies at
+//! 4. [`tower_http::limit::RequestBodyLimitLayer`] caps form bodies at
 //!    [`MAX_BODY_BYTES`].
-//! 6. [`middleware::security_headers::SecurityHeadersLayer`] decorates
+//! 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
@@ -60,9 +57,7 @@ use crate::jose::dpop::{DpopRequest, verify_dpop};
 use crate::middleware::audit::AuditEvent;
 use crate::middleware::ratelimit::RateLimitLayer;
 use crate::middleware::request_id::{REQUEST_ID_HEADER, RequestId, RequestIdLayer};
-use crate::middleware::security_headers::{
-  SecurityHeadersLayer, StripProxyHeadersLayer, cors_layer,
-};
+use crate::middleware::security_headers::{SecurityHeadersLayer, cors_layer};
 use crate::protocol::mtls::{self, MtlsError};
 use crate::protocol::{authorize, dcr, device, introspect, metadata, par, revoke, token};
 use crate::resilience::{ResilienceConfig, ResilienceLayer};
@@ -104,10 +99,9 @@ pub fn build_router(state: AppState) -> axum::Router {
 ///
 /// Layers below run outer-to-inner in declaration order here because each
 /// [`axum::Router::layer`] call wraps everything added before it; the calls
-/// are therefore issued innermost-first. Conditional layers (CORS, proxy
-/// stripping) slot into their spec positions only when configured.
+/// are therefore issued innermost-first. The conditional CORS layer slots
+/// into its spec position only when configured.
 pub fn build_router_with_options(state: AppState, options: RouterOptions) -> axum::Router {
-  let trusted_proxy = state.config.trusted_proxy;
   let cors = cors_layer(&state.config.cors_allowed_origins);
   // Task 44 resilience insertion: timeout + concurrency-limit + load-shed sit
   // outside the rate limiter (so overload sheds 503 before it can queue into
@@ -167,11 +161,6 @@ pub fn build_router_with_options(state: AppState, options: RouterOptions) -> axu
     Some(layer) => router.layer(layer),
     None => router,
   };
-  let router = if trusted_proxy.is_none() {
-    router.layer(StripProxyHeadersLayer)
-  } else {
-    router
-  };
   router.layer(RequestIdLayer)
 }
 
@@ -311,7 +300,7 @@ mod router_tests {
 
   use super::{RouterOptions, build_router, build_router_with_options, build_server_config};
   use crate::config::Config;
-  use crate::middleware::security_headers::{StripProxyHeadersLayer, cors_layer};
+  use crate::middleware::security_headers::cors_layer;
   use crate::state::{AppState, Clock, RedisConn};
 
   /// Test body result (no `unwrap`/`expect` under the deny lints).
@@ -690,32 +679,28 @@ mod router_tests {
 
   /// The engaged limiter fails every classified OAuth route closed (503 +
   /// limiter marker); metadata and liveness pass through untouched.
-  ///
-  /// `KNOWN_UNCLASSIFIED` tracks routes the task-13 classifier
-  /// (`Rule::for_path`, last-segment match) does not yet cover: `POST /par`
-  /// has no arm at all, and `/register/{client_id}` carries a dynamic last
-  /// segment. Both traverse the limiter layer (mounted globally) but the
-  /// rule passes them through today — filed as a task-13 follow-up in the
-  /// task-40 evidence. The assertions below ratchet the fix: once the rule
-  /// covers them they limiter-503 like the rest and the entries must be
-  /// deleted (a stale entry fails this test on purpose).
   #[tokio::test]
   async fn router_rate_limit_engaged_on_every_oauth_route() -> TestResult {
-    const KNOWN_UNCLASSIFIED: &[(&str, &str)] =
-      &[("POST", "/par"), ("GET", "/register/some-client")];
     let router = build_router(limited_state(Vec::new())?);
     let form = [("content-type", "application/x-www-form-urlencoded")];
     let protected: &[Route<'_>] = &[
       ("GET", "/authorize?client_id=nobody", &[]),
+      ("POST", "/par", &form),
       ("POST", "/token", &form),
       ("POST", "/device_authorization", &form),
       ("GET", "/device", &[]),
       ("POST", "/device", &form),
       ("GET", "/login", &[]),
       ("POST", "/login", &form),
+      ("GET", "/login/success", &[]),
+      ("GET", "/login/change-password", &[]),
+      ("GET", "/login/totp/setup", &[]),
+      ("GET", "/login/totp/confirm", &[]),
+      ("GET", "/logout", &[]),
       ("GET", "/consent", &[]),
       ("POST", "/consent", &form),
       ("POST", "/register", &form),
+      ("GET", "/register/some-client", &[]),
       ("POST", "/introspect", &form),
       ("POST", "/revoke", &form),
     ];
@@ -728,21 +713,6 @@ mod router_tests {
         );
       }
     }
-    for (method, uri) in KNOWN_UNCLASSIFIED {
-      let (status, response_headers, body) = drive(router.clone(), method, uri, &form, "").await?;
-      if status == StatusCode::NOT_FOUND {
-        return Err(format!("route {method} {uri} must stay mounted").into());
-      }
-      if body.contains(LIMITER_MARKER) {
-        return Err(
-          format!("route {method} {uri} is limiter-gated now: delete its KNOWN_UNCLASSIFIED entry")
-            .into(),
-        );
-      }
-      if response_headers.get("x-request-id").is_none() {
-        return Err(format!("route {method} {uri} must still carry the middleware headers").into());
-      }
-    }
     // Metadata has no limiter rule: it reaches its handler (which fails
     // closed on its own with a different body), proving the exemption.
     let (_, _, meta_body) = drive(
@@ -764,6 +734,52 @@ mod router_tests {
     Ok(())
   }
 
+  /// Every mounted sensitive route is classified by `Rule::for_path`, and the
+  /// exempt routes are not. The list mirrors the mount table in
+  /// `build_router_with_options`; a newly mounted sensitive endpoint that is
+  /// not classified fails here.
+  #[test]
+  fn router_every_sensitive_route_has_a_rate_limit_rule() -> TestResult {
+    let config = test_config(
+      "redis://127.0.0.1:1",
+      serde_json::json!({"enabled": true}),
+      Vec::new(),
+    )?;
+    for path in [
+      "/authorize",
+      "/par",
+      "/token",
+      "/device_authorization",
+      "/device",
+      "/login",
+      "/login/success",
+      "/login/change-password",
+      "/login/totp/setup",
+      "/login/totp/confirm",
+      "/logout",
+      "/consent",
+      "/register",
+      "/register/some-client",
+      "/introspect",
+      "/revoke",
+    ] {
+      if crate::middleware::ratelimit::Rule::for_path(config.rate_limit, path).is_none() {
+        return Err(format!("sensitive route {path} must have a rate-limit rule").into());
+      }
+    }
+    for path in [
+      "/.well-known/oauth-authorization-server",
+      "/health/live",
+      "/health/ready",
+      "/assets/app.css",
+    ] {
+      if crate::middleware::ratelimit::Rule::for_path(config.rate_limit, path).is_some() {
+        return Err(format!("exempt route {path} must not have a rate-limit rule").into());
+      }
+    }
+    Ok(())
+  }
+
   /// CORS default: a browser `Origin` gets no CORS headers back.
   #[tokio::test]
   async fn router_cors_default_deny() -> TestResult {
@@ -837,49 +853,6 @@ mod router_tests {
     Ok(())
   }
 
-  /// Without a trusted proxy, forged client-cert headers never reach the
-  /// inner service.
-  #[tokio::test]
-  async fn router_proxy_cert_header_stripped_without_trusted_proxy() -> TestResult {
-    use tower::Layer as _;
-    let inner = tower::service_fn(|request: Request<Body>| async move {
-      let seen = request
-        .headers()
-        .contains_key("x-forwarded-tls-client-cert")
-        || request
-          .headers()
-          .contains_key("x-forwarded-tls-client-verify");
-      let mut response = axum::http::Response::new(Body::empty());
-      response.headers_mut().insert(
-        "x-headers-seen",
-        if seen {
-          header::HeaderValue::from_static("1")
-        } else {
-          header::HeaderValue::from_static("0")
-        },
-      );
-      Ok::<_, std::convert::Infallible>(response)
-    });
-    let service = StripProxyHeadersLayer.layer(inner);
-    let request = Request::builder()
-      .uri("/introspect")
-      .header("x-forwarded-tls-client-cert", "Zm9yZ2Vk")
-      .header("x-forwarded-tls-client-verify", "SUCCESS")
-      .body(Body::empty())?;
-    let response = tower::ServiceExt::oneshot(service, request)
-      .await
-      .map_err(|_| "strip service error")?;
-    let seen = response
-      .headers()
-      .get("x-headers-seen")
-      .and_then(|value| value.to_str().ok())
-      .unwrap_or("1");
-    if seen != "0" {
-      return Err("proxy cert headers must be stripped without trusted_proxy".into());
-    }
-    Ok(())
-  }
-
   /// `GET /authorize` with no usable client never redirects with 307/308.
   #[tokio::test]
   async fn router_authorize_entry_never_307() -> TestResult {

+ 67 - 14
src/store/mod.rs

@@ -397,6 +397,36 @@ impl Store {
   pub(crate) fn access_key_prefix(&self) -> String {
     format!("{}access:", self.token_prefix)
   }
+
+  /// Key prefix for server-side login session records (`session:<id>`).
+  #[must_use]
+  pub(crate) fn session_key_prefix(&self) -> String {
+    format!("{}session:", self.token_prefix)
+  }
+
+  /// Key prefix for authorization-code records (`code:<hash>`).
+  #[must_use]
+  pub(crate) fn code_key_prefix(&self) -> String {
+    format!("{}code:", self.token_prefix)
+  }
+
+  /// Key prefix for device-code records (`device:<hash>`).
+  #[must_use]
+  pub(crate) fn device_key_prefix(&self) -> String {
+    format!("{}device:", self.token_prefix)
+  }
+
+  /// Key prefix for user-code to device-hash mappings (`user_code:<hash>`).
+  #[must_use]
+  pub(crate) fn user_code_key_prefix(&self) -> String {
+    format!("{}user_code:", self.token_prefix)
+  }
+
+  /// Key prefix for grant-chain records (`grant:<id>`).
+  #[must_use]
+  pub(crate) fn grant_key_prefix(&self) -> String {
+    format!("{}grant:", self.token_prefix)
+  }
 }
 
 /// SHA-256 hex of `input` (key/bucket hashing; never a raw secret or token).
@@ -436,26 +466,33 @@ pub(crate) fn hmac_sha256(pepper: &[u8], message: &[u8]) -> [u8; 32] {
 // Typed hash-keyed access
 // ---------------------------------------------------------------------------
 
-/// Write a record as a Redis hash plus TTL (idempotent rewrite; safe to retry
-/// on transient errors via [`retry_op`]).
+/// Write a record as one atomic `HSET` + `PEXPIRE` script (idempotent
+/// rewrite; safe to retry on transient errors via [`retry_op`]). The fields
+/// and the expiry land together, so no crash can leave an immortal key.
 pub(crate) async fn hset_record(
   conn: &mut RedisConn,
   key: &str,
   pairs: &[(String, String)],
   ttl: Duration,
 ) -> Result<(), StoreError> {
-  let mut pipe = redis::pipe();
-  {
-    let cmd = pipe.cmd("HSET").arg(key);
-    for (field, value) in pairs {
-      cmd.arg(field).arg(value);
-    }
-  }
-  pipe
-    .cmd("PEXPIRE")
-    .arg(key)
-    .arg(ttl.as_millis() as u64)
-    .query_async::<()>(conn)
+  // A field-less `HSET` is a Redis arity error; with nothing to write, the
+  // TTL touch alone remains (a no-op on a missing key, a refresh otherwise).
+  if pairs.is_empty() {
+    let _: () = redis::cmd("PEXPIRE")
+      .arg(key)
+      .arg(ttl.as_millis() as u64)
+      .query_async(conn)
+      .await
+      .map_err(StoreError::Redis)?;
+    return Ok(());
+  }
+  let mut invocation = HSET_TTL.prepare_invoke();
+  invocation.key(key).arg(ttl.as_millis() as u64);
+  for (field, value) in pairs {
+    invocation.arg(field).arg(value);
+  }
+  let _: i64 = invocation
+    .invoke_async(conn)
     .await
     .map_err(StoreError::Redis)?;
   Ok(())
@@ -502,6 +539,22 @@ pub(crate) fn flat_to_map(flat: Vec<String>) -> Result<HashMap<String, String>,
 // Lua registry (every multi-key op is single-slot via the `{state}` tag)
 // ---------------------------------------------------------------------------
 
+/// Atomic record write: `HSET` the fields, then `PEXPIRE` the key, before the
+/// script returns — the record can never be observed (or survive a crash)
+/// without its TTL.
+///
+/// KEYS: record. ARGV: TTL ms, then the flattened field/value pairs (never
+/// empty: [`hset_record`] guards that case).
+pub(crate) static HSET_TTL: LazyLock<Script> = LazyLock::new(|| {
+  Script::new(
+    r"
+    redis.call('HSET', KEYS[1], unpack(ARGV, 2))
+    redis.call('PEXPIRE', KEYS[1], ARGV[1])
+    return 1
+    ",
+  )
+});
+
 /// Single-use consume with `GETDEL` semantics: return the hash fields and
 /// delete the key atomically, or `false` when absent.
 pub(crate) static SCRIPT_CONSUME_HASH: LazyLock<Script> = LazyLock::new(|| {

+ 91 - 4
src/store/tests_store.rs

@@ -17,7 +17,8 @@ use testcontainers::{
 
 use super::{
   ErrorClass, OpClass, Store, StoreError, classify_redis, codes, dcr, device, durability_barrier,
-  grants, may_retry_op, par, pending, ratelimit, retry_op, sessions, sha256_hex, tokens,
+  grants, hget_record, hset_record, may_retry_op, par, pending, ratelimit, retry_op, sessions,
+  sha256_hex, tokens,
 };
 use crate::{
   config::{ConfigError, DurabilityMode, RedisConfig, TtlConfig, redis::RawRedisConfig},
@@ -1275,9 +1276,9 @@ async fn store_login_failure_counter_ttl_within_lockout_window() -> TestResult {
   assert_ttl_within(pttl_ms(&mut conn, &key).await?, window)
 }
 
-/// docs/REDIS.md: the DPoP and `private_key_jwt` replay markers are bounded by
-/// the proof window ([`crate::jose::dpop::DPOP_JTI_TTL`] and the assertion
-/// module's `JTI_TTL`, both 300 s).
+/// docs/REDIS.md: the DPoP replay marker is bounded by the proof window
+/// ([`crate::jose::dpop::DPOP_JTI_TTL`]); the `private_key_jwt` marker is
+/// bounded by the assertion's remaining validity (at most 360 s).
 #[tokio::test]
 async fn store_replay_marker_ttl_within_proof_window() -> TestResult {
   let (_container, url) = redis_container(Vec::new()).await?;
@@ -1518,3 +1519,89 @@ async fn store_retry_budget_bounds_idempotent_reads() -> TestResult {
   }
   Ok(())
 }
+
+/// The record write is a single atomic `HSET`+`PEXPIRE` script: a record
+/// observed after [`hset_record`] always carries both its fields AND a live
+/// TTL, and a rewrite re-arms the expiry (never a persistent key).
+#[tokio::test]
+async fn store_hset_record_writes_fields_and_ttl() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, _store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let key = unique("hset-record");
+  let ttl = Duration::from_secs(300);
+  let pairs = vec![
+    ("client_id".to_owned(), "client-1".to_owned()),
+    ("scope".to_owned(), "orders:read".to_owned()),
+  ];
+
+  hset_record(&mut conn, &key, &pairs, ttl).await?;
+  let record = hget_record(&mut conn, &key).await?;
+  if record
+    .as_ref()
+    .is_none_or(|fields| fields.get("client_id").map(String::as_str) != Some("client-1"))
+  {
+    return Err(TestError::Unexpected(
+      "written record must carry its fields".to_owned(),
+    ));
+  }
+  assert_ttl_within(pttl_ms(&mut conn, &key).await?, ttl)?;
+
+  // Rewrite: a shorter lifetime must be observable, proving the write
+  // re-arms the TTL and does not leave the previous expiry in place.
+  let rewrite = Duration::from_secs(120);
+  let updated = vec![
+    ("client_id".to_owned(), "client-2".to_owned()),
+    ("scope".to_owned(), "orders:read".to_owned()),
+  ];
+  hset_record(&mut conn, &key, &updated, rewrite).await?;
+  let record = hget_record(&mut conn, &key).await?;
+  if record
+    .as_ref()
+    .is_none_or(|fields| fields.get("client_id").map(String::as_str) != Some("client-2"))
+  {
+    return Err(TestError::Unexpected(
+      "rewrite must replace the field value".to_owned(),
+    ));
+  }
+  assert_ttl_within(pttl_ms(&mut conn, &key).await?, rewrite)?;
+
+  // A key whose TTL was stripped must regain a live expiry when rewritten
+  // through the atomic write, so no record can remain persistent.
+  let _: () = redis::cmd("PERSIST")
+    .arg(&key)
+    .query_async(&mut conn)
+    .await?;
+  if pttl_ms(&mut conn, &key).await? != -1 {
+    return Err(TestError::Unexpected(
+      "PERSIST must strip the TTL for the simulation".to_owned(),
+    ));
+  }
+  hset_record(&mut conn, &key, &pairs, ttl).await?;
+  assert_ttl_within(pttl_ms(&mut conn, &key).await?, ttl)
+}
+
+/// A field-less `HSET` is a Redis arity error; the empty-pairs guard falls
+/// back to the TTL touch alone, so an existing record keeps its fields and a
+/// live expiry instead of becoming persistent (or erroring out).
+#[tokio::test]
+async fn store_hset_record_empty_pairs_keeps_ttl() -> TestResult {
+  let (_container, url) = redis_container(Vec::new()).await?;
+  let (mut conn, _store) = direct_store(&url, DurabilityMode::WaitAof).await?;
+  let key = unique("hset-empty");
+  let ttl = Duration::from_secs(300);
+  let pairs = vec![("client_id".to_owned(), "client-1".to_owned())];
+
+  hset_record(&mut conn, &key, &pairs, ttl).await?;
+  hset_record(&mut conn, &key, &[], ttl).await?;
+
+  let record = hget_record(&mut conn, &key).await?;
+  if record
+    .as_ref()
+    .is_none_or(|fields| fields.get("client_id").map(String::as_str) != Some("client-1"))
+  {
+    return Err(TestError::Unexpected(
+      "empty rewrite must preserve the fields".to_owned(),
+    ));
+  }
+  assert_ttl_within(pttl_ms(&mut conn, &key).await?, ttl)
+}

+ 2 - 3
src/tls.rs

@@ -5,9 +5,8 @@
 //! NOT `axum-server`, whose `RustlsAcceptor` discards the TLS session and
 //! exposes no `peer_certificates()`). After the handshake, the verified peer
 //! certificate chain is inserted into every request's extensions as
-//! [`PeerCertificates`], which `mtls.rs` (todo 21) reads. The `trusted_proxy`
-//! header mode is the documented alternative for deployments that terminate
-//! TLS at a reverse proxy.
+//! [`PeerCertificates`], which `mtls.rs` reads. The verified chain is the only
+//! source of mTLS evidence; no forwarded header is ever trusted.
 
 use std::{io, sync::Arc};
 

+ 21 - 3
src/ui/login.rs

@@ -619,17 +619,21 @@ pub enum ConfirmOutcome {
   },
   /// Authenticator proven but a password change is still required.
   StillRestricted,
-  /// Wrong code: the session is unchanged and may retry.
+  /// Wrong code, no enrolled secret, or a locked-out account: the session is
+  /// unchanged and may retry.
   Rejected,
 }
 
 /// Confirm TOTP enrollment with an authenticator code (TOTP only: recovery
-/// codes never confirm enrollment).
+/// codes never confirm enrollment). Failed attempts increment the shared
+/// login lockout counter; a successful confirmation clears it.
 ///
 /// # Errors
 ///
 /// Returns `invalid_request` without a subject session and a masked `5xx`
-/// for store/crypto/database failures. A wrong code is `Ok(Rejected)`.
+/// for store/crypto/database failures. A wrong code, and a locked-out account
+/// even when the code is correct, are `Ok(Rejected)` with no distinguishing
+/// signal.
 pub async fn confirm_totp(
   deps: &mut LoginDeps<'_>,
   session_id: &str,
@@ -641,11 +645,23 @@ pub async fn confirm_totp(
   if live.record.subject.is_empty() {
     return Err(AppError::invalid_request("the login session has expired"));
   }
+  // Failed-confirmation lockout: the same counter and policy `attempt_login`
+  // uses, keyed by the session subject. Checked before verification so a
+  // locked account rejects even a correct code, indistinguishably from a
+  // wrong one, until the window expires.
+  let policy = user_repo::LockoutPolicy {
+    threshold: LOCKOUT_THRESHOLD,
+    lock_duration: LOCKOUT_WINDOW,
+  };
+  if user_repo::is_locked_out(deps.conn, deps.store, policy, &live.record.subject).await? {
+    return Ok(ConfirmOutcome::Rejected);
+  }
   let Some(user) = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?
   else {
     return Err(AppError::invalid_request("the login session has expired"));
   };
   let Some(blob) = user_repo::totp_secret_blob(deps.pool, user.id).await? else {
+    user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject).await?;
     return Ok(ConfirmOutcome::Rejected);
   };
   let secret = totp::decrypt_secret(deps.keys, &blob)?;
@@ -667,6 +683,7 @@ pub async fn confirm_totp(
   )
   .await?
   {
+    user_repo::register_login_failure(deps.conn, deps.store, policy, &live.record.subject).await?;
     audit_failure(
       live.meta.pending_request_id.as_deref(),
       Some(&live.record.subject),
@@ -674,6 +691,7 @@ pub async fn confirm_totp(
     );
     return Ok(ConfirmOutcome::Rejected);
   }
+  user_repo::clear_login_failures(deps.conn, deps.store, &live.record.subject).await?;
   let fresh = user_repo::find_by_email(deps.pool, deps.keys, &live.record.subject).await?;
   if fresh.is_some_and(|user| !user.must_change_password) {
     let promoted = promote_to_full(deps, session_id, &live).await?;

+ 205 - 5
src/ui/login/tests/enroll.rs

@@ -1,18 +1,26 @@
 //! `test(login)` enrollment suite: bootstrap cycle, gating, password change.
 
+use axum::Extension;
+use axum::extract::{Form, State};
+use axum::http::StatusCode;
+use axum_extra::extract::CookieJar;
+
+use super::super::TOTP_CONFIRM_ACTION;
 use super::{
-  Harness, LoginAttempt, TestError, TestResult, attempt, create_user, current_code, deps_of,
-  harness, pending_request, presession,
+  Harness, LoginAttempt, TestError, TestResult, app_state, attempt, create_user, current_code,
+  deps_of, harness, pending_request, presession,
 };
 use crate::{
   crypto::totp,
   db::user_repo,
   error::OAuthErrorCode,
+  middleware::request_id::RequestId,
   store::sessions,
   ui::login::{
-    CHANGE_PASSWORD_PATH, CONSENT_PATH, ConfirmOutcome, LOGIN_SUCCESS_PATH, LoginOutcome,
-    PasswordOutcome, attempt_login, bind_pending_request, change_password, confirm_totp,
-    load_session, logout, provision_totp, restricted_allows_path,
+    CHANGE_PASSWORD_PATH, CONSENT_PATH, ConfirmForm, ConfirmOutcome, LOCKOUT_THRESHOLD,
+    LOCKOUT_WINDOW, LOGIN_SUCCESS_PATH, LoginOutcome, PasswordOutcome, attempt_login,
+    bind_pending_request, change_password, confirm_totp, form_token, load_session, logout,
+    post_totp_confirm, provision_totp, restricted_allows_path, session_cookie,
   },
 };
 
@@ -443,3 +451,195 @@ async fn login_totp_enrollment() -> TestResult {
   }
   Ok(())
 }
+
+/// Drive one `POST /login/totp/confirm` against a restricted session, returning
+/// the status and body bytes of the rendered page.
+async fn post_confirm(
+  harness: &Harness,
+  session_id: &str,
+  csrf: &str,
+  code: &str,
+) -> Result<(StatusCode, Vec<u8>), TestError> {
+  let jar = CookieJar::new().add(session_cookie(&harness.config, session_id));
+  let response = post_totp_confirm(
+    State(app_state(harness)),
+    Extension(RequestId::new()),
+    jar,
+    Form(ConfirmForm {
+      code: code.to_owned(),
+      csrf: csrf.to_owned(),
+    }),
+  )
+  .await;
+  let status = response.status();
+  let body = axum::body::to_bytes(response.into_body(), usize::MAX)
+    .await
+    .map_err(|_| unexpected("response body"))?;
+  Ok((status, body.to_vec()))
+}
+
+#[tokio::test]
+async fn login_confirm_burst_locks_and_rejects_correct_code() -> TestResult {
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "confirm-lock@example.test",
+    "temp-password-20",
+    true,
+    false,
+  )
+  .await?;
+  let restricted = restricted_login(&mut h, &user.email, &user.password, None).await?;
+  let offer = {
+    let mut deps = deps_of(&mut h);
+    provision_totp(&mut deps, &restricted).await?
+  };
+  let bad = wrong_for_secret(&offer.secret, h.counter)?;
+  for _ in 0..LOCKOUT_THRESHOLD {
+    let outcome = {
+      let mut deps = deps_of(&mut h);
+      confirm_totp(&mut deps, &restricted, &bad).await?
+    };
+    if !matches!(outcome, ConfirmOutcome::Rejected) {
+      return Err(unexpected("a wrong confirmation code must be rejected"));
+    }
+  }
+  let policy = user_repo::LockoutPolicy {
+    threshold: LOCKOUT_THRESHOLD,
+    lock_duration: LOCKOUT_WINDOW,
+  };
+  if !user_repo::is_locked_out(&mut h.conn, &h.store, policy, &user.email).await? {
+    return Err(unexpected("a burst of wrong codes must lock the account"));
+  }
+  let verifier = totp::build_totp(&offer.secret)?;
+  let correct = verifier
+    .generate(h.counter * totp::TOTP_STEP_SECS)
+    .to_string();
+  let outcome = {
+    let mut deps = deps_of(&mut h);
+    confirm_totp(&mut deps, &restricted, &correct).await?
+  };
+  if !matches!(outcome, ConfirmOutcome::Rejected) {
+    return Err(unexpected(
+      "a locked account must reject even a correct code",
+    ));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn login_confirm_success_clears_failures() -> TestResult {
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "confirm-clear@example.test",
+    "temp-password-21",
+    true,
+    false,
+  )
+  .await?;
+  let restricted = restricted_login(&mut h, &user.email, &user.password, None).await?;
+  {
+    let mut deps = deps_of(&mut h);
+    match change_password(
+      &mut deps,
+      &restricted,
+      "new-strong-password-21",
+      "new-strong-password-21",
+    )
+    .await?
+    {
+      PasswordOutcome::StillRestricted { .. } => {}
+      PasswordOutcome::Promoted { .. } => {
+        return Err(unexpected("promotion needs TOTP enrollment first"));
+      }
+    }
+  }
+  let offer = {
+    let mut deps = deps_of(&mut h);
+    provision_totp(&mut deps, &restricted).await?
+  };
+  let bad = wrong_for_secret(&offer.secret, h.counter)?;
+  for _ in 1..LOCKOUT_THRESHOLD {
+    let outcome = {
+      let mut deps = deps_of(&mut h);
+      confirm_totp(&mut deps, &restricted, &bad).await?
+    };
+    if !matches!(outcome, ConfirmOutcome::Rejected) {
+      return Err(unexpected("a wrong confirmation code must be rejected"));
+    }
+  }
+  let count = user_repo::login_failure_count(&mut h.conn, &h.store, &user.email).await?;
+  if count != LOCKOUT_THRESHOLD - 1 {
+    return Err(unexpected("each wrong code must count toward lockout"));
+  }
+  let verifier = totp::build_totp(&offer.secret)?;
+  let correct = verifier
+    .generate(h.counter * totp::TOTP_STEP_SECS)
+    .to_string();
+  let outcome = {
+    let mut deps = deps_of(&mut h);
+    confirm_totp(&mut deps, &restricted, &correct).await?
+  };
+  match outcome {
+    ConfirmOutcome::Completed { .. } => {}
+    _ => {
+      return Err(unexpected(
+        "a correct code below the threshold must complete enrollment",
+      ));
+    }
+  }
+  let count = user_repo::login_failure_count(&mut h.conn, &h.store, &user.email).await?;
+  if count != 0 {
+    return Err(unexpected(
+      "a successful confirmation must clear the failure counter",
+    ));
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn login_confirm_locked_page_matches_wrong_code() -> TestResult {
+  let mut h = harness().await?;
+  let user = create_user(
+    &mut h,
+    "confirm-oracle@example.test",
+    "temp-password-22",
+    true,
+    false,
+  )
+  .await?;
+  let restricted = restricted_login(&mut h, &user.email, &user.password, None).await?;
+  let offer = {
+    let mut deps = deps_of(&mut h);
+    provision_totp(&mut deps, &restricted).await?
+  };
+  let live = load_session(&mut h.conn, &h.store, &restricted)
+    .await?
+    .ok_or_else(|| unexpected("the restricted session must be live"))?;
+  let csrf = form_token(&live.meta.csrf_secret, TOTP_CONFIRM_ACTION);
+  let bad = wrong_for_secret(&offer.secret, h.counter)?;
+  let (wrong_status, wrong_body) = post_confirm(&h, &restricted, &csrf, &bad).await?;
+  for _ in 1..LOCKOUT_THRESHOLD {
+    let _ = post_confirm(&h, &restricted, &csrf, &bad).await?;
+  }
+  let policy = user_repo::LockoutPolicy {
+    threshold: LOCKOUT_THRESHOLD,
+    lock_duration: LOCKOUT_WINDOW,
+  };
+  if !user_repo::is_locked_out(&mut h.conn, &h.store, policy, &user.email).await? {
+    return Err(unexpected("the confirm burst must lock the account"));
+  }
+  let (locked_status, locked_body) = post_confirm(&h, &restricted, &csrf, &bad).await?;
+  if wrong_status != locked_status {
+    return Err(unexpected(
+      "a locked rejection must keep the wrong-code status",
+    ));
+  }
+  if wrong_body != locked_body {
+    return Err(unexpected(
+      "a locked rejection must render exactly like a wrong code",
+    ));
+  }
+  Ok(())
+}

+ 2 - 3
tests/bootstrap.rs

@@ -27,9 +27,8 @@
 
 #[path = "mtls/certs.rs"]
 // The fixture module is shared per-target with `test(mtls)`: this target
-// uses a subset (`der_b64` belongs to the proxy-header suite), so the
-// include carries a scoped, documented allow instead of restructuring a
-// peer-owned file.
+// uses a subset of it, so the include carries a scoped, documented allow
+// instead of restructuring a peer-owned file.
 #[allow(dead_code)]
 mod certs;
 

+ 1 - 1
tests/e2e_abuse.rs

@@ -326,7 +326,7 @@ async fn e2e_rate_limit_429_with_retry_after() -> Result<(), TestError> {
   Ok(())
 }
 
-/// Forged proxy client-cert headers are stripped: the request is treated as
+/// Forged proxy client-cert headers are never read: the request is treated as
 /// uncertified (401 without credentials), and valid credentials still work
 /// with the forged headers present.
 #[tokio::test]

+ 4 - 78
tests/mtls.rs

@@ -3,8 +3,8 @@
 //! Covers the two RFC 8705 verifiers (chain-skipping `self_signed` vs
 //! `WebPkiClientVerifier` + client-CA bundle), thumbprint/DN matching,
 //! rejection of unknown/missing/ambiguous/cross-wired/expired identities,
-//! the trusted-proxy header mode, and the request-extension injection over
-//! a real TLS handshake through [`d_oauth::tls::serve_tls`].
+//! and the request-extension injection over a real TLS handshake through
+//! [`d_oauth::tls::serve_tls`].
 //!
 //! Test names contain `mtls` so
 //! `cargo nextest run -E 'test(mtls)'` selects exactly them. Bodies use
@@ -13,19 +13,14 @@
 #[path = "mtls/certs.rs"]
 mod certs;
 
-use std::{
-  net::{IpAddr, SocketAddr},
-  sync::Arc,
-};
+use std::{net::SocketAddr, sync::Arc};
 
 use certs::FixtureError;
 use d_oauth::{
   db::client_repo::{self, Client, NewClient},
   error::{AppError, OAuthErrorCode},
   keys::{KeyId, KeyRing},
-  protocol::mtls::{
-    self, MtlsCnf, MtlsError, PROXY_CERT_HEADER, PROXY_VERIFY_HEADER, PROXY_VERIFY_SUCCESS,
-  },
+  protocol::mtls::{self, MtlsCnf, MtlsError},
 };
 use secrecy::SecretString;
 mod common;
@@ -568,75 +563,6 @@ fn mtls_subject_dn_ambiguity_rejected() -> TestResult {
   Ok(())
 }
 
-/// Build request parts carrying the given proxy headers.
-fn mtls_proxy_parts(pairs: &[(&str, &str)]) -> Result<axum::http::request::Parts, TestError> {
-  let mut builder = axum::http::Request::builder().uri("/token");
-  for (name, value) in pairs {
-    builder = builder.header(*name, *value);
-  }
-  Ok(builder.body(())?.into_parts().0)
-}
-
-/// A forged proxy header is ignored without a valid verification signal,
-/// and always ignored when no proxy is trusted.
-#[test]
-fn mtls_forged_proxy_header_ignored_without_success_signal() -> TestResult {
-  let der_b64 = certs::der_b64(certs::CLIENT_SELF_CERT_PEM)?;
-  let proxy: Option<IpAddr> = Some("127.0.0.1".parse()?);
-  // No trusted proxy: even a SUCCESS-signed header is ignored.
-  let parts = mtls_proxy_parts(&[
-    (PROXY_VERIFY_HEADER, PROXY_VERIFY_SUCCESS),
-    (PROXY_CERT_HEADER, &der_b64),
-  ])?;
-  assert!(mtls::presented_leaf(&parts, None)?.is_none());
-  // Trusted proxy but no signal: ignored.
-  let parts = mtls_proxy_parts(&[(PROXY_CERT_HEADER, &der_b64)])?;
-  assert!(mtls::presented_leaf(&parts, proxy)?.is_none());
-  // Trusted proxy with a failed signal: ignored.
-  let parts = mtls_proxy_parts(&[
-    (PROXY_VERIFY_HEADER, "FAILED"),
-    (PROXY_CERT_HEADER, &der_b64),
-  ])?;
-  assert!(mtls::presented_leaf(&parts, proxy)?.is_none());
-  // SUCCESS signal but no certificate: nothing to trust.
-  let parts = mtls_proxy_parts(&[(PROXY_VERIFY_HEADER, PROXY_VERIFY_SUCCESS)])?;
-  assert!(mtls::presented_leaf(&parts, proxy)?.is_none());
-  Ok(())
-}
-
-/// A SUCCESS-signed header is honored only with `trusted_proxy`, and the
-/// direct listener strips inbound headers before routing.
-#[test]
-fn mtls_proxy_success_signal_accepted_and_stripped() -> TestResult {
-  let der_b64 = certs::der_b64(certs::CLIENT_SELF_CERT_PEM)?;
-  let proxy: Option<IpAddr> = Some("127.0.0.1".parse()?);
-  let parts = mtls_proxy_parts(&[
-    (PROXY_VERIFY_HEADER, PROXY_VERIFY_SUCCESS),
-    (PROXY_CERT_HEADER, &der_b64),
-  ])?;
-  let leaf = mtls::presented_leaf(&parts, proxy)?.ok_or(MtlsError::MissingCertificate)?;
-  assert_eq!(mtls::thumbprint_b64url(leaf.as_ref()), SELF_THUMBPRINT);
-  // A SUCCESS-signed garbage header fails closed instead of binding.
-  let parts = mtls_proxy_parts(&[
-    (PROXY_VERIFY_HEADER, PROXY_VERIFY_SUCCESS),
-    (PROXY_CERT_HEADER, "!!!"),
-  ])?;
-  assert!(matches!(
-    mtls::presented_leaf(&parts, proxy),
-    Err(MtlsError::MalformedCertificate)
-  ));
-  // Stripping removes both headers, so downstream sees no certificate.
-  let mut parts = mtls_proxy_parts(&[
-    (PROXY_VERIFY_HEADER, PROXY_VERIFY_SUCCESS),
-    (PROXY_CERT_HEADER, &der_b64),
-  ])?;
-  mtls::strip_proxy_headers(&mut parts.headers);
-  assert!(!parts.headers.contains_key(PROXY_CERT_HEADER));
-  assert!(!parts.headers.contains_key(PROXY_VERIFY_HEADER));
-  assert!(mtls::presented_leaf(&parts, proxy)?.is_none());
-  Ok(())
-}
-
 /// Handler echo for the handshake test: the bound thumbprint, or `no-cert`.
 async fn mtls_thumbprint_handler(req: axum::http::Request<axum::body::Body>) -> String {
   match req.extensions().get::<d_oauth::tls::PeerCertificates>() {

+ 0 - 7
tests/mtls/certs.rs

@@ -8,7 +8,6 @@
 //! `tests/mtls.rs`. The expired fixture is deliberately outside its
 //! validity window and must stay that way.
 
-use base64::{Engine as _, engine::general_purpose::STANDARD};
 use rustls::pki_types::{CertificateDer, PrivateKeyDer, pem::PemObject};
 
 /// TLS server certificate (`CN=localhost`, SAN `localhost`/127.0.0.1).
@@ -48,9 +47,3 @@ pub fn cert_der(pem: &str) -> Result<CertificateDer<'static>, FixtureError> {
 pub fn private_key(pem: &str) -> Result<PrivateKeyDer<'static>, FixtureError> {
   Ok(PrivateKeyDer::from_pem_slice(pem.as_bytes())?)
 }
-
-/// Standard-base64 DER of the first certificate in a PEM fixture (the
-/// trusted-proxy header encoding).
-pub fn der_b64(pem: &str) -> Result<String, FixtureError> {
-  Ok(STANDARD.encode(cert_der(pem)?.as_ref()))
-}

+ 71 - 3
tests/scope_engine.rs

@@ -157,28 +157,96 @@ fn scope_refresh_narrows_only_and_rechecks_catalog() -> TestResult {
   let enabled = names(&["read", "write"]);
   let previous = names(&["read", "write"]);
   assert_eq!(
-    narrow_on_refresh(&previous, None, &enabled)?.render(),
+    narrow_on_refresh(&previous, None, &enabled, &enabled, &enabled)?.render(),
     "read write"
   );
   assert_eq!(
-    narrow_on_refresh(&previous, Some("read"), &enabled)?.render(),
+    narrow_on_refresh(&previous, Some("read"), &enabled, &enabled, &enabled)?.render(),
     "read"
   );
   assert!(is_invalid_scope(narrow_on_refresh(
     &names(&["read"]),
     Some("read write"),
+    &enabled,
+    &enabled,
     &enabled
   )));
   assert!(is_invalid_scope(narrow_on_refresh(
     &previous,
     Some("admin"),
+    &enabled,
+    &enabled,
     &enabled
   )));
   assert!(is_invalid_scope(narrow_on_refresh(
     &previous,
     None,
-    &names(&["read"])
+    &names(&["read"]),
+    &enabled,
+    &enabled
+  )));
+  Ok(())
+}
+
+#[test]
+fn scope_refresh_enforces_current_entitlements() -> TestResult {
+  let enabled = names(&["read", "write"]);
+  let previous = names(&["read", "write"]);
+  let entitled_both = names(&["read", "write"]);
+  let entitled_read = names(&["read"]);
+  let registered_both = names(&["read", "write"]);
+  let registered_read = names(&["read"]);
+  // Omitted scope silently narrows to the still-entitled, still-registered
+  // intersection; a fully de-entitled grant narrows to empty.
+  assert_eq!(
+    narrow_on_refresh(&previous, None, &enabled, &registered_both, &entitled_read)?.render(),
+    "read"
+  );
+  assert_eq!(
+    narrow_on_refresh(&previous, None, &enabled, &registered_read, &entitled_both)?.render(),
+    "read"
+  );
+  assert_eq!(
+    narrow_on_refresh(&previous, None, &enabled, &registered_both, &entitled_both)?.render(),
+    "read write"
+  );
+  assert!(
+    narrow_on_refresh(
+      &previous,
+      None,
+      &enabled,
+      &registered_both,
+      &ScopeSet::empty()
+    )?
+    .is_empty()
+  );
+  // An explicit request for a no-longer-entitled or de-registered scope is
+  // `invalid_scope`; a still-entitled explicit narrow is kept.
+  assert!(is_invalid_scope(narrow_on_refresh(
+    &previous,
+    Some("read write"),
+    &enabled,
+    &registered_both,
+    &entitled_read
   )));
+  assert!(is_invalid_scope(narrow_on_refresh(
+    &previous,
+    Some("read write"),
+    &enabled,
+    &registered_read,
+    &entitled_both
+  )));
+  assert_eq!(
+    narrow_on_refresh(
+      &previous,
+      Some("read"),
+      &enabled,
+      &registered_both,
+      &entitled_both
+    )?
+    .render(),
+    "read"
+  );
   Ok(())
 }