Efren Yevale Varela 2 тижнів тому
батько
коміт
35345f1021
68 змінених файлів з 3397 додано та 259 видалено
  1. 352 0
      .sqlx/query-c863821c56eeba6123480977785913db5d056b3ec050bef3eaae8c3e05b8f118.json
  2. 1 1
      Cargo.toml
  3. 3 2
      README.md
  4. 10 6
      docs/ARCHITECTURE.md
  5. 7 2
      docs/CONFIGURATION.md
  6. 1 1
      docs/DATABASE.md
  7. 46 10
      docs/OAUTH.md
  8. 10 5
      docs/OPERATIONS.md
  9. 18 11
      docs/REDIS.md
  10. 13 5
      docs/RS-CONTRACT.md
  11. 6 0
      docs/SECURITY.md
  12. 27 3
      src/cli/resource.rs
  13. 30 0
      src/cli/tests.rs
  14. 8 0
      src/config.rs
  15. 58 1
      src/config/build.rs
  16. 8 4
      src/config/jwt.rs
  17. 136 0
      src/config/tests.rs
  18. 65 0
      src/db/client_repo.rs
  19. 24 4
      src/jose/jwt_access.rs
  20. 67 0
      src/jose/jwt_access/tests.rs
  21. 4 0
      src/middleware/ratelimit.rs
  22. 18 0
      src/middleware/ratelimit/tests.rs
  23. 20 6
      src/protocol/authorize.rs
  24. 220 1
      src/protocol/authorize/tests.rs
  25. 288 12
      src/protocol/cimd.rs
  26. 424 2
      src/protocol/cimd/tests.rs
  27. 63 0
      src/protocol/cimd/tests_principal.rs
  28. 12 10
      src/protocol/dcr/manage/revoke.rs
  29. 112 0
      src/protocol/dcr/manage/tests_delete.rs
  30. 21 17
      src/protocol/device.rs
  31. 35 21
      src/protocol/introspect.rs
  32. 8 2
      src/protocol/introspect/tests.rs
  33. 1 0
      src/protocol/metadata/tests.rs
  34. 14 12
      src/protocol/par.rs
  35. 42 19
      src/protocol/params.rs
  36. 109 3
      src/protocol/params/tests.rs
  37. 35 3
      src/protocol/revoke.rs
  38. 1 0
      src/protocol/revoke/tests.rs
  39. 3 2
      src/protocol/revoke/tests/jwt.rs
  40. 54 13
      src/protocol/ssrf.rs
  41. 48 0
      src/protocol/ssrf/tests.rs
  42. 14 8
      src/protocol/token/dispatch.rs
  43. 136 3
      src/protocol/token/grants/authorization_code.rs
  44. 7 0
      src/protocol/token/grants/client_credentials.rs
  45. 43 0
      src/protocol/token/grants/client_credentials/tests/jwt.rs
  46. 18 2
      src/protocol/token/grants/device_code/issue.rs
  47. 74 1
      src/protocol/token/grants/device_code/tests/scope.rs
  48. 13 4
      src/protocol/token/grants/device_code/tests/setup.rs
  49. 11 20
      src/protocol/token/grants/refresh_token.rs
  50. 1 0
      src/protocol/token/grants/refresh_token/tests.rs
  51. 1 0
      src/protocol/token/grants/refresh_token/tests/audience.rs
  52. 1 0
      src/protocol/token/grants/refresh_token/tests/disabled_subject.rs
  53. 1 0
      src/protocol/token/grants/refresh_token/tests/entitlements.rs
  54. 1 0
      src/protocol/token/grants/refresh_token/tests/failover.rs
  55. 161 6
      src/protocol/token/grants/refresh_token/tests/jwt.rs
  56. 1 0
      src/protocol/token/grants/token_exchange.rs
  57. 1 0
      src/protocol/token/grants/token_exchange/tests.rs
  58. 6 3
      src/protocol/token/service.rs
  59. 3 0
      src/protocol/token/tests.rs
  60. 72 4
      src/protocol/token/tests/jwt.rs
  61. 44 2
      src/protocol/token/types.rs
  62. 120 10
      src/routes.rs
  63. 19 0
      src/store/mod.rs
  64. 34 2
      src/store/tokens.rs
  65. 34 5
      src/ui/consent.rs
  66. 137 2
      src/ui/consent/tests.rs
  67. 1 0
      tests/e2e/harness.rs
  68. 21 9
      tests/e2e_grants.rs

+ 352 - 0
.sqlx/query-c863821c56eeba6123480977785913db5d056b3ec050bef3eaae8c3e05b8f118.json

@@ -0,0 +1,352 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, dpop_bound_access_tokens, access_token_format, active, created_at, updated_at FROM oauth_clients WHERE client_id = $1",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "client_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "client_secret_expires_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_secret_expires_at"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "client_id_issued_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_id_issued_at"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "client_name",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name"
+          }
+        }
+      },
+      {
+        "ordinal": 5,
+        "name": "client_name_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "client_name_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 6,
+        "name": "grant_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "grant_types"
+          }
+        }
+      },
+      {
+        "ordinal": 7,
+        "name": "response_types",
+        "type_info": "TextArray",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "response_types"
+          }
+        }
+      },
+      {
+        "ordinal": 8,
+        "name": "scope",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "scope"
+          }
+        }
+      },
+      {
+        "ordinal": 9,
+        "name": "token_endpoint_auth_method",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "token_endpoint_auth_method"
+          }
+        }
+      },
+      {
+        "ordinal": 10,
+        "name": "jwks",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks"
+          }
+        }
+      },
+      {
+        "ordinal": 11,
+        "name": "jwks_uri",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri"
+          }
+        }
+      },
+      {
+        "ordinal": 12,
+        "name": "jwks_uri_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "jwks_uri_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 13,
+        "name": "tls_client_auth_subject_dn",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn"
+          }
+        }
+      },
+      {
+        "ordinal": 14,
+        "name": "tls_client_auth_subject_dn_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_subject_dn_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 15,
+        "name": "tls_client_auth_cert_thumbprint",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint"
+          }
+        }
+      },
+      {
+        "ordinal": 16,
+        "name": "tls_client_auth_cert_thumbprint_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "tls_client_auth_cert_thumbprint_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 17,
+        "name": "request_uris",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris"
+          }
+        }
+      },
+      {
+        "ordinal": 18,
+        "name": "request_uris_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "request_uris_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 19,
+        "name": "contacts",
+        "type_info": "Jsonb",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts"
+          }
+        }
+      },
+      {
+        "ordinal": 20,
+        "name": "contacts_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "contacts_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 21,
+        "name": "software_id",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id"
+          }
+        }
+      },
+      {
+        "ordinal": 22,
+        "name": "software_id_enc",
+        "type_info": "Bytea",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "software_id_enc"
+          }
+        }
+      },
+      {
+        "ordinal": 23,
+        "name": "dpop_bound_access_tokens",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "dpop_bound_access_tokens"
+          }
+        }
+      },
+      {
+        "ordinal": 24,
+        "name": "access_token_format",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "access_token_format"
+          }
+        }
+      },
+      {
+        "ordinal": 25,
+        "name": "active",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "active"
+          }
+        }
+      },
+      {
+        "ordinal": 26,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "created_at"
+          }
+        }
+      },
+      {
+        "ordinal": 27,
+        "name": "updated_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "updated_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      true,
+      false,
+      true,
+      true,
+      false,
+      false,
+      false,
+      false,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      true,
+      false,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "c863821c56eeba6123480977785913db5d056b3ec050bef3eaae8c3e05b8f118"
+}

+ 1 - 1
Cargo.toml

@@ -24,7 +24,7 @@ base64 = "0.23.1"
 base64ct = "1.8.3"
 clap = { version = "4.6.7", features = ["derive"] }
 config = "0.15.27"
-ed25519-dalek = { version = "2.2.0", default-features = false }
+ed25519-dalek = { version = "2.2.0", default-features = false, features = ["zeroize"] }
 hmac = "0.13.0"
 hyper = "1.11.1"
 hyper-util = { version = "0.1.21", features = ["server-auto", "tokio", "service"] }

+ 3 - 2
README.md

@@ -9,8 +9,9 @@ includes the host `localhost` only when `allow_localhost_redirect` is set).
 It implements the authorization-code, refresh-token, client-credentials,
 device-code, and token-exchange grants, with DPoP, mTLS, PAR, JAR, dynamic
 client registration, introspection, and revocation. Tokens are opaque and
-hashed at rest by default; an opt-in per-client or global `jwt` access-token
-format mints RFC 9068 `at+jwt` tokens verified against the AS JWKS. All
+hashed at rest by default; a global enable switch plus a per-client `jwt`
+access-token format mints RFC 9068 `at+jwt` tokens verified against the AS
+JWKS. All
 durable domain state is in PostgreSQL and all ephemeral state is in Redis, so
 replicas are interchangeable.
 

+ 10 - 6
docs/ARCHITECTURE.md

@@ -29,7 +29,7 @@ the library.
 | `src/db/resource_repo.rs` | Exact-match CRUD for the `resource_servers` catalog: `upsert` preserves `id`/`enabled`, `find_enabled`, `list_enabled`, and `disable`. |
 | `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
 | `src/protocol/` | OAuth endpoints and grants, plus the subject-scoped Redis purges (`src/protocol/purge.rs`: `purge_subject` for `user disable`, `purge_sibling_sessions` on credential change). |
-| `src/protocol/params.rs` | Shared raw-parameter parsing (`parse_pairs`, `repeated_values`, the form-content-type gate) and fail-closed RFC 8707 audience validation (`validate_resources`, `audience_binds`). |
+| `src/protocol/params.rs` | Shared raw-parameter parsing (`parse_pairs`, `values_of`, the form-content-type gate) and fail-closed RFC 8707 audience validation (`validate_resources`, `audience_binds`). |
 | `src/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/resilience.rs` | Load-shed, timeout, and concurrency-limit guard between CORS and rate limiting. |
@@ -79,11 +79,15 @@ Five parameter-carrying endpoints parse the raw query or body instead of using
 `Query<HashMap>` or `Form<...>`, because those extractors consume the raw input
 and cannot see a repeated `resource`: `GET /authorize`, `POST /par`,
 `POST /token`, `POST /device_authorization`, and `POST /introspect`.
-`src/protocol/params.rs` captures repeats in wire order while keeping the
-single-valued map last-wins. For the body endpoints it preserves the prior
-transport contract (`415` on a non-urlencoded body, `400` on a malformed or
-non-UTF-8 body, and the documented `invalid_request` mapping for a duplicated
-known field). `/token` rejects a `resource` on token exchange before any catalog
+`POST /revoke` uses the same raw body parser for duplicate-field rejection but
+is audience-agnostic. `/authorize` uses `RawQuery`; the POST handlers use
+`RawForm`, which checks the content type before buffering the body, then the
+shared raw parser. `src/protocol/params.rs` captures repeats in wire order while
+keeping the single-valued map last-wins. The body endpoints return `415` on a
+non-urlencoded body and `400` on a malformed or non-UTF-8 body; a duplicated
+known field is JSON `invalid_request` on `/token`, `/introspect`,
+`/device_authorization`, and `/revoke` (PAR and authorize keep last-wins
+absorption). `/token` rejects a `resource` on token exchange before any catalog
 lookup.
 
 ## The two listeners

+ 7 - 2
docs/CONFIGURATION.md

@@ -340,8 +340,8 @@ validating example document, is in
   enforce only a minimum). A `jwt_signing_key_file` mount works exactly like the
   other `*_file` keys and is trimmed of trailing newlines; the inline form wins
   when both are set. The key material is turned into a real JOSE signing key at
-  load, so an all-zero or out-of-range EC scalar fails at boot with
-  `InvalidJwtSigningKey`, not at the first mint. Enabling
+  load, so an all-zero EC scalar or EdDSA seed, or an out-of-range EC scalar,
+  fails at boot with `InvalidJwtSigningKey`, not at the first mint. Enabling
   `jwt_access_tokens_enabled` without a signing key fails closed with
   `MissingJwtSigningKey`.
 - `jwt_signing_previous_keys` retains rotated-out keys as a list of
@@ -357,6 +357,11 @@ validating example document, is in
   (`InvalidCimdFetchTimeout`) and `cimd_max_bytes` between 1 and 5120
   (`InvalidCimdMaxBytes`). A malformed value fails the load rather than
   silently meaning "no limit".
+- `protected_resources_enabled` is validated at load
+  (`InvalidProtectedResources`): it requires `resource_indicators_enabled=true`
+  and a non-empty, duplicate-free `protected_resource_ids` list of absolute
+  `https` URIs with a host and no wildcard, userinfo, fragment, or query. With
+  the flag off the identifiers are inert.
 
 ## The timeout ladder
 

+ 1 - 1
docs/DATABASE.md

@@ -50,7 +50,7 @@ One row per registered client (RFC 7591 / RFC 7592).
 | `active` | `bool` | Soft disable, default true. |
 | `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
 | `dpop_bound_access_tokens` | `bool` | Whether this client's tokens require DPoP proof (`migrations/1791244800_client_dpop_bound.sql`). |
-| `access_token_format` | `text` | `opaque` (default) or `jwt`, checked by `ck_oauth_clients_access_token_format`; `jwt` is honored only while JWT access tokens are enabled (`migrations/1791504000_resource_servers.sql`). Dynamic registration always inserts `opaque`; only `client set-access-token-format` changes it. |
+| `access_token_format` | `text` | `opaque` (default) or `jwt`, checked by `ck_oauth_clients_access_token_format`; `jwt` is honored only while JWT access tokens are enabled (`migrations/1791504000_resource_servers.sql`). Dynamic registration always inserts `opaque`; `client set-access-token-format` and `config sync` can change it. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
 Four partial unique indexes enforce client-identity uniqueness. Two are on the

+ 46 - 10
docs/OAUTH.md

@@ -149,7 +149,9 @@ A document is accepted only when all of these hold:
   Every shared-secret and mTLS method is rejected.
 - Any `jwks_uri` member is rejected. For `private_key_jwt`, an inline `jwks`
   with a non-empty `keys` array is required. This is inline-only by decision,
-  so client authentication never starts a second network fetch.
+  so client authentication never starts a second network fetch. An inline
+  `jwks` on a `none` document is ignored: that client does not authenticate
+  with a key.
 - `redirect_uris` must be a non-empty array. Each entry is an absolute
   `http`/`https` URI with a non-empty host and no userinfo, query, or fragment,
   and no entry may contain `*`. Plaintext `http` is accepted only for an RFC
@@ -173,8 +175,10 @@ outage is a fail-closed `503`.
 Resolution and the synthetic principal:
 
 - A registered row always wins. A row whose `client_id` happens to be an
