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"
 base64ct = "1.8.3"
 clap = { version = "4.6.7", features = ["derive"] }
 clap = { version = "4.6.7", features = ["derive"] }
 config = "0.15.27"
 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"
 hmac = "0.13.0"
 hyper = "1.11.1"
 hyper = "1.11.1"
 hyper-util = { version = "0.1.21", features = ["server-auto", "tokio", "service"] }
 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,
 It implements the authorization-code, refresh-token, client-credentials,
 device-code, and token-exchange grants, with DPoP, mTLS, PAR, JAR, dynamic
 device-code, and token-exchange grants, with DPoP, mTLS, PAR, JAR, dynamic
 client registration, introspection, and revocation. Tokens are opaque and
 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
 durable domain state is in PostgreSQL and all ephemeral state is in Redis, so
 replicas are interchangeable.
 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/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/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/` | 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/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/resilience.rs` | Load-shed, timeout, and concurrency-limit guard between CORS and rate limiting. |
 | `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
 `Query<HashMap>` or `Form<...>`, because those extractors consume the raw input
 and cannot see a repeated `resource`: `GET /authorize`, `POST /par`,
 and cannot see a repeated `resource`: `GET /authorize`, `POST /par`,
 `POST /token`, `POST /device_authorization`, and `POST /introspect`.
 `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.
 lookup.
 
 
 ## The two listeners
 ## 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
   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
   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
   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
   `jwt_access_tokens_enabled` without a signing key fails closed with
   `MissingJwtSigningKey`.
   `MissingJwtSigningKey`.
 - `jwt_signing_previous_keys` retains rotated-out keys as a list of
 - `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
   (`InvalidCimdFetchTimeout`) and `cimd_max_bytes` between 1 and 5120
   (`InvalidCimdMaxBytes`). A malformed value fails the load rather than
   (`InvalidCimdMaxBytes`). A malformed value fails the load rather than
   silently meaning "no limit".
   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
 ## 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. |
 | `active` | `bool` | Soft disable, default true. |
 | `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
 | `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`). |
 | `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. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
 
 Four partial unique indexes enforce client-identity uniqueness. Two are on the
 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.
   Every shared-secret and mTLS method is rejected.
 - Any `jwks_uri` member is rejected. For `private_key_jwt`, an inline `jwks`
 - 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,
   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
 - `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,
   `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
   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:
 Resolution and the synthetic principal:
 
 
 - A registered row always wins. A row whose `client_id` happens to be an
 - 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`
 - 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
   for the request. No `/register` row is written and no initial-access token
   is consumed.
   is consumed.
@@ -196,13 +200,27 @@ Resolution and the synthetic principal:
   are exactly the same as before.
   are exactly the same as before.
 
 
 The validated document is cached for 15 minutes under the `{state}` keyspace as
 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
 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
 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
 ## The five grants
 
 