-  `https://` URL is resolved from Postgres with no document fetch. Only a
-  missing row can fall back to CIMD.
+  `https://` URL is resolved from Postgres with no document fetch. An existing
+  inactive row also blocks the document fetch (it resolved to "client is no
+  longer active"), so deactivation cannot be bypassed; only a truly missing row
+  can fall back to CIMD.
 - The validated document is materialized as an in-memory `client_repo::Client`
   for the request. No `/register` row is written and no initial-access token
   is consumed.
@@ -196,13 +200,27 @@ Resolution and the synthetic principal:
   are exactly the same as before.
 
 The validated document is cached for 15 minutes under the `{state}` keyspace as
-`cimd:<sha256(url)>` (the hash of the URL, never the raw URL). Only the raw
-document is cached, and it is revalidated on every read, so a scope disabled or
-a catalog row removed during the TTL takes effect on the next request. A
+`cimd:<sha256(url)>` (the hash of the URL, never the raw URL). The parsed
+document (unknown members dropped) is cached, and it is revalidated on every
+read, so a scope disabled or a catalog row removed during the TTL takes effect
+on the next request. A
 malformed cache entry is deleted and treated as a miss. A cache hit does not
 fetch; a document changed at its origin may therefore be served from cache for
-up to the 15-minute TTL. Redis is not a second source of truth, and a Redis
-failure on this path is a fail-closed `503`.
+up to the 15-minute TTL. An origin-level fetch failure (network/TLS/non-2xx,
+timeout, or non-JSON body) is negatively cached for 30 seconds under
+`cimd:neg:<sha256(url)>`. A fresh-fetch validation failure is short-cached for
+2 seconds under `cimd:invalid:<sha256(url)>` (a single-flight completion
+marker), so a corrected document is seen within 2 seconds and waiters do not
+re-fetch a deterministically invalid one; a validation failure on an
+already-cached document writes no marker. If the cache write after a successful
+validation fails, the validated document is still served, uncached. Concurrent
+misses for the same URL elect one fetcher through a `cimd:lock:<sha256(url)>`
+Redis lock (TTL = the fetch timeout plus 1 s); the other requests wait for the
+positive or negative result for the fetch budget plus 1.5 s, then try once more
+to acquire the lock and otherwise fail closed. The lock is released with a
+compare-and-delete, so a holder whose TTL expired cannot release a successor's
+lock. Redis is not a second source of truth; a Redis read or lock failure on
+this path is a fail-closed `503`.
 
 ## The five grants
 
@@ -320,7 +338,12 @@ authorization server:
   literally registered in the catalog.
 - Values are never delimiter-joined. A URI containing a comma is stored as its
   own array element, so it cannot collide with a joined string.
-- The validated set is sorted and deduplicated before it is pinned.
+- At most 32 raw `resource` values are accepted per request; more is
+  `invalid_target`. Values containing an ASCII control character (at minimum
+  NUL) are `invalid_target` before any catalog lookup.
+- Duplicates are deduplicated before catalog validation so each distinct
+  value costs one lookup. The validated set is sorted and deduplicated
+  before it is pinned.
 
 `resource` is a repeated parameter. On `GET /authorize` it is read from the
 query string; on `POST /par`, `POST /token`, `POST /device_authorization`, and
@@ -387,6 +410,10 @@ every requested value; otherwise the response is the ordinary inactive body
 test, so a token pinned to a superset stays active. Without `resource`,
 introspection remains audience-agnostic.
 
+`token_type` is `DPoP` for an access token bound with `cnf.jkt` (RFC 9449
+§6.2), `Bearer` for an unbound or mTLS-only access token, and omitted for a
+refresh token.
+
 ### `invalid_target` and `invalid_grant`
 
 | Situation | Error |
@@ -466,7 +493,7 @@ Errors use the RFC 6749 §5.2 body `{error, error_description}`.
 | --- | --- | --- |
 | `invalid_request` | 400, or 302 on the `/authorize` error redirect once `client_id` and `redirect_uri` are validated | Missing or duplicated parameter; PAR misuse; JAR misuse. |
 | `invalid_client` | 401 with `WWW-Authenticate` for Basic or Bearer, else 400 | Bad client authentication; ambiguous mTLS certificate. |
-| `invalid_grant` | 400 | Unknown, consumed, or foreign code; rotation reuse; PKCE mismatch; proof against the wrong grant. |
+| `invalid_grant` | 400 | Unknown, consumed, or foreign code; rotation reuse; PKCE mismatch; proof against the wrong grant; the token request's resource set differing from the code/device set. |
 | `invalid_scope` | 400 | Unknown, disabled, over-registered, or non-entitled scope; refresh widening. |
 | `invalid_target` | 400, or 302 on the `/authorize` error redirect once `redirect_uri` is validated | Resource indicator rejected: flag off, non-`https` or relative value, unknown or disabled catalog row, or a refresh audience that exceeds the grant. |
 | `unsupported_grant_type` | 400 | A `grant_type` outside the five. |
@@ -527,6 +554,15 @@ tombstones, and reuse detection are identical for both formats. Token exchange
 is opaque-only: a `jwt`-format caller of the exchange grant is refused with
 `503`.
 
+A JWT access token always carries a **non-empty** audience. A `jwt` client must
+request at least one `resource` at `/authorize`, `/device_authorization`, or
+`client_credentials`; an unbound grant (or a token request whose bound set is
+empty) fails closed with `invalid_target`, never `aud: []`. The authorization-
+code and device records are consumed before that mint-time check, so a `jwt`
+client that authorized without a resource must re-run the flow with one. A
+refresh of an unbound grant fails `invalid_target` before rotation, leaving the
+refresh token live.
+
 The JOSE header carries `typ: at+jwt`, the configured `kid`, and the configured
 algorithm (`ES256` or `EdDSA`; there is no RSA branch because the key material
 is a 32-byte P-256 scalar or Ed25519 seed). The claim set is the RFC 9068 §2.2

+ 10 - 5
docs/OPERATIONS.md

@@ -147,8 +147,9 @@ d-oauth-server resource-server delete <identifier>
 
 A resource server is an RFC 8707 resource indicator (the `resource` parameter
 and the `aud` target of an access token). `identifier` matches exactly — no
-normalization, sub-path, or wildcard. A `*` anywhere in the identifier is
-rejected, so one catalog row can never stand for an entire origin. `create`
+normalization, sub-path, or wildcard. It must be an absolute `https` URI with a
+host; a `*`, userinfo, fragment, or query is rejected, so one catalog row can
+never stand for an entire origin or a non-fetchable identifier. `create`
 upserts: it creates the row, updates its description, or leaves an unchanged
 row alone; it never silently re-enables an operator-disabled row, and `list`
 shows both states. `delete` removes the row outright (nothing references the
@@ -286,8 +287,9 @@ d-oauth-server resource-server list
 ```
 
 `identifier` matches exactly, with no normalization, sub-path, or wildcard; a
-`*` anywhere is rejected at creation, so one catalog row can never stand for an
-entire origin. `create` upserts (it creates the row, updates its description, or
+`*` anywhere is rejected at creation, as are a non-`https` scheme, a missing
+host, userinfo, a fragment, or a query, so one catalog row can never stand for an
+entire origin or a non-fetchable identifier. `create` upserts (it creates the row, updates its description, or
 leaves an unchanged row alone) and never silently re-enables an
 operator-disabled row. `list` shows both the `enabled` and `disabled` states.
 Use `resource-server enable|disable <identifier>` to flip a row and
@@ -326,7 +328,10 @@ The full member set and an example document are in
 Optionally, advertise the RS identifier on the AS side through the RFC 9728 §4
 `protected_resources` member: set `protected_resources_enabled=true` and list the
 identifier in `protected_resource_ids`. Both default off and empty, so the AS
-advertises no `protected_resources` until enabled. `resource_metadata` is never
+advertises no `protected_resources` until enabled. The load is fail-closed:
+the flag requires `resource_indicators_enabled=true` and a non-empty,
+duplicate-free list of absolute `https` identifiers with no wildcard,
+userinfo, fragment, or query. `resource_metadata` is never
 an AS metadata member (RFC 9728 §4, §5.1); it belongs only on the RS challenge in
 step 3.
 

+ 18 - 11
docs/REDIS.md

@@ -3,8 +3,9 @@
 Redis holds every short-lived value: authorization codes, access and refresh
 tokens, grant chains, sessions, PAR request URIs, device codes, DCR
 initial-access tokens, replay markers, and rate-limit counters. No raw token is
-ever a Redis key or value; keys are keyed hashes and values hold only derived
-state.
+ever a Redis key or value; token keys are keyed hashes and values hold only
+derived state. The JWT marker and CIMD cache use an unkeyed SHA-256 over a
+random `jti` or a public document URL respectively, neither of which is secret.
 
 ## Purpose
 
@@ -21,7 +22,7 @@ differ.
 | Key | Contents | TTL |
 | --- | --- | --- |
 | `oauth:{state}:code:<hash>` | Single-use authorization code, PKCE-bound. | `ttl.code` (60s), clamped to 60 s (`CODE_TTL_CAP`) |
-| `oauth:{state}:access:<hash>` | Access-token record. | `ttl.access` (300s) |
+| `oauth:{state}:access:<hash>` | Access-token record. | `ttl.access_secs` (default 300s) |
 | `oauth:{state}:access:<hash>:act` | Token-exchange `act` claim sidecar. | Lives with the access record |
 | `oauth:{state}:refresh:<hash>` | Refresh-token record or tombstone. | `ttl.refresh` (30d) |
 | `oauth:{state}:grant:<id>` | Grant-chain record. | Refresh lifetime |
@@ -42,13 +43,17 @@ differ.
 | `oauth:{state}:loginfail:ip:<sha256>:<sha256>` | Composite failed-login counter for one source IP and one account (both halves hashed; locks that IP only). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:unknown:<sha256>` | Unknown-label bucket for one source IP (hashed IP only; no per-label keys, so label enumeration cannot spray the keyspace). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. The fingerprint is an HMAC under the token pepper (never the code itself), so a dump does not reveal the code. | 180 s (`USED_CODE_TTL`; fixed, 6× the 30 s step) |
-| `oauth:{state}:jwt:<jti-hash>` | JWT access-token kill marker (allowlist: a signed token is valid only while this hash is live). Carries `grant_id`, `client_id`, and the raw `subject` (email/user-id; empty for client-credentials), matching the `purge_subject` `subject` decoder. | `ttl.access` (300s) |
-| `oauth:{state}:cimd:<sha256>` | Cached client-id-metadata document (keyed by a hash of the URL, never the raw URL). | Bounded cache lifetime |
+| `oauth:{state}:jwt:<jti-hash>` | JWT access-token kill marker (allowlist: a signed token is valid only while this hash is live). Carries `grant_id`, `client_id`, and the raw `subject` (email/user-id; empty for client-credentials), matching the `purge_subject` `subject` decoder. | `ttl.access_secs` (default 300s) |
+| `oauth:{state}:cimd:<sha256>` | Cached client-id-metadata document (keyed by a hash of the URL, never the raw URL). | 15 minutes |
+| `oauth:{state}:cimd:neg:<sha256>` | Negative cache for an origin-level CIMD fetch failure (network/TLS/non-2xx, timeout, non-JSON). | 30 seconds |
+| `oauth:{state}:cimd:invalid:<sha256>` | Short completion marker for a fresh-fetch CIMD validation failure, so waiters do not re-fetch a deterministically invalid document. | 2 seconds |
+| `oauth:{state}:cimd:lock:<sha256>` | Single-flight fetcher election for one uncached CIMD URL; a random token is released with a compare-and-delete Lua script. | `cimd_fetch_timeout_secs` + 1 s (2–11 s) |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 
-`<hash>` is always a keyed hash. Tokens are opaque, so the server can look a
-record up by re-hashing the presented value but cannot recover the raw value
-from storage.
+`<hash>` is a keyed hash for token lookups: tokens are opaque, so the server can
+look a record up by re-hashing the presented value but cannot recover the raw
+value from storage. The `<jti-hash>` of a JWT marker and the CIMD `<sha256>` are
+unkeyed because their inputs are already a random token id and a public URL.
 
 Resource audiences (`aud`, RFC 8707) ride on seven record families as a field
 named `aud`: `code:`, `access:`, `refresh:`, `grant:`, `par:`, `device:`, and
@@ -66,7 +71,7 @@ Postgres, not Redis.
 
 JWT access tokens use an allowlist, not a denylist. Each mint writes a
 `jwt:<jti-hash>` marker (`grant_id`, `client_id`, `subject`, TTL
-`ttl.access`) in the same atomic script as the grant membership, and a token is
+`ttl.access_secs`) in the same atomic script as the grant membership, and a token is
 live only while that hash exists. Because the marker rides the `{state}` tag, it
 is single-slot with the access record and the grant set. Three paths delete it:
 
@@ -200,7 +205,9 @@ rotation mapping or the record instead of replaying the write.
 
 ## Security properties
 
-- Only keyed hashes are stored. A Redis dump does not reveal a usable token.
+- Token keys are keyed hashes; the JWT marker and CIMD cache use an unkeyed
+  SHA-256 over a random `jti` or a public URL. A Redis dump does not reveal a
+  usable token.
 - `noeviction` and AOF are required by default and asserted at startup unless
   the matching opt-out flag (`redis.noeviction_required` or `redis.aof_required`)
   is set false.
@@ -242,7 +249,7 @@ field is the canonical email or the user-id string:
   cascade already removes markers for grant-bound tokens; this sweep covers a
   marker whose grant set is already gone. Client-credentials markers carry an
   empty subject, so the subject decoder never matches them; they expire with
-  `ttl.access`.
+  `ttl.access_secs`.
 
 Records without a `subject` field are never touched: `par:`, `pending:`,
 `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the

+ 13 - 5
docs/RS-CONTRACT.md

@@ -218,8 +218,10 @@ This AS additionally accepts a repeated `resource` parameter on the
 introspection request as a LOCAL EXTENSION to RFC 7662, which defines no
 `resource` parameter. When present, the token's `aud` must include every
 requested member (set inclusion, so a token pinned to a superset stays active);
-a token that does not include every requested member returns the inactive shape,
-never an error. An RS uses this to ask whether a token is bound to its own
+a token that does not include every requested member returns the inactive shape.
+A requested value that is not an enabled catalog row (or a request while
+resource indicators are disabled) is `invalid_target` before the lookup, matching
+the other endpoints. An RS uses this to ask whether a token is bound to its own
 resource identifier.
 
 ## Validating the metadata document
@@ -260,13 +262,19 @@ The AS side of this contract is narrow and fixed:
 - RFC 9728 §4 defines one AS-side member, `protected_resources`, which lists the
   protected resources usable with this AS. It is emitted only when
   `protected_resources_enabled` is set, using the identifiers in
-  `protected_resource_ids`; both default off and empty. Until enabled, the AS
-  advertises no `protected_resources` and clients rely on the RS metadata alone.
+  `protected_resource_ids`; both default off and empty. The identifiers are
+  validated at load (absolute `https`, host, no wildcard/userinfo/fragment/
+  query, no duplicates) and the flag requires `resource_indicators_enabled`.
+  Until enabled, the AS advertises no `protected_resources` and clients rely on
+  the RS metadata alone.
 - Tokens are opaque by default and hashed at rest, so the RS validates them by
   introspection (RFC 7662) rather than by local verification. A client can be
   opted into JWT access tokens, in which case the AS advertises `jwks_uri` and
   the RS may verify locally; local verification cannot observe revocation or
-  `user disable` before `exp`, so a sensitive RS still introspects. See
+  `user disable` before `exp`, so a sensitive RS still introspects. A JWT access
+  token always carries a non-empty `aud`; a JWT-format client must request at
+  least one `resource` and the AS rejects an empty audience with
+  `invalid_target`. See
   [OAUTH.md](OAUTH.md#the-resource-server-trade-off).
 - RFC 8707 resource indicators are honored only when `resource_indicators_enabled`
   is set (default off). The RS should confirm with the operator whether tokens

+ 6 - 0
docs/SECURITY.md

@@ -208,6 +208,8 @@ Threat model for the signer:
 - Key material never appears in `Debug`, an error, a metric label, or a log.
   `Config` and `JwtSigningKey` print only the key id and algorithm; `/jwks.json`
   carries public coordinates only (`kid`, `kty`, `crv`, `x`, and `y` for EC).
+  `JwtClaims`'s `Debug` redacts the PII `sub` and the raw `jti`; only public
+  claim members print.
 - Rotation is verify-only for the old key: the current key signs, every
   `jwt_signing_previous_keys` entry verifies. Kids must be distinct, and a
   previous key cannot be configured without a current key. Retain the old key at
@@ -234,6 +236,10 @@ The marker is torn down by every revocation path:
   `iss`, requires both the signed `client_id` and the marker's `client_id` to
   match the caller, then deletes marker, record, and grant membership with a
   durability barrier. A failed ownership check is a uniform no-op (`200`).
+- DCR `DELETE` (`/register/{client_id}`) sweeps the client's access records; a
+  `jwt:<hash>` access record also deletes its allowlist marker, covering a
+  client-credentials token that has no refresh record to trigger the grant
+  cascade.
 
 A resource server that verifies locally against `/jwks.json` cannot observe any
 of this: it sees the signature, `iss`, `exp`, and its own audience binding, so it

+ 27 - 3
src/cli/resource.rs

@@ -104,11 +104,13 @@ async fn delete(context: &Context, identifier: &str) -> Result<(), CliError> {
 
 /// Validate an RFC 8707 resource identifier.
 ///
-/// The catalog is fail-closed: an empty identifier or a wildcard `*` is
-/// rejected so a single row can never stand for an entire origin.
+/// The catalog is fail-closed: the value must be an absolute `https` URI with
+/// a host, and an empty identifier, a wildcard `*`, userinfo, a fragment, or a
+/// query is rejected so a single row can never stand for an entire origin or a
+/// non-fetchable identifier.
 ///
 /// # Errors
-/// Returns [`CliError::Input`] when the identifier is empty or contains `*`.
+/// Returns [`CliError::Input`] for any of the rejected shapes.
 pub fn validate(identifier: &str) -> Result<(), CliError> {
   if identifier.trim().is_empty() {
     return Err(CliError::Input(
@@ -120,6 +122,28 @@ pub fn validate(identifier: &str) -> Result<(), CliError> {
       "resource identifier must not contain a wildcard '*'".to_owned(),
     ));
   }
+  let url = reqwest::Url::parse(identifier)
+    .map_err(|_| CliError::Input("resource identifier must be an absolute https URI".to_owned()))?;
+  if url.scheme() != "https" || url.host_str().is_none_or(str::is_empty) {
+    return Err(CliError::Input(
+      "resource identifier must be an absolute https URI with a host".to_owned(),
+    ));
+  }
+  if !url.username().is_empty() || url.password().is_some() {
+    return Err(CliError::Input(
+      "resource identifier must not contain userinfo".to_owned(),
+    ));
+  }
+  if url.fragment().is_some() {
+    return Err(CliError::Input(
+      "resource identifier must not contain a fragment".to_owned(),
+    ));
+  }
+  if url.query().is_some() {
+    return Err(CliError::Input(
+      "resource identifier must not contain a query".to_owned(),
+    ));
+  }
   Ok(())
 }
 

+ 30 - 0
src/cli/tests.rs

@@ -1843,6 +1843,36 @@ fn cli_password_file_trims_trailing_newline() -> Result<(), TestError> {
   Ok(())
 }
 
+/// The resource-identifier grammar is shared by the CLI and `config sync`:
+/// only absolute `https` URIs with a host and no wildcard/userinfo/fragment/
+/// query may enter the catalog.
+#[test]
+fn cli_resource_identifier_validation_rejects_non_https_and_userinfo() {
+  for bad in [
+    "not-a-url",
+    "http://api.example.test",
+    "/relative",
+    "https://",
+    "https://user@api.example.test",
+    "https://user:pass@api.example.test",
+    "https://api.example.test#fragment",
+    "https://api.example.test?query=1",
+    "https://*.example.test",
+  ] {
+    assert!(resource::validate(bad).is_err(), "{bad:?} must be rejected");
+  }
+  for good in [
+    "https://api.example.test",
+    "https://api.example.test/a/b",
+    "https://api.example.test:8443/a",
+  ] {
+    assert!(
+      resource::validate(good).is_ok(),
+      "{good:?} must be accepted"
+    );
+  }
+}
+
 /// `resource-server` create/list/enable/disable/delete round-trips, rejects a
 /// wildcard identifier, and re-applies a `config sync` `resource_servers`
 /// array idempotently.

+ 8 - 0
src/config.rs

@@ -428,6 +428,14 @@ pub enum ConfigError {
   /// The CIMD document byte cap was zero or above 5120 bytes.
   #[error("cimd_max_bytes must be between 1 and 5120 bytes")]
   InvalidCimdMaxBytes,
+  /// RFC 9728 `protected_resources` was enabled without a valid, non-empty,
+  /// duplicate-free list of absolute `https` identifiers (and RFC 8707
+  /// indicators enabled).
+  #[error("invalid protected resources configuration: {reason}")]
+  InvalidProtectedResources {
+    /// Why the configuration was rejected.
+    reason: String,
+  },
   /// Argon2 parameters were outside the safe range.
   #[error("invalid argon2 parameters")]
   InvalidArgon2Params,

+ 58 - 1
src/config/build.rs

@@ -3,8 +3,9 @@
 //! Kept in a submodule so the root `config.rs` stays focused on the public
 //! types and loader surface.
 
-use std::{net::SocketAddr, path::Path, str::FromStr, time::Duration};
+use std::{collections::HashSet, net::SocketAddr, path::Path, str::FromStr, time::Duration};
 
+use reqwest::Url;
 use secrecy::{ExposeSecret, SecretString};
 
 use super::{
@@ -27,6 +28,7 @@ const DEFAULT_KEY_ID: &str = "v1";
 impl Config {
   pub(crate) fn build(raw: RawConfig) -> Result<Self, ConfigError> {
     let environment = raw.environment;
+    validate_protected_resources(&raw)?;
     let issuer = parse_issuer(raw.issuer.as_deref())?;
     let bind_addr = parse_socket_addr(raw.bind_addr.as_deref(), DEFAULT_BIND_ADDR, |value| {
       ConfigError::InvalidBindAddr { value }
@@ -307,6 +309,61 @@ fn build_jwt_signing(
   Ok((current, previous))
 }
 
+/// RFC 9728 `protected_resources` is advertised only from a validated,
+/// non-empty, duplicate-free identifier set, and only when the RFC 8707
+/// indicator gate is also on. With the flag off the identifiers are inert.
+fn validate_protected_resources(raw: &RawConfig) -> Result<(), ConfigError> {
+  if !raw.protected_resources_enabled {
+    return Ok(());
+  }
+  if !raw.resource_indicators_enabled {
+    return Err(ConfigError::InvalidProtectedResources {
+      reason: "resource_indicators_enabled must be true".to_owned(),
+    });
+  }
+  if raw.protected_resource_ids.is_empty() {
+    return Err(ConfigError::InvalidProtectedResources {
+      reason: "protected_resource_ids must not be empty".to_owned(),
+    });
+  }
+  let mut seen = HashSet::new();
+  for value in &raw.protected_resource_ids {
+    let url = validate_protected_resource_id(value)?;
+    if !seen.insert(url) {
+      return Err(ConfigError::InvalidProtectedResources {
+        reason: format!("duplicate protected resource id: {value}"),
+      });
+    }
+  }
+  Ok(())
+}
+
+/// One advertised identifier must be an absolute `https` URI with a host and
+/// no wildcard, userinfo, fragment, or query. The parsed record is returned so
+/// duplicates are detected after URL normalization.
+fn validate_protected_resource_id(value: &str) -> Result<Url, ConfigError> {
+  let invalid = |reason: &str| ConfigError::InvalidProtectedResources {
+    reason: format!("{reason}: {value}"),
+  };
+  if value.contains('*') {
+    return Err(invalid("wildcards are not allowed"));
+  }
+  let url = Url::parse(value).map_err(|_| invalid("must be an absolute URI"))?;
+  if url.scheme() != "https" || url.host_str().is_none_or(str::is_empty) {
+    return Err(invalid("must be an absolute https URI with a host"));
+  }
+  if !url.username().is_empty() || url.password().is_some() {
+    return Err(invalid("userinfo is not allowed"));
+  }
+  if url.fragment().is_some() {
+    return Err(invalid("a fragment is not allowed"));
+  }
+  if url.query().is_some() {
+    return Err(invalid("a query is not allowed"));
+  }
+  Ok(url)
+}
+
 /// CIMD fetch timeout must sit inside the 1-10 second window.
 fn validate_cimd_fetch_timeout(secs: u64) -> Result<Duration, ConfigError> {
   if (1..=10).contains(&secs) {

+ 8 - 4
src/config/jwt.rs

@@ -11,6 +11,7 @@ use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
 use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, encode};
 use secrecy::{ExposeSecret, SecretString};
 use serde::Deserialize;
+use zeroize::Zeroizing;
 
 use super::ConfigError;
 use crate::keys::KeyId;
@@ -100,13 +101,16 @@ impl JwtSigningKey {
     field: &'static str,
   ) -> Result<Self, ConfigError> {
     let raw = secret.expose_secret().as_bytes();
-    let der = match alg {
+    if alg == JwtSigningAlg::EdDsa && raw.iter().all(|byte| *byte == 0) {
+      return Err(ConfigError::InvalidJwtSigningKey { key: field });
+    }
+    let der = Zeroizing::new(match alg {
       JwtSigningAlg::Es256 => p256_pkcs8(raw),
       JwtSigningAlg::EdDsa => ed25519_pkcs8(raw),
-    };
+    });
     let key = match alg {
-      JwtSigningAlg::Es256 => EncodingKey::from_ec_der(&der),
-      JwtSigningAlg::EdDsa => EncodingKey::from_ed_der(&der),
+      JwtSigningAlg::Es256 => EncodingKey::from_ec_der(der.as_slice()),
+      JwtSigningAlg::EdDsa => EncodingKey::from_ed_der(der.as_slice()),
     };
     encode(&Header::new(alg.algorithm()), &serde_json::json!({}), &key)
       .map_err(|_| ConfigError::InvalidJwtSigningKey { key: field })?;

+ 136 - 0
src/config/tests.rs

@@ -700,6 +700,121 @@ fn config_localhost_redirect_defaults_off() -> Result<(), Box<dyn Error>> {
   Ok(())
 }
 
+/// RFC 9728 `protected_resources` cannot be enabled without identifiers.
+#[test]
+fn config_rejects_protected_resources_without_ids() {
+  let mut map = base();
+  map.insert("protected_resources_enabled".to_owned(), Value::Bool(true));
+  map.insert("resource_indicators_enabled".to_owned(), Value::Bool(true));
+  assert!(matches!(
+    try_load(&map),
+    Err(ConfigError::InvalidProtectedResources { .. })
+  ));
+}
+
+/// Advertised protected resources require the RFC 8707 indicator gate.
+#[test]
+fn config_rejects_protected_resources_without_resource_indicators() {
+  let mut map = base();
+  map.insert("protected_resources_enabled".to_owned(), Value::Bool(true));
+  map.insert(
+    "protected_resource_ids".to_owned(),
+    json!(["https://api.example.test"]),
+  );
+  assert!(matches!(
+    try_load(&map),
+    Err(ConfigError::InvalidProtectedResources { .. })
+  ));
+}
+
+/// Every advertised identifier must be an absolute `https` URI with a host
+/// and no wildcard, userinfo, fragment, or query.
+#[test]
+fn config_rejects_invalid_protected_resource_ids() {
+  for id in [
+    "*",
+    "https://api.example.test/*",
+    "http://api.example.test",
+    "api.example.test",
+    "/relative",
+    "https://user@api.example.test",
+    "https://user:pass@api.example.test",
+    "https://api.example.test#fragment",
+    "https://api.example.test?query=1",
+    "https://",
+  ] {
+    let mut map = base();
+    map.insert("protected_resources_enabled".to_owned(), Value::Bool(true));
+    map.insert("resource_indicators_enabled".to_owned(), Value::Bool(true));
+    map.insert("protected_resource_ids".to_owned(), json!([id]));
+    assert!(
+      matches!(
+        try_load(&map),
+        Err(ConfigError::InvalidProtectedResources { .. })
+      ),
+      "protected resource id {id:?} must be rejected"
+    );
+  }
+}
+
+/// Duplicate identifiers would be advertised twice, so they fail at load.
+#[test]
+fn config_rejects_duplicate_protected_resource_ids() {
+  let mut map = base();
+  map.insert("protected_resources_enabled".to_owned(), Value::Bool(true));
+  map.insert("resource_indicators_enabled".to_owned(), Value::Bool(true));
+  map.insert(
+    "protected_resource_ids".to_owned(),
+    json!(["https://api.example.test", "https://api.example.test"]),
+  );
+  assert!(matches!(
+    try_load(&map),
+    Err(ConfigError::InvalidProtectedResources { .. })
+  ));
+}
+
+/// Valid identifiers load unchanged when both gates are on.
+#[test]
+fn config_accepts_valid_protected_resources() -> Result<(), Box<dyn Error>> {
+  let mut map = base();
+  map.insert("protected_resources_enabled".to_owned(), Value::Bool(true));
+  map.insert("resource_indicators_enabled".to_owned(), Value::Bool(true));
+  map.insert(
+    "protected_resource_ids".to_owned(),
+    json!([
+      "https://api.example.test",
+      "https://rs.example.test:8443/a/b"
+    ]),
+  );
+  let cfg = try_load(&map)?;
+  assert!(cfg.protected_resources_enabled);
+  assert_eq!(
+    cfg.protected_resource_ids,
+    vec![
+      "https://api.example.test".to_owned(),
+      "https://rs.example.test:8443/a/b".to_owned()
+    ]
+  );
+  Ok(())
+}
+
+/// With the feature flag off the identifiers stay inert (existing behavior).
+#[test]
+fn config_ignores_protected_resource_ids_when_disabled() -> Result<(), Box<dyn Error>> {
+  let mut map = base();
+  map.insert(
+    "protected_resource_ids".to_owned(),
+    json!(["not a uri", "*"]),
+  );
+  let cfg = try_load(&map)?;
+  assert!(!cfg.protected_resources_enabled);
+  assert_eq!(
+    cfg.protected_resource_ids,
+    vec!["not a uri".to_owned(), "*".to_owned()]
+  );
+  Ok(())
+}
+
 /// A JWT signing key that is not exactly 32 bytes is rejected at load with the
 /// offending key named — both short and over-long material.
 #[test]
@@ -777,6 +892,27 @@ fn config_rejects_zero_scalar_jwt_key() {
   ));
 }
 
+/// An all-zero Ed25519 seed is a valid but publicly forgeable key, so it is
+/// rejected at load just like an out-of-range EC scalar.
+#[test]
+fn config_rejects_zero_seed_eddsa_jwt_key() {
+  let mut map = base();
+  map.insert(
+    "jwt_signing_key".to_owned(),
+    Value::String("\u{0}".repeat(32)),
+  );
+  map.insert(
+    "jwt_access_token_signing_alg".to_owned(),
+    Value::String("EdDSA".to_owned()),
+  );
+  assert!(matches!(
+    try_load(&map),
+    Err(ConfigError::InvalidJwtSigningKey {
+      key: "jwt_signing_key"
+    })
+  ));
+}
+
 /// Enabling JWT access tokens without a signing key fails closed.
 #[test]
 fn config_rejects_jwt_enabled_without_key() {

+ 65 - 0
src/db/client_repo.rs

@@ -259,6 +259,71 @@ pub async fn find_active_by_client_id(
     .transpose()
 }
 
+/// Find a client by its public `client_id`, active or not.
+///
+/// An inactive row is returned as-is (with `active == false`) so callers
+/// that must not fall through to a registration-free fallback — CIMD
+/// `resolve_client` — can distinguish "disabled" from "missing" without a
+/// second query.
+///
+/// # Errors
+///
+/// Returns [`AppError::PiiDecrypt`] when a stored ciphertext fails to open,
+/// or a masked error on any database failure.
+pub async fn find_by_client_id_any_state(
+  pool: &PgPool,
+  keys: &KeyRing,
+  client_id: &str,
+) -> Result<Option<Client>, AppError> {
+  let row = sqlx::query!(
+    "SELECT id, client_id, client_secret_expires_at, client_id_issued_at, client_name, \
+       client_name_enc, grant_types, response_types, scope, token_endpoint_auth_method, jwks, \
+       jwks_uri, jwks_uri_enc, tls_client_auth_subject_dn, tls_client_auth_subject_dn_enc, \
+       tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
+       request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
+       dpop_bound_access_tokens, access_token_format, active, created_at, updated_at \
+     FROM oauth_clients WHERE client_id = $1",
+    client_id,
+  )
+  .fetch_optional(pool)
+  .await?;
+  row
+    .map(|row| {
+      Ok(Client {
+        id: row.id,
+        client_id: row.client_id,
+        client_secret_expires_at: row.client_secret_expires_at,
+        client_id_issued_at: row.client_id_issued_at,
+        client_name: decrypt_required_text(keys, row.client_name_enc, row.client_name)?,
+        grant_types: row.grant_types,
+        response_types: row.response_types,
+        scope: row.scope,
+        token_endpoint_auth_method: row.token_endpoint_auth_method,
+        jwks: row.jwks,
+        jwks_uri: decrypt_optional_text(keys, row.jwks_uri_enc, row.jwks_uri)?,
+        tls_client_auth_subject_dn: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_subject_dn_enc,
+          row.tls_client_auth_subject_dn,
+        )?,
+        tls_client_auth_cert_thumbprint: decrypt_optional_text(
+          keys,
+          row.tls_client_auth_cert_thumbprint_enc,
+          row.tls_client_auth_cert_thumbprint,
+        )?,
+        request_uris: decrypt_optional_json(keys, row.request_uris_enc, row.request_uris)?,
+        contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
+        software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
+        dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+        access_token_format: row.access_token_format,
+        active: row.active,
+        created_at: row.created_at,
+        updated_at: row.updated_at,
+      })
+    })
+    .transpose()
+}
+
 /// Find a client by its surrogate id, active or not.
 ///
 /// # Errors

+ 24 - 4
src/jose/jwt_access.rs

@@ -51,7 +51,7 @@ const LEEWAY_SECS: u64 = 0;
 /// `scope` is the space-joined granted scope; `aud` is always an array (this
 /// AS never emits the string form). `sub` is empty for client-credentials
 /// tokens. `client_id` is REQUIRED (§2.2): a token without it is malformed.
-#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
 pub struct JwtClaims {
   /// Token issuer: the AS `issuer`.
   pub iss: String,
@@ -87,6 +87,25 @@ pub struct ActClaim {
   pub sub: String,
 }
 
+impl fmt::Debug for JwtClaims {
+  /// Locked [`fmt::Debug`]: the raw `sub` (PII) and `jti` (allowlist key
+  /// material) are redacted; the remaining members are public identifiers.
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("JwtClaims")
+      .field("iss", &self.iss)
+      .field("sub", &"[REDACTED]")
+      .field("aud", &self.aud)
+      .field("scope", &self.scope)
+      .field("exp", &self.exp)
+      .field("iat", &self.iat)
+      .field("jti", &"[REDACTED]")
+      .field("client_id", &self.client_id)
+      .field("cnf", &self.cnf)
+      .field("act", &self.act)
+      .finish()
+  }
+}
+
 /// A verified access token: signature, claims, marker, and account checks all
 /// passed.
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -300,8 +319,9 @@ impl JwtAccessSigner {
     Ok(VerifiedJwt { claims })
   }
 
-  /// Full liveness verification: [`Self::verify`] plus the allowlist marker
-  /// and the disabled-subject re-check (same `subject_account_status` call
+  /// Full liveness verification: [`Self::verify`] plus the strict allowlist
+  /// marker (marker + grant membership, [`marker_live_strict`]) and the
+  /// disabled-subject re-check (same `subject_account_status` call
   /// introspection uses).
   ///
   /// # Errors
@@ -320,7 +340,7 @@ impl JwtAccessSigner {
     expected_aud: Option<&[String]>,
   ) -> Result<VerifiedJwt, JwtError> {
     let verified = self.verify(token, expected_aud)?;
-    if !marker_live(conn, store, &jti_hash(&verified.claims.jti)).await? {
+    if !marker_live_strict(conn, store, &jti_hash(&verified.claims.jti)).await? {
       return Err(JwtError::MarkerAbsent);
     }
     if !subject_active(pool, keys, &verified.claims.sub).await? {

+ 67 - 0
src/jose/jwt_access/tests.rs

@@ -107,6 +107,7 @@ async fn write_marker(
   subject: &str,
 ) -> Result<(), TestError> {
   let key = store.jwt_marker_key(&jti_hash(jti));
+  let hash = jti_hash(jti);
   redis::cmd("HSET")
     .arg(&key)
     .arg("grant_id")
@@ -117,6 +118,11 @@ async fn write_marker(
     .arg(subject)
     .query_async::<()>(conn)
     .await?;
+  redis::cmd("SADD")
+    .arg(store.grant_tokens_key("grant-1"))
+    .arg(format!("jwt:{hash}"))
+    .query_async::<()>(conn)
+    .await?;
   Ok(())
 }
 
@@ -422,6 +428,44 @@ async fn jwt_verify_live_rejects_disabled_subject() -> TestResult {
   }
 }
 
+/// Strict liveness: a marker hash without grant membership is dead
+/// (`MarkerAbsent`), even though the marker-only check reads live.
+#[tokio::test]
+async fn jwt_verify_live_rejects_orphan_marker() -> TestResult {
+  let (signer, _) = test_signer()?;
+  let (mut conn, store) = test_redis().await?;
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let claims = JwtClaims {
+    sub: String::new(),
+    ..valid_claims(now_unix())
+  };
+  let token = signer.sign(&claims)?;
+  let hash = jti_hash(&claims.jti);
+  let key = store.jwt_marker_key(&hash);
+  redis::cmd("HSET")
+    .arg(&key)
+    .arg("grant_id")
+    .arg("orphan-grant")
+    .arg("client_id")
+    .arg("test-client")
+    .arg("subject")
+    .arg("")
+    .query_async::<()>(&mut conn)
+    .await?;
+  if !marker_live(&mut conn, &store, &hash).await? {
+    return Err("orphan marker must read live on the marker-only check".into());
+  }
+  match signer
+    .verify_live(&mut conn, &store, &pool, &keys, &token, None)
+    .await
+  {
+    Err(JwtError::MarkerAbsent) => Ok(()),
+    Err(got) => Err(format!("expected MarkerAbsent, got {got:?}").into()),
+    Ok(_) => Err("orphan marker token was accepted".into()),
+  }
+}
+
 /// `Debug` on the signer and on every key shape carries no key material: the
 /// raw 32-byte scalars appear nowhere; only `kid`/`alg`/public coordinates.
 #[test]
@@ -457,3 +501,26 @@ fn jwt_debug_redacts_key_material() -> TestResult {
   }
   Ok(())
 }
+
+/// `JwtClaims` Debug never prints the PII `sub` or the raw `jti` (the
+/// allowlist marker key material); public claim members stay visible.
+#[test]
+fn jwt_claims_debug_redacts_sub_and_jti() {
+  let claims = JwtClaims {
+    iss: "https://auth.example.test".to_owned(),
+    sub: "user@example.test".to_owned(),
+    aud: vec!["https://api.example.test".to_owned()],
+    scope: "read".to_owned(),
+    exp: 1_700_000_000,
+    iat: 1_699_999_700,
+    jti: "raw-jti-marker".to_owned(),
+    client_id: "client-abc".to_owned(),
+    cnf: None,
+    act: None,
+  };
+  let rendered = format!("{claims:?}");
+  assert!(!rendered.contains("user@example.test"));
+  assert!(!rendered.contains("raw-jti-marker"));
+  assert!(rendered.contains("[REDACTED]"));
+  assert!(rendered.contains("client-abc"));
+}

+ 4 - 0
src/middleware/ratelimit.rs

@@ -127,6 +127,10 @@ impl Rule {
         per_ip: limits.login_per_ip,
         per_client: None,
       }),
+      "/jwks.json" => Some(Self {
+        per_ip: limits.per_ip,
+        per_client: None,
+      }),
       "/register" => Some(Self {
         per_ip: limits.per_ip,
         per_client: None,

+ 18 - 0
src/middleware/ratelimit/tests.rs

@@ -17,6 +17,7 @@ use super::test_support::{
   Extras, Passthrough, TestResult, live_state, peer, retry_after, scan_keys, send, send_with,
   test_config, unavailable_state,
 };
+use crate::config::RateLimitConfig;
 
 /// N in-window requests pass; the N+1th is `429` + `Retry-After`.
 #[tokio::test]
@@ -220,6 +221,23 @@ async fn ratelimit_token_fails_closed_on_store_outage() -> TestResult {
   Ok(())
 }
 
+/// `/jwks.json` is anonymous, so it is classified under the per-IP budget
+/// only and never gets a per-client bucket.
+#[test]
+fn ratelimit_classifies_jwks_json_under_per_ip_budget() {
+  let limits = RateLimitConfig {
+    per_ip: 7,
+    per_client: 11,
+    ..RateLimitConfig::default()
+  };
+  let rule = super::Rule::for_path(&limits, "/jwks.json");
+  assert!(rule.is_some(), "/jwks.json must be rate-limit classified");
+  if let Some(rule) = rule {
+    assert_eq!(rule.per_ip, limits.per_ip);
+    assert_eq!(rule.per_client, None);
+  }
+}
+
 /// Protected-path classification is exact: an unlisted path is untouched even
 /// while the store is down.
 #[tokio::test]

+ 20 - 6
src/protocol/authorize.rs

@@ -252,12 +252,6 @@ pub async fn validate(mut input: AuthorizeInput<'_>) -> Result<ValidatedRequest,
   let merged_claims = jar::resolve(input.conn, input.store, &jar_input)
     .await
     .map_err(AuthorizeFailure::Page)?;
-  // Query-presented indicators union with JAR/PAR-pinned ones (both are
-  // catalog-validated below, so the union cannot smuggle anything past the
-  // catalog). A `resource` in both transports is already rejected by the
-  // merge as `invalid_request_object`.
-  let mut resources = input.resources.clone();
-  resources.extend(merged_claims.resource);
   let merged = &merged_claims.params;
 
   let expected_client_id = resolution.client_id();
@@ -309,6 +303,26 @@ pub async fn validate(mut input: AuthorizeInput<'_>) -> Result<ValidatedRequest,
     iss: input.issuer.to_owned(),
   };
 
+  // JAR/PAR pins the audience in exactly one transport: the PAR record's
+  // `aud` or the signed object's `resource` claim. A query `resource` beside
+  // either would union into, and widen, that pinned set; a PAR record strips
+  // `resource` at push, so the merge cannot see the duplicate. Reject it
+  // fail-closed (RFC 9101 §5.2, RFC 8707 §2.1).
+  let jar_present =
+    input.query.get("request").is_some() || input.query.get("request_uri").is_some();
+  if jar_present && !input.resources.is_empty() {
+    return Err(redirect_err(AppError::invalid_request_object(
+      "resource must be carried in the request object, not the query",
+    )));
+  }
+  let resources = if jar_present {
+    merged_claims.resource
+  } else {
+    let mut resources = input.resources.clone();
+    resources.extend(merged_claims.resource);
+    resources
+  };
+
   // Resource indicators (RFC 8707) validate after the redirect target, so a
   // bad audience redirects (never a page): unknown, non-https, or flag-off
   // `resource` is `invalid_target`, and the sorted deduped set pins into

+ 220 - 1
src/protocol/authorize/tests.rs

@@ -3,7 +3,12 @@
 use std::{collections::HashMap, time::Duration};
 
 use axum::http::{StatusCode, header};
+use base64::Engine as _;
+use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
+use ring::rand::SystemRandom;
+use ring::signature::{Ed25519KeyPair, KeyPair as _};
 use secrecy::SecretString;
+use time::OffsetDateTime;
 use uuid::Uuid;
 
 use super::{AuthorizeFailure, AuthorizeInput, validate};
@@ -16,7 +21,11 @@ use crate::{
   },
   error::{AppError, OAuthErrorCode},
   keys::{KeyId, KeyRing},
-  protocol::cimd,
+  protocol::{
+    cimd,
+    client_auth::ClientAuthRequest,
+    par::{ParContext, ParStores, push_authorization_request},
+  },
   state::RedisConn,
   store::Store,
   test_support,
@@ -137,6 +146,84 @@ fn test_keys() -> TestResult<KeyRing> {
   )?)
 }
 
+/// An Ed25519 signer plus the matching inline JWKS for a JAR regression.
+struct TestSigner {
+  encoding: EncodingKey,
+  jwks: serde_json::Value,
+}
+
+impl TestSigner {
+  /// Generate a fresh Ed25519 keypair and its one-key inline JWKS.
+  fn generate(kid: &str) -> TestResult<Self> {
+    let rng = SystemRandom::new();
+    let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).map_err(|_| "ed25519 keygen failed")?;
+    let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).map_err(|_| "ed25519 reload failed")?;
+    let x = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(pair.public_key().as_ref());
+    Ok(Self {
+      encoding: EncodingKey::from_ed_der(pkcs8.as_ref()),
+      jwks: serde_json::json!({
+        "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x, "kid": kid }]
+      }),
+    })
+  }
+
+  /// Sign a request object bound to `client_id` and the test issuer.
+  fn request_object(
+    &self,
+    kid: &str,
+    client_id: &str,
+    extra: serde_json::Value,
+  ) -> TestResult<String> {
+    let now = OffsetDateTime::now_utc().unix_timestamp();
+    let mut claims = serde_json::json!({
+      "iss": client_id,
+      "aud": ISSUER,
+      "exp": now + 300,
+      "iat": now,
+    });
+    let object = claims
+      .as_object_mut()
+      .ok_or("request object claims must be an object")?;
+    let extra = extra
+      .as_object()
+      .ok_or("request object extra claims must be an object")?;
+    for (name, value) in extra {
+      object.insert(name.clone(), value.clone());
+    }
+    let mut header = Header::new(Algorithm::EdDSA);
+    header.kid = Some(kid.to_owned());
+    Ok(encode(&header, &claims, &self.encoding)?)
+  }
+}
+
+/// Register a client whose inline JWKS verifies signed request objects.
+async fn provision_signing(
+  pool: &sqlx::PgPool,
+  keys: &KeyRing,
+  client_id: &str,
+  jwks: serde_json::Value,
+) -> TestResult {
+  let mut new = fixture(client_id, true);
+  new.jwks = Some(jwks);
+  client_repo::insert(pool, keys, false, &new).await?;
+  let (row, _) = redirect_repo::upsert(
+    pool,
+    keys,
+    false,
+    &redirect_repo::NewRedirectUri {
+      id: Uuid::new_v4(),
+      uri: REDIRECT,
+      kind: redirect_repo::RedirectKind::Authorize,
+      description: "authorize test",
+      created_by: None,
+    },
+  )
+  .await?;
+  redirect_repo::attach(pool, client_id, row.id, None).await?;
+  scope_repo::upsert(pool, "api:read", "read api").await?;
+  Ok(())
+}
+
 /// A valid plain-query authorize map for the provisioned client.
 fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
   query(&[
@@ -1126,3 +1213,135 @@ async fn authorize_accepts_repeated_resource() -> TestResult {
   }
   Ok(())
 }
+
+/// A query `resource` appended to a PAR `request_uri` must not union into the
+/// pinned audience: the request rejects as `invalid_request_object` through
+/// the validated redirect (RFC 9101 §5.2, RFC 8707 §2.1).
+#[tokio::test]
+async fn authorize_rejects_query_resource_beside_par() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let client_id = "authz-par-resource";
+  provision(&pool, &keys, client_id, true).await?;
+  resource_repo::upsert(&pool, "https://a.example.test/", "a").await?;
+  resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
+  let (mut conn, prefix, ratelimit) = shared_redis().await?;
+  let store = Store::from_parts(prefix, ratelimit, DurabilityMode::WaitAof);
+
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let pushed_params = valid_query(client_id, &challenge);
+  let auth = ClientAuthRequest {
+    basic_header: None,
+    client_id: Some(client_id),
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    peer_certs: None,
+  };
+  let ctx = ParContext {
+    token_endpoint: "https://auth.example.test/token",
+    now: OffsetDateTime::now_utc(),
+    par_ttl: Duration::from_secs(90),
+    allow_localhost_redirect: false,
+    cimd: None,
+    resource_indicators_enabled: true,
+  };
+  let pinned = ["https://a.example.test/".to_owned()];
+  let pushed = {
+    let mut stores = ParStores {
+      pool: &pool,
+      keys: &keys,
+      conn: &mut conn,
+      store: &store,
+    };
+    push_authorization_request(&mut stores, &ctx, &auth, &pushed_params, &pinned)
+      .await
+      .map_err(|error| format!("PAR push rejected: {}", error.code()))?
+  };
+
+  let appended = "https://b.example.test/".to_owned();
+  let params = query(&[
+    ("client_id", client_id),
+    ("request_uri", pushed.request_uri.as_str()),
+    ("resource", appended.as_str()),
+  ]);
+
+  match validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
+    query: &params,
+    resources: vec![appended],
+    resource_indicators_enabled: true,
+  })
+  .await
+  {
+    Ok(validated) => Err(format!("PAR audience was widened to {:?}", validated.aud).into()),
+    Err(AuthorizeFailure::Redirect { target, error, .. }) => {
+      assert_eq!(target, REDIRECT);
+      assert_eq!(error.code(), OAuthErrorCode::InvalidRequestObject);
+      Ok(())
+    }
+    Err(other) => Err(format!("expected invalid_request_object, got {}", other.code()).into()),
+  }
+}
+
+/// A query `resource` beside an inline signed request object is rejected:
+/// the audience travels in the object, never the query (RFC 9101 §5.2).
+#[tokio::test]
+async fn authorize_rejects_query_resource_beside_inline_request() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let client_id = "authz-jar-resource";
+  let signer = TestSigner::generate("jar-resource-key")?;
+  provision_signing(&pool, &keys, client_id, signer.jwks.clone()).await?;
+  resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let request = signer.request_object(
+    "jar-resource-key",
+    client_id,
+    serde_json::json!({
+      "response_type": "code",
+      "redirect_uri": REDIRECT,
+      "scope": "api:read",
+      "code_challenge": challenge,
+      "code_challenge_method": "S256",
+      "state": "jar-state",
+    }),
+  )?;
+  let appended = "https://b.example.test/".to_owned();
+  let params = query(&[
+    ("client_id", client_id),
+    ("request", request.as_str()),
+    ("resource", appended.as_str()),
+  ]);
+  let mut conn = offline_redis()?;
+  let store = test_store();
+
+  match validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
+    query: &params,
+    resources: vec![appended],
+    resource_indicators_enabled: true,
+  })
+  .await
+  {
+    Ok(validated) => Err(format!("JAR pinned the query audience {:?}", validated.aud).into()),
+    Err(AuthorizeFailure::Redirect { target, error, .. }) => {
+      assert_eq!(target, REDIRECT);
+      assert_eq!(error.code(), OAuthErrorCode::InvalidRequestObject);
+      Ok(())
+    }
+    Err(other) => Err(format!("expected invalid_request_object, got {}", other.code()).into()),
+  }
+}

+ 288 - 12
src/protocol/cimd.rs

@@ -42,7 +42,7 @@ mod tests;
 #[cfg(test)]
 mod tests_principal;
 
-use std::{collections::HashSet, time::Duration};
+use std::{collections::HashSet, sync::LazyLock, time::Duration};
 
 use serde::{Deserialize, Serialize};
 use sqlx::PgPool;
@@ -62,6 +62,26 @@ use crate::{
 /// How long a validated CIMD document stays cached (seconds).
 const CACHE_TTL_SECS: u64 = 15 * 60;
 
+/// How long a fetch failure stays negatively cached (seconds).
+const NEG_CACHE_TTL_SECS: u64 = 30;
+
+/// How long a fresh-fetch validation failure stays short-cached (seconds).
+/// This is a single-flight completion marker: it stops every waiter from
+/// re-fetching a deterministically invalid document, while a corrected
+/// document is seen within this short window.
+const INVALID_CACHE_TTL_SECS: u64 = 2;
+
+/// Extra lifetime on the single-flight lock beyond the fetch budget: the
+/// elected fetcher finishes its bounded fetch and cache write inside this.
+const LOCK_TTL_MARGIN: Duration = Duration::from_secs(1);
+
+/// Extra wait beyond the fetch budget before a waiter tries to acquire the
+/// lock itself: longer than [`LOCK_TTL_MARGIN`] so an abandoned lock expires.
+const LOCK_WAIT_MARGIN: Duration = Duration::from_millis(1_500);
+
+/// Waiter poll interval for the positive and negative cache.
+const LOCK_POLL_INTERVAL: Duration = Duration::from_millis(25);
+
 /// Grant types a CIMD client may declare (CIMD §4.1; no device or
 /// client-credentials grants in a registration-free document).
 const ALLOWED_GRANTS: [&str; 2] = ["authorization_code", "refresh_token"];
@@ -290,6 +310,27 @@ pub fn cache_key(token_prefix: &str, client_id_url: &str) -> String {
   )
 }
 
+/// The negative cache key for origin-level fetch failures: `{state}`-prefixed
+/// `cimd:neg:<sha256(url)>`, same prefix/hash conventions as [`cache_key`].
+/// The raw URL is never stored.
+#[must_use]
+pub fn negative_cache_key(token_prefix: &str, client_id_url: &str) -> String {
+  format!(
+    "{token_prefix}cimd:neg:{}",
+    sha256_hex(client_id_url.as_bytes())
+  )
+}
+
+/// The short cache key for a fresh-fetch validation failure:
+/// `{state}`-prefixed `cimd:invalid:<sha256(url)>`.
+#[must_use]
+pub fn invalid_cache_key(token_prefix: &str, client_id_url: &str) -> String {
+  format!(
+    "{token_prefix}cimd:invalid:{}",
+    sha256_hex(client_id_url.as_bytes())
+  )
+}
+
 /// Per-request CIMD resolution settings for a client-auth consumer.
 ///
 /// `None` at a call site means CIMD is disabled there (`config.cimd_enabled`
@@ -314,9 +355,10 @@ pub struct CimdSettings<'a> {
 /// (token dispatch, `client_auth`, `/revoke`, `/introspect`, authorize/JAR,
 /// PAR, device poll).
 ///
-/// A registered row always wins: when `find_active_by_client_id` names a row
-/// — including a row whose `client_id` happens to be an `https://` URL — that
-/// row is returned and no document is fetched. Otherwise, when `settings` is
+/// A registered row always wins: when `find_by_client_id_any_state` names a
+/// row — including a row whose `client_id` happens to be an `https://` URL —
+/// an active row is returned and an inactive row resolves to `None` (no fetch,
+/// so deactivation cannot be bypassed). Otherwise, when `settings` is
 /// `Some` and the id parses as `https://`, the document is resolved through
 /// [`resolve_with_catalog`] (cache, else guarded fetch + strict validation)
 /// and materialized via [`ValidatedCimd::to_client`]. Any other id, or a
@@ -342,11 +384,16 @@ pub async fn resolve_client(
   settings: Option<CimdSettings<'_>>,
   client_id: &str,
 ) -> Result<Option<client_repo::Client>, CimdError> {
-  let row = client_repo::find_active_by_client_id(pool, keys, client_id)
+  let row = client_repo::find_by_client_id_any_state(pool, keys, client_id)
     .await
     .map_err(CimdError::Lookup)?;
   if let Some(client) = row {
-    return Ok(Some(client));
+    if client.active {
+      return Ok(Some(client));
+    }
+    // A disabled row is dead: the caller keeps its unknown-client error
+    // and no document is fetched, so deactivation cannot be bypassed.
+    return Ok(None);
   }
   let Some(settings) = settings else {
     return Ok(None);
@@ -464,10 +511,28 @@ pub async fn fetch_and_validate(
   validate_document(client_id_url, &document, validation)
 }
 
-/// Resolve a CIMD client: cache hit, else fetch + validate + cache.
+/// Resolve a CIMD client: negative-cache hit fails fast, else cache hit,
+/// else one elected fetcher plus waiters.
+///
+/// An origin-level fetch failure (`Fetch`/`InvalidJson`) is negatively cached
+/// for 30 seconds under `cimd:neg:<sha256(url)>`. A fresh-fetch validation
+/// failure is short-cached for [`INVALID_CACHE_TTL_SECS`] under
+/// `cimd:invalid:<sha256(url)>` as a single-flight completion marker, so a
+/// corrected document is seen within that short window; a validation failure
+/// on a cached document writes no marker. A cached document is revalidated so
+/// catalog changes are honored during the 15-minute TTL. If the cache write
+/// after a successful validation fails, the validated document is still
+/// served (uncached).
+///
+/// Concurrent misses for the same URL elect exactly one fetcher through a
+/// `{state}` Redis lock (`cimd:lock:<sha256(url)>`, TTL = the fetch budget plus
+/// [`LOCK_TTL_MARGIN`]). The other requests poll the positive and negative
+/// caches for up to the fetch budget plus [`LOCK_WAIT_MARGIN`], then try once
+/// more to acquire the lock; if it is still held they fail closed with
+/// [`CimdError::Fetch`]. A holder's `release_lock` is a compare-and-delete, and
+/// the lock TTL bounds a crashed holder.
 ///
-/// A cached document is revalidated so catalog changes are honored during the
-/// 15-minute TTL. `token_prefix` must carry the `{state}` hash tag.
+/// `token_prefix` must carry the `{state}` hash tag.
 ///
 /// # Errors
 ///
@@ -481,16 +546,180 @@ pub async fn resolve(
   limits: CimdLimits,
 ) -> Result<ValidatedCimd, CimdError> {
   let key = cache_key(token_prefix, client_id_url);
+  let neg_key = negative_cache_key(token_prefix, client_id_url);
+  let invalid_key = invalid_cache_key(token_prefix, client_id_url);
+  let lock = lock_key(token_prefix, client_id_url);
+  if is_negatively_cached(conn, &neg_key).await? {
+    return Err(CimdError::Fetch);
+  }
   if let Some(document) = get_cached(conn, &key).await? {
     return validate_document(client_id_url, &document, validation);
   }
+  if is_invalid_cached(conn, &invalid_key).await? {
+    return Err(CimdError::Fetch);
+  }
 
-  let document = fetch_document(client_id_url, limits).await?;
-  let validated = validate_document(client_id_url, &document, validation)?;
-  put_cached(conn, &key, &document).await?;
+  let ttl_ms = lock_ttl_ms(limits);
+  if let Some(token) = acquire_lock(conn, &lock, ttl_ms).await? {
+    let result = resolve_under_lock(
+      conn,
+      &key,
+      &neg_key,
+      &invalid_key,
+      client_id_url,
+      validation,
+      limits,
+    )
+    .await;
+    // Best effort: a missed release is bounded by the lock TTL.
+    let _ = release_lock(conn, &lock, &token).await;
+    return result;
+  }
+
+  let deadline = tokio::time::Instant::now()
+    .checked_add(limits.timeout)
+    .and_then(|instant| instant.checked_add(LOCK_WAIT_MARGIN))
+    .unwrap_or_else(tokio::time::Instant::now);
+  loop {
+    if is_negatively_cached(conn, &neg_key).await? {
+      return Err(CimdError::Fetch);
+    }
+    if let Some(document) = get_cached(conn, &key).await? {
+      return validate_document(client_id_url, &document, validation);
+    }
+    if is_invalid_cached(conn, &invalid_key).await? {
+      return Err(CimdError::Fetch);
+    }
+    // A holder that failed validation releases without a cache entry; stop
+    // waiting as soon as the lock is gone and try to elect the next fetcher.
+    let lock_present: bool = redis::cmd("EXISTS")
+      .arg(&lock)
+      .query_async(conn)
+      .await
+      .map_err(cache_backend)?;
+    if !lock_present {
+      break;
+    }
+    let now = tokio::time::Instant::now();
+    if now >= deadline {
+      break;
+    }
+    let left = deadline.saturating_duration_since(now);
+    tokio::time::sleep(left.min(LOCK_POLL_INTERVAL)).await;
+  }
+
+  if let Some(token) = acquire_lock(conn, &lock, ttl_ms).await? {
+    let result = resolve_under_lock(
+      conn,
+      &key,
+      &neg_key,
+      &invalid_key,
+      client_id_url,
+      validation,
+      limits,
+    )
+    .await;
+    let _ = release_lock(conn, &lock, &token).await;
+    return result;
+  }
+  Err(CimdError::Fetch)
+}
+
+/// The lock-held path: re-check the caches (another holder may have just
+/// published), then fetch, validate, and cache.
+async fn resolve_under_lock(
+  conn: &mut RedisConn,
+  key: &str,
+  neg_key: &str,
+  invalid_key: &str,
+  client_id_url: &str,
+  validation: &CimdValidation<'_>,
+  limits: CimdLimits,
+) -> Result<ValidatedCimd, CimdError> {
+  if is_negatively_cached(conn, neg_key).await? {
+    return Err(CimdError::Fetch);
+  }
+  if let Some(document) = get_cached(conn, key).await? {
+    return validate_document(client_id_url, &document, validation);
+  }
+  if is_invalid_cached(conn, invalid_key).await? {
+    return Err(CimdError::Fetch);
+  }
+  let document = match fetch_document(client_id_url, limits).await {
+    Ok(document) => document,
+    Err(error @ (CimdError::Fetch | CimdError::InvalidJson)) => {
+      put_negative(conn, neg_key).await?;
+      return Err(error);
+    }
+    Err(error) => return Err(error),
+  };
+  let validated = match validate_document(client_id_url, &document, validation) {
+    Ok(validated) => validated,
+    Err(error) => {
+      if put_invalid(conn, invalid_key).await.is_err() {
+        tracing::warn!("cimd invalid-document marker write failed");
+      }
+      return Err(error);
+    }
+  };
+  if put_cached(conn, key, &document).await.is_err() {
+    tracing::warn!("cimd cache population failed after validation");
+  }
   Ok(validated)
 }
 
+/// The single-flight lock key: `{state}`-prefixed, hash of the URL, never the
+/// raw URL.
+fn lock_key(token_prefix: &str, client_id_url: &str) -> String {
+  format!(
+    "{token_prefix}cimd:lock:{}",
+    sha256_hex(client_id_url.as_bytes())
+  )
+}
+
+/// Lock lifetime in milliseconds: the fetch budget plus a scheduling margin.
+fn lock_ttl_ms(limits: CimdLimits) -> u64 {
+  u64::try_from(limits.timeout.saturating_add(LOCK_TTL_MARGIN).as_millis()).unwrap_or(u64::MAX)
+}
+
+/// Acquire the single-flight lock with `SET NX PX`. The random token is the
+/// compare-and-delete value; `None` means another request holds it.
+async fn acquire_lock(
+  conn: &mut RedisConn,
+  key: &str,
+  ttl_ms: u64,
+) -> Result<Option<String>, CimdError> {
+  let token = crate::crypto::tokens::mint_token()
+    .map_err(|_| CimdError::Backend(AppError::temporarily_unavailable()))?;
+  let acquired: Option<String> = redis::cmd("SET")
+    .arg(key)
+    .arg(&token)
+    .arg("NX")
+    .arg("PX")
+    .arg(ttl_ms)
+    .query_async(conn)
+    .await
+    .map_err(cache_backend)?;
+  Ok(acquired.map(|_| token))
+}
+
+/// Compare-and-delete the single-flight lock, so a holder whose TTL expired
+/// cannot release a successor's lock.
+static RELEASE_LOCK: LazyLock<redis::Script> = LazyLock::new(|| {
+  redis::Script::new(
+    "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end",
+  )
+});
+
+/// Release the lock only when it still holds `token`.
+async fn release_lock(conn: &mut RedisConn, key: &str, token: &str) -> Result<(), CimdError> {
+  let mut invocation = RELEASE_LOCK.prepare_invoke();
+  invocation.key(key);
+  invocation.arg(token);
+  let _: i64 = invocation.invoke_async(conn).await.map_err(cache_backend)?;
+  Ok(())
+}
+
 /// Resolve a CIMD client while loading the enabled scope catalog from
 /// Postgres. Convenience wrapper for request handlers.
 ///
@@ -546,6 +775,53 @@ async fn get_cached(conn: &mut RedisConn, key: &str) -> Result<Option<CimdDocume
   }
 }
 
+/// Whether an origin-level fetch failure is negatively cached. A Redis
+/// failure is fail-closed [`CimdError::Backend`].
+async fn is_negatively_cached(conn: &mut RedisConn, neg_key: &str) -> Result<bool, CimdError> {
+  let present: bool = redis::cmd("EXISTS")
+    .arg(neg_key)
+    .query_async(conn)
+    .await
+    .map_err(cache_backend)?;
+  Ok(present)
+}
+
+/// Record an origin-level fetch failure for [`NEG_CACHE_TTL_SECS`]. A Redis
+/// failure is fail-closed [`CimdError::Backend`].
+async fn put_negative(conn: &mut RedisConn, neg_key: &str) -> Result<(), CimdError> {
+  redis::cmd("SET")
+    .arg(neg_key)
+    .arg("1")
+    .arg("EX")
+    .arg(NEG_CACHE_TTL_SECS)
+    .query_async::<()>(conn)
+    .await
+    .map_err(cache_backend)
+}
+
+/// Whether a fresh-fetch validation failure is short-cached. A Redis failure
+/// is fail-closed [`CimdError::Backend`].
+async fn is_invalid_cached(conn: &mut RedisConn, invalid_key: &str) -> Result<bool, CimdError> {
+  let present: bool = redis::cmd("EXISTS")
+    .arg(invalid_key)
+    .query_async(conn)
+    .await
+    .map_err(cache_backend)?;
+  Ok(present)
+}
+
+/// Record a fresh-fetch validation failure for [`INVALID_CACHE_TTL_SECS`].
+async fn put_invalid(conn: &mut RedisConn, invalid_key: &str) -> Result<(), CimdError> {
+  redis::cmd("SET")
+    .arg(invalid_key)
+    .arg("1")
+    .arg("EX")
+    .arg(INVALID_CACHE_TTL_SECS)
+    .query_async::<()>(conn)
+    .await
+    .map_err(cache_backend)
+}
+
 /// Cache a validated document for [`CACHE_TTL_SECS`].
 async fn put_cached(
   conn: &mut RedisConn,

+ 424 - 2
src/protocol/cimd/tests.rs

@@ -6,8 +6,9 @@ use std::{collections::HashSet, time::Duration};
 use serde_json::{Value, json};
 
 use super::{
-  CimdAuthMethod, CimdDocument, CimdError, CimdLimits, CimdValidation, ValidatedCimd, cache_key,
-  fetch_document, get_cached, put_cached, resolve, validate_document,
+  CimdAuthMethod, CimdDocument, CimdError, CimdLimits, CimdValidation, ValidatedCimd, acquire_lock,
+  cache_key, fetch_document, get_cached, invalid_cache_key, lock_key, negative_cache_key,
+  put_cached, release_lock, resolve, validate_document,
 };
 use crate::state::RedisConn;
 
@@ -349,3 +350,424 @@ async fn cimd_cache_round_trip() -> TestResult {
   }
   Ok(())
 }
+
+/// A seeded negative key fails fast with `Fetch` before any fetch: even a
+/// valid cached document is shadowed while the negative holds, and the raw
+/// URL never appears in the key.
+#[tokio::test]
+async fn cimd_negative_cache_blocks_fetch() -> TestResult {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let mut conn = RedisConn::Single(manager);
+  let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
+  // `.invalid` can never fetch, so any fetch attempt would be observable as
+  // DNS work; the seeded negative must short-circuit before that.
+  let url = "https://client.invalid/neg-blocked.json";
+  let document = parse(document_value(url, &["https://client.invalid/cb"]))?;
+  let key = cache_key(&prefix, url);
+  put_cached(&mut conn, &key, &document)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("cache write: {error}")))?;
+  let neg = negative_cache_key(&prefix, url);
+  if !neg.starts_with(&prefix) || !neg.contains("cimd:neg:") || neg.contains("client.invalid") {
+    return Err(TestError::Unexpected(format!("bad negative key {neg}")));
+  }
+  redis::cmd("SET")
+    .arg(&neg)
+    .arg("1")
+    .arg("EX")
+    .arg(30)
+    .query_async::<()>(&mut conn)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("negative seed: {error}")))?;
+  let enabled = scopes();
+  match resolve(
+    &mut conn,
+    &prefix,
+    url,
+    &CimdValidation {
+      enabled_scopes: &enabled,
+      allow_localhost: false,
+    },
+    CimdLimits {
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(1),
+    },
+  )
+  .await
+  {
+    Err(CimdError::Fetch) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "negative hit must be Fetch, got {other}"
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "negative cache must block even a cached document".to_owned(),
+      ));
+    }
+  }
+  // The valid positive entry survives the negative hit untouched.
+  match get_cached(&mut conn, &key)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("cache read: {error}")))?
+  {
+    Some(cached) if cached.client_id == url => Ok(()),
+    _ => Err(TestError::Unexpected(
+      "positive cache must survive a negative hit".to_owned(),
+    )),
+  }
+}
+
+/// An origin-level fetch failure writes the negative key (30 s TTL); a
+/// validation failure served from cache never does, so a fixed document is
+/// seen immediately.
+#[tokio::test]
+async fn cimd_fetch_failure_negatively_cached() -> TestResult {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let mut conn = RedisConn::Single(manager);
+  let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
+  let url = "https://client.invalid/neg-write.json";
+  let enabled = scopes();
+  match resolve(
+    &mut conn,
+    &prefix,
+    url,
+    &CimdValidation {
+      enabled_scopes: &enabled,
+      allow_localhost: false,
+    },
+    CimdLimits {
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(2),
+    },
+  )
+  .await
+  {
+    Err(CimdError::Fetch | CimdError::InvalidJson) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "unfetchable origin must fail fetch-side, got {other}"
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "unfetchable origin resolved".to_owned(),
+      ));
+    }
+  }
+  let neg = negative_cache_key(&prefix, url);
+  let ttl: i64 = redis::cmd("TTL")
+    .arg(&neg)
+    .query_async(&mut conn)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("negative ttl: {error}")))?;
+  if ttl <= 0 || ttl > 30 {
+    return Err(TestError::Unexpected(format!(
+      "negative key must carry a 30 s TTL, got {ttl}"
+    )));
+  }
+  Ok(())
+}
+
+/// A validation failure served from cache is not negatively cached (no fetch
+/// occurred); only a fresh-fetch validation failure writes the 2 s
+/// `cimd:invalid:` completion marker. The cached document echoes another URL,
+/// so `resolve` reports `ClientIdMismatch` and leaves no negative key behind.
+#[tokio::test]
+async fn cimd_validation_failure_not_negatively_cached() -> TestResult {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let mut conn = RedisConn::Single(manager);
+  let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
+  let url = "https://client.invalid/neg-nocache.json";
+  let wrong = parse(document_value(
+    "https://other.invalid/cimd.json",
+    &["https://other.invalid/cb"],
+  ))?;
+  let key = cache_key(&prefix, url);
+  put_cached(&mut conn, &key, &wrong)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("cache write: {error}")))?;
+  let enabled = scopes();
+  match resolve(
+    &mut conn,
+    &prefix,
+    url,
+    &CimdValidation {
+      enabled_scopes: &enabled,
+      allow_localhost: false,
+    },
+    CimdLimits {
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(1),
+    },
+  )
+  .await
+  {
+    Err(CimdError::ClientIdMismatch) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "mismatched cache hit must be ClientIdMismatch, got {other}"
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "mismatched cached document was accepted".to_owned(),
+      ));
+    }
+  }
+  let neg = negative_cache_key(&prefix, url);
+  let present: bool = redis::cmd("EXISTS")
+    .arg(&neg)
+    .query_async(&mut conn)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("negative read: {error}")))?;
+  if present {
+    return Err(TestError::Unexpected(
+      "validation failure must not be negatively cached".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// The single-flight lock is exclusive, ignores a release with the wrong
+/// token, and can be reacquired after the owner releases it.
+#[tokio::test]
+async fn cimd_single_flight_lock_is_exclusive() -> TestResult {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let mut conn = RedisConn::Single(manager);
+  let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
+  let key = lock_key(&prefix, "https://client.invalid/lock.json");
+  let ttl = 3_000;
+  let Some(token) = acquire_lock(&mut conn, &key, ttl)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("acquire: {error}")))?
+  else {
+    return Err(TestError::Unexpected("first acquire must win".to_owned()));
+  };
+  if acquire_lock(&mut conn, &key, ttl)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("second acquire: {error}")))?
+    .is_some()
+  {
+    return Err(TestError::Unexpected(
+      "a held lock must not be acquired twice".to_owned(),
+    ));
+  }
+  release_lock(&mut conn, &key, "wrong-token")
+    .await
+    .map_err(|error| TestError::Unexpected(format!("wrong release: {error}")))?;
+  if acquire_lock(&mut conn, &key, ttl)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("reacquire: {error}")))?
+    .is_some()
+  {
+    return Err(TestError::Unexpected(
+      "a wrong-token release must not drop the lock".to_owned(),
+    ));
+  }
+  release_lock(&mut conn, &key, &token)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("owner release: {error}")))?;
+  let Some(next) = acquire_lock(&mut conn, &key, ttl)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("final acquire: {error}")))?
+  else {
+    return Err(TestError::Unexpected(
+      "the owner release must free the lock".to_owned(),
+    ));
+  };
+  release_lock(&mut conn, &key, &next)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("final release: {error}")))?;
+  Ok(())
+}
+
+/// Two concurrent cache misses for the same uncached URL both return the
+/// document published by the elected fetcher. The `.invalid` host proves
+/// neither waiter started a fetch of its own.
+#[tokio::test]
+async fn cimd_single_flight_waiters_share_one_fetch() -> TestResult {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let mut holder = RedisConn::Single(manager.clone());
+  let mut waiter_a = RedisConn::Single(manager.clone());
+  let mut waiter_b = RedisConn::Single(manager);
+  let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
+  let url = "https://client.invalid/single-flight.json";
+  let lock = lock_key(&prefix, url);
+  let token = acquire_lock(&mut holder, &lock, 3_000)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("lock: {error}")))?
+    .ok_or_else(|| TestError::Unexpected("lock must be free".to_owned()))?;
+  let key = cache_key(&prefix, url);
+  let document = parse(document_value(url, &["https://client.invalid/cb"]))?;
+  let enabled = scopes();
+  let validation = CimdValidation {
+    enabled_scopes: &enabled,
+    allow_localhost: false,
+  };
+  let limits = CimdLimits {
+    max_bytes: 5_120,
+    timeout: Duration::from_secs(1),
+  };
+  let a = resolve(&mut waiter_a, &prefix, url, &validation, limits);
+  let b = resolve(&mut waiter_b, &prefix, url, &validation, limits);
+  let publish = async {
+    tokio::time::sleep(Duration::from_millis(100)).await;
+    put_cached(&mut holder, &key, &document)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("cache write: {error}")))?;
+    release_lock(&mut holder, &lock, &token)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("release: {error}")))?;
+    Ok::<(), TestError>(())
+  };
+  let (a_result, b_result, published) = tokio::join!(a, b, publish);
+  published?;
+  for (label, result) in [("a", a_result), ("b", b_result)] {
+    match result {
+      Ok(validated) if validated.client_id == url => {}
+      Ok(validated) => {
+        return Err(TestError::Unexpected(format!(
+          "waiter {label} saw the wrong client: {}",
+          validated.client_id
+        )));
+      }
+      Err(error) => {
+        return Err(TestError::Unexpected(format!(
+          "waiter {label} rejected the published document: {error}"
+        )));
+      }
+    }
+  }
+  Ok(())
+}
+
+/// A waiter observes the elected fetcher's negative cache and fails with the
+/// same fetch error instead of starting a second fetch.
+#[tokio::test]
+async fn cimd_single_flight_waiter_observes_negative() -> TestResult {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let mut holder = RedisConn::Single(manager.clone());
+  let mut waiter = RedisConn::Single(manager);
+  let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
+  let url = "https://client.invalid/single-flight-neg.json";
+  let lock = lock_key(&prefix, url);
+  let token = acquire_lock(&mut holder, &lock, 3_000)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("lock: {error}")))?
+    .ok_or_else(|| TestError::Unexpected("lock must be free".to_owned()))?;
+  let neg = negative_cache_key(&prefix, url);
+  let enabled = scopes();
+  let validation = CimdValidation {
+    enabled_scopes: &enabled,
+    allow_localhost: false,
+  };
+  let limits = CimdLimits {
+    max_bytes: 5_120,
+    timeout: Duration::from_secs(1),
+  };
+  let waiting = resolve(&mut waiter, &prefix, url, &validation, limits);
+  let publish = async {
+    tokio::time::sleep(Duration::from_millis(100)).await;
+    redis::cmd("SET")
+      .arg(&neg)
+      .arg("1")
+      .arg("EX")
+      .arg(30)
+      .query_async::<()>(&mut holder)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("negative seed: {error}")))?;
+    release_lock(&mut holder, &lock, &token)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("release: {error}")))?;
+    Ok::<(), TestError>(())
+  };
+  let (result, published) = tokio::join!(waiting, publish);
+  published?;
+  match result {
+    Err(CimdError::Fetch) => Ok(()),
+    Err(other) => Err(TestError::Unexpected(format!(
+      "waiter must observe the elected fetch failure, got {other}"
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "waiter accepted a token the elected fetcher rejected".to_owned(),
+    )),
+  }
+}
+
+/// A fresh-fetch validation failure is short-cached under `cimd:invalid:`: a
+/// later resolve for the same URL fails fast with `Fetch` without a network
+/// fetch, and the marker TTL is the 2-second completion window.
+#[tokio::test]
+async fn cimd_invalid_cache_blocks_fetch() -> TestResult {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let mut conn = RedisConn::Single(manager);
+  let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
+  let url = "https://client.invalid/invalid-cached.json";
+  let key = invalid_cache_key(&prefix, url);
+  if !key.starts_with(&prefix) || !key.contains("cimd:invalid:") || key.contains("client.invalid") {
+    return Err(TestError::Unexpected(format!("bad invalid key {key}")));
+  }
+  redis::cmd("SET")
+    .arg(&key)
+    .arg("1")
+    .arg("EX")
+    .arg(2)
+    .query_async::<()>(&mut conn)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("invalid seed: {error}")))?;
+  let enabled = scopes();
+  match resolve(
+    &mut conn,
+    &prefix,
+    url,
+    &CimdValidation {
+      enabled_scopes: &enabled,
+      allow_localhost: false,
+    },
+    CimdLimits {
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(1),
+    },
+  )
+  .await
+  {
+    Err(CimdError::Fetch) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "invalid-cache hit must be Fetch, got {other}"
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "invalid-cache hit fetched or accepted a document".to_owned(),
+      ));
+    }
+  }
+  let ttl: i64 = redis::cmd("TTL")
+    .arg(&key)
+    .query_async(&mut conn)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("invalid ttl: {error}")))?;
+  if ttl <= 0 || ttl > 2 {
+    return Err(TestError::Unexpected(format!(
+      "invalid key must carry a 2 s TTL, got {ttl}"
+    )));
+  }
+  Ok(())
+}

+ 63 - 0
src/protocol/cimd/tests_principal.rs

@@ -1161,3 +1161,66 @@ async fn cimd_fetch_failure_maps_fail_closed() -> TestResult {
     ))),
   }
 }
+
+/// A disabled URL-shaped row is dead: `resolve_client` returns `Ok(None)` —
+/// the caller keeps its unknown-client error — and never falls through to a
+/// CIMD fetch (CIMD enabled, no cache seed; `.invalid` could never fetch).
+#[tokio::test]
+async fn cimd_inactive_url_row_never_fetches() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("inactiverow");
+  let params =
+    Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::Unexpected(format!("{error:?}")))?;
+  let secret_hash = hash_secret("row-secret", &params)
+    .map_err(|error| TestError::Unexpected(format!("{error:?}")))?;
+  client_repo::insert(
+    &h.pool,
+    &h.ring,
+    false,
+    &NewClient {
+      id: uuid::Uuid::new_v4(),
+      client_id: url.clone(),
+      client_secret_hash: Some(secret_hash),
+      client_secret_expires_at: None,
+      client_name: "inactive row".to_owned(),
+      grant_types: vec!["authorization_code".to_owned()],
+      response_types: vec!["code".to_owned()],
+      scope: "api:read".to_owned(),
+      token_endpoint_auth_method: TokenEndpointAuthMethod::ClientSecretPost
+        .as_str()
+        .to_owned(),
+      jwks: None,
+      jwks_uri: None,
+      tls_client_auth_subject_dn: None,
+      tls_client_auth_cert_thumbprint: None,
+      request_uris: None,
+      contacts: None,
+      software_id: None,
+      dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
+      registration_access_token_hash: None,
+    },
+  )
+  .await?;
+  if !client_repo::deactivate(&h.pool, &url).await? {
+    return Err(TestError::Unexpected(
+      "deactivate reported no change".to_owned(),
+    ));
+  }
+  let token_prefix = h.token_prefix.clone();
+  let settings = CimdSettings {
+    token_prefix: &token_prefix,
+    max_bytes: 5_120,
+    timeout: Duration::from_secs(2),
+    allow_localhost: false,
+  };
+  match resolve_client(&h.pool, &h.ring, &mut h.conn, Some(settings), &url).await {
+    Ok(None) => Ok(()),
+    Ok(Some(_)) => Err(TestError::Unexpected(
+      "inactive row must not resolve".to_owned(),
+    )),
+    Err(error) => Err(TestError::Unexpected(format!(
+      "inactive row must be Ok(None) without fetching, got {error}"
+    ))),
+  }
+}