@@ -320,7 +338,12 @@ authorization server:
   literally registered in the catalog.
   literally registered in the catalog.
 - Values are never delimiter-joined. A URI containing a comma is stored as its
 - 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.
   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
 `resource` is a repeated parameter. On `GET /authorize` it is read from the
 query string; on `POST /par`, `POST /token`, `POST /device_authorization`, and
 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`,
 test, so a token pinned to a superset stays active. Without `resource`,
 introspection remains audience-agnostic.
 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`
 ### `invalid_target` and `invalid_grant`
 
 
 | Situation | Error |
 | 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_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_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_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. |
 | `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. |
 | `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
 is opaque-only: a `jwt`-format caller of the exchange grant is refused with
 `503`.
 `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
 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
 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
 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
 A resource server is an RFC 8707 resource indicator (the `resource` parameter
 and the `aud` target of an access token). `identifier` matches exactly — no
 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
 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`
 row alone; it never silently re-enables an operator-disabled row, and `list`
 shows both states. `delete` removes the row outright (nothing references the
 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
 `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
 leaves an unchanged row alone) and never silently re-enables an
 operator-disabled row. `list` shows both the `enabled` and `disabled` states.
 operator-disabled row. `list` shows both the `enabled` and `disabled` states.
 Use `resource-server enable|disable <identifier>` to flip a row and
 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
 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
 `protected_resources` member: set `protected_resources_enabled=true` and list the
 identifier in `protected_resource_ids`. Both default off and empty, so the AS
 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
 an AS metadata member (RFC 9728 §4, §5.1); it belongs only on the RS challenge in
 step 3.
 step 3.
 
 

+ 18 - 11
docs/REDIS.md

@@ -3,8 +3,9 @@
 Redis holds every short-lived value: authorization codes, access and refresh
 Redis holds every short-lived value: authorization codes, access and refresh
 tokens, grant chains, sessions, PAR request URIs, device codes, DCR
 tokens, grant chains, sessions, PAR request URIs, device codes, DCR
 initial-access tokens, replay markers, and rate-limit counters. No raw token is
 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
 ## Purpose
 
 
@@ -21,7 +22,7 @@ differ.
 | Key | Contents | TTL |
 | 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}: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}:access:<hash>:act` | Token-exchange `act` claim sidecar. | Lives with the access record |
 | `oauth:{state}:refresh:<hash>` | Refresh-token record or tombstone. | `ttl.refresh` (30d) |
 | `oauth:{state}:refresh:<hash>` | Refresh-token record or tombstone. | `ttl.refresh` (30d) |
 | `oauth:{state}:grant:<id>` | Grant-chain record. | Refresh lifetime |
 | `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:ip:<sha256>:<sha256>` | Composite failed-login counter for one source IP and one account (both halves hashed; locks that IP only). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:unknown:<sha256>` | Unknown-label bucket for one source IP (hashed IP only; no per-label keys, so label enumeration cannot spray the keyspace). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:unknown:<sha256>` | Unknown-label bucket for one source IP (hashed IP only; no per-label keys, so label enumeration cannot spray the keyspace). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. The fingerprint is an HMAC under the token pepper (never the code itself), so a dump does not reveal the code. | 180 s (`USED_CODE_TTL`; fixed, 6× the 30 s step) |
 | `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) |
 | `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
 Resource audiences (`aud`, RFC 8707) ride on seven record families as a field
 named `aud`: `code:`, `access:`, `refresh:`, `grant:`, `par:`, `device:`, and
 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 access tokens use an allowlist, not a denylist. Each mint writes a
 `jwt:<jti-hash>` marker (`grant_id`, `client_id`, `subject`, TTL
 `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
 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:
 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
 ## 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
 - `noeviction` and AOF are required by default and asserted at startup unless
   the matching opt-out flag (`redis.noeviction_required` or `redis.aof_required`)
   the matching opt-out flag (`redis.noeviction_required` or `redis.aof_required`)
   is set false.
   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
   cascade already removes markers for grant-bound tokens; this sweep covers a
   marker whose grant set is already gone. Client-credentials markers carry an
   marker whose grant set is already gone. Client-credentials markers carry an
   empty subject, so the subject decoder never matches them; they expire with
   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:`,
 Records without a `subject` field are never touched: `par:`, `pending:`,
 `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the
 `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
 introspection request as a LOCAL EXTENSION to RFC 7662, which defines no
 `resource` parameter. When present, the token's `aud` must include every
 `resource` parameter. When present, the token's `aud` must include every
 requested member (set inclusion, so a token pinned to a superset stays active);
 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.
 resource identifier.
 
 
 ## Validating the metadata document
 ## 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
 - 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 usable with this AS. It is emitted only when
   `protected_resources_enabled` is set, using the identifiers in
   `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
 - 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
   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
   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
   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).
   [OAUTH.md](OAUTH.md#the-resource-server-trade-off).
 - RFC 8707 resource indicators are honored only when `resource_indicators_enabled`
 - RFC 8707 resource indicators are honored only when `resource_indicators_enabled`
   is set (default off). The RS should confirm with the operator whether tokens
   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.
 - 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`
   `Config` and `JwtSigningKey` print only the key id and algorithm; `/jwks.json`
   carries public coordinates only (`kid`, `kty`, `crv`, `x`, and `y` for EC).
   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
 - 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
   `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
   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
   `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
   match the caller, then deletes marker, record, and grant membership with a
   durability barrier. A failed ownership check is a uniform no-op (`200`).
   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
 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
 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.
 /// 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
 /// # 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> {
 pub fn validate(identifier: &str) -> Result<(), CliError> {
   if identifier.trim().is_empty() {
   if identifier.trim().is_empty() {
     return Err(CliError::Input(
     return Err(CliError::Input(
@@ -120,6 +122,28 @@ pub fn validate(identifier: &str) -> Result<(), CliError> {
       "resource identifier must not contain a wildcard '*'".to_owned(),
       "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(())
   Ok(())
 }
 }
 
 

+ 30 - 0
src/cli/tests.rs

@@ -1843,6 +1843,36 @@ fn cli_password_file_trims_trailing_newline() -> Result<(), TestError> {
   Ok(())
   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
 /// `resource-server` create/list/enable/disable/delete round-trips, rejects a
 /// wildcard identifier, and re-applies a `config sync` `resource_servers`
 /// wildcard identifier, and re-applies a `config sync` `resource_servers`
 /// array idempotently.
 /// 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.
   /// The CIMD document byte cap was zero or above 5120 bytes.
   #[error("cimd_max_bytes must be between 1 and 5120 bytes")]
   #[error("cimd_max_bytes must be between 1 and 5120 bytes")]
   InvalidCimdMaxBytes,
   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.
   /// Argon2 parameters were outside the safe range.
   #[error("invalid argon2 parameters")]
   #[error("invalid argon2 parameters")]
   InvalidArgon2Params,
   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
 //! Kept in a submodule so the root `config.rs` stays focused on the public
 //! types and loader surface.
 //! 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 secrecy::{ExposeSecret, SecretString};
 
 
 use super::{
 use super::{
@@ -27,6 +28,7 @@ const DEFAULT_KEY_ID: &str = "v1";
 impl Config {
 impl Config {
   pub(crate) fn build(raw: RawConfig) -> Result<Self, ConfigError> {
   pub(crate) fn build(raw: RawConfig) -> Result<Self, ConfigError> {
     let environment = raw.environment;
     let environment = raw.environment;
+    validate_protected_resources(&raw)?;
     let issuer = parse_issuer(raw.issuer.as_deref())?;
     let issuer = parse_issuer(raw.issuer.as_deref())?;
     let bind_addr = parse_socket_addr(raw.bind_addr.as_deref(), DEFAULT_BIND_ADDR, |value| {
     let bind_addr = parse_socket_addr(raw.bind_addr.as_deref(), DEFAULT_BIND_ADDR, |value| {
       ConfigError::InvalidBindAddr { value }
       ConfigError::InvalidBindAddr { value }
@@ -307,6 +309,61 @@ fn build_jwt_signing(
   Ok((current, previous))
   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.
 /// CIMD fetch timeout must sit inside the 1-10 second window.
 fn validate_cimd_fetch_timeout(secs: u64) -> Result<Duration, ConfigError> {
 fn validate_cimd_fetch_timeout(secs: u64) -> Result<Duration, ConfigError> {
   if (1..=10).contains(&secs) {
   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 jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, encode};
 use secrecy::{ExposeSecret, SecretString};
 use secrecy::{ExposeSecret, SecretString};
 use serde::Deserialize;
 use serde::Deserialize;
+use zeroize::Zeroizing;
 
 
 use super::ConfigError;
 use super::ConfigError;
 use crate::keys::KeyId;
 use crate::keys::KeyId;
@@ -100,13 +101,16 @@ impl JwtSigningKey {
     field: &'static str,
     field: &'static str,
   ) -> Result<Self, ConfigError> {
   ) -> Result<Self, ConfigError> {
     let raw = secret.expose_secret().as_bytes();
     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::Es256 => p256_pkcs8(raw),
       JwtSigningAlg::EdDsa => ed25519_pkcs8(raw),
       JwtSigningAlg::EdDsa => ed25519_pkcs8(raw),
-    };
+    });
     let key = match alg {
     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)
     encode(&Header::new(alg.algorithm()), &serde_json::json!({}), &key)
       .map_err(|_| ConfigError::InvalidJwtSigningKey { key: field })?;
       .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(())
   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
 /// 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.
 /// offending key named — both short and over-long material.
 #[test]
 #[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.
 /// Enabling JWT access tokens without a signing key fails closed.
 #[test]
 #[test]
 fn config_rejects_jwt_enabled_without_key() {
 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()
     .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.
 /// Find a client by its surrogate id, active or not.
 ///
 ///
 /// # Errors
 /// # 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
 /// `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
 /// 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.
 /// 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 {
 pub struct JwtClaims {
   /// Token issuer: the AS `issuer`.
   /// Token issuer: the AS `issuer`.
   pub iss: String,
   pub iss: String,
@@ -87,6 +87,25 @@ pub struct ActClaim {
   pub sub: String,
   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
 /// A verified access token: signature, claims, marker, and account checks all
 /// passed.
 /// passed.
 #[derive(Debug, Clone, PartialEq, Eq)]
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -300,8 +319,9 @@ impl JwtAccessSigner {
     Ok(VerifiedJwt { claims })
     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).
   /// introspection uses).
   ///
   ///
   /// # Errors
   /// # Errors
@@ -320,7 +340,7 @@ impl JwtAccessSigner {
     expected_aud: Option<&[String]>,
     expected_aud: Option<&[String]>,
   ) -> Result<VerifiedJwt, JwtError> {
   ) -> Result<VerifiedJwt, JwtError> {
     let verified = self.verify(token, expected_aud)?;
     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);
       return Err(JwtError::MarkerAbsent);
     }
     }
     if !subject_active(pool, keys, &verified.claims.sub).await? {
     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,
   subject: &str,
 ) -> Result<(), TestError> {
 ) -> Result<(), TestError> {
   let key = store.jwt_marker_key(&jti_hash(jti));
   let key = store.jwt_marker_key(&jti_hash(jti));
+  let hash = jti_hash(jti);
   redis::cmd("HSET")
   redis::cmd("HSET")
     .arg(&key)
     .arg(&key)
     .arg("grant_id")
     .arg("grant_id")
@@ -117,6 +118,11 @@ async fn write_marker(
     .arg(subject)
     .arg(subject)
     .query_async::<()>(conn)
     .query_async::<()>(conn)
     .await?;
     .await?;
+  redis::cmd("SADD")
+    .arg(store.grant_tokens_key("grant-1"))
+    .arg(format!("jwt:{hash}"))
+    .query_async::<()>(conn)
+    .await?;
   Ok(())
   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
 /// `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.
 /// raw 32-byte scalars appear nowhere; only `kid`/`alg`/public coordinates.
 #[test]
 #[test]
@@ -457,3 +501,26 @@ fn jwt_debug_redacts_key_material() -> TestResult {
   }
   }
   Ok(())
   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_ip: limits.login_per_ip,
         per_client: None,
         per_client: None,
       }),
       }),
+      "/jwks.json" => Some(Self {
+        per_ip: limits.per_ip,
+        per_client: None,
+      }),
       "/register" => Some(Self {
       "/register" => Some(Self {
         per_ip: limits.per_ip,
         per_ip: limits.per_ip,
         per_client: None,
         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,
   Extras, Passthrough, TestResult, live_state, peer, retry_after, scan_keys, send, send_with,
   test_config, unavailable_state,
   test_config, unavailable_state,
 };
 };
+use crate::config::RateLimitConfig;
 
 
 /// N in-window requests pass; the N+1th is `429` + `Retry-After`.
 /// N in-window requests pass; the N+1th is `429` + `Retry-After`.
 #[tokio::test]
 #[tokio::test]
@@ -220,6 +221,23 @@ async fn ratelimit_token_fails_closed_on_store_outage() -> TestResult {
   Ok(())
   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
 /// Protected-path classification is exact: an unlisted path is untouched even
 /// while the store is down.
 /// while the store is down.
 #[tokio::test]
 #[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)
   let merged_claims = jar::resolve(input.conn, input.store, &jar_input)
     .await
     .await
     .map_err(AuthorizeFailure::Page)?;
     .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 merged = &merged_claims.params;
 
 
   let expected_client_id = resolution.client_id();
   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(),
     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
   // Resource indicators (RFC 8707) validate after the redirect target, so a
   // bad audience redirects (never a page): unknown, non-https, or flag-off
   // bad audience redirects (never a page): unknown, non-https, or flag-off
   // `resource` is `invalid_target`, and the sorted deduped set pins into
   // `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 std::{collections::HashMap, time::Duration};
 
 
 use axum::http::{StatusCode, header};
 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 secrecy::SecretString;
+use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
 use super::{AuthorizeFailure, AuthorizeInput, validate};
 use super::{AuthorizeFailure, AuthorizeInput, validate};
@@ -16,7 +21,11 @@ use crate::{
   },
   },
   error::{AppError, OAuthErrorCode},
   error::{AppError, OAuthErrorCode},
   keys::{KeyId, KeyRing},
   keys::{KeyId, KeyRing},
-  protocol::cimd,
+  protocol::{
+    cimd,
+    client_auth::ClientAuthRequest,
+    par::{ParContext, ParStores, push_authorization_request},
+  },
   state::RedisConn,
   state::RedisConn,
   store::Store,
   store::Store,
   test_support,
   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.
 /// A valid plain-query authorize map for the provisioned client.
 fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
 fn valid_query(client_id: &str, challenge: &str) -> HashMap<String, String> {
   query(&[
   query(&[
@@ -1126,3 +1213,135 @@ async fn authorize_accepts_repeated_resource() -> TestResult {
   }
   }
   Ok(())
   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)]
 #[cfg(test)]
 mod tests_principal;
 mod tests_principal;
 
 
-use std::{collections::HashSet, time::Duration};
+use std::{collections::HashSet, sync::LazyLock, time::Duration};
 
 
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
 use sqlx::PgPool;
 use sqlx::PgPool;
@@ -62,6 +62,26 @@ use crate::{
 /// How long a validated CIMD document stays cached (seconds).
 /// How long a validated CIMD document stays cached (seconds).
 const CACHE_TTL_SECS: u64 = 15 * 60;
 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
 /// Grant types a CIMD client may declare (CIMD §4.1; no device or
 /// client-credentials grants in a registration-free document).
 /// client-credentials grants in a registration-free document).
 const ALLOWED_GRANTS: [&str; 2] = ["authorization_code", "refresh_token"];
 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.
 /// Per-request CIMD resolution settings for a client-auth consumer.
 ///
 ///
 /// `None` at a call site means CIMD is disabled there (`config.cimd_enabled`
 /// `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,
 /// (token dispatch, `client_auth`, `/revoke`, `/introspect`, authorize/JAR,
 /// PAR, device poll).
 /// 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
 /// `Some` and the id parses as `https://`, the document is resolved through
 /// [`resolve_with_catalog`] (cache, else guarded fetch + strict validation)
 /// [`resolve_with_catalog`] (cache, else guarded fetch + strict validation)
 /// and materialized via [`ValidatedCimd::to_client`]. Any other id, or a
 /// and materialized via [`ValidatedCimd::to_client`]. Any other id, or a
@@ -342,11 +384,16 @@ pub async fn resolve_client(
   settings: Option<CimdSettings<'_>>,
   settings: Option<CimdSettings<'_>>,
   client_id: &str,
   client_id: &str,
 ) -> Result<Option<client_repo::Client>, CimdError> {
 ) -> 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
     .await
     .map_err(CimdError::Lookup)?;
     .map_err(CimdError::Lookup)?;
   if let Some(client) = row {
   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 {
   let Some(settings) = settings else {
     return Ok(None);
     return Ok(None);
@@ -464,10 +511,28 @@ pub async fn fetch_and_validate(
   validate_document(client_id_url, &document, validation)
   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
 /// # Errors
 ///
 ///
@@ -481,16 +546,180 @@ pub async fn resolve(
   limits: CimdLimits,
   limits: CimdLimits,
 ) -> Result<ValidatedCimd, CimdError> {
 ) -> Result<ValidatedCimd, CimdError> {
   let key = cache_key(token_prefix, client_id_url);
   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? {
   if let Some(document) = get_cached(conn, &key).await? {
     return validate_document(client_id_url, &document, validation);
     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)
   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
 /// Resolve a CIMD client while loading the enabled scope catalog from
 /// Postgres. Convenience wrapper for request handlers.
 /// 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`].
 /// Cache a validated document for [`CACHE_TTL_SECS`].
 async fn put_cached(
 async fn put_cached(
   conn: &mut RedisConn,
   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 serde_json::{Value, json};
 
 
 use super::{
 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;
 use crate::state::RedisConn;
 
 
@@ -349,3 +350,424 @@ async fn cimd_cache_round_trip() -> TestResult {
   }
   }
   Ok(())
   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 {
   for key in access_keys {
     let fields = read_fields(conn, &key).await?;
     let fields = read_fields(conn, &key).await?;
     if is_owned(&fields, client_id) {
     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?;
       del_key(conn, &key).await?;
       summary.access += 1;
       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,
 //! `test(dcr_manage)` delete suite: cross-client denial, DELETE, revocation,
 //! and Redis-loss survival.
 //! and Redis-loss survival.
 
 
+use secrecy::SecretString;
 use testcontainers::{
 use testcontainers::{
   GenericImage, ImageExt as _,
   GenericImage, ImageExt as _,
   core::{IntoContainerPort, WaitFor},
   core::{IntoContainerPort, WaitFor},
@@ -14,8 +15,15 @@ use super::support::{
   Harness, TestError, TestResult, expect_app_error, expect_forbidden, expect_unauthorized, harness,
   Harness, TestError, TestResult, expect_app_error, expect_forbidden, expect_unauthorized, harness,
   harness_with_redis, manage_parts, mtls_request, register, register_mtls, try_register,
   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::db::client_repo::{self, NewClient};
 use crate::error::OAuthErrorCode;
 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};
 use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService};
 
 
 #[tokio::test]
 #[tokio::test]
@@ -166,6 +174,7 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
           dpop_required: false,
           dpop_required: false,
           subject: String::new(),
           subject: String::new(),
           scope: String::new(),
           scope: String::new(),
+          access_scope: String::new(),
           grant_id: Uuid::new_v4().to_string(),
           grant_id: Uuid::new_v4().to_string(),
           proof: SenderProof::none(),
           proof: SenderProof::none(),
           aud: Vec::new(),
           aud: Vec::new(),
@@ -189,6 +198,7 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
         dpop_required: false,
         dpop_required: false,
         subject: String::new(),
         subject: String::new(),
         scope: String::new(),
         scope: String::new(),
+        access_scope: String::new(),
         grant_id: Uuid::new_v4().to_string(),
         grant_id: Uuid::new_v4().to_string(),
         proof: SenderProof::none(),
         proof: SenderProof::none(),
         aud: Vec::new(),
         aud: Vec::new(),
@@ -237,6 +247,108 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
   Ok(())
   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]
 #[tokio::test]
 async fn dcr_manage_delete_token_denials_match_read() -> TestResult {
 async fn dcr_manage_delete_token_denials_match_read() -> TestResult {
   // RFC 7592 §2.3: DELETE is authenticated by the registration access token.
   // 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 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 rand::{TryRng as _, rngs::SysRng};
 use secrecy::{ExposeSecret as _, SecretString};
 use secrecy::{ExposeSecret as _, SecretString};
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
@@ -507,20 +507,31 @@ async fn put_binding(
 
 
 /// Axum handler for `POST /device_authorization` (wired by task 40).
 /// 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
 /// 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(
 pub async fn handler(
   State(state): State<AppState>,
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
   headers: HeaderMap,
-  body: axum::body::Bytes,
+  body: RawForm,
 ) -> axum::response::Response {
 ) -> axum::response::Response {
   use axum::response::IntoResponse as _;
   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) {
   if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
     return reject.into_response();
     return reject.into_response();
   }
   }
@@ -529,10 +540,7 @@ pub async fn handler(
     Err(reject) => return reject.into_response(),
     Err(reject) => return reject.into_response(),
   };
   };
   if crate::protocol::params::has_duplicate_field(&pairs, &DeviceAuthorizationForm::KNOWN_FIELDS) {
   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();
       .into_response();
   }
   }
   let map = crate::protocol::params::single_valued_map(&pairs);
   let map = crate::protocol::params::single_valued_map(&pairs);
@@ -543,12 +551,8 @@ pub async fn handler(
     )
     )
       .into_response();
       .into_response();
   };
   };
-  let text = match crate::protocol::params::form_text(&body) {
-    Ok(text) => text,
-    Err(reject) => return reject.into_response(),
-  };
   let resources =
   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 dpop_proof = headers.get("dpop").and_then(|value| value.to_str().ok());
   let basic_header = headers
   let basic_header = headers
     .get(axum::http::header::AUTHORIZATION)
     .get(axum::http::header::AUTHORIZATION)

+ 35 - 21
src/protocol/introspect.rs

@@ -21,7 +21,7 @@ mod tests;
 
 
 use std::time::Duration;
 use std::time::Duration;
 
 
-use axum::{Extension, extract::State, http::HeaderMap};
+use axum::{Extension, extract::RawForm, extract::State, http::HeaderMap};
 use secrecy::SecretString;
 use secrecy::SecretString;
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
 use sha2::{Digest as _, Sha256};
 use sha2::{Digest as _, Sha256};
@@ -46,6 +46,8 @@ use crate::{
 pub const REFRESH_TOKEN_HINT: &str = "refresh_token";
 pub const REFRESH_TOKEN_HINT: &str = "refresh_token";
 /// `token_type` reported for an active access token (RFC 6749 §5.1).
 /// `token_type` reported for an active access token (RFC 6749 §5.1).
 pub const BEARER: &str = "Bearer";
 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).
 /// Form fields accepted at `POST /introspect` (RFC 7662 §2.1).
 #[derive(Debug, Deserialize)]
 #[derive(Debug, Deserialize)]
@@ -409,11 +411,16 @@ async fn introspect_jwt(
   if !inputs.resource.is_empty() && !audience_includes(&claims.aud, inputs.resource) {
   if !inputs.resource.is_empty() && !audience_includes(&claims.aud, inputs.resource) {
     return Ok(IntrospectionResponse::inactive());
     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 {
   Ok(IntrospectionResponse {
     active: true,
     active: true,
     scope: Some(claims.scope),
     scope: Some(claims.scope),
     client_id: Some(claims.client_id),
     client_id: Some(claims.client_id),
-    token_type: Some(BEARER.to_owned()),
+    token_type: Some(token_type.to_owned()),
     exp: Some(claims.exp),
     exp: Some(claims.exp),
     iat: Some(claims.iat),
     iat: Some(claims.iat),
     sub: (!claims.sub.is_empty()).then_some(claims.sub),
     sub: (!claims.sub.is_empty()).then_some(claims.sub),
@@ -527,16 +534,14 @@ async fn active_response(
   ctx: &IntrospectContext<'_>,
   ctx: &IntrospectContext<'_>,
   located: Located,
   located: Located,
 ) -> Result<IntrospectionResponse, AppError> {
 ) -> Result<IntrospectionResponse, AppError> {
-  let (key, ttl, token_type) = match located.kind {
+  let (key, ttl) = match located.kind {
     TokenKind::Access => (
     TokenKind::Access => (
       stores.store.access_key(&located.hash),
       stores.store.access_key(&located.hash),
       ctx.service.access_ttl(),
       ctx.service.access_ttl(),
-      Some(BEARER.to_owned()),
     ),
     ),
     TokenKind::Refresh => (
     TokenKind::Refresh => (
       stores.store.refresh_key(&located.hash),
       stores.store.refresh_key(&located.hash),
       ctx.service.refresh_ttl(),
       ctx.service.refresh_ttl(),
-      None,
     ),
     ),
   };
   };
   let (iat, exp) = token_window(stores.conn, &key, ttl, ctx.now_unix).await?;
   let (iat, exp) = token_window(stores.conn, &key, ttl, ctx.now_unix).await?;
@@ -546,6 +551,11 @@ async fn active_response(
     None
     None
   };
   };
   let cnf = Cnf::parse_storage(&located.cnf).map_err(AppError::from)?;
   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 {
   Ok(IntrospectionResponse {
     active: true,
     active: true,
     scope: Some(located.scope),
     scope: Some(located.scope),
@@ -647,12 +657,17 @@ pub async fn read_act(
 
 
 /// Axum handler for `POST /introspect` (wired by task 40).
 /// 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.
 /// (`invalid_target`) before any lookup runs.
 ///
 ///
 /// RFC 7662 defines no `resource` parameter. This endpoint accepts it as a
 /// RFC 7662 defines no `resource` parameter. This endpoint accepts it as a
@@ -664,9 +679,15 @@ pub async fn handler(
   State(state): State<AppState>,
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
   headers: HeaderMap,
-  body: axum::body::Bytes,
+  body: RawForm,
 ) -> axum::response::Response {
 ) -> axum::response::Response {
   use axum::response::IntoResponse as _;
   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) {
   if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
     return reject.into_response();
     return reject.into_response();
   }
   }
@@ -675,20 +696,13 @@ pub async fn handler(
     Err(reject) => return reject.into_response(),
     Err(reject) => return reject.into_response(),
   };
   };
   if crate::protocol::params::has_duplicate_field(&pairs, &IntrospectForm::KNOWN_FIELDS) {
   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();
       .into_response();
   }
   }
   let map = crate::protocol::params::single_valued_map(&pairs);
   let map = crate::protocol::params::single_valued_map(&pairs);
   let form = IntrospectForm::from_map(&map);
   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 =
   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(
   let resources = match crate::protocol::params::validate_resources(
     &state.pool,
     &state.pool,
     state.config.resource_indicators_enabled,
     state.config.resource_indicators_enabled,

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

@@ -168,6 +168,7 @@ async fn mint_bound(
           dpop_required: false,
           dpop_required: false,
           subject: "user-1".to_owned(),
           subject: "user-1".to_owned(),
           scope: "orders:read".to_owned(),
           scope: "orders:read".to_owned(),
+          access_scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           grant_id: unique("grant"),
           proof,
           proof,
           aud: Vec::new(),
           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!(response.active, "minted token must be active");
   assert_eq!(response.scope.as_deref(), Some("orders:read"));
   assert_eq!(response.scope.as_deref(), Some("orders:read"));
   assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
   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"));
   assert_eq!(response.sub.as_deref(), Some("user-1"));
   let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
   let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
   assert_eq!(cnf.jkt.as_deref(), Some("jkt-thumbnail"));
   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")?;
   let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
   assert_eq!(cnf.jkt.as_deref(), Some("jkt-dpop-only"));
   assert_eq!(cnf.jkt.as_deref(), Some("jkt-dpop-only"));
   assert_eq!(cnf.x5t_s256, None, "no mTLS binding was proven");
   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 json = serde_json::to_value(&response)?;
   let cnf_json = json
   let cnf_json = json
     .get("cnf")
     .get("cnf")
@@ -898,6 +901,7 @@ async fn mint_audience_subject(
           dpop_required: false,
           dpop_required: false,
           subject: subject.to_owned(),
           subject: subject.to_owned(),
           scope: "orders:read".to_owned(),
           scope: "orders:read".to_owned(),
+          access_scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           grant_id: unique("grant"),
           proof: SenderProof::none(),
           proof: SenderProof::none(),
           aud: aud.clone(),
           aud: aud.clone(),
@@ -928,6 +932,7 @@ async fn mint_subject(
           dpop_required: false,
           dpop_required: false,
           subject: subject.to_owned(),
           subject: subject.to_owned(),
           scope: "orders:read".to_owned(),
           scope: "orders:read".to_owned(),
+          access_scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           grant_id: unique("grant"),
           proof: SenderProof::none(),
           proof: SenderProof::none(),
           aud: Vec::new(),
           aud: Vec::new(),
@@ -1355,6 +1360,7 @@ async fn mint_jwt(
         dpop_required: false,
         dpop_required: false,
         subject: subject.to_owned(),
         subject: subject.to_owned(),
         scope: "orders:read".to_owned(),
         scope: "orders:read".to_owned(),
+        access_scope: "orders:read".to_owned(),
         grant_id: unique("grant"),
         grant_id: unique("grant"),
         proof,
         proof,
         aud: aud.clone(),
         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.client_id.as_deref(), Some(client_id.as_str()));
   assert_eq!(response.scope.as_deref(), Some("orders:read"));
   assert_eq!(response.scope.as_deref(), Some("orders:read"));
   assert_eq!(response.sub.as_deref(), Some("jwt-subject-1"));
   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!(
   assert_eq!(
     response.aud,
     response.aud,
     Some(vec![audience_a.to_owned(), audience_b.to_owned()])
     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",
     "totp_encryption_key": "metadata-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "metadata-test-pii-key-0123456789abcdef",
     "pii_encryption_key": "metadata-test-pii-key-0123456789abcdef",
     "protected_resources_enabled": true,
     "protected_resources_enabled": true,
+    "resource_indicators_enabled": true,
     "protected_resource_ids": ids,
     "protected_resource_ids": ids,
   });
   });
   let source = ::config::Config::try_from(&fixture)?;
   let source = ::config::Config::try_from(&fixture)?;

+ 14 - 12
src/protocol/par.rs

@@ -10,7 +10,7 @@
 use std::{collections::HashMap, time::Duration};
 use std::{collections::HashMap, time::Duration};
 
 
 use axum::{
 use axum::{
-  extract::State,
+  extract::{RawForm, State},
   http::{HeaderMap, StatusCode},
   http::{HeaderMap, StatusCode},
 };
 };
 use secrecy::SecretString;
 use secrecy::SecretString;
@@ -256,15 +256,21 @@ async fn validate_request_rules(
 pub async fn par_handler(
 pub async fn par_handler(
   State(state): State<AppState>,
   State(state): State<AppState>,
   headers: HeaderMap,
   headers: HeaderMap,
-  body: axum::body::Bytes,
+  body: RawForm,
 ) -> axum::response::Response {
 ) -> axum::response::Response {
   use axum::response::IntoResponse as _;
   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`
   // 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) {
   if let Err(reject) = super::params::require_form_content_type(&headers) {
     return reject.into_response();
     return reject.into_response();
   }
   }
@@ -273,11 +279,7 @@ pub async fn par_handler(
     Err(reject) => return reject.into_response(),
     Err(reject) => return reject.into_response(),
   };
   };
   let form = super::params::single_valued_map(&pairs);
   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
   let header_value = headers
     .get(axum::http::header::AUTHORIZATION)
     .get(axum::http::header::AUTHORIZATION)
     .and_then(|value| value.to_str().ok());
     .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
 //! - [`parse_pairs`] / [`single_valued_map`] — the strict urlencoded parse
 //!   (last-wins, mirroring `serde_urlencoded`'s `HashMap` absorption).
 //!   (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
 //! - [`require_form_content_type`] / [`form_pairs`] / [`query_pairs`] — the
 //!   transport contract the `Form`/`Query` extractors used to enforce
 //!   transport contract the `Form`/`Query` extractors used to enforce
 //!   (415 on a non-urlencoded body, 400 on malformed input), so the
 //!   (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.
 /// RFC 8707 indicator parameter name.
 pub const RESOURCE_PARAM: &str = "resource";
 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.
 /// Urlencoded parse failure.
 ///
 ///
 /// Decoding itself mirrors `serde_urlencoded` and is infallible (a bare `%`
 /// 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, ""),
       None => (segment, ""),
     };
     };
     pairs.push((decode_component(name), decode_component(value)));
     pairs.push((decode_component(name), decode_component(value)));
+    if pairs.len() > MAX_FORM_PAIRS {
+      return Err(ParamsError);
+    }
   }
   }
   Ok(pairs)
   Ok(pairs)
 }
 }
@@ -164,22 +176,18 @@ pub fn single_valued_map(pairs: &[(String, String)]) -> HashMap<String, String>
   map
   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
 /// 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).
 /// 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]
 #[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
 /// 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.
 /// absorbed here, exactly as `serde_urlencoded` absorbed it.
 #[must_use]
 #[must_use]
 pub fn has_duplicate_field(pairs: &[(String, String)], known: &[&str]) -> bool {
 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 {
   for (name, _) in pairs {
     if known.contains(&name.as_str()) && !seen.insert(name.as_str()) {
     if known.contains(&name.as_str()) && !seen.insert(name.as_str()) {
       return true;
       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
 /// Borrow a request body as text, mirroring `Form`'s 400
 /// (`FailedToDeserializeForm`) on non-UTF-8 input.
 /// (`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
 /// # Errors
 ///
 ///
@@ -295,13 +303,28 @@ pub async fn validate_resources(
   if values.is_empty() {
   if values.is_empty() {
     return Ok(Vec::new());
     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 {
   if !enabled {
     return Err(AppError::invalid_target(
     return Err(AppError::invalid_target(
       "resource indicators are not enabled",
       "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 {
   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)
     let absolute_https = reqwest::Url::parse(value)
       .ok()
       .ok()
       .filter(|url| url.scheme() == "https" && url.has_host())
       .filter(|url| url.scheme() == "https" && url.has_host())
@@ -317,7 +340,7 @@ pub async fn validate_resources(
         "unknown or disabled resource server",
         "unknown or disabled resource server",
       ));
       ));
     }
     }
-    aud.push(value.clone());
+    aud.push(value.to_owned());
   }
   }
   aud.sort();
   aud.sort();
   aud.dedup();
   aud.dedup();

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

@@ -2,8 +2,9 @@
 //! transport-contract preservation, and fail-closed resource validation.
 //! transport-contract preservation, and fail-closed resource validation.
 
 
 use super::{
 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};
 use crate::{db::resource_repo, error::OAuthErrorCode};
 
 
@@ -14,7 +15,8 @@ type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
 #[test]
 #[test]
 fn params_repeated_resource_is_captured_in_order() -> TestResult {
 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 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
   if values
     != vec![
     != vec![
       "https://api.example.test/a".to_owned(),
       "https://api.example.test/a".to_owned(),
@@ -26,6 +28,110 @@ fn params_repeated_resource_is_captured_in_order() -> TestResult {
   Ok(())
   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.
 /// The single-valued map is last-wins: duplicates are silently absorbed.
 #[test]
 #[test]
 fn params_single_valued_map_absorbs_duplicates_last_wins() -> TestResult {
 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 std::collections::HashMap;
 
 
 use axum::{
 use axum::{
-  Extension, Form, Json,
-  extract::State,
+  Extension, Json,
+  extract::{RawForm, State},
   http::{HeaderMap, StatusCode, header},
   http::{HeaderMap, StatusCode, header},
   response::{IntoResponse, Response},
   response::{IntoResponse, Response},
 };
 };
@@ -332,6 +332,19 @@ struct ErrorBody {
   error_description: &'static str,
   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).
 /// `POST /revoke` (RFC 7009 §2.1).
 ///
 ///
 /// Credentials come from the `Authorization` header and the form body only; a
 /// 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>,
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
   headers: HeaderMap,
-  Form(form): Form<HashMap<String, String>>,
+  body: RawForm,
 ) -> Result<Response, AppError> {
 ) -> 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 service = TokenService::new(&state.config.ttl, &state.keys)?;
   let auth = ClientAuthRequest {
   let auth = ClientAuthRequest {
     basic_header: headers
     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,
     dpop_required: false,
     subject: "user-1".to_owned(),
     subject: "user-1".to_owned(),
     scope: "orders:read".to_owned(),
     scope: "orders:read".to_owned(),
+    access_scope: "orders:read".to_owned(),
     grant_id: grant.to_owned(),
     grant_id: grant.to_owned(),
     proof: SenderProof::none(),
     proof: SenderProof::none(),
     aud: Vec::new(),
     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,
     dpop_required: false,
     subject: subject.to_owned(),
     subject: subject.to_owned(),
     scope: "orders:read".to_owned(),
     scope: "orders:read".to_owned(),
+    access_scope: "orders:read".to_owned(),
     grant_id: grant.to_owned(),
     grant_id: grant.to_owned(),
     proof: SenderProof::none(),
     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,
     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
 ///   (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),
 ///   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),
 ///   `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`,
 /// - 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),
 ///   and `2001:20::/28` (ORCHID/ORCHIDv2 identifiers), `2001::/32` (Teredo),
 ///   `2002::/16` (6to4), and `64:ff9b::/96` / `64:ff9b:1::/48` (NAT64
 ///   `2002::/16` (6to4), and `64:ff9b::/96` / `64:ff9b:1::/48` (NAT64
 ///   translation — can reach an embedded private IPv4 target).
 ///   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]
 #[must_use]
 pub fn is_blocked_ip(ip: &IpAddr) -> bool {
 pub fn is_blocked_ip(ip: &IpAddr) -> bool {
   match ip {
   match ip {
     IpAddr::V4(v4) => is_blocked_v4(v4),
     IpAddr::V4(v4) => is_blocked_v4(v4),
     IpAddr::V6(v6) => {
     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);
         return is_blocked_v4(&v4);
       }
       }
       v6.is_loopback()
       v6.is_loopback()
@@ -110,10 +113,24 @@ pub fn is_blocked_ip(ip: &IpAddr) -> bool {
           && v6.segments()[2] == 0
           && v6.segments()[2] == 0
           && v6.segments()[3] == 0)
           && v6.segments()[3] == 0)
         || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0002 && v6.segments()[2] == 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
         // Non-routable identifier prefixes: ORCHID (2001:10::/28) and
         // ORCHIDv2 (2001:20::/28).
         // ORCHIDv2 (2001:20::/28).
         || (v6.segments()[0] == 0x2001 && (v6.segments()[1] & 0xfff0) == 0x0010)
         || (v6.segments()[0] == 0x2001 && (v6.segments()[1] & 0xfff0) == 0x0010)
         || (v6.segments()[0] == 0x2001 && (v6.segments()[1] & 0xfff0) == 0x0020)
         || (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),
         // IPv4 transition/translation: Teredo (2001::/32), 6to4 (2002::/16),
         // NAT64 well-known (64:ff9b::/96) and local-use (64:ff9b:1::/48).
         // NAT64 well-known (64:ff9b::/96) and local-use (64:ff9b:1::/48).
         || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0)
         || (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)
     || (o[0] == 192 && o[1] == 88 && o[2] == 99)
     // Benchmarking (RFC 2544): 198.18.0.0/15.
     // Benchmarking (RFC 2544): 198.18.0.0/15.
     || (o[0] == 198 && (o[1] == 18 || o[1] == 19))
     || (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).
     // Reserved (240.0.0.0/4, includes 255.255.255.255 broadcast).
     || o[0] >= 240
     || o[0] >= 240
     || o == AZURE_METADATA_V4
     || 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
   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.
 /// Fetch `raw` through the full SSRF guard with per-call limits.
 ///
 ///
 /// Same policy as [`fetch_guarded_bytes`] — `https`-only, no redirects, a
 /// 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
 /// 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.
 /// 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
 /// # Errors
 ///
 ///
 /// Returns [`SsrfError`] when the URL policy, DNS check, fetch, status, or
 /// 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,
   max_bytes: usize,
   timeout: Duration,
   timeout: Duration,
 ) -> Result<Vec<u8>, SsrfError> {
 ) -> Result<Vec<u8>, SsrfError> {
+  if timeout.is_zero() {
+    return Err(SsrfError::FetchFailed);
+  }
   let url = validate_fetch_url(raw)?;
   let url = validate_fetch_url(raw)?;
   let host = url.host_str().unwrap_or_default().to_owned();
   let host = url.host_str().unwrap_or_default().to_owned();
   let port = url.port_or_known_default().unwrap_or(443);
   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
 /// 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",
     "203.0.113.1",
     "100.64.0.1",
     "100.64.0.1",
     "240.0.0.1",
     "240.0.0.1",
+    "192.31.196.1",
+    "192.52.193.1",
+    "192.175.48.1",
     "64:ff9b::7f00:1",
     "64:ff9b::7f00:1",
     "64:ff9b:1::1",
     "64:ff9b:1::1",
     "2002:7f00:1::",
     "2002:7f00:1::",
     "2001::1",
     "2001::1",
+    "2001:1::1",
     "2001:2::1",
     "2001:2::1",
+    "2001:3::1",
+    "2001:4:112::1",
     "2001:10::1",
     "2001:10::1",
     "2001:20::1",
     "2001:20::1",
     "100::1",
     "100::1",
     "2001:db8::1",
     "2001:db8::1",
+    "::127.0.0.1",
+    "::169.254.169.254",
+    "::ffff:0:127.0.0.1",
     "::ffff:0.0.0.1",
     "::ffff:0.0.0.1",
     "::ffff:192.0.0.1",
     "::ffff:192.0.0.1",
     "::ffff:198.18.0.1",
     "::ffff:198.18.0.1",
@@ -378,7 +387,9 @@ fn ssrf_rejects_special_use_ranges() -> TestResult {
     "192.0.1.1",
     "192.0.1.1",
     "198.17.0.1",
     "198.17.0.1",
     "198.20.0.1",
     "198.20.0.1",
+    "192.31.197.1",
     "2001:4860:4860::8888",
     "2001:4860:4860::8888",
+    "::8.8.8.8",
   ] {
   ] {
     if blocked(addr)? {
     if blocked(addr)? {
       return Err(TestError::Unexpected(format!("{addr} should pass")));
       return Err(TestError::Unexpected(format!("{addr} should pass")));
@@ -386,3 +397,40 @@ fn ssrf_rejects_special_use_ranges() -> TestResult {
   }
   }
   Ok(())
   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;
 mod grants;
 
 
 use axum::Json;
 use axum::Json;
-use axum::extract::{Extension, State};
+use axum::extract::{Extension, RawForm, State};
 use axum::http::{HeaderMap, HeaderValue, header};
 use axum::http::{HeaderMap, HeaderValue, header};
 use axum::response::{IntoResponse, Response};
 use axum::response::{IntoResponse, Response};
 use secrecy::SecretString;
 use secrecy::SecretString;
@@ -433,15 +433,21 @@ pub async fn handler(
   State(state): State<AppState>,
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
   headers: HeaderMap,
-  body: axum::body::Bytes,
+  body: RawForm,
 ) -> Response {
 ) -> 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
   // 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
   // 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,
   // authorization-code branch binds the validated set to the redeemed code,
   // and the remaining grants wire it in later todos.
   // and the remaining grants wire it in later todos.
   if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
   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 map = crate::protocol::params::single_valued_map(&pairs);
   let form = TokenForm::from_map(&map);
   let form = TokenForm::from_map(&map);
   let resources =
   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;
   // RFC 8707 resource indicators do not apply to RFC 8693 token exchange;
   // reject the parameter BEFORE the catalog lookup so a bogus value cannot
   // reject the parameter BEFORE the catalog lookup so a bogus value cannot
   // surface `invalid_target` from a grant that must answer `invalid_request`.
   // 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?;
   let proofs = fresh_proofs(state, &client, inputs, used_basic).await?;
   require_dpop_binding(&client, &proofs)?;
   require_dpop_binding(&client, &proofs)?;
   if let Some(want) = non_empty(&record.cnf) {
   if let Some(want) = non_empty(&record.cnf) {
@@ -241,6 +250,7 @@ pub async fn redeem_code(
         dpop_required: client.dpop_bound_access_tokens,
         dpop_required: client.dpop_bound_access_tokens,
         subject: record.subject.clone(),
         subject: record.subject.clone(),
         scope: record.scope.clone(),
         scope: record.scope.clone(),
+        access_scope: record.scope.clone(),
         grant_id: record.grant_id.clone(),
         grant_id: record.grant_id.clone(),
         proof: proofs,
         proof: proofs,
         aud: record.aud.clone(),
         aud: record.aud.clone(),
@@ -1868,6 +1878,7 @@ mod tests {
     let issuer = CodeIssuer::new(&h.ttl, &h.ring)?;
     let issuer = CodeIssuer::new(&h.ttl, &h.ring)?;
     let approved = ScopeSet::parse("orders:read")?;
     let approved = ScopeSet::parse("orders:read")?;
     let entitled = ScopeSet::parse("orders:read orders:admin")?;
     let entitled = ScopeSet::parse("orders:read orders:admin")?;
+    let pinned = vec![AUD_ONE.to_owned()];
     let issued = issuer
     let issued = issuer
       .issue(
       .issue(
         &mut h.conn,
         &mut h.conn,
@@ -1883,7 +1894,7 @@ mod tests {
           code_challenge: CHALLENGE,
           code_challenge: CHALLENGE,
           dpop_jkt: None,
           dpop_jkt: None,
           state: None,
           state: None,
-          aud: &[],
+          aud: &pinned,
           issuer: ISSUER,
           issuer: ISSUER,
         },
         },
       )
       )
@@ -1909,7 +1920,7 @@ mod tests {
       assertion: None,
       assertion: None,
       dpop_proof: None,
       dpop_proof: None,
       peer_certs: None,
       peer_certs: None,
-      aud: &[],
+      aud: &pinned,
     };
     };
     let mut state = CodeGrantState {
     let mut state = CodeGrantState {
       pool: &h.pool,
       pool: &h.pool,
@@ -1940,8 +1951,14 @@ mod tests {
       ));
       ));
     }
     }
     let verified = signer
     let verified = signer
-      .verify(&tokens.access_token, None)
+      .verify(&tokens.access_token, Some(&pinned))
       .map_err(|error| TestError::Unexpected(format!("fresh jwt rejected: {error:?}")))?;
       .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() {
     if verified.claims.cnf.is_some() {
       return Err(TestError::Unexpected(
       return Err(TestError::Unexpected(
         "an unbound public jwt must carry no cnf".to_owned(),
         "an unbound public jwt must carry no cnf".to_owned(),
@@ -1949,4 +1966,120 @@ mod tests {
     }
     }
     Ok(())
     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(),
     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 grant_id = crypto_tokens::mint_token().map_err(AppError::internal)?;
   let minted = service
   let minted = service
     .mint(
     .mint(
@@ -150,6 +156,7 @@ pub async fn issue_client_credentials(
         dpop_required: false,
         dpop_required: false,
         subject: String::new(),
         subject: String::new(),
         scope: issued.render(),
         scope: issued.render(),
+        access_scope: issued.render(),
         grant_id: grant_id.clone(),
         grant_id: grant_id.clone(),
         proof: input.proof.clone(),
         proof: input.proof.clone(),
         aud: input.aud.to_vec(),
         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 super::{Ctx, TestError, TestResult, dpop_proof, ring, test_redis, unique};
 use crate::{
 use crate::{
   config::{JwtSigningAlg, JwtSigningKey, TtlConfig},
   config::{JwtSigningAlg, JwtSigningKey, TtlConfig},
+  error::OAuthErrorCode,
   jose::jwt_access::{JwtAccessSigner, jti_hash, marker_live},
   jose::jwt_access::{JwtAccessSigner, jti_hash, marker_live},
   keys::KeyId,
   keys::KeyId,
   protocol::{
   protocol::{
@@ -150,3 +151,45 @@ async fn jwt_mint_carries_aud_and_cnf() -> TestResult {
   }
   }
   Ok(())
   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::jose::jwt_access::JwtAccessSigner;
 use crate::{
 use crate::{
   config::GrantKind,
   config::GrantKind,
-  db::{scope_repo, user_scope_repo},
+  db::{scope_repo, user_repo, user_scope_repo},
   error::AppError,
   error::AppError,
   protocol::{
   protocol::{
     device::{binding_key, read_binding},
     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(),
       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() {
   if consumed.grant_id.is_empty() || consumed.subject.is_empty() {
     return Err(AppError::server_error().into());
     return Err(AppError::server_error().into());
   }
   }
@@ -118,6 +124,7 @@ pub(super) async fn issue_approved(
         dpop_required: redemption.dpop_required,
         dpop_required: redemption.dpop_required,
         subject: consumed.subject.clone(),
         subject: consumed.subject.clone(),
         scope: effective.clone(),
         scope: effective.clone(),
+        access_scope: effective.clone(),
         grant_id: consumed.grant_id.clone(),
         grant_id: consumed.grant_id.clone(),
         proof: redemption.proofs.clone(),
         proof: redemption.proofs.clone(),
         aud: consumed.aud.clone(),
         aud: consumed.aud.clone(),
@@ -163,7 +170,16 @@ async fn approved_scope(
   client: &crate::db::Client,
   client: &crate::db::Client,
   consumed: &device_store::DeviceRecord,
   consumed: &device_store::DeviceRecord,
 ) -> Result<String, DevicePollError> {
 ) -> 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 =
   let client_registered =
     ScopeSet::parse(client.scope.trim()).map_err(|_| AppError::server_error())?;
     ScopeSet::parse(client.scope.trim()).map_err(|_| AppError::server_error())?;
   let enabled_names = scope_repo::list_enabled(state.pool)
   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::{TestError, TestResult, harness, unique};
 use super::{keys, setup};
 use super::{keys, setup};
 use crate::{
 use crate::{
-  db::TokenEndpointAuthMethod, error::OAuthErrorCode, protocol::device::DeviceHttpInputs,
+  db::TokenEndpointAuthMethod,
+  error::OAuthErrorCode,
+  protocol::{device::DeviceHttpInputs, token::TokenService},
 };
 };
 
 
 #[tokio::test]
 #[tokio::test]
@@ -121,3 +123,74 @@ async fn test_grant_device_scope_reduced_to_user_entitlement() -> TestResult {
   }
   }
   Ok(())
   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(())
   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,
   pool: &PgPool,
   config: &Config,
   config: &Config,
   scopes: &[&str],
   scopes: &[&str],
-) -> Result<Uuid, TestError> {
+) -> Result<user_repo::UserRecord, TestError> {
   let email = format!("{}@example.test", unique("user"));
   let email = format!("{}@example.test", unique("user"));
   let record = user_repo::create_user(
   let record = user_repo::create_user(
     pool,
     pool,
@@ -93,5 +93,14 @@ pub(super) async fn entitled_user(
   for scope in scopes {
   for scope in scopes {
     crate::db::user_scope_repo::entitle(pool, record.id, scope, None).await?;
     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},
   protocol::scope::{ScopeSet, refresh_scopes},
   state::RedisConn,
   state::RedisConn,
   store::{
   store::{
-    Store, StoreError, durability_barrier,
+    Store, durability_barrier,
     grants::revoke_by_token_hash,
     grants::revoke_by_token_hash,
     tokens::{AccessRecord, RefreshRecord, RotateOutcome, RotationInput},
     tokens::{AccessRecord, RefreshRecord, RotateOutcome, RotationInput},
   },
   },
@@ -264,6 +264,11 @@ pub async fn refresh(
   // refresh token stays bound to the full original set (NOT §3).
   // refresh token stays bound to the full original set (NOT §3).
   let original_aud = found.record.aud.clone();
   let original_aud = found.record.aud.clone();
   let (access_aud, refresh_aud) = resolve_audience(&original_aud, input.requested_resources)?;
   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
   let minted = service
     .mint_for_rotation(
     .mint_for_rotation(
       conn,
       conn,
@@ -274,6 +279,7 @@ pub async fn refresh(
         dpop_required: input.dpop_required,
         dpop_required: input.dpop_required,
         subject: found.record.subject.clone(),
         subject: found.record.subject.clone(),
         scope: retained.render(),
         scope: retained.render(),
+        access_scope: access.render(),
         grant_id: found.record.grant_id.clone(),
         grant_id: found.record.grant_id.clone(),
         proof: input.proof.clone(),
         proof: input.proof.clone(),
         aud: access_aud.clone(),
         aud: access_aud.clone(),
@@ -462,29 +468,14 @@ async fn discard_orphan(
     .map_err(AppError::from)?;
     .map_err(AppError::from)?;
   if let Some(jti_hash) = minted.jwt_jti_hash() {
   if let Some(jti_hash) = minted.jwt_jti_hash() {
     let member = minted.access_hash.clone();
     let member = minted.access_hash.clone();
-    let record_key = store.access_key(&member);
     let grant_id = crate::store::tokens::get_access(conn, store, &member)
     let grant_id = crate::store::tokens::get_access(conn, store, &member)
       .await
       .await
       .map_err(AppError::from)?
       .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
       .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)
     durability_barrier(conn, store)
       .await
       .await
       .map_err(AppError::from)?;
       .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,
         dpop_required: kind == ClientKind::Public,
         subject: unique("subject"),
         subject: unique("subject"),
         scope: scope.to_owned(),
         scope: scope.to_owned(),
+        access_scope: scope.to_owned(),
         grant_id: unique("grant"),
         grant_id: unique("grant"),
         proof,
         proof,
         aud: Vec::new(),
         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,
         dpop_required: true,
         subject: unique("audience-subject"),
         subject: unique("audience-subject"),
         scope: scope.to_owned(),
         scope: scope.to_owned(),
+        access_scope: scope.to_owned(),
         grant_id: unique("audience-grant"),
         grant_id: unique("audience-grant"),
         proof,
         proof,
         aud: aud.to_vec(),
         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,
           dpop_required: true,
           subject: subject.to_owned(),
           subject: subject.to_owned(),
           scope: "read".to_owned(),
           scope: "read".to_owned(),
+          access_scope: "read".to_owned(),
           grant_id: unique("grant"),
           grant_id: unique("grant"),
           proof,
           proof,
           aud: Vec::new(),
           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,
           dpop_required: true,
           subject: subject.to_owned(),
           subject: subject.to_owned(),
           scope: scope.to_owned(),
           scope: scope.to_owned(),
+          access_scope: scope.to_owned(),
           grant_id: unique("grant"),
           grant_id: unique("grant"),
           proof,
           proof,
           aud: Vec::new(),
           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,
         dpop_required: true,
         subject: unique("failover-subject"),
         subject: unique("failover-subject"),
         scope: "read".to_owned(),
         scope: "read".to_owned(),
+        access_scope: "read".to_owned(),
         grant_id: unique("failover-grant"),
         grant_id: unique("failover-grant"),
         proof: proof.clone(),
         proof: proof.clone(),
         aud: Vec::new(),
         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 super::{TestError, TestResult, dpop_proof, input, is_invalid_grant, test_redis, unique};
 use crate::{
 use crate::{
   config::{JwtSigningAlg, JwtSigningKey, TtlConfig},
   config::{JwtSigningAlg, JwtSigningKey, TtlConfig},
-  jose::jwt_access::JwtAccessSigner,
+  error::OAuthErrorCode,
+  jose::jwt_access::{JwtAccessSigner, jti_hash},
   keys::KeyId,
   keys::KeyId,
   protocol::{
   protocol::{
     scope::ScopeSet,
     scope::ScopeSet,
@@ -14,6 +15,12 @@ use crate::{
   store::tokens as low,
   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).
 /// A fixed 32-byte ASCII signing key (the e2e JWT fixture material).
 const JWT_TEST_KEY: &str = "bcdefghijklmnopqrstuvwxyzabcdefg";
 const JWT_TEST_KEY: &str = "bcdefghijklmnopqrstuvwxyzabcdefg";
 
 
@@ -62,10 +69,11 @@ async fn mint_jwt_grant(
         dpop_required: true,
         dpop_required: true,
         subject: unique("subject"),
         subject: unique("subject"),
         scope: "read".to_owned(),
         scope: "read".to_owned(),
+        access_scope: "read".to_owned(),
         grant_id: unique("grant"),
         grant_id: unique("grant"),
         proof,
         proof,
-        aud: Vec::new(),
-        refresh_aud: Vec::new(),
+        aud: jwt_aud(),
+        refresh_aud: jwt_aud(),
         format: TokenFormat::Jwt,
         format: TokenFormat::Jwt,
       },
       },
       super::now_unix(),
       super::now_unix(),
@@ -79,6 +87,97 @@ async fn mint_jwt_grant(
   Ok(minted)
   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
 /// `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
 /// for the discarded pre-mint `jti` is gone afterwards, with no live record
 /// or grant-set member left behind.
 /// or grant-set member left behind.
@@ -98,10 +197,11 @@ async fn jwt_discard_orphan_deletes_marker_by_key() -> TestResult {
         dpop_required: true,
         dpop_required: true,
         subject: unique("subject"),
         subject: unique("subject"),
         scope: "read".to_owned(),
         scope: "read".to_owned(),
+        access_scope: "read".to_owned(),
         grant_id: unique("grant"),
         grant_id: unique("grant"),
         proof,
         proof,
-        aud: Vec::new(),
-        refresh_aud: Vec::new(),
+        aud: jwt_aud(),
+        refresh_aud: jwt_aud(),
         format: TokenFormat::Jwt,
         format: TokenFormat::Jwt,
       },
       },
       super::now_unix(),
       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();
   let added: Vec<&String> = after.iter().filter(|key| !before.contains(key)).collect();
   if !added.is_empty() {
   if !added.is_empty() {
     return Err(TestError::Unexpected(format!(
     return Err(TestError::Unexpected(format!(
-      "failed rotation left live jwt markers: {added:?}"
+      "failed rotation leaves live jwt markers: {added:?}"
     )));
     )));
   }
   }
   Ok(())
   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`.
         // never be wrapped in an `at+jwt`.
         subject: subject.record.subject.clone(),
         subject: subject.record.subject.clone(),
         scope: issued.render(),
         scope: issued.render(),
+        access_scope: issued.render(),
         grant_id,
         grant_id,
         proof: input.proof.clone(),
         proof: input.proof.clone(),
         aud: Vec::new(),
         aud: Vec::new(),

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

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

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

@@ -230,7 +230,7 @@ impl TokenService {
     let access = low::AccessRecord {
     let access = low::AccessRecord {
       client_id: request.client_id.clone(),
       client_id: request.client_id.clone(),
       grant_id: request.grant_id.clone(),
       grant_id: request.grant_id.clone(),
-      scope: request.scope.clone(),
+      scope: request.access_scope.clone(),
       subject: request.subject.clone(),
       subject: request.subject.clone(),
       cnf: cnf_json.clone(),
       cnf: cnf_json.clone(),
       aud: request.aud.clone(),
       aud: request.aud.clone(),
@@ -305,6 +305,9 @@ impl TokenService {
     now_unix: i64,
     now_unix: i64,
     apply_barrier: bool,
     apply_barrier: bool,
   ) -> Result<MintedTokens, TokenServiceError> {
   ) -> Result<MintedTokens, TokenServiceError> {
+    if request.aud.is_empty() {
+      return Err(TokenServiceError::EmptyAudience);
+    }
     let signer = self
     let signer = self
       .jwt
       .jwt
       .as_ref()
       .as_ref()
@@ -319,7 +322,7 @@ impl TokenService {
       iss: signer.issuer().to_owned(),
       iss: signer.issuer().to_owned(),
       sub: request.subject.clone(),
       sub: request.subject.clone(),
       aud: request.aud.clone(),
       aud: request.aud.clone(),
-      scope: request.scope.clone(),
+      scope: request.access_scope.clone(),
       exp: access_expires_at,
       exp: access_expires_at,
       iat: issued_at,
       iat: issued_at,
       jti: jti_raw,
       jti: jti_raw,
@@ -338,7 +341,7 @@ impl TokenService {
     let access = low::AccessRecord {
     let access = low::AccessRecord {
       client_id: request.client_id.clone(),
       client_id: request.client_id.clone(),
       grant_id: request.grant_id.clone(),
       grant_id: request.grant_id.clone(),
-      scope: request.scope.clone(),
+      scope: request.access_scope.clone(),
       subject: request.subject.clone(),
       subject: request.subject.clone(),
       cnf: cnf_json.clone(),
       cnf: cnf_json.clone(),
       aud: request.aud.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,
     dpop_required: kind == ClientKind::Public,
     subject: "user-1".to_owned(),
     subject: "user-1".to_owned(),
     scope: "orders:read".to_owned(),
     scope: "orders:read".to_owned(),
+    access_scope: "orders:read".to_owned(),
     grant_id: grant.to_owned(),
     grant_id: grant.to_owned(),
     proof,
     proof,
     aud: Vec::new(),
     aud: Vec::new(),
@@ -165,6 +166,7 @@ async fn public_unbound_gets_bearer() -> TestResult {
         dpop_required: false,
         dpop_required: false,
         subject: "user-1".to_owned(),
         subject: "user-1".to_owned(),
         scope: "orders:read".to_owned(),
         scope: "orders:read".to_owned(),
+        access_scope: "orders:read".to_owned(),
         grant_id: unique("grant"),
         grant_id: unique("grant"),
         proof: SenderProof::none(),
         proof: SenderProof::none(),
         aud: Vec::new(),
         aud: Vec::new(),
@@ -197,6 +199,7 @@ async fn public_bound_requires_dpop() -> TestResult {
         dpop_required: true,
         dpop_required: true,
         subject: "user-1".to_owned(),
         subject: "user-1".to_owned(),
         scope: "orders:read".to_owned(),
         scope: "orders:read".to_owned(),
+        access_scope: "orders:read".to_owned(),
         grant_id: unique("grant"),
         grant_id: unique("grant"),
         proof: SenderProof::none(),
         proof: SenderProof::none(),
         aud: Vec::new(),
         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 super::{TestError, TestResult, mint_request, ring, test_redis, unique};
 use crate::{
 use crate::{
   config::{JwtSigningAlg, JwtSigningKey, TtlConfig},
   config::{JwtSigningAlg, JwtSigningKey, TtlConfig},
+  error::{AppError, OAuthErrorCode},
   jose::jwt_access::{JwtAccessSigner, jti_hash, marker_live, marker_live_strict},
   jose::jwt_access::{JwtAccessSigner, jti_hash, marker_live, marker_live_strict},
   keys::KeyId,
   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).
 /// 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(),
     SenderProof::none(),
   );
   );
   request.format = TokenFormat::Jwt;
   request.format = TokenFormat::Jwt;
+  request.aud = vec!["https://api.example.test/".to_owned()];
+  request.refresh_aud = request.aud.clone();
   let minted = service
   let minted = service
     .mint(&mut conn, &store, &request, super::now_unix())
     .mint(&mut conn, &store, &request, super::now_unix())
     .await?;
     .await?;
@@ -71,8 +74,14 @@ async fn jwt_mint_revoke_race_no_bypass() -> TestResult {
     ));
     ));
   }
   }
   let verified = signer
   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:?}")))?;
     .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 hash = jti_hash(&verified.claims.jti);
   let member = format!("jwt:{hash}");
   let member = format!("jwt:{hash}");
   if !marker_live(&mut conn, &store, &hash)
   if !marker_live(&mut conn, &store, &hash)
@@ -169,6 +178,8 @@ async fn jwt_cascade_revoke_deletes_marker() -> TestResult {
     SenderProof::none(),
     SenderProof::none(),
   );
   );
   request.format = TokenFormat::Jwt;
   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
   // Empty subject keeps the liveness verdict on the marker alone (no account
   // lookup), so the assertion pins exactly the allowlist behavior.
   // lookup), so the assertion pins exactly the allowlist behavior.
   request.subject = String::new();
   request.subject = String::new();
@@ -176,7 +187,7 @@ async fn jwt_cascade_revoke_deletes_marker() -> TestResult {
     .mint(&mut conn, &store, &request, super::now_unix())
     .mint(&mut conn, &store, &request, super::now_unix())
     .await?;
     .await?;
   let verified = signer
   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:?}")))?;
     .map_err(|error| TestError::Unexpected(format!("fresh jwt rejected: {error:?}")))?;
   let hash = jti_hash(&verified.claims.jti);
   let hash = jti_hash(&verified.claims.jti);
   let marker_key = store.jwt_marker_key(&hash);
   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
   // The cascade revokes liveness, not the signature: the token must still
   // parse and verify while both liveness checks report dead.
   // parse and verify while both liveness checks report dead.
   signer
   signer
-    .verify(&minted.access_token, None)
+    .verify(&minted.access_token, Some(&request.aud))
     .map_err(|error| TestError::Unexpected(format!("signature check failed: {error:?}")))?;
     .map_err(|error| TestError::Unexpected(format!("signature check failed: {error:?}")))?;
   Ok(())
   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).
   /// so this is unreachable in practice; fail closed).
   #[error("JWT minting failed")]
   #[error("JWT minting failed")]
   Jwt(#[from] crate::jose::jwt_access::JwtError),
   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 {
 impl From<TokenServiceError> for AppError {
   /// Map a service failure onto the RFC error model: a missing public-client
   /// 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
   /// 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 {
   fn from(error: TokenServiceError) -> Self {
     match error {
     match error {
       TokenServiceError::MissingDpopBinding => {
       TokenServiceError::MissingDpopBinding => {
         Self::invalid_grant("public clients require a DPoP-bound token")
         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::Store(inner) => Self::from(inner),
+      TokenServiceError::JwtSignerMissing | TokenServiceError::Jwt(_) => {
+        Self::temporarily_unavailable()
+      }
       other => Self::internal(other),
       other => Self::internal(other),
     }
     }
   }
   }
@@ -192,6 +204,14 @@ pub(super) fn resolve_cnf(
 /// so the rotation script — which commits the [`RotationInput`] records
 /// so the rotation script — which commits the [`RotationInput`] records
 /// verbatim and never rewrites the grant record — preserves the full binding
 /// verbatim and never rewrites the grant record — preserves the full binding
 /// while the access token narrows.
 /// 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)]
 #[derive(Debug, Clone, PartialEq, Eq)]
 pub struct MintRequest {
 pub struct MintRequest {
   /// Client the tokens are issued to.
   /// Client the tokens are issued to.
@@ -204,8 +224,12 @@ pub struct MintRequest {
   pub dpop_required: bool,
   pub dpop_required: bool,
   /// Resource-owner subject, empty for client-credentials tokens.
   /// Resource-owner subject, empty for client-credentials tokens.
   pub subject: String,
   pub subject: String,
-  /// Space-joined scope.
+  /// Space-joined scope: the REFRESH/grant scope (retained set).
   pub scope: String,
   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.
   /// Grant chain the tokens join.
   pub grant_id: String,
   pub grant_id: String,
   /// Freshly verified proofs for this request.
   /// Freshly verified proofs for this request.
@@ -328,3 +352,21 @@ impl fmt::Debug for FoundRefresh {
       .finish()
       .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 {
 ) -> Response {
   // `RawQuery` + a manual parse replaces `Query<HashMap>`: the extractor
   // `RawQuery` + a manual parse replaces `Query<HashMap>`: the extractor
   // consumed the raw query, hiding repeats — the repeated `resource`
   // 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
   // while the single-valued map keeps last-wins absorption. A malformed
   // query is the same 400 `Query` rendered.
   // query is the same 400 `Query` rendered.
   let raw = raw.as_deref().unwrap_or("");
   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 query = crate::protocol::params::single_valued_map(&pairs);
   let resources =
   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 mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
   let input = authorize::AuthorizeInput {
   let input = authorize::AuthorizeInput {
@@ -1226,9 +1226,10 @@ mod router_tests {
     Ok(())
     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
   /// U+FFFD) reaches downstream validation (`invalid_client`: no
   /// credentials name a client), and a non-urlencoded body is 415.
   /// credentials name a client), and a non-urlencoded body is 415.
   #[tokio::test]
   #[tokio::test]
@@ -1275,7 +1276,7 @@ mod router_tests {
   }
   }
 
 
   /// `POST /token` preserves the documented duplicate-parameter mapping
   /// `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.
   /// (RFC 6749 §5.2), and a non-urlencoded body is 415.
   #[tokio::test]
   #[tokio::test]
   async fn router_token_duplicate_param_preserved() -> TestResult {
   async fn router_token_duplicate_param_preserved() -> TestResult {
@@ -1309,10 +1310,14 @@ mod router_tests {
   }
   }
 
 
   /// `POST /introspect` and `POST /device_authorization` preserve the
   /// `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
   /// downstream expectation differs per path: introspect has no
   /// credentials, so `invalid_client` (401); device has an empty
   /// credentials, so `invalid_client` (401); device has an empty
   /// `client_id`, so `invalid_request` (400).
   /// `client_id`, so `invalid_request` (400).
@@ -1373,6 +1378,111 @@ mod router_tests {
     Ok(())
     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.
   /// `GET /authorize` with no usable client never redirects with 307/308.
   #[tokio::test]
   #[tokio::test]
   async fn router_authorize_entry_never_307() -> TestResult {
   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.
 /// Fixed-window rate-limit counter: `INCR` plus `PEXPIRE`-on-first.
 pub(crate) static SCRIPT_RATELIMIT_INCR: LazyLock<Script> = LazyLock::new(|| {
 pub(crate) static SCRIPT_RATELIMIT_INCR: LazyLock<Script> = LazyLock::new(|| {
   Script::new(
   Script::new(

+ 34 - 2
src/store/tokens.rs

@@ -21,8 +21,8 @@ use std::{collections::HashMap, time::Duration};
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
 
 
 use super::{
 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;
 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
   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
 /// Store an access-token record and index it in its grant set WITHOUT the
 /// durability barrier.
 /// durability barrier.
 ///
 ///

+ 34 - 5
src/ui/consent.rs

@@ -35,6 +35,7 @@ use crate::{
   keys::KeyRing,
   keys::KeyRing,
   middleware::{audit::AuditEvent, request_id::RequestId},
   middleware::{audit::AuditEvent, request_id::RequestId},
   protocol::{
   protocol::{
+    cimd::{self, CimdSettings},
     code,
     code,
     scope::{self, ScopeSet},
     scope::{self, ScopeSet},
   },
   },
@@ -137,6 +138,9 @@ pub struct ConsentDeps<'a> {
   pub store: &'a Store,
   pub store: &'a Store,
   /// Versioned PII keys for the encrypted user-email lookup.
   /// Versioned PII keys for the encrypted user-email lookup.
   pub keys: &'a KeyRing,
   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
 /// 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(
 async fn client_and_user(
-  deps: &ConsentDeps<'_>,
+  deps: &mut ConsentDeps<'_>,
   pending: &PendingAuth,
   pending: &PendingAuth,
   subject: &str,
   subject: &str,
 ) -> Result<(client_repo::Client, user_repo::UserRecord), AppError> {
 ) -> 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)
   let user = user_repo::find_by_email(deps.pool, deps.keys, subject)
     .await?
     .await?
     .filter(|user| !user.disabled)
     .filter(|user| !user.disabled)
@@ -435,11 +450,18 @@ pub async fn get_consent(
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
   let session_id = login::session_id_from_jar(&state.config, &jar);
   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 {
   let mut deps = ConsentDeps {
     pool: &state.pool,
     pool: &state.pool,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     keys: &state.keys,
     keys: &state.keys,
+    cimd,
   };
   };
   match load_consent_view(&mut deps, &session_id).await {
   match load_consent_view(&mut deps, &session_id).await {
     Ok(Some(view)) => render(ConsentTemplate {
     Ok(Some(view)) => render(ConsentTemplate {
@@ -478,11 +500,18 @@ pub async fn post_consent(
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
   let session_id = login::session_id_from_jar(&state.config, &jar);
   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 {
   let mut deps = ConsentDeps {
     pool: &state.pool,
     pool: &state.pool,
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     keys: &state.keys,
     keys: &state.keys,
+    cimd,
   };
   };
   let submission = ConsentSubmission {
   let submission = ConsentSubmission {
     session_id: &session_id,
     session_id: &session_id,

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

@@ -33,7 +33,10 @@ use crate::{
   },
   },
   error::{AppError, OAuthErrorCode},
   error::{AppError, OAuthErrorCode},
   keys::{KeyId, KeyRing},
   keys::{KeyId, KeyRing},
-  protocol::code,
+  protocol::{
+    cimd::{self, CimdDocument, CimdSettings},
+    code,
+  },
   state::RedisConn,
   state::RedisConn,
   store::{
   store::{
     self, Store, codes,
     self, Store, codes,
@@ -59,6 +62,8 @@ struct Harness {
   conn: RedisConn,
   conn: RedisConn,
   /// Store handle over the test keyspaces.
   /// Store handle over the test keyspaces.
   store: Store,
   store: Store,
+  /// Token keyspace prefix (CIMD cache key layout).
+  token_prefix: String,
   /// PII key ring for the encrypted user-email lookup (strict encrypt-only).
   /// PII key ring for the encrypted user-email lookup (strict encrypt-only).
   keys: KeyRing,
   keys: KeyRing,
 }
 }
@@ -68,12 +73,17 @@ async fn harness() -> Result<Harness, TestError> {
   let (db, pool) = crate::db::test_support::migrated_pool().await?;
   let (db, pool) = crate::db::test_support::migrated_pool().await?;
   let manager = crate::test_support::redis_manager().await?;
   let manager = crate::test_support::redis_manager().await?;
   let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
   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 {
   Ok(Harness {
     _db: db,
     _db: db,
     pool,
     pool,
     conn: RedisConn::Single(manager),
     conn: RedisConn::Single(manager),
     store,
     store,
+    token_prefix: state_prefix,
     keys: test_ring()?,
     keys: test_ring()?,
   })
   })
 }
 }
@@ -85,6 +95,23 @@ fn deps_of(h: &mut Harness) -> ConsentDeps<'_> {
     conn: &mut h.conn,
     conn: &mut h.conn,
     store: &h.store,
     store: &h.store,
     keys: &h.keys,
     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())
   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
 /// Approve issues a code whose stored record carries the effective scope
 /// (the unentitled `orders:write` is dropped before issuance).
 /// (the unentitled `orders:write` is dropped before issuance).
 #[tokio::test]
 #[tokio::test]

+ 1 - 0
tests/e2e/harness.rs

@@ -299,6 +299,7 @@ impl World {
           dpop_required: kind == ClientKind::Public,
           dpop_required: kind == ClientKind::Public,
           subject: "e2e-subject".to_owned(),
           subject: "e2e-subject".to_owned(),
           scope: scope.to_owned(),
           scope: scope.to_owned(),
+          access_scope: scope.to_owned(),
           grant_id: uuid::Uuid::new_v4().to_string(),
           grant_id: uuid::Uuid::new_v4().to_string(),
           proof: SenderProof {
           proof: SenderProof {
             dpop_jkt: jkt.map(str::to_owned),
             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!({
     serde_json::json!({
       "jwt_access_tokens_enabled": true,
       "jwt_access_tokens_enabled": true,
       "jwt_signing_key": JWT_TEST_KEY,
       "jwt_signing_key": JWT_TEST_KEY,
+      "resource_indicators_enabled": true,
     })
     })
   } else {
   } else {
     serde_json::json!({})
     serde_json::json!({})
   };
   };
   let world = World::start_with(open_rate_limit(), overrides).await?;
   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(
   seed_client(
     &world.pool,
     &world.pool,
     &world.state.keys,
     &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
 /// 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]
 #[tokio::test]
 async fn jwt_client_with_flag_on_resolves_jwt() -> Result<(), TestError> {
 async fn jwt_client_with_flag_on_resolves_jwt() -> Result<(), TestError> {
   let world = setup_with_jwt_flag(true).await?;
   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
   let (status, _, body) = world
     .post_form(
     .post_form(
       "/token",
       "/token",
-      &[("grant_type", "client_credentials")],
+      &[
+        ("grant_type", "client_credentials"),
+        ("resource", RESOURCE_A),
+      ],
       Some((CLIENT_ID, CLIENT_SECRET)),
       Some((CLIENT_ID, CLIENT_SECRET)),
       &[],
       &[],
     )
     )
@@ -1525,11 +1535,13 @@ async fn jwt_client_with_flag_on_resolves_jwt() -> Result<(), TestError> {
     status == reqwest::StatusCode::OK,
     status == reqwest::StatusCode::OK,
     format!("the gate's positive branch must reach minting: {status} {body}"),
     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(
   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(())
   Ok(())
 }
 }