+ 12 - 10
src/protocol/dcr/manage/revoke.rs

@@ -68,16 +68,18 @@ pub async fn revoke_client_tokens(
   for key in access_keys {
     let fields = read_fields(conn, &key).await?;
     if is_owned(&fields, client_id) {
-      if let (Some(hash), Some(grant)) = (
-        key.strip_prefix(&store.access_key_prefix()),
-        fields.get("grant_id"),
-      ) {
-        let _: () = redis::cmd("SREM")
-          .arg(store.grant_tokens_key(grant))
-          .arg(hash)
-          .query_async(conn)
-          .await
-          .map_err(store_mapped)?;
+      if let Some(hash) = key.strip_prefix(&store.access_key_prefix()) {
+        if let Some(jti_hash) = hash.strip_prefix("jwt:") {
+          del_key(conn, &store.jwt_marker_key(jti_hash)).await?;
+        }
+        if let Some(grant) = fields.get("grant_id") {
+          let _: () = redis::cmd("SREM")
+            .arg(store.grant_tokens_key(grant))
+            .arg(hash)
+            .query_async(conn)
+            .await
+            .map_err(store_mapped)?;
+        }
       }
       del_key(conn, &key).await?;
       summary.access += 1;

+ 112 - 0
src/protocol/dcr/manage/tests_delete.rs

@@ -1,6 +1,7 @@
 //! `test(dcr_manage)` delete suite: cross-client denial, DELETE, revocation,
 //! and Redis-loss survival.
 
+use secrecy::SecretString;
 use testcontainers::{
   GenericImage, ImageExt as _,
   core::{IntoContainerPort, WaitFor},
@@ -14,8 +15,15 @@ use super::support::{
   Harness, TestError, TestResult, expect_app_error, expect_forbidden, expect_unauthorized, harness,
   harness_with_redis, manage_parts, mtls_request, register, register_mtls, try_register,
 };
+use crate::config::{JwtSigningAlg, JwtSigningKey};
 use crate::db::client_repo::{self, NewClient};
 use crate::error::OAuthErrorCode;
+use crate::jose::jwt_access::JwtAccessSigner;
+use crate::keys::KeyId;
+use crate::protocol::scope::ScopeSet;
+use crate::protocol::token::grants::client_credentials::{
+  ClientCredentialsInput, issue_client_credentials,
+};
 use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService};
 
 #[tokio::test]
@@ -166,6 +174,7 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
           dpop_required: false,
           subject: String::new(),
           scope: String::new(),
+          access_scope: String::new(),
           grant_id: Uuid::new_v4().to_string(),
           proof: SenderProof::none(),
           aud: Vec::new(),
@@ -189,6 +198,7 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
         dpop_required: false,
         subject: String::new(),
         scope: String::new(),
+        access_scope: String::new(),
         grant_id: Uuid::new_v4().to_string(),
         proof: SenderProof::none(),
         aud: Vec::new(),
@@ -237,6 +247,108 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
   Ok(())
 }
 
+/// RFC 7592 DELETE must also remove the JWT allowlist marker. A
+/// client-credentials token has no refresh half (the grant revokes it before
+/// returning), so the access scan is the only path that can delete its
+/// `jwt:<jti-hash>` marker.
+#[tokio::test]
+async fn dcr_manage_delete_removes_jwt_marker() -> TestResult {
+  let mut harness = harness("catalog").await?;
+  let registered = register(&mut harness, "https://app.example.test/cb", None).await?;
+  let raw_key = SecretString::from("bcdefghijklmnopqrstuvwxyzabcdefg");
+  let key = JwtSigningKey::from_raw(
+    KeyId::new("test-kid"),
+    JwtSigningAlg::Es256,
+    &raw_key,
+    "jwt_signing_key",
+  )
+  .map_err(|error| TestError::Unexpected(format!("signing key rejected: {error}")))?;
+  let signer = JwtAccessSigner::new(harness.config.issuer.clone(), key, Vec::new());
+  let service = TokenService::new(&harness.config.ttl, &harness.config.keys)
+    .map_err(|error| TestError::Unexpected(format!("token service: {error}")))?
+    .with_jwt_signer(signer);
+  let empty = ScopeSet::empty();
+  let proof = SenderProof::none();
+  let aud = vec!["https://api.example.test/".to_owned()];
+  let issued = issue_client_credentials(
+    &mut harness.conn,
+    &harness.store,
+    &service,
+    &ClientCredentialsInput {
+      client_id: &registered.client_id,
+      client_kind: ClientKind::Confidential,
+      client_registered: &empty,
+      enabled: &empty,
+      requested_scope: None,
+      dpop_bound_jkt: None,
+      mtls_bound_x5t_s256: None,
+      proof: &proof,
+      aud: &aud,
+      token_format: TokenFormat::Jwt,
+      now_unix: time::OffsetDateTime::now_utc().unix_timestamp(),
+    },
+  )
+  .await
+  .map_err(|error| TestError::Unexpected(format!("client-credentials mint failed: {error}")))?;
+  let jti_hash = issued
+    .access_hash
+    .strip_prefix("jwt:")
+    .ok_or_else(|| TestError::Unexpected("jwt mint did not use a jwt: member".to_owned()))?
+    .to_owned();
+  let marker_key = harness.store.jwt_marker_key(&jti_hash);
+  let marker_before: bool = redis::cmd("EXISTS")
+    .arg(&marker_key)
+    .query_async(&mut harness.conn)
+    .await?;
+  if !marker_before {
+    return Err(TestError::Unexpected(
+      "jwt marker missing before delete".to_owned(),
+    ));
+  }
+
+  {
+    let (mut stores, ctx) = manage_parts(&mut harness);
+    delete_client(
+      &mut stores,
+      &ctx,
+      &registered.client_id,
+      Some(&registered.reg_token),
+    )
+    .await
+    .map_err(|error| TestError::Unexpected(format!("delete failed: {error:?}")))?;
+  }
+
+  let marker_after: bool = redis::cmd("EXISTS")
+    .arg(&marker_key)
+    .query_async(&mut harness.conn)
+    .await?;
+  if marker_after {
+    return Err(TestError::Unexpected(
+      "deleted client's jwt marker survived".to_owned(),
+    ));
+  }
+  let access =
+    crate::store::tokens::get_access(&mut harness.conn, &harness.store, &issued.access_hash)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("lookup failed: {error}")))?;
+  if access.is_some() {
+    return Err(TestError::Unexpected(
+      "deleted client's jwt access record survived".to_owned(),
+    ));
+  }
+  let member: bool = redis::cmd("SISMEMBER")
+    .arg(harness.store.grant_tokens_key(&issued.grant_id))
+    .arg(&issued.access_hash)
+    .query_async(&mut harness.conn)
+    .await?;
+  if member {
+    return Err(TestError::Unexpected(
+      "deleted client's jwt grant membership survived".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn dcr_manage_delete_token_denials_match_read() -> TestResult {
   // RFC 7592 §2.3: DELETE is authenticated by the registration access token.

+ 21 - 17
src/protocol/device.rs

@@ -24,7 +24,7 @@ mod tests;
 
 use std::time::Duration;
 
-use axum::{Extension, extract::State, http::HeaderMap};
+use axum::{Extension, extract::RawForm, extract::State, http::HeaderMap};
 use rand::{TryRng as _, rngs::SysRng};
 use secrecy::{ExposeSecret as _, SecretString};
 use serde::{Deserialize, Serialize};
@@ -507,20 +507,31 @@ async fn put_binding(
 
 /// Axum handler for `POST /device_authorization` (wired by task 40).
 ///
-/// Thin glue: `Bytes` + a manual parse replaces `Form<...>` (the extractor
+/// Thin glue: `RawForm` + a manual parse replaces `Form<...>` (the extractor
 /// consumed the raw body, hiding repeats), then delegates to
-/// [`authorize_device`]. The explicit content-type gate preserves the 415
-/// `Form` rendered (`Bytes` never fails on content-type), a malformed body
-/// or a repeated known field is the same 400, and the repeated `resource`
-/// indicator (RFC 8707) is captured in order by
-/// `params::repeated_values`.
+/// [`authorize_device`]. `RawForm` rejects a wrong content type with 415
+/// before buffering the body; the explicit exact-MIME gate below is the
+/// second check for the prefix-match case
+/// (`application/x-www-form-urlencodedevil`). A malformed (non-UTF-8) body
+/// is 400 plain text (deliberately stricter than the old `Form`, which
+/// lossily accepted raw non-UTF-8 bytes); a repeated known field is JSON
+/// `invalid_request` (the old `Form` returned 422 `Failed to deserialize
+/// form body`, so the new JSON 400 is the intended contract, not parity).
+/// The repeated `resource` indicator (RFC 8707) is captured in order by
+/// `params::values_of`.
 pub async fn handler(
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
-  body: axum::body::Bytes,
+  body: RawForm,
 ) -> axum::response::Response {
   use axum::response::IntoResponse as _;
+  // `RawForm` gates the content type BEFORE `Bytes` buffering, so a wrong
+  // content type is rejected 415 without reading the (up to 2 MiB) body.
+  // The explicit exact-MIME gate below is the second check: `RawForm` uses a
+  // case-sensitive prefix match, so it accepts e.g.
+  // `application/x-www-form-urlencodedevil`, which the exact gate rejects.
+  let body = body.0;
   if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
     return reject.into_response();
   }
@@ -529,10 +540,7 @@ pub async fn handler(
     Err(reject) => return reject.into_response(),
   };
   if crate::protocol::params::has_duplicate_field(&pairs, &DeviceAuthorizationForm::KNOWN_FIELDS) {
-    return (
-      axum::http::StatusCode::BAD_REQUEST,
-      "Failed to deserialize form",
-    )
+    return AppError::invalid_request("the request body includes a parameter more than once")
       .into_response();
   }
   let map = crate::protocol::params::single_valued_map(&pairs);
@@ -543,12 +551,8 @@ pub async fn handler(
     )
       .into_response();
   };
-  let text = match crate::protocol::params::form_text(&body) {
-    Ok(text) => text,
-    Err(reject) => return reject.into_response(),
-  };
   let resources =
-    crate::protocol::params::repeated_values(text, crate::protocol::params::RESOURCE_PARAM);
+    crate::protocol::params::values_of(&pairs, crate::protocol::params::RESOURCE_PARAM);
   let dpop_proof = headers.get("dpop").and_then(|value| value.to_str().ok());
   let basic_header = headers
     .get(axum::http::header::AUTHORIZATION)

+ 35 - 21
src/protocol/introspect.rs

@@ -21,7 +21,7 @@ mod tests;
 
 use std::time::Duration;
 
-use axum::{Extension, extract::State, http::HeaderMap};
+use axum::{Extension, extract::RawForm, extract::State, http::HeaderMap};
 use secrecy::SecretString;
 use serde::{Deserialize, Serialize};
 use sha2::{Digest as _, Sha256};
@@ -46,6 +46,8 @@ use crate::{
 pub const REFRESH_TOKEN_HINT: &str = "refresh_token";
 /// `token_type` reported for an active access token (RFC 6749 §5.1).
 pub const BEARER: &str = "Bearer";
+/// `token_type` reported for a DPoP-bound access token (RFC 9449 §6.2).
+pub const DPOP: &str = "DPoP";
 
 /// Form fields accepted at `POST /introspect` (RFC 7662 §2.1).
 #[derive(Debug, Deserialize)]
@@ -409,11 +411,16 @@ async fn introspect_jwt(
   if !inputs.resource.is_empty() && !audience_includes(&claims.aud, inputs.resource) {
     return Ok(IntrospectionResponse::inactive());
   }
+  let token_type = if claims.cnf.as_ref().is_some_and(|cnf| cnf.jkt.is_some()) {
+    DPOP
+  } else {
+    BEARER
+  };
   Ok(IntrospectionResponse {
     active: true,
     scope: Some(claims.scope),
     client_id: Some(claims.client_id),
-    token_type: Some(BEARER.to_owned()),
+    token_type: Some(token_type.to_owned()),
     exp: Some(claims.exp),
     iat: Some(claims.iat),
     sub: (!claims.sub.is_empty()).then_some(claims.sub),
@@ -527,16 +534,14 @@ async fn active_response(
   ctx: &IntrospectContext<'_>,
   located: Located,
 ) -> Result<IntrospectionResponse, AppError> {
-  let (key, ttl, token_type) = match located.kind {
+  let (key, ttl) = match located.kind {
     TokenKind::Access => (
       stores.store.access_key(&located.hash),
       ctx.service.access_ttl(),
-      Some(BEARER.to_owned()),
     ),
     TokenKind::Refresh => (
       stores.store.refresh_key(&located.hash),
       ctx.service.refresh_ttl(),
-      None,
     ),
   };
   let (iat, exp) = token_window(stores.conn, &key, ttl, ctx.now_unix).await?;
@@ -546,6 +551,11 @@ async fn active_response(
     None
   };
   let cnf = Cnf::parse_storage(&located.cnf).map_err(AppError::from)?;
+  let token_type = match located.kind {
+    TokenKind::Access if cnf.jkt.is_some() => Some(DPOP.to_owned()),
+    TokenKind::Access => Some(BEARER.to_owned()),
+    TokenKind::Refresh => None,
+  };
   Ok(IntrospectionResponse {
     active: true,
     scope: Some(located.scope),
@@ -647,12 +657,17 @@ pub async fn read_act(
 
 /// Axum handler for `POST /introspect` (wired by task 40).
 ///
-/// Thin glue: `Bytes` + a manual parse replaces `Form<IntrospectForm>` (the
-/// extractor consumed the raw body, hiding repeats), then builds the token
-/// service and delegates to [`introspect`]. The explicit content-type gate
-/// preserves the 415 `Form` rendered (`Bytes` never fails on
-/// content-type), a malformed body or a repeated known field is the same
-/// 400, and a present `resource` indicator (RFC 8707) validates fail-closed
+/// Thin glue: `RawForm` + a manual parse replaces `Form<IntrospectForm>`
+/// (the extractor consumed the raw body, hiding repeats), then builds the
+/// token service and delegates to [`introspect`]. `RawForm` rejects a wrong
+/// content type with 415 before buffering the body; the explicit exact-MIME
+/// gate below is the second check for the prefix-match case
+/// (`application/x-www-form-urlencodedevil`). A malformed (non-UTF-8) body
+/// is 400 plain text (deliberately stricter than the old `Form`, which
+/// lossily accepted raw non-UTF-8 bytes); a repeated known field is JSON
+/// `invalid_request` (the old `Form` returned 422 `Failed to deserialize
+/// form body`, so the new JSON 400 is the intended contract, not parity).
+/// A present `resource` indicator (RFC 8707) validates fail-closed
 /// (`invalid_target`) before any lookup runs.
 ///
 /// RFC 7662 defines no `resource` parameter. This endpoint accepts it as a
@@ -664,9 +679,15 @@ pub async fn handler(
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
-  body: axum::body::Bytes,
+  body: RawForm,
 ) -> axum::response::Response {
   use axum::response::IntoResponse as _;
+  // `RawForm` gates the content type BEFORE `Bytes` buffering, so a wrong
+  // content type is rejected 415 without reading the (up to 2 MiB) body.
+  // The explicit exact-MIME gate below is the second check: `RawForm` uses a
+  // case-sensitive prefix match, so it accepts e.g.
+  // `application/x-www-form-urlencodedevil`, which the exact gate rejects.
+  let body = body.0;
   if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
     return reject.into_response();
   }
@@ -675,20 +696,13 @@ pub async fn handler(
     Err(reject) => return reject.into_response(),
   };
   if crate::protocol::params::has_duplicate_field(&pairs, &IntrospectForm::KNOWN_FIELDS) {
-    return (
-      axum::http::StatusCode::BAD_REQUEST,
-      "Failed to deserialize form",
-    )
+    return AppError::invalid_request("the request body includes a parameter more than once")
       .into_response();
   }
   let map = crate::protocol::params::single_valued_map(&pairs);
   let form = IntrospectForm::from_map(&map);
-  let text = match crate::protocol::params::form_text(&body) {
-    Ok(text) => text,
-    Err(reject) => return reject.into_response(),
-  };
   let resources =
-    crate::protocol::params::repeated_values(text, crate::protocol::params::RESOURCE_PARAM);
+    crate::protocol::params::values_of(&pairs, crate::protocol::params::RESOURCE_PARAM);
   let resources = match crate::protocol::params::validate_resources(
     &state.pool,
     state.config.resource_indicators_enabled,

+ 8 - 2
src/protocol/introspect/tests.rs

@@ -168,6 +168,7 @@ async fn mint_bound(
           dpop_required: false,
           subject: "user-1".to_owned(),
           scope: "orders:read".to_owned(),
+          access_scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           proof,
           aud: Vec::new(),
@@ -346,7 +347,7 @@ async fn test_introspect_active_access_token_reports_shape() -> TestResult {
   assert!(response.active, "minted token must be active");
   assert_eq!(response.scope.as_deref(), Some("orders:read"));
   assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
-  assert_eq!(response.token_type.as_deref(), Some("Bearer"));
+  assert_eq!(response.token_type.as_deref(), Some("DPoP"));
   assert_eq!(response.sub.as_deref(), Some("user-1"));
   let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
   assert_eq!(cnf.jkt.as_deref(), Some("jkt-thumbnail"));
@@ -619,6 +620,8 @@ async fn test_introspect_dpop_bound_token_reports_jkt_only() -> TestResult {
   let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
   assert_eq!(cnf.jkt.as_deref(), Some("jkt-dpop-only"));
   assert_eq!(cnf.x5t_s256, None, "no mTLS binding was proven");
+  // RFC 9449 §6.2: a DPoP-bound access token introspects as `DPoP`.
+  assert_eq!(response.token_type.as_deref(), Some("DPoP"));
   let json = serde_json::to_value(&response)?;
   let cnf_json = json
     .get("cnf")
@@ -898,6 +901,7 @@ async fn mint_audience_subject(
           dpop_required: false,
           subject: subject.to_owned(),
           scope: "orders:read".to_owned(),
+          access_scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           proof: SenderProof::none(),
           aud: aud.clone(),
@@ -928,6 +932,7 @@ async fn mint_subject(
           dpop_required: false,
           subject: subject.to_owned(),
           scope: "orders:read".to_owned(),
+          access_scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           proof: SenderProof::none(),
           aud: Vec::new(),
@@ -1355,6 +1360,7 @@ async fn mint_jwt(
         dpop_required: false,
         subject: subject.to_owned(),
         scope: "orders:read".to_owned(),
+        access_scope: "orders:read".to_owned(),
         grant_id: unique("grant"),
         proof,
         aud: aud.clone(),
@@ -1460,7 +1466,7 @@ async fn test_introspect_dual_path() -> TestResult {
   assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
   assert_eq!(response.scope.as_deref(), Some("orders:read"));
   assert_eq!(response.sub.as_deref(), Some("jwt-subject-1"));
-  assert_eq!(response.token_type.as_deref(), Some("Bearer"));
+  assert_eq!(response.token_type.as_deref(), Some("DPoP"));
   assert_eq!(
     response.aud,
     Some(vec![audience_a.to_owned(), audience_b.to_owned()])

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

@@ -60,6 +60,7 @@ fn test_config_protected(ids: &[&str]) -> Result<Config, ConfigError> {
     "totp_encryption_key": "metadata-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "metadata-test-pii-key-0123456789abcdef",
     "protected_resources_enabled": true,
+    "resource_indicators_enabled": true,
     "protected_resource_ids": ids,
   });
   let source = ::config::Config::try_from(&fixture)?;

+ 14 - 12
src/protocol/par.rs

@@ -10,7 +10,7 @@
 use std::{collections::HashMap, time::Duration};
 
 use axum::{
-  extract::State,
+  extract::{RawForm, State},
   http::{HeaderMap, StatusCode},
 };
 use secrecy::SecretString;
@@ -256,15 +256,21 @@ async fn validate_request_rules(
 pub async fn par_handler(
   State(state): State<AppState>,
   headers: HeaderMap,
-  body: axum::body::Bytes,
+  body: RawForm,
 ) -> axum::response::Response {
   use axum::response::IntoResponse as _;
-  // `Bytes` + a manual parse replaces `Form<HashMap>`: the extractor
+  // `RawForm` gates the content type BEFORE `Bytes` buffering, so a wrong
+  // content type is rejected 415 without reading the (up to 2 MiB) body.
+  // The explicit exact-MIME gate below is the second check: `RawForm` uses a
+  // case-sensitive prefix match, so it accepts e.g.
+  // `application/x-www-form-urlencodedevil`, which the exact gate rejects.
+  let body = body.0;
+  // `RawForm` + a manual parse replaces `Form<HashMap>`: the extractor
   // consumed the raw body, hiding repeats — the repeated `resource`
-  // indicator (RFC 8707) is captured in order by `params::repeated_values`
-  // while the single-valued map keeps last-wins absorption. The explicit
-  // content-type gate preserves the 415 `Form` rendered (`Bytes` never
-  // fails on content-type), and a malformed body is the same 400.
+  // indicator (RFC 8707) is captured in order by `params::values_of`
+  // while the single-valued map keeps last-wins absorption. A malformed
+  // body is the same 400 (deliberately stricter than the old `Form`, which
+  // lossily accepted raw non-UTF-8 bytes).
   if let Err(reject) = super::params::require_form_content_type(&headers) {
     return reject.into_response();
   }
@@ -273,11 +279,7 @@ pub async fn par_handler(
     Err(reject) => return reject.into_response(),
   };
   let form = super::params::single_valued_map(&pairs);
-  let text = match super::params::form_text(&body) {
-    Ok(text) => text,
-    Err(reject) => return reject.into_response(),
-  };
-  let resources = super::params::repeated_values(text, super::params::RESOURCE_PARAM);
+  let resources = super::params::values_of(&pairs, super::params::RESOURCE_PARAM);
   let header_value = headers
     .get(axum::http::header::AUTHORIZATION)
     .and_then(|value| value.to_str().ok());

+ 42 - 19
src/protocol/params.rs

@@ -12,8 +12,8 @@
 //!
 //! - [`parse_pairs`] / [`single_valued_map`] — the strict urlencoded parse
 //!   (last-wins, mirroring `serde_urlencoded`'s `HashMap` absorption).
-//! - [`repeated_values`] — the ONE helper capturing repeated `resource`
-//!   values in order from a raw query/body string.
+//! - [`values_of`] — the ONE helper capturing repeated `resource` values
+//!   in wire order from already-parsed pairs.
 //! - [`require_form_content_type`] / [`form_pairs`] / [`query_pairs`] — the
 //!   transport contract the `Form`/`Query` extractors used to enforce
 //!   (415 on a non-urlencoded body, 400 on malformed input), so the
@@ -54,6 +54,15 @@ use crate::{db::resource_repo, error::AppError};
 /// RFC 8707 indicator parameter name.
 pub const RESOURCE_PARAM: &str = "resource";
 
+/// Hard cap on urlencoded pairs per body/query, bounding the allocation
+/// amplification of a many-pair body (a 2 MiB body of repeated pairs
+/// otherwise allocates ~170 MB of `String` pairs).
+pub const MAX_FORM_PAIRS: usize = 1024;
+
+/// Hard cap on raw `resource` values per request, enforced fail-closed with
+/// `invalid_target` before any catalog lookup.
+pub const MAX_RESOURCE_VALUES: usize = 32;
+
 /// Urlencoded parse failure.
 ///
 /// Decoding itself mirrors `serde_urlencoded` and is infallible (a bare `%`
@@ -146,6 +155,9 @@ pub fn parse_pairs(raw: &str) -> Result<Vec<(String, String)>, ParamsError> {
       None => (segment, ""),
     };
     pairs.push((decode_component(name), decode_component(value)));
+    if pairs.len() > MAX_FORM_PAIRS {
+      return Err(ParamsError);
+    }
   }
   Ok(pairs)
 }
@@ -164,22 +176,18 @@ pub fn single_valued_map(pairs: &[(String, String)]) -> HashMap<String, String>
   map
 }
 
-/// Capture every `name` value in order from a raw query/body string.
+/// Capture every `name` value in wire order from already-parsed pairs.
 ///
 /// This is the ONE helper for the query/body endpoints (JAR arrays are
 /// handled out-of-band by the merge carrier, never by this function).
-/// Infallible by design: handlers run the strict [`parse_pairs`] first for
-/// error mapping and only call this on input that already parsed, so an
-/// undecodable segment is skipped as unreachable rather than erroring.
+/// Handlers pass the [`parse_pairs`] output they already hold, so the raw
+/// input is never parsed twice.
 #[must_use]
-pub fn repeated_values(raw: &str, name: &str) -> Vec<String> {
-  match parse_pairs(raw) {
-    Ok(pairs) => pairs
-      .into_iter()
-      .filter_map(|(key, value)| (key == name).then_some(value))
-      .collect(),
-    Err(ParamsError) => Vec::new(),
-  }
+pub fn values_of(pairs: &[(String, String)], name: &str) -> Vec<String> {
+  pairs
+    .iter()
+    .filter_map(|(key, value)| (key == name).then_some(value.clone()))
+    .collect()
 }
 
 /// Whether any KNOWN field repeats — mirrors serde's duplicate-field
@@ -191,7 +199,7 @@ pub fn repeated_values(raw: &str, name: &str) -> Vec<String> {
 /// absorbed here, exactly as `serde_urlencoded` absorbed it.
 #[must_use]
 pub fn has_duplicate_field(pairs: &[(String, String)], known: &[&str]) -> bool {
-  let mut seen: HashSet<&str> = HashSet::with_capacity(pairs.len());
+  let mut seen: HashSet<&str> = HashSet::with_capacity(known.len());
   for (name, _) in pairs {
     if known.contains(&name.as_str()) && !seen.insert(name.as_str()) {
       return true;
@@ -236,8 +244,8 @@ pub fn require_form_content_type(headers: &HeaderMap) -> Result<(), TransportRej
 /// Borrow a request body as text, mirroring `Form`'s 400
 /// (`FailedToDeserializeForm`) on non-UTF-8 input.
 ///
-/// Handlers use this before [`parse_pairs`] + [`repeated_values`] so the
-/// raw text stays available for the repeated-value capture.
+/// Handlers use this before [`parse_pairs`] + [`values_of`] so the raw
+/// text stays available for the repeated-value capture.
 ///
 /// # Errors
 ///
@@ -295,13 +303,28 @@ pub async fn validate_resources(
   if values.is_empty() {
     return Ok(Vec::new());
   }
+  if values.len() > MAX_RESOURCE_VALUES {
+    return Err(AppError::invalid_target("too many resource values"));
+  }
+  if values
+    .iter()
+    .any(|value| value.chars().any(|c| c.is_ascii_control()))
+  {
+    return Err(AppError::invalid_target(
+      "resource must not contain control characters",
+    ));
+  }
   if !enabled {
     return Err(AppError::invalid_target(
       "resource indicators are not enabled",
     ));
   }
-  let mut aud = Vec::with_capacity(values.len());
+  let mut distinct: HashSet<&str> = HashSet::with_capacity(values.len());
   for value in values {
+    distinct.insert(value.as_str());
+  }
+  let mut aud = Vec::with_capacity(distinct.len());
+  for value in distinct {
     let absolute_https = reqwest::Url::parse(value)
       .ok()
       .filter(|url| url.scheme() == "https" && url.has_host())
@@ -317,7 +340,7 @@ pub async fn validate_resources(
         "unknown or disabled resource server",
       ));
     }
-    aud.push(value.clone());
+    aud.push(value.to_owned());
   }
   aud.sort();
   aud.dedup();

+ 109 - 3
src/protocol/params/tests.rs

@@ -2,8 +2,9 @@
 //! transport-contract preservation, and fail-closed resource validation.
 
 use super::{
-  RESOURCE_PARAM, form_pairs, has_duplicate_field, parse_pairs, query_pairs, repeated_values,
-  require_form_content_type, single_valued_map, validate_resources,
+  MAX_FORM_PAIRS, MAX_RESOURCE_VALUES, RESOURCE_PARAM, form_pairs, has_duplicate_field,
+  parse_pairs, query_pairs, require_form_content_type, single_valued_map, validate_resources,
+  values_of,
 };
 use crate::{db::resource_repo, error::OAuthErrorCode};
 
@@ -14,7 +15,8 @@ type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
 #[test]
 fn params_repeated_resource_is_captured_in_order() -> TestResult {
   let raw = "client_id=web&resource=https%3A%2F%2Fapi.example.test%2Fa&scope=read&resource=https%3A%2F%2Fapi.example.test%2Fb";
-  let values = repeated_values(raw, RESOURCE_PARAM);
+  let pairs = parse_pairs(raw)?;
+  let values = values_of(&pairs, RESOURCE_PARAM);
   if values
     != vec![
       "https://api.example.test/a".to_owned(),
@@ -26,6 +28,110 @@ fn params_repeated_resource_is_captured_in_order() -> TestResult {
   Ok(())
 }
 
+/// A body with more than `MAX_FORM_PAIRS` pairs is rejected, bounding the
+/// ~170 MB amplification of a 2 MiB repeated-pair body.
+#[test]
+fn params_form_pair_cap_rejects_oversized_body() -> TestResult {
+  let mut raw = String::new();
+  for index in 0..=MAX_FORM_PAIRS {
+    if index > 0 {
+      raw.push('&');
+    }
+    raw.push_str("a=1");
+  }
+  match parse_pairs(&raw) {
+    Err(_) => Ok(()),
+    Ok(_) => Err("oversized pair body was silently accepted".into()),
+  }
+}
+
+/// More than `MAX_RESOURCE_VALUES` raw values are `invalid_target`.
+#[tokio::test]
+async fn params_resource_value_cap_rejects_oversized_set() -> TestResult {
+  let (_db, pool) = crate::db::test_support::migrated_pool().await?;
+  let values: Vec<String> = (0..=MAX_RESOURCE_VALUES)
+    .map(|index| format!("https://api.example.test/{index}"))
+    .collect();
+  match validate_resources(&pool, true, &values).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidTarget => Ok(()),
+    Err(error) => Err(format!("wrong code for oversized resource set: {}", error.code()).into()),
+    Ok(_) => Err("oversized resource set was accepted".into()),
+  }
+}
+
+/// Duplicate resource values collapse to one sorted output with a single
+/// catalog lookup per distinct value.
+#[tokio::test]
+async fn params_duplicate_resources_deduped_before_catalog() -> TestResult {
+  let (_db, pool) = crate::db::test_support::migrated_pool().await?;
+  resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
+  resource_repo::upsert(&pool, "https://a.example.test/", "a").await?;
+  let aud = validate_resources(
+    &pool,
+    true,
+    &[
+      "https://b.example.test/".to_owned(),
+      "https://a.example.test/".to_owned(),
+      "https://b.example.test/".to_owned(),
+      "https://a.example.test/".to_owned(),
+    ],
+  )
+  .await?;
+  if aud
+    != vec![
+      "https://a.example.test/".to_owned(),
+      "https://b.example.test/".to_owned(),
+    ]
+  {
+    return Err(format!("duplicates must collapse to one sorted output: {aud:?}").into());
+  }
+  Ok(())
+}
+
+/// A `%00`/NUL resource is `invalid_target`, never a database error or 500.
+#[tokio::test]
+async fn params_nul_resource_is_invalid_target() -> TestResult {
+  let (_db, pool) = crate::db::test_support::migrated_pool().await?;
+  for value in [
+    "https://api.example.test/\0",
+    "https://api.example.test/\x01x",
+  ] {
+    match validate_resources(&pool, true, &[value.to_owned()]).await {
+      Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
+      Err(error) => {
+        return Err(format!("wrong code for control-char resource: {}", error.code()).into());
+      }
+      Ok(_) => return Err(format!("control-char resource accepted: {value:?}").into()),
+    }
+  }
+  Ok(())
+}
+
+/// Repeated-resource capture from pre-parsed pairs matches the old behavior:
+/// wire order, `+` as space, invalid `%` literal, bare name empty, empty
+/// segments skipped, `;` not a separator.
+#[test]
+fn params_values_of_matches_wire_order() -> TestResult {
+  let pairs = parse_pairs(
+    "resource=a+b&resource=%ZZ&empty&resource=x%3By&;resource=semi&other=1&resource=last",
+  )?;
+  let values = values_of(&pairs, RESOURCE_PARAM);
+  if values
+    != vec![
+      "a b".to_owned(),
+      "%ZZ".to_owned(),
+      "x;y".to_owned(),
+      "last".to_owned(),
+    ]
+  {
+    return Err(format!("values_of wire order broken: {values:?}").into());
+  }
+  if !values_of(&pairs, "missing").is_empty() {
+    return Err("unknown name must yield empty".into());
+  }
+  Ok(())
+}
+
 /// The single-valued map is last-wins: duplicates are silently absorbed.
 #[test]
 fn params_single_valued_map_absorbs_duplicates_last_wins() -> TestResult {

+ 35 - 3
src/protocol/revoke.rs

@@ -20,8 +20,8 @@
 use std::collections::HashMap;
 
 use axum::{
-  Extension, Form, Json,
-  extract::State,
+  Extension, Json,
+  extract::{RawForm, State},
   http::{HeaderMap, StatusCode, header},
   response::{IntoResponse, Response},
 };
@@ -332,6 +332,19 @@ struct ErrorBody {
   error_description: &'static str,
 }
 
+/// Fields the extractor recognizes: a repeat of any of these is the
+/// duplicate-field rejection (mapped to JSON `invalid_request`, per
+/// RFC 6749 §5.2 "includes a parameter more than once"). Unknown fields are
+/// silently absorbed, as `serde_urlencoded` absorbed them.
+const KNOWN_FIELDS: [&str; 6] = [
+  "token",
+  "token_type_hint",
+  "client_id",
+  "client_secret",
+  "client_assertion_type",
+  "client_assertion",
+];
+
 /// `POST /revoke` (RFC 7009 §2.1).
 ///
 /// Credentials come from the `Authorization` header and the form body only; a
@@ -359,8 +372,27 @@ pub async fn revoke_handler(
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
-  Form(form): Form<HashMap<String, String>>,
+  body: RawForm,
 ) -> Result<Response, AppError> {
+  // `RawForm` gates the content type BEFORE `Bytes` buffering, so a wrong
+  // content type is rejected 415 without reading the (up to 2 MiB) body.
+  // The explicit exact-MIME gate below is the second check: `RawForm` uses a
+  // case-sensitive prefix match, so it accepts e.g.
+  // `application/x-www-form-urlencodedevil`, which the exact gate rejects.
+  let body = body.0;
+  if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
+    return Ok(reject.into_response());
+  }
+  let pairs = match crate::protocol::params::form_pairs(&body) {
+    Ok(pairs) => pairs,
+    Err(reject) => return Ok(reject.into_response()),
+  };
+  if crate::protocol::params::has_duplicate_field(&pairs, &KNOWN_FIELDS) {
+    return Err(AppError::invalid_request(
+      "the request body includes a parameter more than once",
+    ));
+  }
+  let form = crate::protocol::params::single_valued_map(&pairs);
   let service = TokenService::new(&state.config.ttl, &state.keys)?;
   let auth = ClientAuthRequest {
     basic_header: headers

+ 1 - 0
src/protocol/revoke/tests.rs

@@ -183,6 +183,7 @@ fn mint_request(client: &str, grant: &str) -> MintRequest {
     dpop_required: false,
     subject: "user-1".to_owned(),
     scope: "orders:read".to_owned(),
+    access_scope: "orders:read".to_owned(),
     grant_id: grant.to_owned(),
     proof: SenderProof::none(),
     aud: Vec::new(),

+ 3 - 2
src/protocol/revoke/tests/jwt.rs

@@ -86,10 +86,11 @@ fn jwt_mint_request(client: &str, grant: &str, subject: &str) -> MintRequest {
     dpop_required: false,
     subject: subject.to_owned(),
     scope: "orders:read".to_owned(),
+    access_scope: "orders:read".to_owned(),
     grant_id: grant.to_owned(),
     proof: SenderProof::none(),
-    aud: Vec::new(),
-    refresh_aud: Vec::new(),
+    aud: vec!["https://api.example.test/".to_owned()],
+    refresh_aud: vec!["https://api.example.test/".to_owned()],
     format: TokenFormat::Jwt,
   }
 }

+ 54 - 13
src/protocol/ssrf.rs

@@ -73,27 +73,30 @@ pub enum SsrfError {
 ///   (IETF protocol assignments), `192.0.2.0/24`, `192.88.99.0/24` (6to4
 ///   relay anycast), `192.168.0.0/16`, `198.18.0.0/15` (benchmarking),
 ///   `198.51.100.0/24`, `203.0.113.0/24`, `224.0.0.0/4` (multicast),
-///   `240.0.0.0/4` (reserved, incl. broadcast), and the Azure/Alibaba
-///   metadata addresses.
+///   `240.0.0.0/4` (reserved, incl. broadcast), AS112 `192.31.196.0/24`,
+///   AMT `192.52.193.0/24`, direct-delegation `192.175.48.0/24`, and the
+///   Azure/Alibaba metadata addresses.
 /// - IPv6: `::1`, `::`, `fc00::/7`, `fe80::/10`, `ff00::/8`, `2001:db8::/32`,
-///   `100::/64` (discard-only), `2001:2::/48` (benchmarking), `2001:10::/28`
+///   `100::/64` (discard-only), `2001:2::/48` (benchmarking), PCP
+///   `2001:1::/32`, AMT `2001:3::/32`, AS112 `2001:4:112::/48`, `2001:10::/28`
 ///   and `2001:20::/28` (ORCHID/ORCHIDv2 identifiers), `2001::/32` (Teredo),
 ///   `2002::/16` (6to4), and `64:ff9b::/96` / `64:ff9b:1::/48` (NAT64
 ///   translation — can reach an embedded private IPv4 target).
 ///
-/// IPv4-mapped addresses (`::ffff:a.b.c.d`) are judged by the embedded IPv4
-/// address.
+/// IPv4-mapped (`::ffff:a.b.c.d`) and IPv4-compatible (`::a.b.c.d`) addresses
+/// are judged by the embedded IPv4 address; the IPv4-translated SIIT prefix
+/// `::ffff:0:0/96` is blocked outright.
 #[must_use]
 pub fn is_blocked_ip(ip: &IpAddr) -> bool {
   match ip {
     IpAddr::V4(v4) => is_blocked_v4(v4),
     IpAddr::V6(v6) => {
-      // An IPv4-mapped address (`::ffff:a.b.c.d`) reaches the same host as
-      // its embedded IPv4 address, so it must be judged by that address —
-      // otherwise `::ffff:127.0.0.1` / `::ffff:169.254.169.254` would slip
-      // past the guard. `to_ipv4_mapped` returns `None` for every other
-      // IPv6 form, including IPv4-compatible (`::a.b.c.d`) and NAT64.
-      if let Some(v4) = v6.to_ipv4_mapped() {
+      // An IPv4-mapped (`::ffff:a.b.c.d`) or IPv4-compatible (`::a.b.c.d`)
+      // address reaches the same host as its embedded IPv4 address, so it is
+      // judged by that address — otherwise `::ffff:127.0.0.1` /
+      // `::169.254.169.254` could slip past the guard. NAT64 and the other
+      // transition prefixes are blocked explicitly below.
+      if let Some(v4) = v6.to_ipv4() {
         return is_blocked_v4(&v4);
       }
       v6.is_loopback()
@@ -110,10 +113,24 @@ pub fn is_blocked_ip(ip: &IpAddr) -> bool {
           && v6.segments()[2] == 0
           && v6.segments()[3] == 0)
         || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0002 && v6.segments()[2] == 0)
+        // IPv4-translated SIIT (`::ffff:0:a.b.c.d`, `::ffff:0:0/96`).
+        || (v6.segments()[0] == 0
+          && v6.segments()[1] == 0
+          && v6.segments()[2] == 0
+          && v6.segments()[3] == 0
+          && v6.segments()[4] == 0xffff
+          && v6.segments()[5] == 0)
         // Non-routable identifier prefixes: ORCHID (2001:10::/28) and
         // ORCHIDv2 (2001:20::/28).
         || (v6.segments()[0] == 0x2001 && (v6.segments()[1] & 0xfff0) == 0x0010)
         || (v6.segments()[0] == 0x2001 && (v6.segments()[1] & 0xfff0) == 0x0020)
+        // Anycast/special-purpose: PCP (2001:1::/32), AMT (2001:3::/32),
+        // AS112 (2001:4:112::/48).
+        || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0001)
+        || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0003)
+        || (v6.segments()[0] == 0x2001
+          && v6.segments()[1] == 0x0004
+          && v6.segments()[2] == 0x0112)
         // IPv4 transition/translation: Teredo (2001::/32), 6to4 (2002::/16),
         // NAT64 well-known (64:ff9b::/96) and local-use (64:ff9b:1::/48).
         || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0)
@@ -144,6 +161,11 @@ fn is_blocked_v4(v4: &std::net::Ipv4Addr) -> bool {
     || (o[0] == 192 && o[1] == 88 && o[2] == 99)
     // Benchmarking (RFC 2544): 198.18.0.0/15.
     || (o[0] == 198 && (o[1] == 18 || o[1] == 19))
+    // AS112 (192.31.196.0/24), AMT (192.52.193.0/24), and direct delegation
+    // (192.175.48.0/24) — never public fetch targets.
+    || (o[0] == 192 && o[1] == 31 && o[2] == 196)
+    || (o[0] == 192 && o[1] == 52 && o[2] == 193)
+    || (o[0] == 192 && o[1] == 175 && o[2] == 48)
     // Reserved (240.0.0.0/4, includes 255.255.255.255 broadcast).
     || o[0] >= 240
     || o == AZURE_METADATA_V4
@@ -208,6 +230,14 @@ pub async fn fetch_guarded_bytes(raw: &str) -> Result<Vec<u8>, SsrfError> {
   fetch_guarded_bytes_with(raw, SSRF_MAX_BYTES, SSRF_TIMEOUT).await
 }
 
+/// Remaining budget under a total `deadline`: `None` when the deadline has
+/// passed or exactly zero remains, so the caller fails closed.
+fn remaining_budget(deadline: tokio::time::Instant) -> Option<Duration> {
+  deadline
+    .checked_duration_since(tokio::time::Instant::now())
+    .filter(|left| !left.is_zero())
+}
+
 /// Fetch `raw` through the full SSRF guard with per-call limits.
 ///
 /// Same policy as [`fetch_guarded_bytes`] — `https`-only, no redirects, a
@@ -216,6 +246,10 @@ pub async fn fetch_guarded_bytes(raw: &str) -> Result<Vec<u8>, SsrfError> {
 /// caller's, so a tighter caller (for example CIMD's 5120-byte cap and
 /// configured timeout) reuses the one fetcher instead of adding a second.
 ///
+/// `timeout` is the total budget for the DNS check plus the HTTPS fetch: the
+/// DNS check runs under the deadline, and only the remainder reaches the
+/// fetch. A zero or already-expired budget fails closed.
+///
 /// # Errors
 ///
 /// Returns [`SsrfError`] when the URL policy, DNS check, fetch, status, or
@@ -225,11 +259,18 @@ pub async fn fetch_guarded_bytes_with(
   max_bytes: usize,
   timeout: Duration,
 ) -> Result<Vec<u8>, SsrfError> {
+  if timeout.is_zero() {
+    return Err(SsrfError::FetchFailed);
+  }
   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);
-  let addrs = check_fetch_host(&host, port).await?;
-  fetch_pinned(&url, &host, &addrs, max_bytes, timeout).await
+  let deadline = tokio::time::Instant::now() + timeout;
+  let addrs = tokio::time::timeout_at(deadline, check_fetch_host(&host, port))
+    .await
+    .map_err(|_| SsrfError::FetchFailed)??;
+  let left = remaining_budget(deadline).ok_or(SsrfError::FetchFailed)?;
+  fetch_pinned(&url, &host, &addrs, max_bytes, left).await
 }
 
 /// The post-guard network step shared by every guarded fetch: an `https` GET

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

@@ -357,15 +357,24 @@ fn ssrf_rejects_special_use_ranges() -> TestResult {
     "203.0.113.1",
     "100.64.0.1",
     "240.0.0.1",
+    "192.31.196.1",
+    "192.52.193.1",
+    "192.175.48.1",
     "64:ff9b::7f00:1",
     "64:ff9b:1::1",
     "2002:7f00:1::",
     "2001::1",
+    "2001:1::1",
     "2001:2::1",
+    "2001:3::1",
+    "2001:4:112::1",
     "2001:10::1",
     "2001:20::1",
     "100::1",
     "2001:db8::1",
+    "::127.0.0.1",
+    "::169.254.169.254",
+    "::ffff:0:127.0.0.1",
     "::ffff:0.0.0.1",
     "::ffff:192.0.0.1",
     "::ffff:198.18.0.1",
@@ -378,7 +387,9 @@ fn ssrf_rejects_special_use_ranges() -> TestResult {
     "192.0.1.1",
     "198.17.0.1",
     "198.20.0.1",
+    "192.31.197.1",
     "2001:4860:4860::8888",
+    "::8.8.8.8",
   ] {
     if blocked(addr)? {
       return Err(TestError::Unexpected(format!("{addr} should pass")));
@@ -386,3 +397,40 @@ fn ssrf_rejects_special_use_ranges() -> TestResult {
   }
   Ok(())
 }
+
+/// An expired deadline leaves no budget; a future deadline leaves some. The
+/// helper drives the fail-closed total-timeout budget in
+/// `fetch_guarded_bytes_with`.
+#[test]
+fn ssrf_remaining_budget_expired_is_none() -> TestResult {
+  let past = tokio::time::Instant::now()
+    .checked_sub(Duration::from_secs(1))
+    .ok_or_else(|| TestError::Unexpected("clock must allow a past deadline".to_owned()))?;
+  if super::remaining_budget(past).is_some() {
+    return Err(TestError::Unexpected(
+      "an expired deadline must leave no budget".to_owned(),
+    ));
+  }
+  let future = tokio::time::Instant::now() + Duration::from_secs(5);
+  match super::remaining_budget(future) {
+    Some(left) if !left.is_zero() => Ok(()),
+    _ => Err(TestError::Unexpected(
+      "a future deadline must leave budget".to_owned(),
+    )),
+  }
+}
+
+/// A zero total budget fails closed before any policy or DNS work: even a
+/// blocked target reports `FetchFailed`, never reaching the guard.
+#[tokio::test]
+async fn ssrf_zero_timeout_fails_closed_before_policy() -> TestResult {
+  match fetch_guarded_bytes_with("https://127.0.0.1:8443/doc.json", 5_120, Duration::ZERO).await {
+    Err(super::SsrfError::FetchFailed) => Ok(()),
+    Err(other) => Err(TestError::Unexpected(format!(
+      "zero budget must be FetchFailed, got {other}"
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "zero budget must never fetch".to_owned(),
+    )),
+  }
+}

+ 14 - 8
src/protocol/token/dispatch.rs

@@ -13,7 +13,7 @@
 mod grants;
 
 use axum::Json;
-use axum::extract::{Extension, State};
+use axum::extract::{Extension, RawForm, State};
 use axum::http::{HeaderMap, HeaderValue, header};
 use axum::response::{IntoResponse, Response};
 use secrecy::SecretString;
@@ -433,15 +433,21 @@ pub async fn handler(
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
-  body: axum::body::Bytes,
+  body: RawForm,
 ) -> Response {
-  // `Bytes` + a manual parse replaces `Result<Form<TokenForm>, FormRejection>`:
+  // `RawForm` gates the content type BEFORE `Bytes` buffering, so a wrong
+  // content type is rejected 415 without reading the (up to 2 MiB) body.
+  // The explicit exact-MIME gate below is the second check: `RawForm` uses a
+  // case-sensitive prefix match, so it accepts e.g.
+  // `application/x-www-form-urlencodedevil`, which the exact gate rejects.
+  let body = body.0;
+  // `RawForm` + a manual parse replaces `Result<Form<TokenForm>, FormRejection>`:
   // the extractor consumed the raw body, hiding repeats. The explicit
-  // content-type gate preserves the 415 `Form` rendered (`Bytes` never fails
-  // on content-type); any other transport failure keeps the documented
-  // `invalid_request` mapping ("includes a parameter more than once"). The
+  // exact-MIME gate below is the second content-type check (see above); any
+  // other transport failure keeps the documented `invalid_request` mapping
+  // ("includes a parameter more than once"). The
   // repeated `resource` indicator (RFC 8707) is captured in order by
-  // `params::repeated_values` and validated fail-closed here; the
+  // `params::values_of` and validated fail-closed here; the
   // authorization-code branch binds the validated set to the redeemed code,
   // and the remaining grants wire it in later todos.
   if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
@@ -466,7 +472,7 @@ pub async fn handler(
   let map = crate::protocol::params::single_valued_map(&pairs);
   let form = TokenForm::from_map(&map);
   let resources =
-    crate::protocol::params::repeated_values(text, crate::protocol::params::RESOURCE_PARAM);
+    crate::protocol::params::values_of(&pairs, crate::protocol::params::RESOURCE_PARAM);
   // RFC 8707 resource indicators do not apply to RFC 8693 token exchange;
   // reject the parameter BEFORE the catalog lookup so a bogus value cannot
   // surface `invalid_target` from a grant that must answer `invalid_request`.

+ 136 - 3
src/protocol/token/grants/authorization_code.rs

@@ -208,6 +208,15 @@ pub async fn redeem_code(
     ));
   }
 
+  // The code above is already consumed (single-use): a JWT redemption with an
+  // unbound audience fails here, before the mint, and the client must
+  // re-authorize with a `resource`.
+  if state.token_format == TokenFormat::Jwt && record.aud.is_empty() {
+    return Err(AppError::invalid_target(
+      "jwt access tokens require a resource audience",
+    ));
+  }
+
   let proofs = fresh_proofs(state, &client, inputs, used_basic).await?;
   require_dpop_binding(&client, &proofs)?;
   if let Some(want) = non_empty(&record.cnf) {
@@ -241,6 +250,7 @@ pub async fn redeem_code(
         dpop_required: client.dpop_bound_access_tokens,
         subject: record.subject.clone(),
         scope: record.scope.clone(),
+        access_scope: record.scope.clone(),
         grant_id: record.grant_id.clone(),
         proof: proofs,
         aud: record.aud.clone(),
@@ -1868,6 +1878,7 @@ mod tests {
     let issuer = CodeIssuer::new(&h.ttl, &h.ring)?;
     let approved = ScopeSet::parse("orders:read")?;
     let entitled = ScopeSet::parse("orders:read orders:admin")?;
+    let pinned = vec![AUD_ONE.to_owned()];
     let issued = issuer
       .issue(
         &mut h.conn,
@@ -1883,7 +1894,7 @@ mod tests {
           code_challenge: CHALLENGE,
           dpop_jkt: None,
           state: None,
-          aud: &[],
+          aud: &pinned,
           issuer: ISSUER,
         },
       )
@@ -1909,7 +1920,7 @@ mod tests {
       assertion: None,
       dpop_proof: None,
       peer_certs: None,
-      aud: &[],
+      aud: &pinned,
     };
     let mut state = CodeGrantState {
       pool: &h.pool,
@@ -1940,8 +1951,14 @@ mod tests {
       ));
     }
     let verified = signer
-      .verify(&tokens.access_token, None)
+      .verify(&tokens.access_token, Some(&pinned))
       .map_err(|error| TestError::Unexpected(format!("fresh jwt rejected: {error:?}")))?;
+    if verified.claims.aud != pinned {
+      return Err(TestError::Unexpected(format!(
+        "aud must be {pinned:?}, got {:?}",
+        verified.claims.aud
+      )));
+    }
     if verified.claims.cnf.is_some() {
       return Err(TestError::Unexpected(
         "an unbound public jwt must carry no cnf".to_owned(),
@@ -1949,4 +1966,120 @@ mod tests {
     }
     Ok(())
   }
+
+  /// An authorization code bound to no audience cannot redeem a JWT: the
+  /// redemption fails `invalid_target` (the code is already consumed, so a
+  /// retry observes `invalid_grant`) and no token is returned. The client
+  /// must re-authorize with a `resource`.
+  #[tokio::test]
+  async fn jwt_redeem_unbound_code_is_invalid_target_and_consumes_code() -> TestResult {
+    let mut h = harness().await?;
+    let client_id = unique("public-unbound-jwt-code");
+    insert_client(
+      &h,
+      &client_id,
+      TokenEndpointAuthMethod::None,
+      None,
+      &[super::GRANT_TYPE_AUTHORIZATION_CODE],
+    )
+    .await?;
+    let issuer = CodeIssuer::new(&h.ttl, &h.ring)?;
+    let approved = ScopeSet::parse("orders:read")?;
+    let entitled = ScopeSet::parse("orders:read orders:admin")?;
+    let issued = issuer
+      .issue(
+        &mut h.conn,
+        &h.store,
+        &CodeIssueRequest {
+          client_id: &client_id,
+          client_kind: CodeClientKind::Public,
+          dpop_required: false,
+          user_id: "user-1",
+          redirect_uri: REDIRECT,
+          approved_scope: &approved,
+          user_entitled: &entitled,
+          code_challenge: CHALLENGE,
+          dpop_jkt: None,
+          state: None,
+          aud: &[],
+          issuer: ISSUER,
+        },
+      )
+      .await?;
+    let now = now_unix();
+    let raw = secret("bcdefghijklmnopqrstuvwxyzabcdefg");
+    let key = JwtSigningKey::from_raw(
+      KeyId::new("test-kid"),
+      JwtSigningAlg::Es256,
+      &raw,
+      "jwt_signing_key",
+    )
+    .map_err(|error| TestError::Unexpected(format!("signing key rejected: {error:?}")))?;
+    let signer = JwtAccessSigner::new(ISSUER.to_owned(), key, Vec::new());
+    let inputs = CodeGrantInputs {
+      code: issued.code.as_str(),
+      redirect_uri: REDIRECT,
+      code_verifier: Some(VERIFIER),
+      client_id: Some(client_id.as_str()),
+      basic_header: None,
+      body_secret: None,
+      assertion_type: None,
+      assertion: None,
+      dpop_proof: None,
+      peer_certs: None,
+      aud: &[],
+    };
+    {
+      let mut state = CodeGrantState {
+        pool: &h.pool,
+        conn: &mut h.conn,
+        store: &h.store,
+        keys: &h.ring,
+        issuer: ISSUER,
+        ttl: &h.ttl,
+        now_unix: now,
+        token_format: TokenFormat::Jwt,
+        jwt_signer: Some(signer.clone()),
+        cimd: None,
+      };
+      match super::redeem_code(&mut state, &inputs).await {
+        Err(error) if error.code() == crate::error::OAuthErrorCode::InvalidTarget => {}
+        Err(error) => {
+          return Err(TestError::Unexpected(format!(
+            "an unbound jwt redemption must be invalid_target, got {}",
+            error.code()
+          )));
+        }
+        Ok(_) => {
+          return Err(TestError::Unexpected(
+            "an unbound jwt redemption must never mint".to_owned(),
+          ));
+        }
+      }
+    }
+    // The code was consumed by the failed redemption: a retry observes
+    // `invalid_grant`, proving single-use was not bypassed.
+    let mut retry = CodeGrantState {
+      pool: &h.pool,
+      conn: &mut h.conn,
+      store: &h.store,
+      keys: &h.ring,
+      issuer: ISSUER,
+      ttl: &h.ttl,
+      now_unix: now,
+      token_format: TokenFormat::Jwt,
+      jwt_signer: Some(signer),
+      cimd: None,
+    };
+    match super::redeem_code(&mut retry, &inputs).await {
+      Err(error) if error.code() == crate::error::OAuthErrorCode::InvalidGrant => Ok(()),
+      Err(error) => Err(TestError::Unexpected(format!(
+        "a consumed code must be invalid_grant, got {}",
+        error.code()
+      ))),
+      Ok(_) => Err(TestError::Unexpected(
+        "a consumed code must never mint".to_owned(),
+      )),
+    }
+  }
 }

+ 7 - 0
src/protocol/token/grants/client_credentials.rs

@@ -139,6 +139,12 @@ pub async fn issue_client_credentials(
     exchange_allowlist: &ScopeSet::empty(),
   })?;
 
+  if input.token_format == TokenFormat::Jwt && input.aud.is_empty() {
+    return Err(AppError::invalid_target(
+      "jwt access tokens require a resource audience",
+    ));
+  }
+
   let grant_id = crypto_tokens::mint_token().map_err(AppError::internal)?;
   let minted = service
     .mint(
@@ -150,6 +156,7 @@ pub async fn issue_client_credentials(
         dpop_required: false,
         subject: String::new(),
         scope: issued.render(),
+        access_scope: issued.render(),
         grant_id: grant_id.clone(),
         proof: input.proof.clone(),
         aud: input.aud.to_vec(),

+ 43 - 0
src/protocol/token/grants/client_credentials/tests/jwt.rs

@@ -10,6 +10,7 @@ use super::super::issue_client_credentials;
 use super::{Ctx, TestError, TestResult, dpop_proof, ring, test_redis, unique};
 use crate::{
   config::{JwtSigningAlg, JwtSigningKey, TtlConfig},
+  error::OAuthErrorCode,
   jose::jwt_access::{JwtAccessSigner, jti_hash, marker_live},
   keys::KeyId,
   protocol::{
@@ -150,3 +151,45 @@ async fn jwt_mint_carries_aud_and_cnf() -> TestResult {
   }
   Ok(())
 }
+
+/// `test(jwt_client_credentials_without_resource_is_invalid_target)`: a JWT
+/// client-credentials request with no `resource` fails `invalid_target`
+/// before minting, so no JWT is issued.
+#[tokio::test]
+async fn jwt_client_credentials_without_resource_is_invalid_target() -> TestResult {
+  let (mut conn, store) = test_redis().await?;
+  let signer = test_signer()?;
+  let service = TokenService::new(&TtlConfig::default(), &ring()?)
+    .map_err(TestError::from)?
+    .with_jwt_signer(signer);
+  let client = unique("jwt-cc-no-resource");
+  let registered = ScopeSet::parse("read")?;
+  let enabled = ScopeSet::parse("read")?;
+  let proof = dpop_proof("jwt-no-resource-jkt");
+  let ctx = Ctx {
+    kind: ClientKind::Confidential,
+    registered: &registered,
+    enabled: &enabled,
+    dpop_bound: None,
+    mtls_bound: None,
+    proof: &proof,
+  };
+  let mut inputs = super::input(&client, None, &ctx);
+  inputs.aud = &[];
+  inputs.token_format = TokenFormat::Jwt;
+  match issue_client_credentials(&mut conn, &store, &service, &inputs).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
+    Err(error) => {
+      return Err(TestError::Unexpected(format!(
+        "a resourceless jwt mint must be invalid_target, got {}",
+        error.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a resourceless jwt mint must never issue a token".to_owned(),
+      ));
+    }
+  }
+  Ok(())
+}

+ 18 - 2
src/protocol/token/grants/device_code/issue.rs

@@ -12,7 +12,7 @@ use super::{DevicePollError, DeviceTokenResponse, PollState};
 use crate::jose::jwt_access::JwtAccessSigner;
 use crate::{
   config::GrantKind,
-  db::{scope_repo, user_scope_repo},
+  db::{scope_repo, user_repo, user_scope_repo},
   error::AppError,
   protocol::{
     device::{binding_key, read_binding},
@@ -95,6 +95,12 @@ pub(super) async fn issue_approved(
       AppError::invalid_grant("resource indicators do not match this device authorization").into(),
     );
   }
+  // The device record above is already consumed (single redemption): a JWT
+  // redemption with an unbound audience fails here, before the mint, and the
+  // client must re-authorize with a `resource`.
+  if redemption.token_format == TokenFormat::Jwt && consumed.aud.is_empty() {
+    return Err(AppError::invalid_target("jwt access tokens require a resource audience").into());
+  }
   if consumed.grant_id.is_empty() || consumed.subject.is_empty() {
     return Err(AppError::server_error().into());
   }
@@ -118,6 +124,7 @@ pub(super) async fn issue_approved(
         dpop_required: redemption.dpop_required,
         subject: consumed.subject.clone(),
         scope: effective.clone(),
+        access_scope: effective.clone(),
         grant_id: consumed.grant_id.clone(),
         proof: redemption.proofs.clone(),
         aud: consumed.aud.clone(),
@@ -163,7 +170,16 @@ async fn approved_scope(
   client: &crate::db::Client,
   consumed: &device_store::DeviceRecord,
 ) -> Result<String, DevicePollError> {
-  let user_id = Uuid::parse_str(&consumed.subject).map_err(|_| AppError::server_error())?;
+  // The UI records the authenticated session subject, which is the user's
+  // email; older records and fixtures carry the user UUID. Resolve either to
+  // the entitlement owner and fail closed when the account is not live.
+  let user_id = match user_repo::find_by_email(state.pool, state.keys, &consumed.subject).await? {
+    Some(user) if user.disabled => {
+      return Err(AppError::invalid_grant("the approving user is disabled").into());
+    }
+    Some(user) => user.id,
+    None => Uuid::parse_str(&consumed.subject).map_err(|_| AppError::server_error())?,
+  };
   let client_registered =
     ScopeSet::parse(client.scope.trim()).map_err(|_| AppError::server_error())?;
   let enabled_names = scope_repo::list_enabled(state.pool)

+ 74 - 1
src/protocol/token/grants/device_code/tests/scope.rs

@@ -5,7 +5,9 @@ use super::flows::{authorize, decide, now_unix, poll_once, public_inputs};
 use super::{TestError, TestResult, harness, unique};
 use super::{keys, setup};
 use crate::{
-  db::TokenEndpointAuthMethod, error::OAuthErrorCode, protocol::device::DeviceHttpInputs,
+  db::TokenEndpointAuthMethod,
+  error::OAuthErrorCode,
+  protocol::{device::DeviceHttpInputs, token::TokenService},
 };
 
 #[tokio::test]
@@ -121,3 +123,74 @@ async fn test_grant_device_scope_reduced_to_user_entitlement() -> TestResult {
   }
   Ok(())
 }
+
+/// The device UI approves with the session subject, which is the user's email.
+/// Redemption must resolve that email to the entitlement owner while keeping
+/// the email as the minted token subject.
+#[tokio::test]
+async fn test_grant_device_email_subject_resolves_entitlement() -> TestResult {
+  let mut h = harness().await?;
+  let client_id = unique("public");
+  setup::insert_client(
+    &h.pool,
+    &h.config,
+    &client_id,
+    TokenEndpointAuthMethod::None,
+    None,
+    "orders:read orders:admin",
+  )
+  .await?;
+  setup::enable_scopes(&h.pool, &["orders:admin", "orders:read"]).await?;
+  let user = setup::entitled_user_record(&h.pool, &h.config, &["orders:read"]).await?;
+  let t0 = now_unix();
+  let key = keys::fresh_key()?;
+  let authorize_htu = keys::device_htu(&h.config);
+  let poll_htu = keys::token_htu(&h.config);
+
+  let authorize_proof = keys::mint_proof(&key, &authorize_htu, &unique("jti"), t0)?;
+  let authorize_inputs = DeviceHttpInputs {
+    client_id: client_id.as_str(),
+    scope: None,
+    basic_header: None,
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    dpop_proof: Some(authorize_proof.as_str()),
+    resource: &[],
+    peer_certs: None,
+  };
+  let issued = authorize(&mut h, &authorize_inputs, t0)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("authorize rejected: {error}")))?;
+
+  if !decide(&mut h, &issued.device_code, true, &user.email).await? {
+    return Err(TestError::Unexpected("decide must apply".to_owned()));
+  }
+  let redeem_proof = keys::mint_proof(&key, &poll_htu, &unique("jti"), t0 + 6)?;
+  let tokens = poll_once(
+    &mut h,
+    &public_inputs(&client_id, &issued.device_code, Some(&redeem_proof)),
+    t0 + 6,
+  )
+  .await
+  .map_err(|error| TestError::Unexpected(format!("approved poll rejected: {}", error.code())))?;
+  if tokens.scope != "orders:read" {
+    return Err(TestError::Unexpected(format!(
+      "email subject must resolve to the user entitlement, got {}",
+      tokens.scope
+    )));
+  }
+
+  let service = TokenService::new(&h.config.ttl, &h.config.keys)?;
+  let found = service
+    .find_access(&mut h.conn, &h.store, &tokens.access_token)
+    .await?
+    .ok_or_else(|| TestError::Unexpected("access token must resolve".to_owned()))?;
+  if found.record.subject != user.email {
+    return Err(TestError::Unexpected(format!(
+      "minted subject must stay the approving email, got {}",
+      found.record.subject
+    )));
+  }
+  Ok(())
+}

+ 13 - 4
src/protocol/token/grants/device_code/tests/setup.rs

@@ -71,12 +71,12 @@ pub(super) async fn enable_scopes(pool: &PgPool, names: &[&str]) -> TestResult {
   Ok(())
 }
 
-/// Provision a user and entitle it to the given scopes.
-pub(super) async fn entitled_user(
+/// Provision a user and entitle it to the given scopes, returning the record.
+pub(super) async fn entitled_user_record(
   pool: &PgPool,
   config: &Config,
   scopes: &[&str],
-) -> Result<Uuid, TestError> {
+) -> Result<user_repo::UserRecord, TestError> {
   let email = format!("{}@example.test", unique("user"));
   let record = user_repo::create_user(
     pool,
@@ -93,5 +93,14 @@ pub(super) async fn entitled_user(
   for scope in scopes {
     crate::db::user_scope_repo::entitle(pool, record.id, scope, None).await?;
   }
-  Ok(record.id)
+  Ok(record)
+}
+
+/// Provision a user and entitle it to the given scopes.
+pub(super) async fn entitled_user(
+  pool: &PgPool,
+  config: &Config,
+  scopes: &[&str],
+) -> Result<Uuid, TestError> {
+  Ok(entitled_user_record(pool, config, scopes).await?.id)
 }

+ 11 - 20
src/protocol/token/grants/refresh_token.rs

@@ -38,7 +38,7 @@ use crate::{
   protocol::scope::{ScopeSet, refresh_scopes},
   state::RedisConn,
   store::{
-    Store, StoreError, durability_barrier,
+    Store, durability_barrier,
     grants::revoke_by_token_hash,
     tokens::{AccessRecord, RefreshRecord, RotateOutcome, RotationInput},
   },
@@ -264,6 +264,11 @@ pub async fn refresh(
   // refresh token stays bound to the full original set (NOT §3).
   let original_aud = found.record.aud.clone();
   let (access_aud, refresh_aud) = resolve_audience(&original_aud, input.requested_resources)?;
+  if input.token_format == TokenFormat::Jwt && access_aud.is_empty() {
+    return Err(AppError::invalid_target(
+      "jwt access tokens require a resource audience",
+    ));
+  }
   let minted = service
     .mint_for_rotation(
       conn,
@@ -274,6 +279,7 @@ pub async fn refresh(
         dpop_required: input.dpop_required,
         subject: found.record.subject.clone(),
         scope: retained.render(),
+        access_scope: access.render(),
         grant_id: found.record.grant_id.clone(),
         proof: input.proof.clone(),
         aud: access_aud.clone(),
@@ -462,29 +468,14 @@ async fn discard_orphan(
     .map_err(AppError::from)?;
   if let Some(jti_hash) = minted.jwt_jti_hash() {
     let member = minted.access_hash.clone();
-    let record_key = store.access_key(&member);
     let grant_id = crate::store::tokens::get_access(conn, store, &member)
       .await
       .map_err(AppError::from)?
-      .map(|record| record.grant_id);
-    let _: () = redis::cmd("DEL")
-      .arg(&record_key)
-      .query_async(conn)
+      .map(|record| record.grant_id)
+      .unwrap_or_default();
+    crate::store::tokens::discard_access_jwt(conn, store, &member, jti_hash, &grant_id)
       .await
-      .map_err(StoreError::Redis)?;
-    if let Some(grant_id) = grant_id {
-      let _: () = redis::cmd("SREM")
-        .arg(store.grant_tokens_key(&grant_id))
-        .arg(&member)
-        .query_async(conn)
-        .await
-        .map_err(StoreError::Redis)?;
-    }
-    let _: () = redis::cmd("DEL")
-      .arg(store.jwt_marker_key(jti_hash))
-      .query_async(conn)
-      .await
-      .map_err(StoreError::Redis)?;
+      .map_err(AppError::from)?;
     durability_barrier(conn, store)
       .await
       .map_err(AppError::from)?;

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

@@ -179,6 +179,7 @@ async fn mint_grant(
         dpop_required: kind == ClientKind::Public,
         subject: unique("subject"),
         scope: scope.to_owned(),
+        access_scope: scope.to_owned(),
         grant_id: unique("grant"),
         proof,
         aud: Vec::new(),

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

@@ -54,6 +54,7 @@ async fn mint_aud_grant(
         dpop_required: true,
         subject: unique("audience-subject"),
         scope: scope.to_owned(),
+        access_scope: scope.to_owned(),
         grant_id: unique("audience-grant"),
         proof,
         aud: aud.to_vec(),

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

@@ -80,6 +80,7 @@ async fn mint_subject(
           dpop_required: true,
           subject: subject.to_owned(),
           scope: "read".to_owned(),
+          access_scope: "read".to_owned(),
           grant_id: unique("grant"),
           proof,
           aud: Vec::new(),

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

@@ -88,6 +88,7 @@ async fn mint_subject(
           dpop_required: true,
           subject: subject.to_owned(),
           scope: scope.to_owned(),
+          access_scope: scope.to_owned(),
           grant_id: unique("grant"),
           proof,
           aud: Vec::new(),

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

@@ -56,6 +56,7 @@ async fn grant_refresh_failover_no_double_rotation() -> TestResult {
         dpop_required: true,
         subject: unique("failover-subject"),
         scope: "read".to_owned(),
+        access_scope: "read".to_owned(),
         grant_id: unique("failover-grant"),
         proof: proof.clone(),
         aud: Vec::new(),

+ 161 - 6
src/protocol/token/grants/refresh_token/tests/jwt.rs

@@ -5,7 +5,8 @@
 use super::{TestError, TestResult, dpop_proof, input, is_invalid_grant, test_redis, unique};
 use crate::{
   config::{JwtSigningAlg, JwtSigningKey, TtlConfig},
-  jose::jwt_access::JwtAccessSigner,
+  error::OAuthErrorCode,
+  jose::jwt_access::{JwtAccessSigner, jti_hash},
   keys::KeyId,
   protocol::{
     scope::ScopeSet,
@@ -14,6 +15,12 @@ use crate::{
   store::tokens as low,
 };
 
+/// Resource audience every JWT mint in this module binds (RFC 8707 §2:
+/// a JWT access token without a requested resource is rejected).
+fn jwt_aud() -> Vec<String> {
+  vec!["https://api.example.test/".to_owned()]
+}
+
 /// A fixed 32-byte ASCII signing key (the e2e JWT fixture material).
 const JWT_TEST_KEY: &str = "bcdefghijklmnopqrstuvwxyzabcdefg";
 
@@ -62,10 +69,11 @@ async fn mint_jwt_grant(
         dpop_required: true,
         subject: unique("subject"),
         scope: "read".to_owned(),
+        access_scope: "read".to_owned(),
         grant_id: unique("grant"),
         proof,
-        aud: Vec::new(),
-        refresh_aud: Vec::new(),
+        aud: jwt_aud(),
+        refresh_aud: jwt_aud(),
         format: TokenFormat::Jwt,
       },
       super::now_unix(),
@@ -79,6 +87,97 @@ async fn mint_jwt_grant(
   Ok(minted)
 }
 
+/// A narrowed JWT refresh binds the ACCESS token to the narrowed scope: the
+/// response, the JWT `scope` claim (which is what introspection reports),
+/// and the stored access record all carry `read`, while the rotated refresh
+/// record retains the full `read write` grant scope.
+#[tokio::test]
+async fn jwt_narrowed_refresh_binds_access_scope() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let service = jwt_service(test_signer()?)?;
+  let verifier = test_signer()?;
+  let client = unique("jwt-narrow-client");
+  let proof = dpop_proof(&unique("jwt-narrow-jkt"));
+  let minted = service
+    .mint(
+      &mut conn,
+      &store,
+      &MintRequest {
+        client_id: client.clone(),
+        client_kind: ClientKind::Public,
+        dpop_required: true,
+        subject: unique("subject"),
+        scope: "read write".to_owned(),
+        access_scope: "read write".to_owned(),
+        grant_id: unique("grant"),
+        proof: proof.clone(),
+        aud: jwt_aud(),
+        refresh_aud: jwt_aud(),
+        format: TokenFormat::Jwt,
+      },
+      super::now_unix(),
+    )
+    .await?;
+  let enabled = ScopeSet::parse("read write")?;
+
+  let mut narrowed_input = input(
+    &client,
+    ClientKind::Public,
+    &minted.refresh_token,
+    Some("read"),
+    &proof,
+    &enabled,
+  );
+  narrowed_input.token_format = TokenFormat::Jwt;
+  let narrowed = super::super::refresh(&mut conn, &store, &service, &narrowed_input).await?;
+  if narrowed.scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "response scope not narrowed: {}",
+      narrowed.scope
+    )));
+  }
+  let verified = verifier
+    .verify(&narrowed.access_token, None)
+    .map_err(|error| TestError::Unexpected(format!("rotated jwt rejected: {error}")))?;
+  if verified.claims.scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "jwt scope claim not narrowed: {}",
+      verified.claims.scope
+    )));
+  }
+  let member = format!("jwt:{}", jti_hash(&verified.claims.jti));
+  let access_scope = match low::get_access(&mut conn, &store, &member).await? {
+    Some(record) => record.scope,
+    None => {
+      return Err(TestError::Unexpected(
+        "rotated jwt access record not found".to_owned(),
+      ));
+    }
+  };
+  if access_scope != "read" {
+    return Err(TestError::Unexpected(format!(
+      "access record scope not narrowed: {access_scope}"
+    )));
+  }
+  let refresh_scope = match service
+    .find_refresh(&mut conn, &store, &narrowed.refresh_token)
+    .await?
+  {
+    Some(found) => found.record.scope,
+    None => {
+      return Err(TestError::Unexpected(
+        "rotated refresh token not found".to_owned(),
+      ));
+    }
+  };
+  if refresh_scope != "read write" {
+    return Err(TestError::Unexpected(format!(
+      "refresh scope not retained: {refresh_scope}"
+    )));
+  }
+  Ok(())
+}
+
 /// `discard_orphan` deletes the JWT marker by key: a leaked marker written
 /// for the discarded pre-mint `jti` is gone afterwards, with no live record
 /// or grant-set member left behind.
@@ -98,10 +197,11 @@ async fn jwt_discard_orphan_deletes_marker_by_key() -> TestResult {
         dpop_required: true,
         subject: unique("subject"),
         scope: "read".to_owned(),
+        access_scope: "read".to_owned(),
         grant_id: unique("grant"),
         proof,
-        aud: Vec::new(),
-        refresh_aud: Vec::new(),
+        aud: jwt_aud(),
+        refresh_aud: jwt_aud(),
         format: TokenFormat::Jwt,
       },
       super::now_unix(),
@@ -269,8 +369,63 @@ async fn jwt_failed_rotation_leaves_no_marker() -> TestResult {
   let added: Vec<&String> = after.iter().filter(|key| !before.contains(key)).collect();
   if !added.is_empty() {
     return Err(TestError::Unexpected(format!(
-      "failed rotation left live jwt markers: {added:?}"
+      "failed rotation leaves live jwt markers: {added:?}"
     )));
   }
   Ok(())
 }
+
+/// Refreshing an unbound grant (no `resource` was ever requested) after the
+/// client switches to `Jwt` fails `invalid_target` before minting or
+/// rotating, so the refresh record stays live for a retry.
+#[tokio::test]
+async fn jwt_refresh_of_unbound_grant_is_invalid_target_and_preserves_refresh() -> TestResult {
+  let (_container, mut conn, store, _url) = test_redis().await?;
+  let service = jwt_service(test_signer()?)?;
+  let client = unique("jwt-unbound-refresh-client");
+  let proof = dpop_proof(&unique("jwt-unbound-refresh-jkt"));
+  let minted = super::mint_grant(
+    &service,
+    &mut conn,
+    &store,
+    &client,
+    ClientKind::Public,
+    "read",
+    proof.clone(),
+  )
+  .await?;
+  let enabled = ScopeSet::parse("read")?;
+  let mut refresh_input = input(
+    &client,
+    ClientKind::Public,
+    &minted.refresh_token,
+    None,
+    &proof,
+    &enabled,
+  );
+  refresh_input.token_format = TokenFormat::Jwt;
+  match super::super::refresh(&mut conn, &store, &service, &refresh_input).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
+    Err(error) => {
+      return Err(TestError::Unexpected(format!(
+        "a resourceless jwt refresh must be invalid_target, got {}",
+        error.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "a resourceless jwt refresh must never rotate".to_owned(),
+      ));
+    }
+  }
+  if service
+    .find_refresh(&mut conn, &store, &minted.refresh_token)
+    .await?
+    .is_none()
+  {
+    return Err(TestError::Unexpected(
+      "the refresh token must stay live after an invalid_target rejection".to_owned(),
+    ));
+  }
+  Ok(())
+}

+ 1 - 0
src/protocol/token/grants/token_exchange.rs

@@ -282,6 +282,7 @@ pub async fn exchange(
         // never be wrapped in an `at+jwt`.
         subject: subject.record.subject.clone(),
         scope: issued.render(),
+        access_scope: issued.render(),
         grant_id,
         proof: input.proof.clone(),
         aud: Vec::new(),

+ 1 - 0
src/protocol/token/grants/token_exchange/tests.rs

@@ -103,6 +103,7 @@ async fn mint_subject(
         dpop_required: false,
         subject: subject.to_owned(),
         scope: scope.to_owned(),
+        access_scope: scope.to_owned(),
         grant_id: unique("subject-grant"),
         proof,
         aud: Vec::new(),

+ 6 - 3
src/protocol/token/service.rs

@@ -230,7 +230,7 @@ impl TokenService {
     let access = low::AccessRecord {
       client_id: request.client_id.clone(),
       grant_id: request.grant_id.clone(),
-      scope: request.scope.clone(),
+      scope: request.access_scope.clone(),
       subject: request.subject.clone(),
       cnf: cnf_json.clone(),
       aud: request.aud.clone(),
@@ -305,6 +305,9 @@ impl TokenService {
     now_unix: i64,
     apply_barrier: bool,
   ) -> Result<MintedTokens, TokenServiceError> {
+    if request.aud.is_empty() {
+      return Err(TokenServiceError::EmptyAudience);
+    }
     let signer = self
       .jwt
       .as_ref()
@@ -319,7 +322,7 @@ impl TokenService {
       iss: signer.issuer().to_owned(),
       sub: request.subject.clone(),
       aud: request.aud.clone(),
-      scope: request.scope.clone(),
+      scope: request.access_scope.clone(),
       exp: access_expires_at,
       iat: issued_at,
       jti: jti_raw,
@@ -338,7 +341,7 @@ impl TokenService {
     let access = low::AccessRecord {
       client_id: request.client_id.clone(),
       grant_id: request.grant_id.clone(),
-      scope: request.scope.clone(),
+      scope: request.access_scope.clone(),
       subject: request.subject.clone(),
       cnf: cnf_json.clone(),
       aud: request.aud.clone(),

+ 3 - 0
src/protocol/token/tests.rs

@@ -95,6 +95,7 @@ fn mint_request(client: &str, kind: ClientKind, grant: &str, proof: SenderProof)
     dpop_required: kind == ClientKind::Public,
     subject: "user-1".to_owned(),
     scope: "orders:read".to_owned(),
+    access_scope: "orders:read".to_owned(),
     grant_id: grant.to_owned(),
     proof,
     aud: Vec::new(),
@@ -165,6 +166,7 @@ async fn public_unbound_gets_bearer() -> TestResult {
         dpop_required: false,
         subject: "user-1".to_owned(),
         scope: "orders:read".to_owned(),
+        access_scope: "orders:read".to_owned(),
         grant_id: unique("grant"),
         proof: SenderProof::none(),
         aud: Vec::new(),
@@ -197,6 +199,7 @@ async fn public_bound_requires_dpop() -> TestResult {
         dpop_required: true,
         subject: "user-1".to_owned(),
         scope: "orders:read".to_owned(),
+        access_scope: "orders:read".to_owned(),
         grant_id: unique("grant"),
         proof: SenderProof::none(),
         aud: Vec::new(),

+ 72 - 4
src/protocol/token/tests/jwt.rs

@@ -9,9 +9,10 @@ use secrecy::SecretString;
 use super::{TestError, TestResult, mint_request, ring, test_redis, unique};
 use crate::{
   config::{JwtSigningAlg, JwtSigningKey, TtlConfig},
+  error::{AppError, OAuthErrorCode},
   jose::jwt_access::{JwtAccessSigner, jti_hash, marker_live, marker_live_strict},
   keys::KeyId,
-  protocol::token::{ClientKind, SenderProof, TokenFormat, TokenService},
+  protocol::token::{ClientKind, SenderProof, TokenFormat, TokenService, TokenServiceError},
 };
 
 /// A fixed 32-byte ASCII signing key (the e2e JWT fixture material).
@@ -62,6 +63,8 @@ async fn jwt_mint_revoke_race_no_bypass() -> TestResult {
     SenderProof::none(),
   );
   request.format = TokenFormat::Jwt;
+  request.aud = vec!["https://api.example.test/".to_owned()];
+  request.refresh_aud = request.aud.clone();
   let minted = service
     .mint(&mut conn, &store, &request, super::now_unix())
     .await?;
@@ -71,8 +74,14 @@ async fn jwt_mint_revoke_race_no_bypass() -> TestResult {
     ));
   }
   let verified = signer
-    .verify(&minted.access_token, None)
+    .verify(&minted.access_token, Some(&request.aud))
     .map_err(|error| TestError::Unexpected(format!("fresh jwt rejected: {error:?}")))?;
+  if verified.claims.aud != request.aud {
+    return Err(TestError::Unexpected(format!(
+      "aud must be {:?}, got {:?}",
+      request.aud, verified.claims.aud
+    )));
+  }
   let hash = jti_hash(&verified.claims.jti);
   let member = format!("jwt:{hash}");
   if !marker_live(&mut conn, &store, &hash)
@@ -169,6 +178,8 @@ async fn jwt_cascade_revoke_deletes_marker() -> TestResult {
     SenderProof::none(),
   );
   request.format = TokenFormat::Jwt;
+  request.aud = vec!["https://api.example.test/".to_owned()];
+  request.refresh_aud = request.aud.clone();
   // Empty subject keeps the liveness verdict on the marker alone (no account
   // lookup), so the assertion pins exactly the allowlist behavior.
   request.subject = String::new();
@@ -176,7 +187,7 @@ async fn jwt_cascade_revoke_deletes_marker() -> TestResult {
     .mint(&mut conn, &store, &request, super::now_unix())
     .await?;
   let verified = signer
-    .verify(&minted.access_token, None)
+    .verify(&minted.access_token, Some(&request.aud))
     .map_err(|error| TestError::Unexpected(format!("fresh jwt rejected: {error:?}")))?;
   let hash = jti_hash(&verified.claims.jti);
   let marker_key = store.jwt_marker_key(&hash);
@@ -231,7 +242,64 @@ async fn jwt_cascade_revoke_deletes_marker() -> TestResult {
   // The cascade revokes liveness, not the signature: the token must still
   // parse and verify while both liveness checks report dead.
   signer
-    .verify(&minted.access_token, None)
+    .verify(&minted.access_token, Some(&request.aud))
     .map_err(|error| TestError::Unexpected(format!("signature check failed: {error:?}")))?;
   Ok(())
 }
+
+/// `test(jwt_mint_rejects_empty_audience)`: `mint_jwt` with an empty `aud`
+/// fails with `EmptyAudience` before signing or writing anything, and the
+/// error maps to `invalid_target` (HTTP 400), never `server_error`.
+#[tokio::test]
+async fn jwt_mint_rejects_empty_audience() -> TestResult {
+  let (mut conn, store) = test_redis().await?;
+  let signer = test_signer()?;
+  let service = jwt_service(signer)?;
+  let client = unique("jwt-empty-aud-client");
+  let grant = unique("jwt-empty-aud-grant");
+  let mut request = mint_request(
+    &client,
+    ClientKind::Confidential,
+    &grant,
+    SenderProof::none(),
+  );
+  request.format = TokenFormat::Jwt;
+  request.aud = Vec::new();
+  request.refresh_aud = Vec::new();
+  match service
+    .mint(&mut conn, &store, &request, super::now_unix())
+    .await
+  {
+    Err(TokenServiceError::EmptyAudience) => {}
+    Err(other) => {
+      return Err(TestError::Unexpected(format!(
+        "empty-audience jwt mint must be EmptyAudience, got {other}"
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "an empty-audience jwt mint must never succeed".to_owned(),
+      ));
+    }
+  }
+  let mapped = AppError::from(TokenServiceError::EmptyAudience);
+  if mapped.code() != OAuthErrorCode::InvalidTarget {
+    return Err(TestError::Unexpected(format!(
+      "EmptyAudience must map to invalid_target, got {}",
+      mapped.code()
+    )));
+  }
+  if mapped.status() != axum::http::StatusCode::BAD_REQUEST {
+    return Err(TestError::Unexpected(format!(
+      "EmptyAudience must render 400, got {}",
+      mapped.status()
+    )));
+  }
+  if mapped.description() != "jwt access tokens require a resource audience" {
+    return Err(TestError::Unexpected(format!(
+      "EmptyAudience description mismatch: {}",
+      mapped.description()
+    )));
+  }
+  Ok(())
+}

+ 44 - 2
src/protocol/token/types.rs

@@ -56,18 +56,30 @@ pub enum TokenServiceError {
   /// so this is unreachable in practice; fail closed).
   #[error("JWT minting failed")]
   Jwt(#[from] crate::jose::jwt_access::JwtError),
+  /// A `Jwt` mint ran with an empty audience: RFC 9068 `aud` is mandatory,
+  /// so a JWT access token without a requested RFC 8707 resource is
+  /// rejected before signing or writing anything.
+  #[error("jwt access tokens require a resource audience")]
+  EmptyAudience,
 }
 
 impl From<TokenServiceError> for AppError {
   /// Map a service failure onto the RFC error model: a missing public-client
   /// binding is the caller's `invalid_grant`; store failures reuse the store
-  /// mapping (fail closed); everything else is masked as `server_error`.
+  /// mapping (fail closed); a JWT signing/deployment failure is the fail-closed
+  /// `503`; everything else is masked as `server_error`.
   fn from(error: TokenServiceError) -> Self {
     match error {
       TokenServiceError::MissingDpopBinding => {
         Self::invalid_grant("public clients require a DPoP-bound token")
       }
+      TokenServiceError::EmptyAudience => {
+        Self::invalid_target("jwt access tokens require a resource audience")
+      }
       TokenServiceError::Store(inner) => Self::from(inner),
+      TokenServiceError::JwtSignerMissing | TokenServiceError::Jwt(_) => {
+        Self::temporarily_unavailable()
+      }
       other => Self::internal(other),
     }
   }
@@ -192,6 +204,14 @@ pub(super) fn resolve_cnf(
 /// so the rotation script — which commits the [`RotationInput`] records
 /// verbatim and never rewrites the grant record — preserves the full binding
 /// while the access token narrows.
+///
+/// Scope travels as the same kind of pair: [`MintRequest::scope`] is the
+/// REFRESH/grant scope (the retained set, re-checked against the current
+/// catalog, client registration, and entitlements), while
+/// [`MintRequest::access_scope`] is the ACCESS scope (the narrowed subset on
+/// an explicit `scope` request, else equal to `scope`). The JWT `scope`
+/// claim and the stored access record bind `access_scope`; the refresh and
+/// grant records bind `scope`.
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct MintRequest {
   /// Client the tokens are issued to.
@@ -204,8 +224,12 @@ pub struct MintRequest {
   pub dpop_required: bool,
   /// Resource-owner subject, empty for client-credentials tokens.
   pub subject: String,
-  /// Space-joined scope.
+  /// Space-joined scope: the REFRESH/grant scope (retained set).
   pub scope: String,
+  /// Space-joined ACCESS scope: the narrowed subset on an explicit `scope`
+  /// request, else equal to [`MintRequest::scope`]. Binds the JWT `scope`
+  /// claim and the stored access record.
+  pub access_scope: String,
   /// Grant chain the tokens join.
   pub grant_id: String,
   /// Freshly verified proofs for this request.
@@ -328,3 +352,21 @@ impl fmt::Debug for FoundRefresh {
       .finish()
   }
 }
+
+#[cfg(test)]
+mod tests {
+  use axum::http::StatusCode;
+
+  use super::TokenServiceError;
+  use crate::{error::AppError, jose::jwt_access::JwtError};
+
+  /// A missing signer or a signing failure is a deployment/dependency failure:
+  /// it must fail closed with `503`, never a masked `500`.
+  #[test]
+  fn jwt_service_failures_map_to_503() {
+    let missing = AppError::from(TokenServiceError::JwtSignerMissing);
+    assert_eq!(missing.status(), StatusCode::SERVICE_UNAVAILABLE);
+    let signing = AppError::from(TokenServiceError::Jwt(JwtError::NoSigningKey));
+    assert_eq!(signing.status(), StatusCode::SERVICE_UNAVAILABLE);
+  }
+}

+ 120 - 10
src/routes.rs

@@ -222,7 +222,7 @@ async fn authorize_entry(
 ) -> Response {
   // `RawQuery` + a manual parse replaces `Query<HashMap>`: the extractor
   // consumed the raw query, hiding repeats — the repeated `resource`
-  // indicator (RFC 8707) is captured in order by `params::repeated_values`
+  // indicator (RFC 8707) is captured in order by `params::values_of`
   // while the single-valued map keeps last-wins absorption. A malformed
   // query is the same 400 `Query` rendered.
   let raw = raw.as_deref().unwrap_or("");
@@ -232,7 +232,7 @@ async fn authorize_entry(
   };
   let query = crate::protocol::params::single_valued_map(&pairs);
   let resources =
-    crate::protocol::params::repeated_values(raw, crate::protocol::params::RESOURCE_PARAM);
+    crate::protocol::params::values_of(&pairs, crate::protocol::params::RESOURCE_PARAM);
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let input = authorize::AuthorizeInput {
@@ -1226,9 +1226,10 @@ mod router_tests {
     Ok(())
   }
 
-  /// `POST /par` preserves the `Form` transport contract after the `Bytes`
-  /// switch: a structurally unreadable body (raw non-UTF-8 bytes) is the
-  /// same 400 `Form` rendered, a lossy-encoded body (`%FF` decodes to
+  /// `POST /par` preserves the transport contract after the `RawForm`
+  /// switch: a structurally unreadable body (raw non-UTF-8 bytes) is 400
+  /// (deliberately stricter than the old `Form`, which lossily accepted raw
+  /// non-UTF-8 bytes), a lossy-encoded body (`%FF` decodes to
   /// U+FFFD) reaches downstream validation (`invalid_client`: no
   /// credentials name a client), and a non-urlencoded body is 415.
   #[tokio::test]
@@ -1275,7 +1276,7 @@ mod router_tests {
   }
 
   /// `POST /token` preserves the documented duplicate-parameter mapping
-  /// after the `Bytes` switch: a repeated known field is `invalid_request`
+  /// after the `RawForm` switch: a repeated known field is `invalid_request`
   /// (RFC 6749 §5.2), and a non-urlencoded body is 415.
   #[tokio::test]
   async fn router_token_duplicate_param_preserved() -> TestResult {
@@ -1309,10 +1310,14 @@ mod router_tests {
   }
 
   /// `POST /introspect` and `POST /device_authorization` preserve the
-  /// `Form` transport contract after the `Bytes` switch: a structurally
-  /// unreadable body (raw non-UTF-8 bytes) is the same 400 `Form`
-  /// rendered, a lossy-encoded body (`%FF` decodes to U+FFFD) reaches
-  /// downstream validation, and non-urlencoded bodies are 415. The
+  /// transport contract after the `RawForm` switch: a structurally
+  /// unreadable body (raw non-UTF-8 bytes) is 400 plain text (deliberately
+  /// stricter than the old `Form`, which lossily accepted raw non-UTF-8
+  /// bytes), a lossy-encoded body (`%FF` decodes to U+FFFD) reaches
+  /// downstream validation, and non-urlencoded bodies are 415. A repeated
+  /// known field is JSON `invalid_request` (the old `Form` returned 422
+  /// `Failed to deserialize form body`, so the new JSON 400 is the intended
+  /// contract, not parity). The
   /// downstream expectation differs per path: introspect has no
   /// credentials, so `invalid_client` (401); device has an empty
   /// `client_id`, so `invalid_request` (400).
@@ -1373,6 +1378,111 @@ mod router_tests {
     Ok(())
   }
 
+  /// A repeated known field on `POST /introspect` is JSON `invalid_request`
+  /// (RFC 6749 §5.2), never the old `Form` 422 `Failed to deserialize form
+  /// body` and never plain text.
+  #[tokio::test]
+  async fn router_introspect_duplicate_param_is_json_invalid_request() -> TestResult {
+    let urlencoded = [("content-type", "application/x-www-form-urlencoded")];
+    let (status, _, body) = drive(
+      build_router(passthrough_state()?),
+      "POST",
+      "/introspect",
+      &urlencoded,
+      "token=abc&token=def",
+    )
+    .await?;
+    if status != StatusCode::BAD_REQUEST || !body.contains("invalid_request") {
+      return Err(
+        format!("duplicate introspect param must be invalid_request 400, got {status}: {body}")
+          .into(),
+      );
+    }
+    if body.contains("Failed to deserialize form") {
+      return Err("duplicate introspect param must be JSON, not the plain-text form error".into());
+    }
+    Ok(())
+  }
+
+  /// A repeated known field on `POST /device_authorization` is JSON
+  /// `invalid_request` (RFC 6749 §5.2), never the old `Form` 422 `Failed to
+  /// deserialize form body` and never plain text.
+  #[tokio::test]
+  async fn router_device_duplicate_param_is_json_invalid_request() -> TestResult {
+    let urlencoded = [("content-type", "application/x-www-form-urlencoded")];
+    let (status, _, body) = drive(
+      build_router(passthrough_state()?),
+      "POST",
+      "/device_authorization",
+      &urlencoded,
+      "client_id=nobody&client_id=somebody",
+    )
+    .await?;
+    if status != StatusCode::BAD_REQUEST || !body.contains("invalid_request") {
+      return Err(
+        format!("duplicate device param must be invalid_request 400, got {status}: {body}").into(),
+      );
+    }
+    if body.contains("Failed to deserialize form") {
+      return Err("duplicate device param must be JSON, not the plain-text form error".into());
+    }
+    Ok(())
+  }
+
+  /// `POST /revoke` preserves the transport contract after the `RawForm`
+  /// switch: a repeated known field is JSON `invalid_request`, a
+  /// non-urlencoded body is 415, and a well-formed unauthenticated body
+  /// still parses and reaches authentication (`invalid_client` 401),
+  /// proving the field mapping survived the `Form<HashMap>` migration.
+  #[tokio::test]
+  async fn router_revoke_transport_contract_preserved() -> TestResult {
+    let urlencoded = [("content-type", "application/x-www-form-urlencoded")];
+    let (dup_status, _, dup_body) = drive(
+      build_router(passthrough_state()?),
+      "POST",
+      "/revoke",
+      &urlencoded,
+      "token=abc&token=def",
+    )
+    .await?;
+    if dup_status != StatusCode::BAD_REQUEST || !dup_body.contains("invalid_request") {
+      return Err(
+        format!("duplicate revoke param must be invalid_request 400, got {dup_status}: {dup_body}")
+          .into(),
+      );
+    }
+    if dup_body.contains("Failed to deserialize form") {
+      return Err("duplicate revoke param must be JSON, not the plain-text form error".into());
+    }
+    let json = [("content-type", "application/json")];
+    let (ctype_status, _, _) = drive(
+      build_router(passthrough_state()?),
+      "POST",
+      "/revoke",
+      &json,
+      "token=abc",
+    )
+    .await?;
+    if ctype_status != StatusCode::UNSUPPORTED_MEDIA_TYPE {
+      return Err(format!("non-form revoke body must be 415, got {ctype_status}").into());
+    }
+    let (auth_status, _, auth_body) = drive(
+      build_router(passthrough_state()?),
+      "POST",
+      "/revoke",
+      &urlencoded,
+      "token=abc",
+    )
+    .await?;
+    if auth_status != StatusCode::UNAUTHORIZED || !auth_body.contains("invalid_client") {
+      return Err(
+        format!("well-formed unauthenticated revoke must be invalid_client 401, got {auth_status}: {auth_body}")
+          .into(),
+      );
+    }
+    Ok(())
+  }
+
   /// `GET /authorize` with no usable client never redirects with 307/308.
   #[tokio::test]
   async fn router_authorize_entry_never_307() -> TestResult {

+ 19 - 0
src/store/mod.rs

@@ -970,6 +970,25 @@ pub(crate) static SCRIPT_GRANT_REVOKE: LazyLock<Script> = LazyLock::new(|| {
   )
 });
 
+/// Atomic orphan cleanup for a discarded JWT rotation pre-mint.
+///
+/// KEYS: access record, grant token set (or a same-slot dummy when there is
+/// no grant id), allowlist marker. ARGV: member, grant id (empty means the
+/// `SREM` is skipped). One command so a discarded pre-mint can never leave a
+/// live marker next to a surviving record/member.
+pub(crate) static SCRIPT_DISCARD_ACCESS_JWT: LazyLock<Script> = LazyLock::new(|| {
+  Script::new(
+    r"
+    redis.call('DEL', KEYS[1])
+    if ARGV[2] ~= '' then
+      redis.call('SREM', KEYS[2], ARGV[1])
+    end
+    redis.call('DEL', KEYS[3])
+    return 1
+    ",
+  )
+});
+
 /// Fixed-window rate-limit counter: `INCR` plus `PEXPIRE`-on-first.
 pub(crate) static SCRIPT_RATELIMIT_INCR: LazyLock<Script> = LazyLock::new(|| {
   Script::new(

+ 34 - 2
src/store/tokens.rs

@@ -21,8 +21,8 @@ use std::{collections::HashMap, time::Duration};
 use serde::{Deserialize, Serialize};
 
 use super::{
-  OpClass, SCRIPT_PUT_MEMBER, SCRIPT_ROTATE_REFRESH, Store, StoreError, durability_barrier,
-  hmac_sha256, may_retry_op, retry_op,
+  OpClass, SCRIPT_DISCARD_ACCESS_JWT, SCRIPT_PUT_MEMBER, SCRIPT_ROTATE_REFRESH, Store, StoreError,
+  durability_barrier, hmac_sha256, may_retry_op, retry_op,
 };
 use crate::state::RedisConn;
 
@@ -241,6 +241,38 @@ pub async fn put_access_jwt(
   put_access_inner(conn, store, member, record, ttl, true, Some(&marker)).await
 }
 
+/// Atomically discard a JWT rotation pre-mint: delete its access record,
+/// remove its grant-set member, and delete its allowlist marker in one Lua
+/// command. An empty `grant_id` skips the `SREM` and uses the record key as
+/// the same-slot dummy.
+pub async fn discard_access_jwt(
+  conn: &mut RedisConn,
+  store: &Store,
+  member: &str,
+  jti_hash: &str,
+  grant_id: &str,
+) -> Result<(), StoreError> {
+  let record_key = store.access_key(member);
+  let marker_key = store.jwt_marker_key(jti_hash);
+  let set_key = if grant_id.is_empty() {
+    record_key.clone()
+  } else {
+    store.grant_tokens_key(grant_id)
+  };
+  let mut invocation = SCRIPT_DISCARD_ACCESS_JWT.prepare_invoke();
+  invocation
+    .key(&record_key)
+    .key(&set_key)
+    .key(&marker_key)
+    .arg(member)
+    .arg(grant_id);
+  let _: i64 = invocation
+    .invoke_async(conn)
+    .await
+    .map_err(StoreError::Redis)?;
+  Ok(())
+}
+
 /// Store an access-token record and index it in its grant set WITHOUT the
 /// durability barrier.
 ///

+ 34 - 5
src/ui/consent.rs

@@ -35,6 +35,7 @@ use crate::{
   keys::KeyRing,
   middleware::{audit::AuditEvent, request_id::RequestId},
   protocol::{
+    cimd::{self, CimdSettings},
     code,
     scope::{self, ScopeSet},
   },
@@ -137,6 +138,9 @@ pub struct ConsentDeps<'a> {
   pub store: &'a Store,
   /// Versioned PII keys for the encrypted user-email lookup.
   pub keys: &'a KeyRing,
+  /// CIMD resolution settings; `None` keeps CIMD disabled, while a registered
+  /// active row still wins when enabled (CIMD draft §3).
+  pub cimd: Option<CimdSettings<'a>>,
 }
 
 /// Whether `live` is a full authenticated session (never a pre-session or a
@@ -270,15 +274,26 @@ fn client_kind_of(client: &client_repo::Client) -> code::CodeClientKind {
   }
 }
 
-/// Load the active client and the non-disabled user for a pending request.
+/// Load the resolved client and the non-disabled user for a pending request.
+///
+/// Resolution is the single CIMD seam: a registered active row wins, and when
+/// CIMD is enabled an `https://` client id with no row materializes the
+/// synthetic client from its validated document.
 async fn client_and_user(
-  deps: &ConsentDeps<'_>,
+  deps: &mut ConsentDeps<'_>,
   pending: &PendingAuth,
   subject: &str,
 ) -> Result<(client_repo::Client, user_repo::UserRecord), AppError> {
-  let client = client_repo::find_active_by_client_id(deps.pool, deps.keys, &pending.client_id)
-    .await?
-    .ok_or_else(|| AppError::invalid_request("the client is no longer active"))?;
+  let client = cimd::resolve_client(
+    deps.pool,
+    deps.keys,
+    deps.conn,
+    deps.cimd,
+    &pending.client_id,
+  )
+  .await
+  .map_err(AppError::from)?
+  .ok_or_else(|| AppError::invalid_request("the client is no longer active"))?;
   let user = user_repo::find_by_email(deps.pool, deps.keys, subject)
     .await?
     .filter(|user| !user.disabled)
@@ -435,11 +450,18 @@ pub async fn get_consent(
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let session_id = login::session_id_from_jar(&state.config, &jar);
+  let cimd = state.config.cimd_enabled.then(|| CimdSettings {
+    token_prefix: &state.config.redis.token_key_prefix,
+    max_bytes: state.config.cimd_max_bytes,
+    timeout: state.config.cimd_fetch_timeout,
+    allow_localhost: state.config.allow_localhost_redirect,
+  });
   let mut deps = ConsentDeps {
     pool: &state.pool,
     conn: &mut conn,
     store: &store,
     keys: &state.keys,
+    cimd,
   };
   match load_consent_view(&mut deps, &session_id).await {
     Ok(Some(view)) => render(ConsentTemplate {
@@ -478,11 +500,18 @@ pub async fn post_consent(
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let session_id = login::session_id_from_jar(&state.config, &jar);
+  let cimd = state.config.cimd_enabled.then(|| CimdSettings {
+    token_prefix: &state.config.redis.token_key_prefix,
+    max_bytes: state.config.cimd_max_bytes,
+    timeout: state.config.cimd_fetch_timeout,
+    allow_localhost: state.config.allow_localhost_redirect,
+  });
   let mut deps = ConsentDeps {
     pool: &state.pool,
     conn: &mut conn,
     store: &store,
     keys: &state.keys,
+    cimd,
   };
   let submission = ConsentSubmission {
     session_id: &session_id,

+ 137 - 2
src/ui/consent/tests.rs

@@ -33,7 +33,10 @@ use crate::{
   },
   error::{AppError, OAuthErrorCode},
   keys::{KeyId, KeyRing},
-  protocol::code,
+  protocol::{
+    cimd::{self, CimdDocument, CimdSettings},
+    code,
+  },
   state::RedisConn,
   store::{
     self, Store, codes,
@@ -59,6 +62,8 @@ struct Harness {
   conn: RedisConn,
   /// Store handle over the test keyspaces.
   store: Store,
+  /// Token keyspace prefix (CIMD cache key layout).
+  token_prefix: String,
   /// PII key ring for the encrypted user-email lookup (strict encrypt-only).
   keys: KeyRing,
 }
@@ -68,12 +73,17 @@ async fn harness() -> Result<Harness, TestError> {
   let (db, pool) = crate::db::test_support::migrated_pool().await?;
   let manager = crate::test_support::redis_manager().await?;
   let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
-  let store = Store::from_parts(state_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
+  let store = Store::from_parts(
+    state_prefix.clone(),
+    ratelimit_prefix,
+    DurabilityMode::WaitAof,
+  );
   Ok(Harness {
     _db: db,
     pool,
     conn: RedisConn::Single(manager),
     store,
+    token_prefix: state_prefix,
     keys: test_ring()?,
   })
 }
@@ -85,6 +95,23 @@ fn deps_of(h: &mut Harness) -> ConsentDeps<'_> {
     conn: &mut h.conn,
     store: &h.store,
     keys: &h.keys,
+    cimd: None,
+  }
+}
+
+/// Borrow the harness with CIMD resolution enabled.
+fn cimd_deps_of(h: &mut Harness) -> ConsentDeps<'_> {
+  ConsentDeps {
+    pool: &h.pool,
+    conn: &mut h.conn,
+    store: &h.store,
+    keys: &h.keys,
+    cimd: Some(CimdSettings {
+      token_prefix: &h.token_prefix,
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(2),
+      allow_localhost: false,
+    }),
   }
 }
 
@@ -227,6 +254,114 @@ async fn code_key_count(h: &mut Harness) -> Result<usize, TestError> {
   Ok(keys.len())
 }
 
+/// Seed a CIMD document into the Redis cache so resolution never fetches.
+async fn seed_cimd(
+  h: &mut Harness,
+  client_id_url: &str,
+  document: &CimdDocument,
+) -> Result<(), TestError> {
+  let payload = serde_json::to_string(document)?;
+  redis::cmd("SET")
+    .arg(cimd::cache_key(&h.token_prefix, client_id_url))
+    .arg(payload)
+    .arg("EX")
+    .arg(900)
+    .query_async::<()>(&mut h.conn)
+    .await?;
+  Ok(())
+}
+
+/// A registration-free CIMD client (no Postgres row) loads consent and
+/// approves, using the document's name, scope, DPoP flag, and auth method.
+#[tokio::test]
+async fn consent_cimd_client_without_row_loads_and_approves() -> TestResult {
+  let mut h = harness().await?;
+  let fx = provision(&mut h).await?;
+  let client_id_url = "https://cimd.example.test/client.json";
+  let document = CimdDocument {
+    client_id: client_id_url.to_owned(),
+    redirect_uris: vec!["https://client.example.test/cb".to_owned()],
+    token_endpoint_auth_method: Some("none".to_owned()),
+    grant_types: Some(vec!["authorization_code".to_owned()]),
+    scope: Some("orders:read".to_owned()),
+    jwks: None,
+    jwks_uri: None,
+    client_name: Some("CIMD Test App".to_owned()),
+    dpop_bound_access_tokens: Some(true),
+  };
+  seed_cimd(&mut h, client_id_url, &document).await?;
+  let record = PendingAuth {
+    client_id: client_id_url.to_owned(),
+    redirect_uri: "https://client.example.test/cb".to_owned(),
+    scope: "orders:read orders:write".to_owned(),
+    code_challenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".to_owned(),
+    state: Some("state-xyz".to_owned()),
+    dpop_jkt: Some("cimd-jkt".to_owned()),
+    iss: "https://auth.example.test".to_owned(),
+    post_logout_uri: None,
+    aud: Vec::new(),
+  };
+  pending::put(
+    &mut h.conn,
+    &h.store,
+    &fx.request_id,
+    &record,
+    Duration::from_secs(300),
+  )
+  .await?;
+  let view = {
+    let mut deps = cimd_deps_of(&mut h);
+    load_consent_view(&mut deps, &fx.session_id).await?
+  };
+  let Some(view) = view else {
+    return Err("a CIMD client must render consent without a registered row".into());
+  };
+  if view.client_name != "CIMD Test App" {
+    return Err(format!("document client_name must render: {}", view.client_name).into());
+  }
+  if view.scopes != vec!["orders:read".to_owned()] {
+    return Err(format!("document scope must bound the view: {:?}", view.scopes).into());
+  }
+  let issuer = test_issuer()?;
+  let outcome = {
+    let mut deps = cimd_deps_of(&mut h);
+    submit_consent(
+      &mut deps,
+      ConsentSubmission {
+        session_id: &fx.session_id,
+        request_id: &view.request_id,
+        csrf: &view.csrf,
+        decision: ConsentDecision::Approve,
+      },
+      &issuer,
+    )
+    .await?
+  };
+  let issued = match outcome {
+    ConsentOutcome::Approved { issued } => issued,
+    _ => return Err("a CIMD approval must issue a code".into()),
+  };
+  let stored = codes::consume(&mut h.conn, &h.store, &issued.code_hash)
+    .await?
+    .ok_or_else(|| -> TestError { "the issued code must be stored".into() })?;
+  if stored.client_id != client_id_url {
+    return Err(
+      format!(
+        "code must carry the CIMD client id, got: {}",
+        stored.client_id
+      )
+      .into(),
+    );
+  }
+  if stored.scope != "orders:read" {
+    return Err(format!("code must store the effective scope, got: {}", stored.scope).into());
+  }
+  if stored.cnf != "cimd-jkt" {
+    return Err("the document DPoP flag must bind the code".into());
+  }
+  Ok(())
+}
+
 /// Approve issues a code whose stored record carries the effective scope
 /// (the unentitled `orders:write` is dropped before issuance).
 #[tokio::test]

+ 1 - 0
tests/e2e/harness.rs

@@ -299,6 +299,7 @@ impl World {
           dpop_required: kind == ClientKind::Public,
           subject: "e2e-subject".to_owned(),
           scope: scope.to_owned(),
+          access_scope: scope.to_owned(),
           grant_id: uuid::Uuid::new_v4().to_string(),
           proof: SenderProof {
             dpop_jkt: jkt.map(str::to_owned),

+ 21 - 9
tests/e2e_grants.rs

@@ -1371,11 +1371,19 @@ async fn setup_with_jwt_flag(enabled: bool) -> Result<World, TestError> {
     serde_json::json!({
       "jwt_access_tokens_enabled": true,
       "jwt_signing_key": JWT_TEST_KEY,
+      "resource_indicators_enabled": true,
     })
   } else {
     serde_json::json!({})
   };
   let world = World::start_with(open_rate_limit(), overrides).await?;
+  if enabled {
+    for resource in [RESOURCE_A, RESOURCE_B] {
+      resource_repo::upsert(&world.pool, resource, "e2e resource server")
+        .await
+        .map_err(|error| fail(format!("resource upsert failed: {error}")))?;
+    }
+  }
   seed_client(
     &world.pool,
     &world.state.keys,
@@ -1498,10 +1506,9 @@ async fn jwt_client_flag_off_is_503() -> Result<(), TestError> {
 }
 
 /// The positive branch: `jwt` + the global switch on + a valid signing key
-/// passes the format gate instead of 503.
-///
-/// Todo 26 turns the mint into a real JWT; until then the gate is the only
-/// behavior this test pins, so a 200 opaque token is expected here.
+/// passes the format gate instead of 503 and mints a real JWT bound to the
+/// requested `resource` (a JWT without a resource audience is
+/// `invalid_target`).
 #[tokio::test]
 async fn jwt_client_with_flag_on_resolves_jwt() -> Result<(), TestError> {
   let world = setup_with_jwt_flag(true).await?;
@@ -1512,7 +1519,10 @@ async fn jwt_client_with_flag_on_resolves_jwt() -> Result<(), TestError> {
   let (status, _, body) = world
     .post_form(
       "/token",
-      &[("grant_type", "client_credentials")],
+      &[
+        ("grant_type", "client_credentials"),
+        ("resource", RESOURCE_A),
+      ],
       Some((CLIENT_ID, CLIENT_SECRET)),
       &[],
     )
@@ -1525,11 +1535,13 @@ async fn jwt_client_with_flag_on_resolves_jwt() -> Result<(), TestError> {
     status == reqwest::StatusCode::OK,
     format!("the gate's positive branch must reach minting: {status} {body}"),
   )?;
+  let token = json_body(&body)?["access_token"]
+    .as_str()
+    .map(str::to_owned)
+    .ok_or_else(|| fail("the positive branch mints a token"))?;
   ensure(
-    json_body(&body)?["access_token"]
-      .as_str()
-      .is_some_and(|token| !token.is_empty()),
-    "the positive branch mints a token",
+    token.split('.').count() == 3,
+    "the positive branch mints a JWT",
   )?;
   Ok(())
 }