Jelajahi Sumber

Added optionals

Efren Yevale Varela 2 minggu lalu
induk
melakukan
9478660260
100 mengubah file dengan 11784 tambahan dan 415 penghapusan
  1. 74 0
      .sqlx/query-324869e5bd9d628539bf47cd05b3922f4d3a0be9932ab75271f6690fcaf7b25f.json
  2. 16 4
      .sqlx/query-69140caa2dc62784ec1b425da66212ce36f126e06cc67f99d736c1a2b88b16a8.json
  3. 14 0
      .sqlx/query-6f7321dc8b54b0878866201efde81d7534a3df9de264614a088a4561c6e81136.json
  4. 15 0
      .sqlx/query-844245cb233bbf67d7aea2a549a9c50dbcd08347635e14e3cc31c47c62fb6571.json
  5. 18 5
      .sqlx/query-9882c7a1fc69f2884029ae8e2a6ced18aafb46ee9067b5b780dc246d7ec154d4.json
  6. 16 4
      .sqlx/query-b0dae9eff317f7551fc27d5c4170c7d984f41982ce9b02ccaf7b847417809111.json
  7. 16 4
      .sqlx/query-bbe63b33f926853200871057be0d05221b07ed2c29451a40499334a8971b0e74.json
  8. 16 4
      .sqlx/query-cc67cef87f8559a9838780ace08d0418de846a6d053c39cfe786360a999c9cdc.json
  9. 76 0
      .sqlx/query-d354e20a35922b136d4a2f83fd02ffb771f80ca638e2893f85ceb07e1775d5b1.json
  10. 78 0
      .sqlx/query-ddb3906dfbb822403533a59c1c86b152de78abfcef19ff9f0a8d546adbc02fc3.json
  11. 4 1
      AGENTS.md
  12. 6 4
      Cargo.lock
  13. 2 0
      Cargo.toml
  14. 21 5
      README.md
  15. 4 0
      RUNBOOK.md
  16. 1 0
      deny.toml
  17. 28 5
      docs/ARCHITECTURE.md
  18. 70 4
      docs/CONFIGURATION.md
  19. 1 1
      docs/CONTRIBUTING.md
  20. 24 4
      docs/DATABASE.md
  21. 5 2
      docs/DEPLOYMENT.md
  22. 330 17
      docs/OAUTH.md
  23. 345 6
      docs/OPERATIONS.md
  24. 42 0
      docs/REDIS.md
  25. 303 0
      docs/RS-CONTRACT.md
  26. 205 9
      docs/SECURITY.md
  27. 9 3
      fuzz/fuzz_targets/redirect_uri.rs
  28. 34 0
      migrations/1791504000_resource_servers.sql
  29. 51 0
      src/cli.rs
  30. 28 1
      src/cli/redirect.rs
  31. 169 0
      src/cli/resource.rs
  32. 79 4
      src/cli/sync.rs
  33. 183 4
      src/cli/tests.rs
  34. 72 2
      src/config.rs
  35. 112 1
      src/config/build.rs
  36. 234 0
      src/config/jwt.rs
  37. 34 0
      src/config/raw.rs
  38. 209 0
      src/config/tests.rs
  39. 2 1
      src/crypto/tokens.rs
  40. 39 9
      src/db/client_repo.rs
  41. 1 0
      src/db/client_repo/tests.rs
  42. 7 0
      src/db/client_repo/types.rs
  43. 1 0
      src/db/mod.rs
  44. 1 0
      src/db/redirect_repo/tests.rs
  45. 113 0
      src/db/resource_repo.rs
  46. 130 0
      src/db/resource_repo/tests.rs
  47. 1 2
      src/db/user_repo/lockout.rs
  48. 2 2
      src/db/user_repo/tests.rs
  49. 2 1
      src/error.rs
  50. 15 4
      src/jose/jar.rs
  51. 1 2
      src/jose/jar/fetch.rs
  52. 139 2
      src/jose/jar/merge.rs
  53. 12 6
      src/jose/jar/request_uri.rs
  54. 3 1
      src/jose/jar/verify.rs
  55. 478 0
      src/jose/jwt_access.rs
  56. 459 0
      src/jose/jwt_access/tests.rs
  57. 7 2
      src/jose/mod.rs
  58. 1 1
      src/middleware/ratelimit.rs
  59. 2 1
      src/middleware/redaction.rs
  60. 55 33
      src/middleware/security_headers.rs
  61. 188 22
      src/protocol/authorize.rs
  62. 350 2
      src/protocol/authorize/tests.rs
  63. 654 0
      src/protocol/cimd.rs
  64. 351 0
      src/protocol/cimd/tests.rs
  65. 1163 0
      src/protocol/cimd/tests_principal.rs
  66. 21 11
      src/protocol/client_auth.rs
  67. 2 0
      src/protocol/client_auth/tests_containers.rs
  68. 21 6
      src/protocol/code.rs
  69. 48 0
      src/protocol/code/tests.rs
  70. 45 13
      src/protocol/dcr.rs
  71. 11 3
      src/protocol/dcr/manage/tests_delete.rs
  72. 323 31
      src/protocol/dcr/tests.rs
  73. 100 11
      src/protocol/device.rs
  74. 21 1
      src/protocol/device/tests.rs
  75. 262 10
      src/protocol/introspect.rs
  76. 603 3
      src/protocol/introspect/tests.rs
  77. 164 0
      src/protocol/jwks.rs
  78. 77 4
      src/protocol/metadata.rs
  79. 9 0
      src/protocol/metadata/snapshots/metadata_document.snap
  80. 426 12
      src/protocol/metadata/tests.rs
  81. 3 0
      src/protocol/mod.rs
  82. 105 14
      src/protocol/par.rs
  83. 5 1
      src/protocol/par/tests.rs
  84. 5 4
      src/protocol/par/tests/consume.rs
  85. 133 10
      src/protocol/par/tests/rules.rs
  86. 360 0
      src/protocol/params.rs
  87. 236 0
      src/protocol/params/tests.rs
  88. 85 5
      src/protocol/purge.rs
  89. 30 15
      src/protocol/redirect_repo.rs
  90. 114 23
      src/protocol/redirect_repo/tests.rs
  91. 118 3
      src/protocol/revoke.rs
  92. 6 1
      src/protocol/revoke/tests.rs
  93. 4 2
      src/protocol/revoke/tests/auth.rs
  94. 7 0
      src/protocol/revoke/tests/cascade.rs
  95. 724 0
      src/protocol/revoke/tests/jwt.rs
  96. 1 0
      src/protocol/revoke/tests/mtls.rs
  97. 88 18
      src/protocol/ssrf.rs
  98. 193 1
      src/protocol/ssrf/tests.rs
  99. 238 24
      src/protocol/token/dispatch.rs
  100. 75 4
      src/protocol/token/dispatch/grants.rs

+ 74 - 0
.sqlx/query-324869e5bd9d628539bf47cd05b3922f4d3a0be9932ab75271f6690fcaf7b25f.json

@@ -0,0 +1,74 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT id, identifier, description, enabled, created_at\n         FROM resource_servers WHERE enabled ORDER BY identifier",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "identifier",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "identifier"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "created_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": []
+    },
+    "nullable": [
+      false,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "324869e5bd9d628539bf47cd05b3922f4d3a0be9932ab75271f6690fcaf7b25f"
+}

+ 16 - 4
.sqlx/query-310cbe7ee6b575d3ae246b0e401a58e19c0cf92d2eeb58ebdc512b3319e67cd5.json → .sqlx/query-69140caa2dc62784ec1b425da66212ce36f126e06cc67f99d736c1a2b88b16a8.json

@@ -1,6 +1,6 @@
 {
   "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, active, created_at, updated_at FROM oauth_clients WHERE id = $1",
+  "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 id = $1",
   "describe": {
     "columns": [
       {
@@ -269,6 +269,17 @@
       },
       {
         "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": {
@@ -279,7 +290,7 @@
         }
       },
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -290,7 +301,7 @@
         }
       },
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -333,8 +344,9 @@
       false,
       false,
       false,
+      false,
       false
     ]
   },
-  "hash": "310cbe7ee6b575d3ae246b0e401a58e19c0cf92d2eeb58ebdc512b3319e67cd5"
+  "hash": "69140caa2dc62784ec1b425da66212ce36f126e06cc67f99d736c1a2b88b16a8"
 }

+ 14 - 0
.sqlx/query-6f7321dc8b54b0878866201efde81d7534a3df9de264614a088a4561c6e81136.json

@@ -0,0 +1,14 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE resource_servers SET enabled = false WHERE identifier = $1",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "6f7321dc8b54b0878866201efde81d7534a3df9de264614a088a4561c6e81136"
+}

+ 15 - 0
.sqlx/query-844245cb233bbf67d7aea2a549a9c50dbcd08347635e14e3cc31c47c62fb6571.json

@@ -0,0 +1,15 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "UPDATE oauth_clients SET access_token_format = $2, updated_at = now() WHERE client_id = $1 AND active = true AND access_token_format IS DISTINCT FROM $2",
+  "describe": {
+    "columns": [],
+    "parameters": {
+      "Left": [
+        "Text",
+        "Text"
+      ]
+    },
+    "nullable": []
+  },
+  "hash": "844245cb233bbf67d7aea2a549a9c50dbcd08347635e14e3cc31c47c62fb6571"
+}

+ 18 - 5
.sqlx/query-f85e57a560d33626ceb5b28030d25d6f982f8e781ccf7b3e3b3c8fb61e7eed8b.json → .sqlx/query-9882c7a1fc69f2884029ae8e2a6ced18aafb46ee9067b5b780dc246d7ec154d4.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "INSERT INTO oauth_clients (id, client_id, client_secret_hash, 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_subject_dn_index, tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, tls_client_auth_cert_thumbprint_index, request_uris, request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, registration_access_token_hash, dpop_bound_access_tokens, created_at, updated_at) VALUES ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, now(), now()) RETURNING 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, active, created_at, updated_at",
+  "query": "INSERT INTO oauth_clients (id, client_id, client_secret_hash, 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_subject_dn_index, tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, tls_client_auth_cert_thumbprint_index, request_uris, request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, registration_access_token_hash, dpop_bound_access_tokens, access_token_format, created_at, updated_at) VALUES ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, now(), now()) RETURNING 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",
   "describe": {
     "columns": [
       {
@@ -269,6 +269,17 @@
       },
       {
         "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": {
@@ -279,7 +290,7 @@
         }
       },
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -290,7 +301,7 @@
         }
       },
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -329,7 +340,8 @@
         "Text",
         "Bytea",
         "Text",
-        "Bool"
+        "Bool",
+        "Text"
       ]
     },
     "nullable": [
@@ -359,8 +371,9 @@
       false,
       false,
       false,
+      false,
       false
     ]
   },
-  "hash": "f85e57a560d33626ceb5b28030d25d6f982f8e781ccf7b3e3b3c8fb61e7eed8b"
+  "hash": "9882c7a1fc69f2884029ae8e2a6ced18aafb46ee9067b5b780dc246d7ec154d4"
 }

+ 16 - 4
.sqlx/query-1f4bf0fab2e49823757742bf3d8c0da51893614b667dc5ed39121ecc8f781274.json → .sqlx/query-b0dae9eff317f7551fc27d5c4170c7d984f41982ce9b02ccaf7b847417809111.json

@@ -1,6 +1,6 @@
 {
   "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, active, created_at, updated_at FROM oauth_clients WHERE ($1 OR active = true) ORDER BY created_at, client_id",
+  "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 ($1 OR active = true) ORDER BY created_at, client_id",
   "describe": {
     "columns": [
       {
@@ -269,6 +269,17 @@
       },
       {
         "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": {
@@ -279,7 +290,7 @@
         }
       },
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -290,7 +301,7 @@
         }
       },
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -333,8 +344,9 @@
       false,
       false,
       false,
+      false,
       false
     ]
   },
-  "hash": "1f4bf0fab2e49823757742bf3d8c0da51893614b667dc5ed39121ecc8f781274"
+  "hash": "b0dae9eff317f7551fc27d5c4170c7d984f41982ce9b02ccaf7b847417809111"
 }

+ 16 - 4
.sqlx/query-1cf0676f9a41ce8b8e07e77673d31d225ec331c2fbfc7d6e6ac9fe9598570a21.json → .sqlx/query-bbe63b33f926853200871057be0d05221b07ed2c29451a40499334a8971b0e74.json

@@ -1,6 +1,6 @@
 {
   "db_name": "PostgreSQL",
-  "query": "UPDATE oauth_clients SET client_name = $2, client_name_enc = $3, grant_types = $4, response_types = $5, scope = $6, token_endpoint_auth_method = $7, jwks = $8, jwks_uri = $9, jwks_uri_enc = $10, tls_client_auth_subject_dn = $11, tls_client_auth_subject_dn_enc = $12, tls_client_auth_subject_dn_index = $13, tls_client_auth_cert_thumbprint = $14, tls_client_auth_cert_thumbprint_enc = $15, tls_client_auth_cert_thumbprint_index = $16, request_uris = $17, request_uris_enc = $18, contacts = $19, contacts_enc = $20, software_id = $21, software_id_enc = $22, dpop_bound_access_tokens = $23, updated_at = now() WHERE id = $1 RETURNING 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, active, created_at, updated_at",
+  "query": "UPDATE oauth_clients SET client_name = $2, client_name_enc = $3, grant_types = $4, response_types = $5, scope = $6, token_endpoint_auth_method = $7, jwks = $8, jwks_uri = $9, jwks_uri_enc = $10, tls_client_auth_subject_dn = $11, tls_client_auth_subject_dn_enc = $12, tls_client_auth_subject_dn_index = $13, tls_client_auth_cert_thumbprint = $14, tls_client_auth_cert_thumbprint_enc = $15, tls_client_auth_cert_thumbprint_index = $16, request_uris = $17, request_uris_enc = $18, contacts = $19, contacts_enc = $20, software_id = $21, software_id_enc = $22, dpop_bound_access_tokens = $23, updated_at = now() WHERE id = $1 RETURNING 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",
   "describe": {
     "columns": [
       {
@@ -269,6 +269,17 @@
       },
       {
         "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": {
@@ -279,7 +290,7 @@
         }
       },
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -290,7 +301,7 @@
         }
       },
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -355,8 +366,9 @@
       false,
       false,
       false,
+      false,
       false
     ]
   },
-  "hash": "1cf0676f9a41ce8b8e07e77673d31d225ec331c2fbfc7d6e6ac9fe9598570a21"
+  "hash": "bbe63b33f926853200871057be0d05221b07ed2c29451a40499334a8971b0e74"
 }

+ 16 - 4
.sqlx/query-5328a7be851fc8892f4599363cad74cf4bc08ea045e49105977b833af17e0ce1.json → .sqlx/query-cc67cef87f8559a9838780ace08d0418de846a6d053c39cfe786360a999c9cdc.json

@@ -1,6 +1,6 @@
 {
   "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, active, created_at, updated_at FROM oauth_clients WHERE client_id = $1 AND active = true",
+  "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 AND active = true",
   "describe": {
     "columns": [
       {
@@ -269,6 +269,17 @@
       },
       {
         "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": {
@@ -279,7 +290,7 @@
         }
       },
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -290,7 +301,7 @@
         }
       },
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "type_info": "Timestamptz",
         "origin": {
@@ -333,8 +344,9 @@
       false,
       false,
       false,
+      false,
       false
     ]
   },
-  "hash": "5328a7be851fc8892f4599363cad74cf4bc08ea045e49105977b833af17e0ce1"
+  "hash": "cc67cef87f8559a9838780ace08d0418de846a6d053c39cfe786360a999c9cdc"
 }

+ 76 - 0
.sqlx/query-d354e20a35922b136d4a2f83fd02ffb771f80ca638e2893f85ceb07e1775d5b1.json

@@ -0,0 +1,76 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "SELECT id, identifier, description, enabled, created_at\n         FROM resource_servers WHERE identifier = $1 AND enabled",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "identifier",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "identifier"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "created_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "d354e20a35922b136d4a2f83fd02ffb771f80ca638e2893f85ceb07e1775d5b1"
+}

+ 78 - 0
.sqlx/query-ddb3906dfbb822403533a59c1c86b152de78abfcef19ff9f0a8d546adbc02fc3.json

@@ -0,0 +1,78 @@
+{
+  "db_name": "PostgreSQL",
+  "query": "INSERT INTO resource_servers (id, identifier, description, enabled, created_at)\n       VALUES ($1, $2, $3, true, now())\n       ON CONFLICT (identifier) DO UPDATE\n         SET description = EXCLUDED.description\n       RETURNING id, identifier, description, enabled, created_at",
+  "describe": {
+    "columns": [
+      {
+        "ordinal": 0,
+        "name": "id",
+        "type_info": "Uuid",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "id"
+          }
+        }
+      },
+      {
+        "ordinal": 1,
+        "name": "identifier",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "identifier"
+          }
+        }
+      },
+      {
+        "ordinal": 2,
+        "name": "description",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "description"
+          }
+        }
+      },
+      {
+        "ordinal": 3,
+        "name": "enabled",
+        "type_info": "Bool",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "enabled"
+          }
+        }
+      },
+      {
+        "ordinal": 4,
+        "name": "created_at",
+        "type_info": "Timestamptz",
+        "origin": {
+          "Table": {
+            "table": "resource_servers",
+            "name": "created_at"
+          }
+        }
+      }
+    ],
+    "parameters": {
+      "Left": [
+        "Uuid",
+        "Text",
+        "Text"
+      ]
+    },
+    "nullable": [
+      false,
+      false,
+      false,
+      false,
+      false
+    ]
+  },
+  "hash": "ddb3906dfbb822403533a59c1c86b152de78abfcef19ff9f0a8d546adbc02fc3"
+}

+ 4 - 1
AGENTS.md

@@ -19,7 +19,10 @@ the admin surface.
 | Crypto primitive (hash, compare, PKCE, TOTP) | `src/crypto/` | Constant-time for secret compares. |
 | PII field codec / blind index | `src/crypto/field.rs` | Versioned AES-256-GCM; never log plaintext or key material. |
 | JOSE verification (DPoP, JAR, `private_key_jwt`) | `src/jose/` | Reject fail-closed with the exact error code. |
+| Client ID metadata document (CIMD) fetch, validation, or cache | `src/protocol/cimd.rs` | One guarded `https` fetch (no redirects, byte and time caps); only `none`/`private_key_jwt`, inline `jwks` only, no wildcard redirects; cache under `{state}`. `resolve_client` is the seam: a registered row wins, else the document when `cimd_enabled`. Update [docs/OAUTH.md](docs/OAUTH.md), [docs/SECURITY.md](docs/SECURITY.md), and [docs/OPERATIONS.md](docs/OPERATIONS.md). |
 | Postgres query or repository | `src/db/` | `query!`/`query_as!` macros; regenerate `.sqlx`. |
+| Resource-server (RFC 8707) catalog query | `src/db/resource_repo.rs` | Exact-match only: no wildcard, prefix, or normalization. Update [docs/DATABASE.md](docs/DATABASE.md) and [docs/OAUTH.md](docs/OAUTH.md). |
+| Repeated `resource` parsing or audience binding | `src/protocol/params.rs` | One wire-order capture for the five raw-parameter endpoints; validate fail-closed with `invalid_target`. Update [docs/OAUTH.md](docs/OAUTH.md). |
 | Redis record or Lua script | `src/store/` | Domain keys under `{state}`, rate-limit counters under `{rl}` (must differ); update [docs/REDIS.md](docs/REDIS.md). |
 | OAuth endpoint or grant | `src/protocol/` | Cite the RFC section in a comment. Update [docs/OAUTH.md](docs/OAUTH.md). A subject-scoped Redis purge lives at `src/protocol/purge.rs` (`purge_subject` for `user disable`, `purge_sibling_sessions` on credential change); domain keys under `{state}`, rate-limit counters under `{rl}` and update docs/REDIS.md. |
 | Login, consent, or device page | `src/ui/` | Keep CSP and CSRF intact. |
@@ -27,7 +30,7 @@ the admin surface.
 | Endpoint or layer order | `src/routes.rs` | Update [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). |
 | Listener or shutdown behavior | `src/serve.rs`, `src/tls.rs` | Update [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). |
 | Health or topology behavior | `src/state/` | Readiness is fail-closed. |
-| CLI command or flag | `src/cli.rs`, `src/cli/` | Match the flag exactly in [docs/OPERATIONS.md](docs/OPERATIONS.md). |
+| CLI command or flag | `src/cli.rs`, `src/cli/` | Match the flag exactly in [docs/OPERATIONS.md](docs/OPERATIONS.md). The RFC 8707 catalog lives in `src/cli/resource.rs` (`resource-server create|list|enable|disable|delete`). |
 | Error code or HTTP mapping | `src/error.rs` | Add the variant and its status together. |
 
 If a change spans modules, put the logic in the module that owns the behavior

+ 6 - 4
Cargo.lock

@@ -935,6 +935,7 @@ dependencies = [
  "base64ct",
  "clap",
  "config",
+ "ed25519-dalek",
  "hmac 0.13.0",
  "http-body-util",
  "hyper",
@@ -942,6 +943,7 @@ dependencies = [
  "insta",
  "jsonwebtoken",
  "oauth2",
+ "p256",
  "password-hash",
  "proptest",
  "qrcode",
@@ -3174,9 +3176,9 @@ dependencies = [
 
 [[package]]
 name = "rustls-webpki"
-version = "0.103.15"
+version = "0.103.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
 dependencies = [
  "ring",
  "rustls-pki-types",
@@ -4594,9 +4596,9 @@ dependencies = [
 
 [[package]]
 name = "webpki-roots"
-version = "1.0.9"
+version = "1.0.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
 dependencies = [
  "rustls-pki-types",
 ]

+ 2 - 0
Cargo.toml

@@ -24,11 +24,13 @@ base64 = "0.23.1"
 base64ct = "1.8.3"
 clap = { version = "4.6.7", features = ["derive"] }
 config = "0.15.27"
+ed25519-dalek = { version = "2.2.0", default-features = false }
 hmac = "0.13.0"
 hyper = "1.11.1"
 hyper-util = { version = "0.1.21", features = ["server-auto", "tokio", "service"] }
 jsonwebtoken = { version = "11.1.0", features = ["rust_crypto"] }
 password-hash = "0.6.1"
+p256 = { version = "0.13.2", default-features = false, features = ["ecdsa", "arithmetic"] }
 # TOTP enrollment QR: SVG-only (no `image` codecs), embedded inline in the
 # server-rendered setup page under a `script-src 'none'` policy.
 qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }

+ 21 - 5
README.md

@@ -3,16 +3,20 @@
 An OAuth 2.1-only authorization server written in Rust. It is deliberately not
 backwards compatible with OAuth 2.0: PKCE is mandatory, the implicit and
 resource-owner-password grants do not exist, bearer-in-query is rejected, and
-redirect URIs match exactly.
+redirect URIs match exactly (with the RFC 8252 §7.3 loopback exception, which
+includes the host `localhost` only when `allow_localhost_redirect` is set).
 
 It implements the authorization-code, refresh-token, client-credentials,
 device-code, and token-exchange grants, with DPoP, mTLS, PAR, JAR, dynamic
 client registration, introspection, and revocation. Tokens are opaque and
-hashed at rest. All durable domain state is in PostgreSQL and all ephemeral
-state is in Redis, so replicas are interchangeable.
+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
+durable domain state is in PostgreSQL and all ephemeral state is in Redis, so
+replicas are interchangeable.
 
 - Protocol and deltas: [docs/OAUTH.md](docs/OAUTH.md)
 - Threat model and token design: [docs/SECURITY.md](docs/SECURITY.md)
+- Resource-server contract (RFC 9728): [docs/RS-CONTRACT.md](docs/RS-CONTRACT.md)
 - Vulnerability reporting policy: [SECURITY.md](SECURITY.md)
 - Module layout and request flow: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
 - Every configuration key: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
@@ -20,6 +24,7 @@ state is in Redis, so replicas are interchangeable.
 - Redis keys and durability: [docs/REDIS.md](docs/REDIS.md)
 - Container and cluster: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
 - Admin CLI and runbooks: [docs/OPERATIONS.md](docs/OPERATIONS.md)
+- Resource-server onboarding runbook (RFC 9728): [docs/OPERATIONS.md](docs/OPERATIONS.md#resource-server-onboarding-rfc-9728)
 - Contributor guide: [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md)
 - Changelog: [docs/CHANGELOG.md](docs/CHANGELOG.md)
 - Admin bootstrap procedure: [RUNBOOK.md](RUNBOOK.md)
@@ -140,8 +145,14 @@ curl -sk https://localhost:8080/token \
   -d code_verifier="$VERIFIER"
 ```
 
-5. A public client must present a DPoP proof with the token request; a
-   confidential client authenticates with its secret or an mTLS certificate.
+5. A DPoP-bound public client (`dpop_bound_access_tokens: true`) must present
+   a DPoP proof with the token request; an unbound public client (the
+   default) mints `Bearer` tokens with no proof. A confidential client
+   authenticates with its secret or an mTLS certificate.
+
+Rollout note: unbound public clients become bearer-capable on upgrade.
+Operators wanting DPoP-mandatory public clients register them with
+`dpop_bound_access_tokens: true`.
 
 The `POST /token` dispatcher and the `/register*` DCR routes (RFC 7591/RFC
 7592) are wired, so step 4 redeems a code end to end; a client can also be
@@ -288,6 +299,11 @@ certificate.
   `client attach-redirect-uri|detach-redirect-uri` accept
   `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
   the return targets for `POST /logout` (`GET /logout` is read-only).
+- Resource indicators: `resource-server create|list|enable|disable|delete`
+  manage the RFC 8707 resource-server catalog (exact identifiers, no wildcard).
+- Access-token format: `client set-access-token-format --client-id <id>
+  --format opaque|jwt` is the operator-only opt-in to JWT access tokens;
+  dynamic registration always stores `opaque`.
 - Users and entitlements: `user create|enroll-totp|disable|unlock|grant-scope|revoke-scope|scopes`.
 - Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
 - Declarative apply: `config sync --file` and `config check`.

+ 4 - 0
RUNBOOK.md

@@ -8,6 +8,10 @@ CLI/Job only.
 > Task 48 owns `SECURITY.md`; this file is the task-47 operator runbook.
 > The threat model and the replace-immediately rationale live there.
 
+This file covers admin bootstrap and breach response only. Onboarding a resource
+server (RFC 9728) is a separate operator runbook in
+[docs/OPERATIONS.md](docs/OPERATIONS.md#resource-server-onboarding-rfc-9728).
+
 ## Prerequisites
 
 - Migrations applied exactly once via the migration Job (never per replica):

+ 1 - 0
deny.toml

@@ -12,6 +12,7 @@ yanked = "deny"
 # the padding-timing oracle does not apply. Re-evaluate when rsa ships a fix.
 ignore = [
   { id = "RUSTSEC-2023-0071", reason = "unpatched upstream; RSA verify-only, never decryption" },
+  { id = "RUSTSEC-2026-0374", reason = "unmaintained (no safe upgrade); basic-toml is a compile-time-only dep of the askama_derive proc macro, never in the runtime graph" },
 ]
 
 [bans]

+ 28 - 5
docs/ARCHITECTURE.md

@@ -25,9 +25,11 @@ the library.
 | `src/crypto/` | Constant-time comparison, PKCE, opaque token mint/hash, TOTP, and password/client-secret hashing. |
 | `src/crypto/field.rs` | Versioned AES-256-GCM field codec and keyed-HMAC blind index for the PII columns. |
 | `src/jose/` | DPoP proof verification, JAR request objects, `private_key_jwt` client assertions, and JWK parsing. |
-| `src/db/` | Postgres repositories for the six domain tables. See [DATABASE.md](DATABASE.md). |
+| `src/db/` | Postgres repositories for the seven domain tables, including `resource_repo` for the RFC 8707 resource-server catalog. See [DATABASE.md](DATABASE.md). |
+| `src/db/resource_repo.rs` | Exact-match CRUD for the `resource_servers` catalog: `upsert` preserves `id`/`enabled`, `find_enabled`, `list_enabled`, and `disable`. |
 | `src/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
 | `src/protocol/` | OAuth endpoints and grants, plus the subject-scoped Redis purges (`src/protocol/purge.rs`: `purge_subject` for `user disable`, `purge_sibling_sessions` on credential change). |
+| `src/protocol/params.rs` | Shared raw-parameter parsing (`parse_pairs`, `repeated_values`, the form-content-type gate) and fail-closed RFC 8707 audience validation (`validate_resources`, `audience_binds`). |
 | `src/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/resilience.rs` | Load-shed, timeout, and concurrency-limit guard between CORS and rate limiting. |
@@ -50,7 +52,9 @@ is fixed in `build_router_with_options` in `src/routes.rs`:
    `http_request` tracing span. Tracing and redaction live here.
 2. Security headers: adds `Referrer-Policy`, `X-Content-Type-Options`, a CSP
    with `frame-ancestors 'none'`, HSTS on the TLS listener, and
-   `Cache-Control: no-store` on every response except `/.well-known/…` metadata.
+   `Cache-Control: no-store` on every response except `/.well-known/…` metadata
+   and `GET /jwks.json`, which carries its own short `public, max-age=300`
+   window (a rotated-out key must leave intermediary caches quickly).
    Sits just inside the request-id layer so short-circuiting layers below
    (CORS preflights, resilience 503s, rate-limit 429/503s, body-limit 413s)
    still carry the headers.
@@ -71,6 +75,17 @@ store. Errors are converted to `AppError`, which maps to an OAuth error body and
 the correct HTTP status. A dependency outage on a security path maps to `503`
 with `Retry-After`; it never fails open.
 
+Five parameter-carrying endpoints parse the raw query or body instead of using
+`Query<HashMap>` or `Form<...>`, because those extractors consume the raw input
+and cannot see a repeated `resource`: `GET /authorize`, `POST /par`,
+`POST /token`, `POST /device_authorization`, and `POST /introspect`.
+`src/protocol/params.rs` captures repeats in wire order while keeping the
+single-valued map last-wins. For the body endpoints it preserves the prior
+transport contract (`415` on a non-urlencoded body, `400` on a malformed or
+non-UTF-8 body, and the documented `invalid_request` mapping for a duplicated
+known field). `/token` rejects a `resource` on token exchange before any catalog
+lookup.
+
 ## The two listeners
 
 - The public listener binds `bind_addr` (default `0.0.0.0:8080`) and speaks TLS
@@ -90,9 +105,10 @@ network namespace and cannot reach pod-loopback, so `serve` refuses a loopback
 
 The split is strict and drives the whole design:
 
-- PostgreSQL holds durable domain state only: `oauth_clients`, `users`,
-  `scopes`, `user_scopes`, `redirect_uris`, `client_redirect_uris`, plus sqlx's
-  `_sqlx_migrations`. Nothing is written to Postgres per request. A failed login
+- PostgreSQL holds durable domain state only, in seven tables: `oauth_clients`,
+  `users`, `scopes`, `user_scopes`, `redirect_uris`, `client_redirect_uris`,
+  and `resource_servers`, plus sqlx's `_sqlx_migrations`. Nothing is written to
+  Postgres per request. A failed login
   does not touch Postgres; the counter lives in Redis. Introspection and refresh
   do re-read Postgres on every use: a non-empty subject resolving to a disabled
   user yields inactive (introspection) or `invalid_grant` (refresh) without
@@ -146,6 +162,13 @@ so they stay on a single slot. See [REDIS.md](REDIS.md).
   a CLI catalog mutation is visible immediately on every replica; when Postgres
   is down it returns `503` rather than advertising an empty or unknown scope
   list.
+- `GET /jwks.json` (public JWT access-token verification keys) serves a JWK Set
+  built from the configured signing ring: the current key first, then every
+  retained verify-only key. It is the single cacheable path outside
+  `/.well-known/`: keys are public by definition, and the shorter 300-second
+  window bounds how long a rotated-out key stays cached. With
+  `jwt_access_tokens_enabled` off it returns `{"keys":[]}` deterministically
+  rather than a cacheable `404`.
 - Migrations are applied out of band. A replica whose schema version is behind
   the binary reports not ready and is pulled from service.
 

+ 70 - 4
docs/CONFIGURATION.md

@@ -37,9 +37,10 @@ A secret may be given inline or as a mounted file. Inline wins when both are
 set. Mounted files are read and trimmed of trailing newlines.
 
 String-list keys (`cors_allowed_origins`, `rate_limit.trusted_proxies`,
-`redis.nodes`, `redis.seeds`) accept a real array in the file and either a
-JSON array string or a comma-separated string from the environment, because
-config-rs 0.15 keeps env values as strings and never coerces arrays. Both
+`protected_resource_ids`, `redis.nodes`, `redis.seeds`) accept a real array in
+the file and either a JSON array string or a comma-separated string from the
+environment, because config-rs 0.15 keeps env values as strings and never
+coerces arrays. Both
 `D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='["10.0.0.5"]'` and
 `D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='10.0.0.5,10.0.1.0/24'` load; a blank
 string loads as empty, and malformed JSON array text fails the load instead
@@ -75,6 +76,18 @@ which names the offending key.
 | `cors_allowed_origins` | empty | An explicit origin allowlist. Empty means no CORS layer. A lone `*` is silently dropped (no CORS layer, no error). |
 | `allow_token_exchange` | `false` | Enables the token-exchange grant. |
 | `token_exchange_allowlist` | empty | Rules of `{caller, subject, scopes[]}` for token exchange. |
+| `resource_indicators_enabled` | `false` | Honors the RFC 8707 `resource` indicator on the authorization, PAR, token, device, and introspection endpoints. Off plus any `resource` is `invalid_target`, never a silent drop. |
+| `cimd_enabled` | `false` | Enables Client ID Metadata Document lookup (an `https://` `client_id` is fetched and validated per request). When on, the AS metadata advertises `client_id_metadata_document_supported` and keeps `none` advertised. |
+| `protected_resources_enabled` | `false` | Emits the RFC 9728 `protected_resources` metadata member. |
+| `protected_resource_ids` | empty | Identifiers advertised in `protected_resources` when `protected_resources_enabled` is on. |
+| `allow_localhost_redirect` | `false` | Accepts the RFC 8252 host `localhost` loopback redirect (`127.0.0.1`/`[::1]` stay loopback regardless). |
+| `jwt_access_tokens_enabled` | `false` | Global kill-switch for JWT access tokens (`at+jwt`). Opaque tokens remain the only format while off. |
+| `jwt_signing_key` (`_file`) | none | Active JWT signing key: exactly 32 bytes of raw material. Inline wins; a mounted file is trimmed of trailing newlines. |
+| `jwt_signing_kid` | `v1` | Key id placed in the JOSE header; must match `[A-Za-z0-9_-]+`. |
+| `jwt_signing_previous_keys` | empty | Rotated-out keys of `{kid, key}` for verification only; every `key` must be exactly 32 bytes and every `kid` distinct. |
+| `jwt_access_token_signing_alg` | `ES256` | `ES256` or `EdDSA` only. Both take exactly-32-byte raw key material; RSA is not supported. |
+| `cimd_fetch_timeout_secs` | `5` | CIMD document fetch timeout, 1-10 seconds. |
+| `cimd_max_bytes` | `5120` | CIMD document size cap, 1-5120 bytes. |
 
 ## TLS keys
 
@@ -296,6 +309,55 @@ scope-token grammar, may not be `openid`, and may not exceed 128 characters.
 `token_exchange_allowlist` entries are `{caller, subject, scopes[]}`; caller and
 subject may not be empty.
 
+## JWT access tokens and optional-feature keys
+
+Every optional feature is off by default and none of these keys changes
+default-on behavior. The feature flags (`resource_indicators_enabled`,
+`cimd_enabled`, `protected_resources_enabled`, `jwt_access_tokens_enabled`, and
+`allow_localhost_redirect`) default to `false`; `protected_resource_ids` and
+`jwt_signing_previous_keys` default to empty.
+
+CIMD is the registration-free client path. Enabling it does not change the
+`POST /register` gate: `allow_open_registration` and `dcr_redirect_policy` still
+decide dynamically registered clients, and `allow_localhost_redirect` is the
+single server-wide switch that makes the host `localhost` a loopback redirect
+for both registered and CIMD clients. The operator runbook, including a
+validating example document, is in
+[OPERATIONS.md](OPERATIONS.md#mcp-client-registration).
+
+| Key | Default | Rule |
+| --- | --- | --- |
+| `D_OAUTH_JWT_ACCESS_TOKENS_ENABLED` | `false` | Global kill-switch for JWT access tokens. Enabling it requires a signing key; a configured `jwt` client with the switch off gets `503`. |
+| `D_OAUTH_JWT_SIGNING_KEY` | none | Active signing key: the literal UTF-8 bytes of the string, exactly 32 bytes. Shorter or longer material is `WeakKeyMaterial`; unusable EC/EdDSA material is `InvalidJwtSigningKey`. Inline values are whitespace-trimmed. |
+| `D_OAUTH_JWT_SIGNING_KEY_FILE` | none | Mounted form of the active key. Read verbatim except that trailing CR/LF are stripped; the inline form wins when both are set. |
+| `D_OAUTH_JWT_SIGNING_KID` | `v1` | Key id for the active key, `[A-Za-z0-9_-]+`. It is the JOSE `kid` and the JWKS entry id. |
+| `D_OAUTH_JWT_SIGNING_PREVIOUS_KEYS` | empty | Rotated-out keys, a list of `{kid, key}` objects. Each key is validated exactly like the active key (32 bytes); kids must be distinct and different from the active kid (`DuplicateJwtSigningKid`); configuring any entry requires an active key. Supply as a YAML/TOML array in the `D_OAUTH_CONFIG` file, not as one environment string. |
+| `D_OAUTH_JWT_ACCESS_TOKEN_SIGNING_ALG` | `ES256` | `ES256` or `EdDSA` only. Both use 32-byte raw key material, so there is no RSA/`RS256` branch. All retained keys share this algorithm. |
+
+- `jwt_signing_key` is the active JOSE signing key. It must be **exactly** 32
+  bytes of raw material: shorter or longer material is rejected at load with
+  `WeakKeyMaterial` (this is stricter than the pepper/TOTP/PII chains, which
+  enforce only a minimum). A `jwt_signing_key_file` mount works exactly like the
+  other `*_file` keys and is trimmed of trailing newlines; the inline form wins
+  when both are set. The key material is turned into a real JOSE signing key at
+  load, so an all-zero or out-of-range EC scalar fails at boot with
+  `InvalidJwtSigningKey`, not at the first mint. Enabling
+  `jwt_access_tokens_enabled` without a signing key fails closed with
+  `MissingJwtSigningKey`.
+- `jwt_signing_previous_keys` retains rotated-out keys as a list of
+  `{kid, key}` objects so a token signed under an old `kid` can still verify.
+  Each `key` is validated exactly like the active key (exactly 32 bytes) and
+  every `kid` must be distinct and different from the active `jwt_signing_kid`;
+  a duplicate is rejected with `DuplicateJwtSigningKid`.
+- `jwt_access_token_signing_alg` accepts only `ES256` or `EdDSA`. Both use
+  exactly-32-byte raw key material, so there is no RSA / `RS256` branch.
+- No JWT signing key material is ever printed: `Config`'s `Debug` impl and
+  `JwtSigningKey`'s `Debug` impl emit the key id and algorithm only.
+- `cimd_fetch_timeout_secs` must be between 1 and 10 seconds
+  (`InvalidCimdFetchTimeout`) and `cimd_max_bytes` between 1 and 5120
+  (`InvalidCimdMaxBytes`). A malformed value fails the load rather than
+  silently meaning "no limit".
+
 ## The timeout ladder
 
 The four database and request timeouts must satisfy:
@@ -313,9 +375,13 @@ the statement.
 - `catalog`: only redirect URIs present in the runtime catalog may be
   registered. This is the default.
 - `catalog_loopback`: catalog URIs plus dynamic loopback URIs (RFC 8252).
+  The host `localhost` counts as loopback here only when
+  `allow_localhost_redirect` is on.
 - `open`: any absolute `https` redirect URI, or an RFC 8252 loopback URI, may
   be registered. This is an explicit opt-in and is not the default; other
-  schemes and plaintext non-loopback URIs are still rejected.
+  schemes and plaintext non-loopback URIs are still rejected. As with
+  `catalog_loopback`, `localhost` is loopback only when
+  `allow_localhost_redirect` is on.
 
 ## Security properties
 

+ 1 - 1
docs/CONTRIBUTING.md

@@ -200,7 +200,7 @@ Every module has a single responsibility:
 | Crypto primitives | `src/crypto/` | Constant-time compare, PKCE, token mint/hash, TOTP, secret hashing. |
 | Field encryption | `src/crypto/field.rs` | AES-256-GCM PII field codec and keyed-HMAC blind index. |
 | JOSE | `src/jose/` | DPoP, JAR, `private_key_jwt` verification, JWK parsing. |
-| Postgres repositories | `src/db/` | The six domain tables. |
+| Postgres repositories | `src/db/` | The seven domain tables. |
 | Redis store | `src/store/` | Ephemeral state: codes, tokens, grants, sessions, PAR, device, DCR, rate limits. |
 | Protocol handlers | `src/protocol/` | OAuth endpoints and grants. |
 | Server-rendered UI | `src/ui/` | Login, consent, and device pages. |

+ 24 - 4
docs/DATABASE.md

@@ -9,9 +9,9 @@ This guide describes the schema, the indexes and constraints, the migration
 lifecycle, and the operational rules for applying migrations. It is the
 reference for anyone changing a table or a query.
 
-## The six tables
+## The seven tables
 
-Migrations create exactly six domain tables plus sqlx's `_sqlx_migrations`
+Migrations create exactly seven domain tables plus sqlx's `_sqlx_migrations`
 bookkeeping table. They create schema only and seed zero rows, so a fresh
 install starts empty by design.
 
@@ -50,6 +50,7 @@ One row per registered client (RFC 7591 / RFC 7592).
 | `active` | `bool` | Soft disable, default true. |
 | `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
 | `dpop_bound_access_tokens` | `bool` | Whether this client's tokens require DPoP proof (`migrations/1791244800_client_dpop_bound.sql`). |
+| `access_token_format` | `text` | `opaque` (default) or `jwt`, checked by `ck_oauth_clients_access_token_format`; `jwt` is honored only while JWT access tokens are enabled (`migrations/1791504000_resource_servers.sql`). Dynamic registration always inserts `opaque`; only `client set-access-token-format` changes it. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
 Four partial unique indexes enforce client-identity uniqueness. Two are on the
@@ -166,6 +167,23 @@ The join between a client and a catalog redirect URI.
 
 Composite primary key `(client_id, redirect_uri_id)`.
 
+### resource_servers
+
+The runtime-managed resource-server (RFC 8707 resource indicator) catalog,
+created empty and populated via the CLI / `config sync`.
+
+| Column | Type | Notes |
+| --- | --- | --- |
+| `id` | `uuid` | Primary key. |
+| `identifier` | `text` | The RFC 8707 resource identifier used as the `resource` parameter and the access-token `aud` target. Globally unique (`ux_resource_servers_identifier`). |
+| `description` | `text` | Default empty. |
+| `enabled` | `bool` | Default true. |
+| `created_at` | `timestamptz` | Creation time. |
+
+The catalog is durable schema only. The access/refresh-token audience a grant
+carries is security-ephemeral and lives in Redis, so there is deliberately no
+grants or audience column here.
+
 ## The citext extension
 
 The `users` migration runs `CREATE EXTENSION IF NOT EXISTS citext`. This makes
@@ -306,7 +324,7 @@ roles reduce the blast radius:
 | Role | Privileges | Used by |
 | --- | --- | --- |
 | Migration role | Owns the schema and holds the `citext` privilege. `CREATE EXTENSION IF NOT EXISTS citext` needs superuser or trusted-extension rights, so this role must have them (see [The citext extension](#the-citext-extension)). | `migrate` |
-| Runtime role | `SELECT`, `INSERT`, `UPDATE`, `DELETE` on the six domain tables, plus read access to `_sqlx_migrations`. No DDL and no superuser. | `serve` |
+| Runtime role | `SELECT`, `INSERT`, `UPDATE`, `DELETE` on the seven domain tables, plus read access to `_sqlx_migrations`. No DDL and no superuser. | `serve` |
 
 The migration role runs once per release; the runtime role is the only one the
 long-lived replicas hold. The server neither creates nor enforces roles. An
@@ -391,7 +409,7 @@ D_OAUTH_DATABASE__URL='postgres://oauth@db.example.com/oauth?sslmode=verify-full
 ```
 
 The command prints `schema current: N known migration(s)`. Run it as a cluster Job and let
-readiness gate the Deployment. To confirm a fresh install, check that the six
+readiness gate the Deployment. To confirm a fresh install, check that the seven
 tables exist and are empty:
 
 ```sql
@@ -408,6 +426,8 @@ SELECT version, success FROM _sqlx_migrations ORDER BY version;
 - `migrations/1791331200_redirect_uri_kind.sql` for `redirect_uris.kind`.
 - `migrations/1791417600_pii_encryption.sql` for the `_enc`/`_index` PII
   columns, their partial unique indexes, and the dropped `NOT NULL`s.
+- `migrations/1791504000_resource_servers.sql` for `resource_servers` and
+  `oauth_clients.access_token_format`.
 - `src/db/` for the repositories.
 - `src/crypto/field.rs` for the field codec and blind index.
 - `src/cli/pii/` for `pii backfill|verify|rotate`.

+ 5 - 2
docs/DEPLOYMENT.md

@@ -266,7 +266,10 @@ A correct Deployment has these properties:
 - Resource requests and limits.
 - A `startupProbe` and `livenessProbe` on `/health/live`, and a
   `readinessProbe` on `/health/ready`, all an HTTP GET against the plaintext
-  health port. The public listener stays TLS-only.
+  health port. The public listener stays TLS-only. Never point a probe at
+  `GET /jwks.json`: it is the public JWT verification-key document and is
+  intentionally cacheable (`public, max-age=300`) by reverse proxies and
+  clients.
 - `terminationGracePeriodSeconds` greater than the drain deadline. The server
   uses a one second readiness propagation grace and a bounded drain below the
   grace budget.
@@ -445,7 +448,7 @@ encrypted backups, so those controls live with the operator. Apply at least:
 
 - **Separate the roles.** Run migrations as a schema-owning role that holds the
   `citext` privilege the `users` migration needs, and run replicas as a runtime
-  role limited to `SELECT`/`INSERT`/`UPDATE`/`DELETE` on the six tables — no
+  role limited to `SELECT`/`INSERT`/`UPDATE`/`DELETE` on the seven tables — no
   DDL, no superuser. The migration Job and the Deployment can then use different
   credentials.
 - **Encrypt the transport and pin the CA.** Production requires

+ 330 - 17
docs/OAUTH.md

@@ -41,7 +41,7 @@ practice in RFC 9700. This server enforces every one of these:
 | The implicit grant is removed | Only `response_type=code` exists; there is no token response. | draft-ietf-oauth-v2-1-16, RFC 9700 |
 | The resource-owner-password grant is removed | There is no `grant_type=password` code path. | draft-ietf-oauth-v2-1-16 |
 | Bearer credentials in the URI query are rejected | Access tokens are presented in the `Authorization` header. | draft-ietf-oauth-v2-1-16 §5.1 |
-| Redirect URIs are matched exactly | No prefix or wildcard match; one loopback exception. | RFC 9700 §2.1, RFC 3986 §6.2.1 |
+| Redirect URIs are matched exactly | No prefix or wildcard match; one RFC 8252 loopback exception (`localhost` only under `allow_localhost_redirect`). | RFC 9700 §2.1, RFC 3986 §6.2.1 |
 | All refresh tokens rotate on every use | Rotation with tombstone reuse detection applies to every refresh grant. | RFC 9700 §2.2.2, §4.14 |
 | Authorization codes are single-use and short-lived | A code is consumed atomically and capped at 60 seconds. | RFC 9700 §4.5.3 |
 | `state` is supported and echoed | The value is returned on the success and error redirects. | RFC 6749 §4.1.2.1 |
@@ -113,7 +113,96 @@ Dynamic registration redirect policy (`dcr_redirect_policy`): `catalog` attaches
 only catalog-registered URIs; `catalog_loopback` additionally upserts RFC 8252
 loopback URIs; `open` additionally upserts any absolute `https` URI with a
 non-empty host and no userinfo, fragment, or whitespace. Loopback URIs are
-always allowed. Anything else is `invalid_redirect_uri`.
+always allowed. Under `catalog_loopback` and `open`, the RFC 8252 loopback
+forms include `http://localhost:{port}` only when `allow_localhost_redirect` is
+set; with it off, `http://localhost` is not loopback and is rejected.
+Anything else is `invalid_redirect_uri`.
+
+A dynamically registered client always stores `opaque` access tokens. JWT
+access tokens are an operator-only opt-in (`client set-access-token-format`),
+so a `POST /register` body carrying `access_token_format` is rejected with
+`invalid_client_metadata` rather than silently ignored.
+
+### Client ID metadata documents (CIMD)
+
+Reference: `draft-ietf-oauth-client-id-metadata-document-02` §3, §4.1, §5.
+
+CIMD removes registration for clients that can publish an `https://` document.
+The client uses that absolute URL as its `client_id`; the authorization server
+fetches the URL, validates the JSON document, and uses the result as the client
+metadata for the request. This is remote-document trust with strict validation,
+not "no fetch" and not "trusted because it registered". `cimd_enabled` (default
+off) is the only switch: with it off an `https://` `client_id` with no
+registered row keeps the existing unknown-client behavior.
+
+A document is accepted only when all of these hold:
+
+- The fetch is one guarded request: `https` only, redirects disabled, DNS
+  pinned to the addresses checked before the request, special-use, private,
+  and loopback targets rejected, no proxy, and both a total timeout
+  (`cimd_fetch_timeout_secs`, default `5`, allowed `1`-`10`) and a body cap
+  (`cimd_max_bytes`, default `5120`, allowed `1`-`5120`). A non-2xx response is
+  a failure. The body must be a JSON object.
+- The document's `client_id` must equal the fetched URL byte for byte. No
+  normalization is performed.
+- `token_endpoint_auth_method` must be absent, `none`, or `private_key_jwt`.
+  Every shared-secret and mTLS method is rejected.
+- Any `jwks_uri` member is rejected. For `private_key_jwt`, an inline `jwks`
+  with a non-empty `keys` array is required. This is inline-only by decision,
+  so client authentication never starts a second network fetch.
+- `redirect_uris` must be a non-empty array. Each entry is an absolute
+  `http`/`https` URI with a non-empty host and no userinfo, query, or fragment,
+  and no entry may contain `*`. Plaintext `http` is accepted only for an RFC
+  8252 §7.3 loopback host: `127.0.0.1` and `[::1]` always, `localhost` only
+  while `allow_localhost_redirect` is on. The check reuses the registered-client
+  redirect matcher, so the two paths cannot disagree.
+- `grant_types`, when present, must be a non-empty subset of
+  `{authorization_code, refresh_token}`; when absent it defaults to
+  `authorization_code`. Device, client-credentials, and token-exchange grants
+  are not available to a CIMD client.
+- `scope`, when present, must be a subset of the enabled scope catalog. An
+  absent or empty `scope` means an empty client scope.
+- `dpop_bound_access_tokens` omitted or `false` means Bearer tokens. `true`
+  means DPoP is required at every gate, exactly like a registered client with
+  that flag.
+
+A bad redirect URI maps to `invalid_redirect_uri`. Every other document or
+fetch failure maps to `invalid_client_metadata`. Only a Redis or Postgres
+outage is a fail-closed `503`.
+
+Resolution and the synthetic principal:
+
+- A registered row always wins. A row whose `client_id` happens to be an
+  `https://` URL is resolved from Postgres with no document fetch. Only a
+  missing row can fall back to CIMD.
+- The validated document is materialized as an in-memory `client_repo::Client`
+  for the request. No `/register` row is written and no initial-access token
+  is consumed.
+- The client is active with the document's grant allowlist, scope bound, DPoP
+  flag, and inline `jwks`. Secret fields, mTLS pins, `jwks_uri`,
+  `request_uris`, contacts, and PII are empty. `access_token_format` is always
+  `opaque`: a document can never opt into JWT access tokens, and the
+  operator-only `client set-access-token-format` command has no CIMD target
+  because there is no row.
+- PKCE (`S256` only), exact redirect matching, PAR/JAR, and error delivery are
+  unchanged. A `private_key_jwt` document authenticates with the inline key
+  set; `private_key_jwt` is authentication only and never a sender constraint.
+  The DPoP/Bearer outcome comes solely from `dpop_bound_access_tokens`.
+- A CIMD `none` client can revoke its own token but cannot introspect: public
+  clients do not introspect. A CIMD `private_key_jwt` client can revoke and
+  introspect.
+- Enabling CIMD does not change the `POST /register` gate. The one-time
+  initial-access token requirement, and the `allow_open_registration` opt-out,
+  are exactly the same as before.
+
+The validated document is cached for 15 minutes under the `{state}` keyspace as
+`cimd:<sha256(url)>` (the hash of the URL, never the raw URL). Only the raw
+document is cached, and it is revalidated on every read, so a scope disabled or
+a catalog row removed during the TTL takes effect on the next request. A
+malformed cache entry is deleted and treated as a miss. A cache hit does not
+fetch; a document changed at its origin may therefore be served from cache for
+up to the 15-minute TTL. Redis is not a second source of truth, and a Redis
+failure on this path is a fail-closed `503`.
 
 ## The five grants
 
@@ -129,7 +218,9 @@ Reference: RFC 6749 §4.1.3, RFC 9700 §4.5.3, RFC 7636, RFC 9449 §6.
 
 Parameters: `code`, `code_verifier`, `client_id`, optional `redirect_uri`,
 `client_secret` or `client_assertion_type` with `client_assertion`, and a DPoP
-proof when the code is DPoP-bound.
+proof when the client is DPoP-bound (`dpop_bound_access_tokens: true`). An
+unbound public client redeems proof-free and receives a `Bearer` token; a
+bound public client without a proof fails closed.
 
 Behavior: the code is consumed exactly once. `redirect_uri` is accept-but-ignore
 (draft-16 §4.1.3, §10.2): a missing or empty value skips the binding check; a
@@ -144,7 +235,10 @@ registered for the refresh grant.
 Reference: RFC 6749 §6, RFC 9700 §2.2.2 and §4.14, RFC 9449 §5.
 
 Parameters: `client_id`, `refresh_token`, optional `requested_scope` (which may
-narrow but never widen), and a freshly verified sender proof. The replacement
+narrow but never widen), and a freshly verified sender proof when the binding
+requires one. An unbound public client whose stored binding is empty rotates
+proof-free and stays `Bearer`; a bound grant still requires its matching proof
+on every rotation. The replacement
 refresh token keeps the presented scope re-checked against the current
 `enabled` catalog and the client's current registration and the user's current
 entitlements (fresh Postgres reads), so a revoked scope or entitlement drops
@@ -175,7 +269,9 @@ intersected with the client's registered scope. No refresh token is issued.
 Reference: RFC 8628 §3.1 through §3.5.
 
 Authorization: `POST /device_authorization` returns a device code, a user code,
-a verification URI, an expiry, and a polling interval. The user completes
+a verification URI, an expiry, and a polling interval. A DPoP-bound public
+client must present a proof to start the flow; an unbound public client starts
+proof-free. The user completes
 `GET /device` and `POST /device` and approves or denies.
 
 Polling: `grant_type=urn:ietf:params:oauth:grant-type:device_code` with
@@ -199,6 +295,116 @@ granted scope is the subject's scope intersected with the caller's registered
 scope, the subject's entitlements, the enabled catalog, and the allowlist, and
 it can only narrow. Chained exchange is rejected.
 
+## Resource indicators (RFC 8707)
+
+Reference: RFC 8707 §2, §2.2, §3, §5.1, §5.2.
+
+The `resource` parameter is honored only when `resource_indicators_enabled` is
+set (default off). With the flag off, any request that carries `resource` fails
+closed with `invalid_target`; the server never silently drops the parameter and
+mints a broader token. The catalog is the enabled `resource_servers` table, an
+exact-match allowlist. It is managed with the `resource-server` subcommands
+(`create` with an optional `--description`, `list`, `enable`, `disable`,
+`delete`) or a `resource_servers` array in `config sync --file`; see
+[OPERATIONS.md](OPERATIONS.md#resource-server-onboarding-rfc-9728).
+
+This server's validation is **stricter than RFC 8707 §2**, which only
+SHOULD-NOTs a query component and leaves the identifier semantics to the
+authorization server:
+
+- Every value must be an absolute `https` URI with a host.
+- It must exactly match an enabled catalog row. There is no prefix, suffix,
+  sub-path, case folding, or wildcard match; `*` cannot be registered.
+- The RFC 8252 §7.3 loopback exception applies only to redirect URIs. It does
+  **not** apply to audiences, so a loopback identifier is valid only when it is
+  literally registered in the catalog.
+- Values are never delimiter-joined. A URI containing a comma is stored as its
+  own array element, so it cannot collide with a joined string.
+- The validated set is sorted and deduplicated before it is pinned.
+
+`resource` is a repeated parameter. On `GET /authorize` it is read from the
+query string; on `POST /par`, `POST /token`, `POST /device_authorization`, and
+`POST /introspect` it is read from the form body. A JAR request object may carry
+`resource` as a JSON array or a single string. A `resource` in both the query
+string and the request object is `invalid_request_object`, because the merged
+single-valued map cannot represent repeats.
+
+### Pinning and binding
+
+| Entry point | Where the audience is pinned | Redemption rule |
+| --- | --- | --- |
+| `GET /authorize` | `PendingAuth.aud`, then the authorization-code record | The token request's set must equal the code's set; any subset, superset, different member, or an unbound request against a pinned code is `invalid_grant` (RFC 8707 §2). |
+| `POST /par` | The PAR record's `aud`; the pushed request is re-validated at authorize | The code carries the same set; the token request must match it exactly. |
+| `POST /device_authorization` | The device record's `aud` | An omitted `resource` at poll time inherits the pinned set; a presented set must equal it, else `invalid_grant`. |
+| `POST /token` with `client_credentials` | The access-token record | No refresh token is issued. |
+| `POST /token` with `refresh_token` | The rotated access and refresh records | See [Refresh audience narrowing](#refresh-audience-narrowing). |
+
+At `/authorize` the catalog check runs after the redirect URI is validated, so a
+bad audience is delivered as an error redirect (`302`) with `invalid_target`
+rather than an HTML error page. At the other endpoints it is the usual JSON error
+response (`400`).
+
+### Refresh audience narrowing
+
+Reference: RFC 8707 §2.2.
+
+A refresh token stays bound to the full audience set the grant was originally
+issued with. On rotation:
+
+- An omitted `resource` keeps that full set on both the access and the
+  replacement refresh token.
+- A present `resource` must be a **subset** of the grant's original set. A
+  superset, a different member, or any member against an unbound grant is
+  `invalid_target`, rejected before anything is minted or rotated, so the
+  presented refresh token stays live.
+- A valid subset narrows only the access token. The replacement refresh token
+  keeps the full original set, so a later rotation can use the full audience
+  again. Scope still intersects and is never widened.
+
+### Token exchange is excluded
+
+RFC 8707 `resource` does not apply to the RFC 8693 token-exchange grant. A
+`resource` on a
+`grant_type=urn:ietf:params:oauth:grant-type:token-exchange` request is
+`invalid_request`, checked before the catalog lookup, so a bogus value cannot
+surface `invalid_target` from a grant whose RFC 8693 semantics are otherwise
+untouched. The exchange mints no audience.
+
+### Introspection
+
+`POST /introspect` reports the token's audience in `aud` as a JSON array of
+strings, including when there is exactly one audience. The member is omitted
+when the record has no audience, and an inactive response never carries it.
+This is a documented deviation from RFC 7662 §2.2, which describes `aud` as a
+string; the array shape follows RFC 9068 §2.2. Both access-token and
+refresh-token records report the audience they carry.
+
+`/introspect` also accepts an **optional repeated `resource`** parameter. This
+is a documented local extension to RFC 7662, which defines no such request
+parameter. When present, the token is reported active only if its `aud` includes
+every requested value; otherwise the response is the ordinary inactive body
+(`{"active": false}`) and the token's metadata is not leaked. Inclusion is the
+test, so a token pinned to a superset stays active. Without `resource`,
+introspection remains audience-agnostic.
+
+### `invalid_target` and `invalid_grant`
+
+| Situation | Error |
+| --- | --- |
+| Flag off and any `resource` present | `invalid_target` |
+| Relative, non-`https`, or hostless value | `invalid_target` |
+| Unknown or disabled catalog row | `invalid_target` |
+| Token request set differs from the pinned code or device set | `invalid_grant` |
+| Device poll presents a set that differs from the pinned set | `invalid_grant` |
+| Refresh presents a superset, a different member, or a member against an unbound grant | `invalid_target` |
+| Introspect presents `resource` the token is not bound to | Inactive body (`200`) |
+| `resource` on token exchange | `invalid_request` |
+| Postgres unavailable during catalog lookup | `temporarily_unavailable` (`503`, or `302` on the `/authorize` error redirect) |
+
+Metadata advertises the capability as the local extension
+`resource_indicators_supported`; see
+[`resource_indicators_supported`](#resource_indicators_supported-local-extension).
+
 ## PKCE
 
 Reference: RFC 7636, RFC 9700 §2.1.1.
@@ -223,7 +429,11 @@ Reference: RFC 3986 §6.2.1, RFC 8252 §7.3, RFC 9700 §2.1.
 - The one exception is a native-app loopback URI. A registered
   `http://127.0.0.1` or `http://[::1]` matches the same scheme, host, and path
   on any port, and the request must carry no userinfo, query, or fragment.
-- `localhost` is never treated as a loopback host.
+- `localhost` is a loopback host only when `allow_localhost_redirect` is set
+  (default off). With the flag on, a registered `http://localhost:{port}/path`
+  matches the same scheme, host, and path on any port, exactly like the
+  literal-IP loopback forms; with it off, `http://localhost` is not a loopback
+  host and is rejected as a registration and as a request.
 - A private-use URI scheme must contain reverse-DNS dot notation (draft-16
   §2.3.1): `com.example.app:/cb` is accepted, `myapp:/cb` is rejected.
   `https` and the loopback `http` exception are unaffected.
@@ -244,8 +454,9 @@ After the redirect URI is validated, errors are delivered on the redirect as
 `302` with `error`, `error_description`, the echoed `state`, and the RFC 9207
 `iss`. This covers
 a missing or unsupported `response_type` (`invalid_request` when absent,
-`unsupported_response_type` otherwise), `invalid_scope`, the PKCE failures,
-and a `post_logout` catalog outage (`temporarily_unavailable`/`server_error`).
+`unsupported_response_type` otherwise), `invalid_scope`, `invalid_target` for a
+rejected `resource` indicator, the PKCE failures, and a `post_logout` catalog
+outage (`temporarily_unavailable`/`server_error`).
 
 ## Error codes
 
@@ -257,6 +468,7 @@ Errors use the RFC 6749 §5.2 body `{error, error_description}`.
 | `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_scope` | 400 | Unknown, disabled, over-registered, or non-entitled scope; refresh widening. |
+| `invalid_target` | 400, or 302 on the `/authorize` error redirect once `redirect_uri` is validated | Resource indicator rejected: flag off, non-`https` or relative value, unknown or disabled catalog row, or a refresh audience that exceeds the grant. |
 | `unsupported_grant_type` | 400 | A `grant_type` outside the five. |
 | `unsupported_response_type` | 302 error redirect | A `response_type` other than `code`. |
 | `unauthorized_client` | 400 | A client that may not use the requested grant. |
@@ -285,7 +497,7 @@ on every use.
 
 | Client class | Binding |
 | --- | --- |
-| Public | DPoP mandatory. A missing proof is a hard `invalid_grant`. |
+| Public | Bearer by default. `dpop_bound_access_tokens: true` makes DPoP mandatory, and a missing proof is a hard `invalid_grant`. |
 | Confidential | mTLS and/or DPoP; the confirmation reports both when both are proven. |
 
 `private_key_jwt` is client authentication only. It produces no confirmation and
@@ -297,22 +509,120 @@ The `exp` acceptance bound is 330 s: `exp` may lie at most 300 s past `now` plus
 30 s clock-skew leeway. The single-use `jti` marker lives for the assertion's
 remaining validity, clamped to `[1 s, 360 s]`.
 
+## JWT access tokens (RFC 9068 profile)
+
+Reference: RFC 9068 §2.1, §2.2, §4; RFC 7517 §5; RFC 8707 §2.
+
+JWT access tokens are opt-in twice. The deployment switch
+`jwt_access_tokens_enabled` (default off) must be on, and the individual client
+must carry `access_token_format = jwt`, set by the operator with
+`client set-access-token-format --client-id <id> --format opaque|jwt`. Dynamic
+registration always stores `opaque`, so a client can never opt itself in. A
+`jwt` client whose deployment switch is off fails closed with `503`
+`temporarily_unavailable` rather than falling back to an opaque token.
+
+Only the access token changes shape. The refresh token, the grant chain, and
+every other grant artifact stay opaque and hashed at rest, so refresh rotation,
+tombstones, and reuse detection are identical for both formats. Token exchange
+is opaque-only: a `jwt`-format caller of the exchange grant is refused with
+`503`.
+
+The JOSE header carries `typ: at+jwt`, the configured `kid`, and the configured
+algorithm (`ES256` or `EdDSA`; there is no RSA branch because the key material
+is a 32-byte P-256 scalar or Ed25519 seed). The claim set is the RFC 9068 §2.2
+profile subset this server emits:
+
+| Claim | Content |
+| --- | --- |
+| `iss` | The configured issuer. |
+| `sub` | The resource owner (email or user id); empty for client credentials. |
+| `aud` | The RFC 8707 audience as a JSON array, never the string form. A refresh that narrows the audience narrows this claim, not the refresh record. |
+| `scope` | The granted scope, space-joined. |
+| `exp`, `iat` | Seconds since the Unix epoch; `exp - iat` equals `ttl.access_secs`. |
+| `jti` | A random token id. Only its hash is stored, as the allowlist marker. |
+| `client_id` | The client the token was issued to (required by §2.2). |
+| `cnf` | Present only when the grant is sender-constrained (DPoP or mTLS). |
+| `act` | Reserved for a delegated token exchange (RFC 8693 §4.1). Exchange is opaque-only today, so no minted JWT carries it. |
+
+In the authorization-server metadata document, a deployment with the switch on
+advertises `jwks_uri` at `<issuer>/jwks.json` and
+`access_token_signing_alg_values_supported` with exactly the configured
+algorithm. Both members are absent when the switch is off, and the algorithm
+list never contains an algorithm the signer cannot produce. `GET /jwks.json`
+serves an RFC 7517 §5 JWK Set containing the current key first and every
+retained verify-only key. The route is public, returns `200 {"keys":[]}` while
+the feature is off, and is served with `Cache-Control: public, max-age=300`, so
+resource servers may cache it for five minutes.
+
+### The resource-server trade-off
+
+A resource server that verifies a JWT locally against `/jwks.json` checks the
+signature, `iss`, `exp`, and its own audience binding. It cannot see this
+server's revocation state. Revoking a JWT removes its `jwt:<jti-hash>` allowlist
+marker, but a local verifier does not consult that marker, so it keeps accepting
+the token until `exp`. The same holds after `user disable` and after a grant
+cascade. This is the deliberate exp-bounded trade-off of self-contained tokens,
+not a bug:
+
+- A resource server that needs immediate revocation **MUST** call
+  `POST /introspect`, which applies the marker, the grant membership, and the
+  current account status on every call.
+- A resource server that verifies locally **MUST** keep `ttl.access_secs` short
+  (the default is 300 s), because `exp` is the only bound on how long a revoked
+  token remains usable there.
+
 ## Metadata
 
 `GET /.well-known/oauth-authorization-server` returns the RFC 8414 document.
-The only state-dependent member is `scopes_supported`, read from the enabled
-`scopes` table on every request. The catalog is mutated out of process by the
-CLI, so a per-request read makes a change visible immediately on every replica
-with no restart. When Postgres is unavailable the endpoint returns `503` rather
-than advertising an unknown list.
-
-The document carries no `jwks_uri`: the server signs no artifact of its own.
-`code_challenge_methods_supported` advertises only `S256`.
+The only per-request state-dependent member is `scopes_supported`, read from the
+enabled `scopes` table on every request. The catalog is mutated out of process
+by the CLI, so a per-request read makes a change visible immediately on every
+replica with no restart. When Postgres is unavailable the endpoint returns `503`
+rather than advertising an unknown list.
+
+`client_id_metadata_document_supported` (CIMD draft §6) is emitted as `true`
+only while `cimd_enabled` is on. With the switch off it is absent, not `false`.
+Clients that select CIMD check it together with `none` in
+`token_endpoint_auth_methods_supported`; `none` is always advertised because it
+is a normal public-client method, independent of the CIMD switch. See
+[Client ID metadata documents (CIMD)](#client-id-metadata-documents-cimd).
+
+`jwks_uri` (RFC 8414 §2) and `access_token_signing_alg_values_supported`
+(RFC 9068 §2.1) are emitted only while `jwt_access_tokens_enabled` is on. When
+on, `jwks_uri` is `<issuer>/jwks.json` and the algorithm list contains exactly
+the configured `jwt_access_token_signing_alg`, `ES256` or `EdDSA`, never `RS256`
+or any other algorithm the signer cannot produce. With the switch off both
+members are absent; see [JWT access tokens (RFC 9068
+profile)](#jwt-access-tokens-rfc-9068-profile). `code_challenge_methods_supported`
+advertises only `S256`.
 `authorization_response_iss_parameter_supported` is true, so the authorization
 response carries `iss` (RFC 9207). The document advertises
 `token_endpoint_auth_signing_alg_values_supported = [ES256, EdDSA, RS256,
 PS256]`; `dpop_signing_alg_values_supported` stays `[ES256, EdDSA, RS256]`.
 
+The document carries no `resource_metadata` member: RFC 9728 defines that member
+as a `WWW-Authenticate` parameter owned by the resource server, not as
+authorization-server metadata (RFC 9728 §4, §5.1). The one RFC 9728 AS-side
+member, `protected_resources`, is emitted only when `protected_resources_enabled`
+is set (default off). The resource-server side of discovery, the challenge, and
+the audience and scope checks are described in [RS-CONTRACT.md](RS-CONTRACT.md).
+
+### `resource_indicators_supported` (local extension)
+
+RFC 8707 registers the `resource` request parameter (§5.1) and the
+`invalid_target` error (§5.2), but defines no authorization-server metadata
+member. `resource_indicators_supported` is therefore this server's vetted local
+extension, not a registered RFC 8414 member: a client must treat it as
+advisory and fall back to attempting `resource` when it is absent.
+
+It is emitted as `true` only when `resource_indicators_enabled` is set (default
+off). With the flag off the member is absent, not `false`, so the document never
+advertises a capability the server would reject with `invalid_target`. When the
+flag is on, the endpoints in
+[Resource indicators (RFC 8707)](#resource-indicators-rfc-8707) accept the
+parameter subject to the enabled resource-server catalog; token exchange is
+excluded.
+
 ## JAR and PAR
 
 Reference: RFC 9101 §4.1 and §5.2, RFC 9126 §2 and §3.
@@ -387,5 +697,8 @@ curl -s https://auth.example.com/.well-known/oauth-authorization-server | jq .
 - `src/protocol/metadata.rs` for the RFC 8414 document.
 - `src/crypto/pkce.rs` for PKCE.
 - `src/protocol/redirect_repo.rs` for redirect matching.
+- `src/protocol/params.rs` for repeated-parameter parsing and RFC 8707
+  audience validation.
+- `src/db/resource_repo.rs` for the resource-server catalog.
 - `src/jose/` for DPoP, JAR, and `private_key_jwt`.
 - `src/error.rs` for the error-to-HTTP mapping.

+ 345 - 6
docs/OPERATIONS.md

@@ -13,8 +13,9 @@ the audit stream, key and certificate rotation, and backup and restore.
 
 - The `migrate` subcommand owns the schema and is the only writer of it.
 - The `serve` subcommand serves OAuth and never migrates and never provisions.
-- Every mutation (scopes, users, entitlements, redirect URIs, client
-  attachments) is audited on the `audit` tracing target.
+- Every mutation (scopes, users, entitlements, redirect URIs, resource
+  servers, client attachments, client access-token format) is audited on the
+  `audit` tracing target.
 - Secrets are read from stdin, a file, or the environment, never from argv. A
   secret is printed only when the operator asked for a one-time value.
 
@@ -106,6 +107,7 @@ d-oauth-server redirect-uri delete <uri> [--kind authorize|post_logout]
 d-oauth-server client attach-redirect-uri --client-id <id> --uri <uri> [--kind authorize|post_logout]
 d-oauth-server client detach-redirect-uri --client-id <id> --uri <uri> [--kind authorize|post_logout]
 d-oauth-server client redirect-uris --client-id <id>
+d-oauth-server client set-access-token-format --client-id <id> --format opaque|jwt
 ```
 
 A redirect URI is a global catalog entry; a client attaches to it. `/authorize`
@@ -133,6 +135,34 @@ or more than one, and `POST /logout` performs no redirect: the target must be
 unambiguous or nothing happens. Registering a second enabled `post_logout` URI
 is a configuration error, not a silent last-writer-wins.
 
+### Resource servers and client access-token format
+
+```bash
+d-oauth-server resource-server create <identifier> [--description <text>]
+d-oauth-server resource-server list
+d-oauth-server resource-server enable <identifier>
+d-oauth-server resource-server disable <identifier>
+d-oauth-server resource-server delete <identifier>
+```
+
+A resource server is an RFC 8707 resource indicator (the `resource` parameter
+and the `aud` target of an access token). `identifier` matches exactly — no
+normalization, sub-path, or wildcard. A `*` anywhere in the identifier is
+rejected, so one catalog row can never stand for an entire origin. `create`
+upserts: it creates the row, updates its description, or leaves an unchanged
+row alone; it never silently re-enables an operator-disabled row, and `list`
+shows both states. `delete` removes the row outright (nothing references the
+catalog directly).
+
+`client set-access-token-format --client-id <id> --format opaque|jwt` is the
+OPERATOR-ONLY opt-in to JWT access tokens for one client; `opaque` (the OAuth
+2.1 baseline and column default) is what every dynamically registered client
+gets. `--format` accepts exactly `opaque` or `jwt`, and `jwt` is honored only
+while JWT access tokens are enabled by configuration. Dynamic client
+registration can never set this: a registration (`POST /register`) body that
+carries `access_token_format` is rejected with `invalid_client_metadata`. The
+JWT signing key is never printed by any command.
+
 ### Declarative config
 
 ```bash
@@ -235,6 +265,212 @@ it `false` they write v3 TOTP blobs, which a version N-1 replica cannot read, so
 the user's next TOTP check fails on an old replica. Keep dual-write on for the
 whole window; turn it off only at the scale-to-zero step above.
 
+## Resource-server onboarding (RFC 9728)
+
+This is the operator-side companion to [RS-CONTRACT.md](RS-CONTRACT.md), the
+normative contract for the resource server (RS). The AS hosts no RS route and
+publishes no protected-resource metadata: onboarding an RS is a catalog step on
+the AS plus a publish, challenge, and rotation step the RS team owns. Do the four
+steps in order; the RS team can follow [RS-CONTRACT.md](RS-CONTRACT.md) from the
+document alone once the identifier and issuer below are fixed.
+
+### 1. Register the resource identifier in the AS catalog
+
+The catalog is the RFC 8707 resource-indicator allowlist and the `aud` target of
+an access token. Register the RS's exact identifier (an absolute `https` URL, no
+fragment, no wildcard):
+
+```bash
+d-oauth-server resource-server create https://api.example.com --description "Orders API"
+d-oauth-server resource-server list
+```
+
+`identifier` matches exactly, with no normalization, sub-path, or wildcard; a
+`*` anywhere is rejected at creation, so one catalog row can never stand for an
+entire origin. `create` upserts (it creates the row, updates its description, or
+leaves an unchanged row alone) and never silently re-enables an
+operator-disabled row. `list` shows both the `enabled` and `disabled` states.
+Use `resource-server enable|disable <identifier>` to flip a row and
+`resource-server delete <identifier>` to remove it. All four mutations are
+audited (`resource_server.create|enable|disable|delete`).
+
+The same steps are declarative through `config sync`; a `resource_servers` entry
+with the identifier, description, and `enabled` flag is applied idempotently
+(see [config sync file format](#config-sync-file-format)).
+
+For tokens to be audience-restricted, set `resource_indicators_enabled=true` so
+the AS honors the RFC 8707 `resource` parameter at the authorization, PAR, token,
+and device endpoints; otherwise the RS cannot rely on the `aud` check alone
+(RFC 9728 §7.4). See [CONFIGURATION.md](CONFIGURATION.md).
+
+### 2. Publish the RS protected-resource metadata document
+
+The RS team publishes `GET /.well-known/oauth-protected-resource` at the
+resource host (RFC 9728 §3); when the identifier carries a path, the terminating
+slash is removed and the path suffix is appended
+(`/.well-known/oauth-protected-resource/resource1`). The document is served as
+`application/json` with a `200 OK` (RFC 9728 §3.2) and its `resource` member must
+be byte-for-byte identical to the identifier registered in step 1 (RFC 9728 §3.3,
+§7.3). The operator's job is to hand the RS team two values:
+
+- `authorization_servers`: the AS `issuer` value (for example
+  `https://auth.example.com`).
+- `scopes_supported`: the runtime scope catalog this AS actually enables, read
+  with `scope list`. It is a supported set, not a request-everything hint
+  (RFC 9728 §7.2); advertising a scope the AS does not enable only misleads
+  clients.
+
+The full member set and an example document are in
+[RS-CONTRACT.md](RS-CONTRACT.md#the-protected-resource-metadata-document).
+
+Optionally, advertise the RS identifier on the AS side through the RFC 9728 §4
+`protected_resources` member: set `protected_resources_enabled=true` and list the
+identifier in `protected_resource_ids`. Both default off and empty, so the AS
+advertises no `protected_resources` until enabled. `resource_metadata` is never
+an AS metadata member (RFC 9728 §4, §5.1); it belongs only on the RS challenge in
+step 3.
+
+### 3. Set the `WWW-Authenticate` challenge on the RS
+
+The RS returns the challenge on an unauthenticated or unacceptable request
+(RFC 9728 §5.1, RFC 6750 §3):
+
+```http
+HTTP/1.1 401 Unauthorized
+WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"
+```
+
+The parameter value is the absolute `https` URL of the document from step 2,
+derived from the same identifier; the RS never invents it ad hoc, and it is never
+sent alongside a success. When the RS later needs clients to pick up a changed
+metadata document, it re-sends a fresh challenge and clients re-fetch and
+re-validate on receipt (RFC 9728 §5.2). The challenge may be combined with other
+parameters (for example `max_age`, RFC 9470) and with schemes other than
+`Bearer` such as `DPoP` (RFC 9449).
+
+### 4. Rotate the identifier and keys
+
+- Resource identifier: register the new identifier with
+  `resource-server create` and keep the old row `enable`d until no live token
+  references it, then `resource-server delete` the old identifier. The RS team
+  re-publishes its metadata document with the new `resource` and re-sends the
+  challenge; clients re-validate against the new value (RFC 9728 §3.3, §5.2).
+  Never leave two enabled identifiers standing for the same deployment if the
+  audience check is meant to be exact.
+- AS token pepper: rotating the pepper invalidates every live opaque token. For
+  an RS that introspects (RFC 7662), that means re-authentication, not a silent
+  accept; overlap the old pepper for at least `ttl.refresh_secs` (see
+  [Key and certificate rotation](#key-and-certificate-rotation)).
+- JWT signing keys: when JWT access tokens are enabled, retain the previous
+  `jwt_signing_previous_keys` across the rotation window so the RS can still
+  verify in-flight tokens; the RS caches the AS JWKS with a positive lifetime and
+  re-fetches on a fresh challenge (RFC 9728 §7.10, §5.2). Never rotate to an
+  empty key set.
+- After any rotation, re-run the discovery flow end to end: call the resource
+  with no token, read the challenge, fetch and validate the metadata, then
+  re-authenticate. The full flow is in [RS-CONTRACT.md](RS-CONTRACT.md#discovery-flow).
+
+## MCP client registration
+
+An MCP client needs a client identity at the authorization server. There are
+three supported paths, in order of preference. They are per-client choices; the
+one server-wide setting they share is whether the host `localhost` counts as an
+RFC 8252 loopback redirect (`allow_localhost_redirect`).
+
+### 1. CIMD (primary, no registration)
+
+Client ID metadata documents let a client identify itself with an `https://`
+URL and skip registration entirely.
+
+1. Set `cimd_enabled=true`. If the client redirects to `http://localhost:{port}`,
+   also set `allow_localhost_redirect=true`.
+2. Restart the server, then confirm the advertised capability:
+
+```bash
+curl -s https://auth.example.com/.well-known/oauth-authorization-server \
+  | jq '{client_id_metadata_document_supported, token_endpoint_auth_methods_supported}'
+```
+
+The output must carry `client_id_metadata_document_supported: true` and `none`
+inside `token_endpoint_auth_methods_supported`. With `cimd_enabled` off the
+first member is absent, not `false`.
+
+3. The client hosts a JSON document at an `https://` URL and uses that exact URL
+   as its `client_id`. The document must validate. A minimal accepted example:
+
+```json
+{
+  "client_id": "https://mcp.example.com/oauth/client-metadata.json",
+  "client_name": "Example MCP client",
+  "redirect_uris": ["https://mcp.example.com/oauth/callback"],
+  "token_endpoint_auth_method": "none",
+  "grant_types": ["authorization_code", "refresh_token"],
+  "scope": "api:read",
+  "dpop_bound_access_tokens": false
+}
+```
+
+Every `scope` token must already be enabled in the runtime catalog
+(`scope list`). `client_id` must equal the URL byte for byte. The authorization
+server fetches the document on the first request and caches it for 15 minutes;
+a changed document is picked up after that window. No `/register` call and no
+initial-access token are involved. A `private_key_jwt` document must carry an
+inline `jwks`; `jwks_uri` is rejected. See
+[OAUTH.md](OAUTH.md#client-id-metadata-documents-cimd).
+
+### 2. Open dynamic client registration
+
+This lets any caller register a client with `POST /register` and no token. It
+needs all of the following:
+
+- `allow_open_registration=true`.
+- A non-`catalog` `dcr_redirect_policy`: `catalog_loopback` or `open`.
+- For an `http://localhost:{port}` callback, `allow_localhost_redirect=true`.
+
+The default `dcr_redirect_policy = catalog` rejects any redirect URI that is
+not already in the redirect catalog, even when open registration is on. Choose
+`catalog_loopback` for catalog URIs plus dynamic loopback URIs, or `open` for
+any absolute `https` URI plus loopback. Literal `127.0.0.1` and `[::1]` are
+always loopback; `localhost` is loopback only with the flag on. Open
+registration is anonymous client creation, so prefer it only where that risk is
+acceptable.
+
+### 3. IAT-gated dynamic client registration
+
+With `allow_open_registration=false` (the default), the client must present a
+one-time initial-access token:
+
+```bash
+d-oauth-server initial-access-token issue --uses <n> --ttl <seconds> --label mcp
+```
+
+The raw token prints once, and only to an interactive terminal unless
+`--show-secret` is passed. The client calls `POST /register` with
+`Authorization: Bearer <token>`:
+
+```bash
+curl -s https://auth.example.com/register \
+  -H 'Authorization: Bearer <token>' \
+  -H 'Content-Type: application/json' \
+  -d '{
+    "redirect_uris": ["https://mcp.example.com/oauth/callback"],
+    "token_endpoint_auth_method": "none",
+    "grant_types": ["authorization_code", "refresh_token"],
+    "scope": "api:read"
+  }' | jq .
+```
+
+Each use authorizes one registration call, whether or not the metadata later
+validates. The redirect policy still applies: with the default `catalog` policy,
+the redirect URI must already be cataloged. For a `localhost` callback, use a
+non-`catalog` policy and set `allow_localhost_redirect=true`.
+
+There is no client-create command in the CLI. A client is created only by
+`POST /register` (with an IAT or under open registration), and the CLI manages
+existing clients through `client attach-redirect-uri`, `client
+detach-redirect-uri`, `client redirect-uris`, and `client
+set-access-token-format`.
+
 ## config sync file format
 
 The file is JSON. Every section is optional and defaults to empty.
@@ -248,12 +484,18 @@ The file is JSON. Every section is optional and defaults to empty.
     { "uri": "https://app.example.com/callback", "enabled": true },
     { "uri": "https://app.example.com/signed-out", "kind": "post_logout", "enabled": true }
   ],
+  "resource_servers": [
+    { "identifier": "https://api.example.com", "description": "Orders API", "enabled": true }
+  ],
   "user_scopes": [
     { "email": "operator@example.com", "scope": "orders:read" }
   ],
   "client_redirect_uris": [
     { "client_id": "example-client", "uri": "https://app.example.com/callback" },
     { "client_id": "example-client", "uri": "https://app.example.com/signed-out", "kind": "post_logout" }
+  ],
+  "client_access_token_formats": [
+    { "client_id": "example-client", "access_token_format": "jwt" }
   ]
 }
 ```
@@ -261,6 +503,10 @@ The file is JSON. Every section is optional and defaults to empty.
 Scope names must match the RFC 6749 scope-token grammar. `openid` is reserved.
 Redirect URIs must be absolute and are matched exactly at `/authorize` (or, for
 `kind: post_logout`, at `POST /logout`). `kind` defaults to `authorize`.
+Resource identifiers are exact RFC 8707 resource indicators; a `*` wildcard is
+rejected. `client_access_token_formats` sets the operator-only per-client
+access-token format (`opaque` or `jwt`); it is the same operator opt-in as
+`client set-access-token-format` (dynamic registration can never set it).
 
 Apply and re-apply:
 
@@ -280,8 +526,10 @@ CLI mutations go to the `audit` target with `event = "cli.mutation"`, an
 `user.grant_scope`,
 `user.revoke_scope`, `scope.create`, `scope.enable`, `scope.disable`,
 `scope.delete`, `redirect_uri.create`, `redirect_uri.enable`,
-`redirect_uri.disable`, `redirect_uri.delete`, `client.attach_redirect_uri`,
-`client.detach_redirect_uri`, `initial_access_token.issue`,
+`redirect_uri.disable`, `redirect_uri.delete`, `resource_server.create`,
+`resource_server.enable`, `resource_server.disable`, `resource_server.delete`,
+`client.attach_redirect_uri`, `client.detach_redirect_uri`,
+`client.set_access_token_format`, `initial_access_token.issue`,
 `pii.backfill`, `pii.rotate`, `totp.rewrap`, `admin.count.changed`, and the
 `admin.bootstrap.*` family. `pii verify` and `user scopes` are read-only and
 emit no mutation event.
@@ -311,6 +559,97 @@ minted under the active pepper.
 replacing the mounted material and performing a rolling restart; there is no
 hot reload, by design.
 
+### JWT access-token signing key
+
+Reference: RFC 9068 §2.1, §4; RFC 7517 §5.
+
+This key is optional and off by default. It is unrelated to `keygen`, which
+emits ES256 material for test fixtures and client-side DPoP only; do not use
+`keygen` output here unless it happens to be exactly 32 bytes.
+
+`jwt_signing_key` is consumed as the literal UTF-8 bytes of the configured
+string, not decoded as base64 or hex. It must be exactly 32 bytes. Generate
+printable material with:
+
+```bash
+openssl rand -hex 16
+```
+
+That prints 32 hexadecimal characters, which are 32 bytes of key material. The
+inline value is trimmed of leading and trailing whitespace; a `*_FILE` mount is
+read verbatim except that trailing CR/LF are removed, so do not add spaces. An
+all-zero or out-of-range P-256 scalar still fails at load with
+`InvalidJwtSigningKey`.
+
+To enable the feature and mint JWTs for one client:
+
+1. Set the environment:
+
+   ```bash
+   export D_OAUTH_JWT_ACCESS_TOKENS_ENABLED=true
+   export D_OAUTH_JWT_SIGNING_KEY="$(openssl rand -hex 16)"
+   export D_OAUTH_JWT_SIGNING_KID=v1
+   export D_OAUTH_JWT_ACCESS_TOKEN_SIGNING_ALG=ES256
+   ```
+
+   `D_OAUTH_JWT_SIGNING_KEY_FILE` mounts the same material as a file. The
+   algorithm is `ES256` (default) or `EdDSA`; both use 32 raw bytes and there is
+   no RSA option. Enabling the switch without a key fails at boot with
+   `MissingJwtSigningKey`.
+
+2. Roll the replicas and confirm the public key set:
+
+   ```bash
+   curl -fsS "$D_OAUTH_ISSUER/jwks.json" | jq -r '.keys[] | "\(.kid) \(.alg) \(.kty)"'
+   ```
+
+   The current key appears first. While the feature is off, the route still
+   answers `200` with `{"keys":[]}`; it is cached for 300 s.
+
+3. Opt a client in, per client, only after the deployment is ready:
+
+   ```bash
+   d-oauth-server client set-access-token-format --client-id <id> --format jwt
+   ```
+
+   Registration can never set this; `opaque` remains the column default.
+
+To rotate:
+
+1. Generate a new key and a new `kid`.
+2. Make the new key active. Move the old key into `jwt_signing_previous_keys`,
+   a list of `{kid, key}` objects supplied in the `D_OAUTH_CONFIG` file (the
+   loader does not accept this list as one environment string):
+
+   ```yaml
+   jwt_access_tokens_enabled: true
+   jwt_signing_key: "<new 32-byte key>"
+   jwt_signing_kid: v2
+   jwt_signing_previous_keys:
+     - kid: v1
+       key: "<old 32-byte key>"
+   ```
+
+   Keep `jwt_access_token_signing_alg` unchanged unless every retained key is
+   regenerated: all entries in the ring share that one value.
+3. Roll every replica, so each one signs with `v2` and verifies `v1`.
+4. Confirm both kids are published, current first:
+
+   ```bash
+   curl -fsS "$D_OAUTH_ISSUER/jwks.json" | jq -r '.keys[].kid'
+   ```
+
+5. Keep the previous key until every token it signed has expired. Access tokens
+   live `ttl.access_secs` (300 s by default); refresh tokens stay opaque and do
+   not use this key. After that window, remove the previous entry and roll the
+   replicas again. A JWKS document cached by a resource server can lag the
+   removal by up to 300 s, which is harmless because no new token names the old
+   kid.
+
+The old key verifies only. It never signs once it is in
+`jwt_signing_previous_keys`, and a duplicate kid (active or previous) is
+rejected at load.
+
 ## PII and TOTP key rotation
 
 Both the PII field-encryption key and the TOTP encryption key are versioned in
@@ -425,8 +764,8 @@ survives a Redis loss.
 
 - `src/cli.rs` for the command tree and flags.
 - `src/cli/ops.rs`, `src/cli/user.rs`, `src/cli/scope.rs`,
-  `src/cli/redirect.rs`, `src/cli/sync.rs`, `src/cli/admin.rs`,
-  `src/cli/secret.rs` for implementations.
+  `src/cli/redirect.rs`, `src/cli/resource.rs`, `src/cli/sync.rs`,
+  `src/cli/admin.rs`, `src/cli/secret.rs` for implementations.
 - `src/cli/pii/` for `pii backfill|verify|rotate` and `src/cli/totp.rs` for
   `totp rewrap`.
 - `src/crypto/field.rs` for the PII field codec and blind index.

+ 42 - 0
docs/REDIS.md

@@ -42,12 +42,49 @@ differ.
 | `oauth:{state}:loginfail:ip:<sha256>:<sha256>` | Composite failed-login counter for one source IP and one account (both halves hashed; locks that IP only). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:unknown:<sha256>` | Unknown-label bucket for one source IP (hashed IP only; no per-label keys, so label enumeration cannot spray the keyspace). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. The fingerprint is an HMAC under the token pepper (never the code itself), so a dump does not reveal the code. | 180 s (`USED_CODE_TTL`; fixed, 6× the 30 s step) |
+| `oauth:{state}:jwt:<jti-hash>` | JWT access-token kill marker (allowlist: a signed token is valid only while this hash is live). Carries `grant_id`, `client_id`, and the raw `subject` (email/user-id; empty for client-credentials), matching the `purge_subject` `subject` decoder. | `ttl.access` (300s) |
+| `oauth:{state}:cimd:<sha256>` | Cached client-id-metadata document (keyed by a hash of the URL, never the raw URL). | Bounded cache lifetime |
 | `oauth:{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.
 
+Resource audiences (`aud`, RFC 8707) ride on seven record families as a field
+named `aud`: `code:`, `access:`, `refresh:`, `grant:`, `par:`, `device:`, and
+`pending:` (the last is a JSON blob, not a hash). The value is a JSON array of
+strings, so a URI that itself contains a comma can never collide with a
+delimiter join. In the hash-backed records it is stored as a JSON string field,
+so the rotation Lua script's `cjson.decode` sees a string rather than a Lua
+table. A record written by a replica that predates audiences carries no `aud`
+field and decodes back to an empty audience, so in-flight records survive a
+rolling deploy; a present-but-malformed field fails closed as `InvalidData`.
+
+The audience is `{state}`-keyspace metadata only. Resource indicators add no key
+family and no `{rl}` counter; the durable catalog of allowed identifiers lives in
+Postgres, not Redis.
+
+JWT access tokens use an allowlist, not a denylist. Each mint writes a
+`jwt:<jti-hash>` marker (`grant_id`, `client_id`, `subject`, TTL
+`ttl.access`) in the same atomic script as the grant membership, and a token is
+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:
+
+- A grant cascade deletes `jwt:<hash>` markers through the third ARGV of the
+  revoke script, together with the member access records and the grant set.
+- `user disable` sweeps `jwt:` keys by their `subject` field. Grant-bound
+  markers are also covered by the cascade; markers for client-credentials
+  tokens (empty subject) and any marker left outside a live grant rely on this
+  sweep.
+- `POST /revoke` with a JWT-shaped token deletes the marker, the
+  `access:jwt:<hash>` record, and the grant membership after verifying the
+  signature, `iss`, and ownership, then runs a durability barrier.
+
+A resource server that verifies locally against `/jwks.json` never reads this
+keyspace, so it cannot observe a deletion. See
+[OAUTH.md](OAUTH.md#the-resource-server-trade-off) for that exp-bounded
+trade-off.
+
 ## Hash tags and single-slot atomicity
 
 Every token-state key carries the `{state}` hash tag, so one Lua operation that
@@ -201,6 +238,11 @@ field is the canonical email or the user-id string:
 - `grant:<id>` families via the grant cascade (member token keys, the
   `:tokens` set, the record, and member `:act` sidecars), plus standalone
   `access:<hash>` (with `:act`) and `refresh:<hash>` records.
+- `jwt:<jti-hash>` allowlist markers whose `subject` field matches. The grant
+  cascade already removes markers for grant-bound tokens; this sweep covers a
+  marker whose grant set is already gone. Client-credentials markers carry an
+  empty subject, so the subject decoder never matches them; they expire with
+  `ttl.access`.
 
 Records without a `subject` field are never touched: `par:`, `pending:`,
 `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the

+ 303 - 0
docs/RS-CONTRACT.md

@@ -0,0 +1,303 @@
+# Resource-server contract (RFC 9728)
+
+This is the contract for the **resource server** (RS) that sits in front of a
+protected API and accepts access tokens minted by this authorization server
+(AS). The RS is a separate service. This repository is the AS only: it hosts no
+resource server and adds no RS route.
+
+The contract is written so an independent RS team can implement discovery,
+challenge, token enforcement, and caching from this document alone, following
+RFC 9728.
+
+- Discovery entry point: `GET /.well-known/oauth-protected-resource` on the RS.
+- Challenge: `WWW-Authenticate: Bearer resource_metadata="..."` (RFC 9728 §5.1).
+- Token enforcement: audience match plus scope check (RFC 9728 §7.4, §7.2).
+- Validation of the metadata document: the returned `resource` must be byte-for-byte
+  identical to the resource identifier the client used (RFC 9728 §3.3).
+- Caching: AS metadata and JWKS are cacheable; protected-resource metadata may
+  change and is re-fetched on a fresh challenge (RFC 9728 §7.10, §5.2).
+
+## Purpose
+
+For the team that owns the API the tokens are for: what to publish, how to
+challenge an unauthenticated request, how to decide a token is acceptable for a
+given endpoint, and what to cache. Read it with
+[SECURITY.md](SECURITY.md) for the token model and [OAUTH.md](OAUTH.md) for the
+AS-side flow. The operator-side steps that pair with this contract (register the
+identifier, publish the document, set the challenge, rotate) are in
+[OPERATIONS.md](OPERATIONS.md#resource-server-onboarding-rfc-9728).
+
+## Scope and non-goals
+
+In scope:
+
+- The protected-resource metadata document the RS publishes.
+- The `WWW-Authenticate` challenge the RS returns to an unauthenticated client.
+- The audience and scope checks the RS performs on every request.
+- How the RS caches authorization-server metadata and keys.
+
+Out of scope, and deliberately not done here:
+
+- This repository does not host a resource server and adds **no** RS route to
+  `src/routes.rs`. The RS is a separate deployable.
+- The AS metadata document (`/.well-known/oauth-authorization-server`, RFC 8414)
+  never carries a `resource_metadata` member. `resource_metadata` is a
+  `WWW-Authenticate` parameter owned by the RS (RFC 9728 §5.1), not an AS
+  metadata member (RFC 9728 §4).
+- No wildcard resource identifier, redirect URI, or CORS origin is introduced by
+  this contract.
+
+## Specification reference
+
+| Section | Title | What it fixes here |
+| --- | --- | --- |
+| RFC 9728 §2 | Protected Resource Metadata | The member set the RS may publish (`resource`, `authorization_servers`, `jwks_uri`, `scopes_supported`, `bearer_methods_supported`, and more). |
+| RFC 9728 §3 | Obtaining Protected Resource Metadata | The well-known URL is derived from the resource identifier. |
+| RFC 9728 §3.2 | Protected Resource Metadata Response | A `200 OK` with `application/json` whose members are a subset of §2. |
+| RFC 9728 §3.3 | Protected Resource Metadata Validation | The returned `resource` must equal the resource identifier used to build the well-known URL. |
+| RFC 9728 §4 | Authorization Server Metadata | Defines `protected_resources` on the AS side. It does **not** add `resource_metadata` to the AS document. |
+| RFC 9728 §5.1 | WWW-Authenticate Response | Introduces the `resource_metadata` parameter carrying the metadata URL. |
+| RFC 9728 §7.2 | Scopes | `scopes_supported` is the supported set, not a request-everything hint. |
+| RFC 9728 §7.3 | Impersonation Attacks | Exact `resource` match defeats resource impersonation. |
+| RFC 9728 §7.4 | Audience-Restricted Access Tokens | The RS must check the token audience; a token minted for one RS must not be usable at another. |
+| RFC 9728 §7.10 | Metadata Caching | Caching guidance for AS metadata and keys. |
+
+Related specifications: RFC 8414 (AS metadata), RFC 7662 (token introspection),
+RFC 8707 (resource indicators), RFC 6750 (Bearer token usage), RFC 9449 (DPoP).
+
+## The protected-resource metadata document
+
+The RS publishes its metadata at a well-known URL derived from its resource
+identifier (RFC 9728 §3). For a resource identifier with no path component, that
+is `/.well-known/oauth-protected-resource` at the resource host. If the resource
+identifier has a path, the terminating slash is removed and the path suffix is
+inserted before it (`/.well-known/oauth-protected-resource/resource1`). The
+document is fetched with an HTTP `GET` and returns `200 OK` with
+`Content-Type: application/json` (RFC 9728 §3.2). Members with zero values are
+omitted.
+
+| Member | Required | Meaning here |
+| --- | --- | --- |
+| `resource` | Yes | The RS's own resource identifier, an absolute `https` URL with no fragment. It must equal the identifier the well-known URL was derived from. |
+| `authorization_servers` | No | JSON array of AS issuer identifiers that can be used with this resource. For this AS, the issuer is the `issuer` config value (for example `https://auth.example.com`). |
+| `jwks_uri` | No | URL of the RS's **own** JWK Set, for keys the RS uses to sign resource responses. This is not the AS key set. |
+| `bearer_methods_supported` | No | How the RS accepts a bearer token. Emit `["header"]` only; this AS rejects tokens in the URI query. |
+| `scopes_supported` | Recommended | Scope values that can be requested for this resource. It is a supported set, not a hint to request all of them (RFC 9728 §7.2). |
+| `resource_name` | Recommended | Human-readable name for display. |
+| `resource_documentation`, `resource_policy_uri`, `resource_tos_uri` | No | Human-readable links. |
+| `tls_client_certificate_bound_access_tokens` | No | Whether the RS accepts RFC 8705 mTLS-bound tokens. |
+| `dpop_signing_alg_values_supported`, `dpop_bound_access_tokens_required` | No | DPoP support. Use the same allowlist this AS uses for DPoP proofs: `ES256`, `EdDSA`, `RS256`. |
+| `resource_signing_alg_values_supported` | No | JWS algorithms the RS supports for signing resource responses. `none` must not be used. |
+
+Example document for an Orders API protected by this AS:
+
+```http
+GET /.well-known/oauth-protected-resource HTTP/1.1
+Host: api.example.com
+```
+
+```json
+{
+  "resource": "https://api.example.com",
+  "authorization_servers": ["https://auth.example.com"],
+  "jwks_uri": "https://api.example.com/.well-known/jwks.json",
+  "bearer_methods_supported": ["header"],
+  "scopes_supported": ["orders:read", "orders:write"],
+  "resource_name": "Orders API",
+  "resource_documentation": "https://api.example.com/docs",
+  "tls_client_certificate_bound_access_tokens": true,
+  "dpop_signing_alg_values_supported": ["ES256", "EdDSA", "RS256"],
+  "dpop_bound_access_tokens_required": false
+}
+```
+
+`scopes_supported` here must be the runtime scope catalog this AS actually
+enables (the `scopes` table), not a hardcoded list. A scope the AS does not
+enable cannot be requested, so advertising it only misleads clients.
+
+## The challenge
+
+An unauthenticated or invalid-token request gets an RFC 6750 `401` with a
+`WWW-Authenticate` header carrying the `resource_metadata` parameter (RFC 9728
+§5.1). The parameter value is the absolute `https` URL of the metadata document
+from the section above. The parameter may be combined with others such as
+`max_age` (RFC 9470) and may be used with schemes other than `Bearer`, such as
+`DPoP` (RFC 9449).
+
+```http
+GET /orders/42 HTTP/1.1
+Host: api.example.com
+```
+
+```http
+HTTP/1.1 401 Unauthorized
+WWW-Authenticate: Bearer resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"
+```
+
+Rules:
+
+- The RS sends this challenge when no credentials are presented or when the
+  presented token is not acceptable. It does not send it alongside a success.
+- If the RS later needs to point clients at a changed metadata document, it
+  re-sends a fresh challenge with the `resource_metadata` value; clients re-fetch
+  and re-validate on receipt (RFC 9728 §5.2).
+- The RS does not invent the metadata URL ad hoc. The value is the URL derived
+  from its resource identifier as in the section above.
+
+## Discovery flow
+
+The client starts with no knowledge of the AS (RFC 9728 §5):
+
+1. The client calls the resource without a token.
+2. The RS replies `401` with `WWW-Authenticate: Bearer resource_metadata="..."`.
+3. The client fetches the protected-resource metadata from that URL.
+4. The client validates it (RFC 9728 §3.3), then reads `authorization_servers`.
+5. The client builds the AS metadata URL from an issuer and fetches it
+   (RFC 8414).
+6. The client runs the authorization-code flow and obtains an access token.
+7. The client calls the resource again with `Authorization: Bearer <token>`.
+8. The RS accepts the token only after the audience and scope checks below.
+
+## Token enforcement
+
+The RS accepts a request only when the token is active, is bound to the RS, and
+carries the scope the endpoint needs. All three checks run on every request; a
+pass on one never substitutes for another.
+
+### Audience match
+
+RFC 9728 §7.4: without audience-restricted tokens, a malicious resource server
+could coax a client into a token it can then replay at a legitimate resource.
+The defense is a per-resource audience. The RS must reject any token whose
+audience does not include its own `resource` identifier. RFC 8707 resource
+indicators are the mechanism by which the client binds the token to this RS.
+
+Two cases:
+
+- JWT access tokens: the RS verifies the signature against the AS key set, then
+  checks that the token's `aud` contains the RS's `resource` value. A missing or
+  non-matching `aud` is a reject.
+- Opaque access tokens (this AS's default): the RS obtains the token's claims by
+  calling `POST /introspect` at the AS (RFC 7662) and requires an active result
+  whose scope it can trust, in a deployment where the token is bound to this RS
+  at mint time via the RFC 8707 `resource` parameter. A token the AS bound to a
+  different resource is a reject.
+
+Either way the rule is the same: a token minted for one resource is not accepted
+at another. Do not skip the audience check because the client cooperated.
+
+### Scope check
+
+The RS maps each endpoint to the scope it requires, then requires that scope to
+be present in the token. For introspection, the `scope` member is the
+space-delimited granted scope (RFC 7662 §2.2). For JWT tokens, read `scope` (or
+`scp`) and treat a missing value as no scope, which is a reject. A token with a
+broader scope than the endpoint needs is still acceptable if it contains the
+required scope; a token missing it is `403` with an RFC 6750 `insufficient_scope`
+challenge. Scope is never widened by the RS.
+
+### Sender constraint
+
+When the AS bound the token to a key or certificate, the RS must re-verify the
+proof of possession on every use: a DPoP proof for `cnf.jkt`, or the client
+certificate for `cnf.x5t#S256` (RFC 9449, RFC 8705). The introspection `cnf`
+member reports the binding. A token bound to a key is not acceptable without a
+fresh, valid proof.
+
+### Introspection integration
+
+When the RS uses RFC 7662 introspection, the request is client-authenticated and
+the active response carries `scope`, `client_id`, `token_type`, `exp`, `iat`,
+`sub`, `aud`, and `cnf`. `aud` is a JSON array of the resource identifiers the
+token is bound to (the RFC 9068 §2.2 array shape, used here for opaque and JWT
+tokens alike) and is omitted when the token has no bound audience. An inactive
+response is exactly `{"active": false}` with no other member. The RS treats
+`active: false` as a reject and never infers activity from anything else.
+
+This AS additionally accepts a repeated `resource` parameter on the
+introspection request as a LOCAL EXTENSION to RFC 7662, which defines no
+`resource` parameter. When present, the token's `aud` must include every
+requested member (set inclusion, so a token pinned to a superset stays active);
+a token that does not include every requested member returns the inactive shape,
+never an error. An RS uses this to ask whether a token is bound to its own
+resource identifier.
+
+## Validating the metadata document
+
+RFC 9728 §3.3: the `resource` member returned by the metadata document must be
+identical to the resource identifier value the well-known URL was built from
+(and, when discovery started from a `resource_metadata` challenge, to the URL
+the client used to reach the resource). If the values differ, the client must
+discard the document. This exact-match rule is what defeats resource
+impersonation (RFC 9728 §7.3). Comparisons are code-point equality with no
+Unicode normalization (RFC 9728 §6).
+
+If the document carries `signed_metadata`, the client must verify that the JWT
+was signed by a key belonging to the issuer it trusts; an invalid signature or
+untrusted issuer is an error.
+
+## Caching
+
+- AS metadata and the AS JWKS are cacheable. Cache them with a positive
+  lifetime and refresh in the background; do not fetch them on every request.
+- Protected-resource metadata may change. A fresh `WWW-Authenticate` challenge
+  signals that it may have changed; re-fetch and re-validate then
+  (RFC 9728 §5.2).
+- Key sets rotate. Retain the previous signing keys for verification across a
+  rotation window so in-flight tokens still verify; never treat an empty key set
+  as "accept all".
+- Never weaken a check to keep a cache warm. A stale key set that fails to
+  verify a token is a reject, not a reason to skip verification.
+
+## This authorization server's side
+
+The AS side of this contract is narrow and fixed:
+
+- The AS metadata document at `/.well-known/oauth-authorization-server`
+  (`src/protocol/metadata.rs`, `AuthorizationServerMetadata`) never contains a
+  `resource_metadata` member. That member is a `WWW-Authenticate` parameter owned
+  by the RS (RFC 9728 §5.1), so placing it in the AS document would be wrong.
+- RFC 9728 §4 defines one AS-side member, `protected_resources`, which lists the
+  protected resources usable with this AS. It is emitted only when
+  `protected_resources_enabled` is set, using the identifiers in
+  `protected_resource_ids`; both default off and empty. Until enabled, the AS
+  advertises no `protected_resources` and clients rely on the RS metadata alone.
+- Tokens are opaque by default and hashed at rest, so the RS validates them by
+  introspection (RFC 7662) rather than by local verification. A client can be
+  opted into JWT access tokens, in which case the AS advertises `jwks_uri` and
+  the RS may verify locally; local verification cannot observe revocation or
+  `user disable` before `exp`, so a sensitive RS still introspects. See
+  [OAUTH.md](OAUTH.md#the-resource-server-trade-off).
+- RFC 8707 resource indicators are honored only when `resource_indicators_enabled`
+  is set (default off). The RS should confirm with the operator whether tokens
+  are audience-restricted in a given deployment before relying on the `aud`
+  check alone.
+
+Because the AS mints opaque tokens by default, an RS paired with this AS
+introspects at `POST /introspect` and enforces the scope and binding from the
+response, plus the audience guarantee the AS applied at mint time.
+
+## Resource-server implementation checklist
+
+- [ ] Publish the protected-resource metadata at the well-known URL derived from
+      the resource identifier, as `application/json`, with `resource` set to
+      that same identifier (RFC 9728 §3.2, §3.3).
+- [ ] Return `WWW-Authenticate: Bearer resource_metadata="..."` on `401`
+      (RFC 9728 §5.1).
+- [ ] Reject a token whose audience does not include the RS's resource
+      identifier (RFC 9728 §7.4).
+- [ ] Require the endpoint's scope and return `insufficient_scope` when absent.
+- [ ] Re-verify sender-constraint proofs on every use.
+- [ ] Cache AS metadata and keys with a positive lifetime; re-fetch on a fresh
+      challenge (RFC 9728 §7.10, §5.2).
+- [ ] Never place `resource_metadata` in any AS metadata document (RFC 9728 §4).
+
+## File and directory references
+
+- `src/protocol/metadata.rs` for the AS metadata document, which carries no
+  `resource_metadata`.
+- `src/protocol/introspect.rs` for the RFC 7662 introspection response an RS
+  consumes.
+- `src/routes.rs` for the AS route table, which contains no RS route.
+- [OAUTH.md](OAUTH.md) and [SECURITY.md](SECURITY.md) for the AS flow and token
+  model.

+ 205 - 9
docs/SECURITY.md

@@ -60,21 +60,56 @@ class, or runtime setting).
 
 | Vector | Existing controls | Residual risk |
 | --- | --- | --- |
-| Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims plus DPoP-JWK/assertion-verify/redirect-URI/scope-parse, with continuous ClusterFuzzLite coverage (`.clusterfuzzlite/`, `cflite_*.yml`) | Fuzzing is not exhaustive. |
+| Application-level RCE in a parser (form bodies, DPoP/JAR/`private_key_jwt` JWTs, DCR JSON, CIMD documents, `jwks_uri`/`request_uri` fetches) | `#![forbid(unsafe_code)]`, no `unwrap`/`panic` in request paths, rustls/ring (no OpenSSL), fuzz targets for token-form/DCR-JSON/JAR-claims plus DPoP-JWK/assertion-verify/redirect-URI/scope-parse, with continuous ClusterFuzzLite coverage (`.clusterfuzzlite/`, `cflite_*.yml`) | Fuzzing is not exhaustive. |
 | Dependency / supply-chain compromise | Committed `Cargo.lock`, `cargo deny`/`cargo audit` in CI, SBOM + provenance + keyless cosign, digest-pinned bases | A zero-day in a trusted crate. |
 | Container escape via kernel or runtime bug | Non-root UID 65532, read-only root filesystem, all capabilities dropped, `allowPrivilegeEscalation: false`, `seccompProfile: RuntimeDefault` | A kernel LPE or runc bug, mitigated but not eliminated. |
 | Secret exfiltration from the process environment | Secrets are injected at runtime, not baked into image layers; the pepper/TOTP/PII keys live outside the database; the Deployment mounts every secret — pepper, TOTP key, PII key, and the database/Redis passwords — as `0400` tmpfs files under `/run/secrets` (read through their `*_FILE` variables), and the passwordless DSNs come from the ConfigMap, so no secret enters the environment | An attacker with code execution can still read the mounted secret files under `/run/secrets`; that is the same capability the server has. |
 | Exposed management surface | The plaintext health listener (8081) is unauthenticated but not published in the Service (only 8080 is) | A misconfigured Service/Ingress could expose it; it must never be internet-facing. |
 | Host access via mounted volumes | `/certs` is mounted read-only at mode `0400`; no Docker socket is mounted | Operator misconfiguration. |
-| Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379) scoped to the namespace hosting them — no internet-wide `0.0.0.0/0` peer; any `ipBlock` peer excludes `169.254.0.0/16`; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. A managed database outside the cluster needs a specific CIDR or a CNI FQDN policy. The default-deny also blocks the JAR `request_uri` HTTPS fetch — remote request objects are unsupported unless the deployment adds a scoped allowance (see `docs/DEPLOYMENT.md`). |
+| Unrestricted pod egress (SSRF to metadata, exfiltration) | `networkpolicy.yaml` default-denies egress except DNS (53 to `kube-system`) and Postgres/Redis (5432/6379) scoped to the namespace hosting them — no internet-wide `0.0.0.0/0` peer; any `ipBlock` peer excludes `169.254.0.0/16`; ingress is limited to pod ports 8080/8081 | Enforcement depends on a NetworkPolicy-capable CNI; a cluster that ignores NetworkPolicy gets no benefit. A managed database outside the cluster needs a specific CIDR or a CNI FQDN policy. The default-deny also blocks the JAR `request_uri` HTTPS fetch and the CIMD `client_id` document fetch, so remote request objects and CIMD are unsupported unless the deployment adds a scoped allowance (see `docs/DEPLOYMENT.md`). |
 | Operator `exec` into a running pod | No shell and a non-root, read-only-rootfs container limit what runs; every secret is a file the container user already reads, and no secret is in the environment | `pods/exec` (and `docker exec`) bypass the application's own authorization; restrict it with RBAC and audit it (`docs/OPERATIONS.md`). |
 | Kubernetes API access from the pod | `automountServiceAccountToken: false`; the binary never calls the API | A future feature that needs the API would have to re-enable it deliberately. |
 | Image/registry compromise | Keyless cosign v3 signing (OCI 1.1 referrer bundles) in the `supply-chain.yml` `merge` job (`Keyless sign (cosign, OIDC)`), verify-before-rollout with `cosign verify` on `ghcr.io/syavne/d-oauth-server:latest` (see `docs/DEPLOYMENT.md`), a mutable `:latest` reference that trades immutability for a simple admin flow, and a local-build fallback that still signs (`cosign sign`) and verifies (`cosign verify`) before loading; each rollout records the digest the tag resolved to in `RUNBOOK.md` | A registry compromise of the mutable `:latest` tag ships if the operator skips the verify step, and the tag can move between verify and pull (TOCTOU), so the verified digest may not be the one a node pulls; an unsigned `docker save \\| docker load` tarball bypasses every control, so it is forbidden except through the documented local sign/verify fallback. |
 | Rate-limit evasion via spoofed `X-Forwarded-For` behind an L7 proxy | Default-empty   `rate_limit.trusted_proxies` (headers ignored unless the TCP peer is an allowlisted proxy IP/CIDR); rightmost-untrusted selection; malformed headers fall back to the verified peer; attribution is bucket selection only, never authentication | An allowlist entry that is too broad (or a compromised proxy) attributes attacker-chosen IPs; keep entries to the proxy peers and never a `/0` (rejected at load). Proxies must emit bare IPs (no port/brackets/`unknown`); anything else is skipped, and only the rightmost 32 entries are considered. |
 
-Last reviewed: 2026-10-13. File-mounted DB/Redis passwords, a mutable `:latest`
-image reference, NetworkPolicy, `automountServiceAccountToken: false`, and
-scheduled fuzzing are implemented in the shipped manifests and workflows.
+`GET /jwks.json` is the only public endpoint the JWT access-token feature adds.
+The AS metadata document advertises it as `jwks_uri`, together with the exact
+configured signing algorithm, only while `jwt_access_tokens_enabled` is on, so a
+disabled deployment exposes neither a key set nor an algorithm claim.
+Its body is public verification material by construction: a JWK Set carrying
+`kid`, `kty`, `crv`, `x` (and `y` for EC), `use: "sig"`, and `alg` for the
+current and retained signing keys, with no private member (`d`, CRT parameters,
+or `k`) ever rendered. Endpoint enumeration therefore reveals no client, token,
+or subject existence; with `jwt_access_tokens_enabled` off it is an empty
+`{"keys":[]}`, and the short cache window bounds how long a rotated-out key
+stays reachable through an intermediary.
+
+CIMD adds a client class fetched from the internet, so it is a deployment-surface
+change, not just an endpoint change. With `cimd_enabled` on, the authorization
+server makes outbound HTTPS requests to a host chosen by the caller (the
+`client_id` URL). The shipped NetworkPolicy default-denies that egress; a
+deployment that enables CIMD must add a scoped allowance for the document hosts
+it intends to trust. The guard blocks internal targets, but it cannot make an
+untrusted origin trustworthy: the document decides the redirect set, scope
+bound, grant allowlist, and DPoP flag within the validation rules described in
+[Client ID metadata documents (CIMD)](#client-id-metadata-documents-cimd). Do not
+enable CIMD in a deployment that cannot enforce egress policy.
+
+This server is the authorization server and hosts no resource server: it adds
+no `/.well-known/oauth-protected-resource` route and no RS route to
+`src/routes.rs`, and its AS metadata document never carries a `resource_metadata`
+member. The resource-server side of discovery, the
+`WWW-Authenticate: Bearer resource_metadata="..."` challenge, and the audience
+and scope checks that stop a token minted for one resource from being replayed
+at another (RFC 9728 §7.4) are a separate deployable's responsibility. That
+contract is in [RS-CONTRACT.md](RS-CONTRACT.md); a resource server that skips the
+audience check re-opens the cross-resource replay this server's audience binding
+is meant to close.
+
+Last reviewed: 2026-10-21. File-mounted DB/Redis passwords, a mutable `:latest`
+image reference, NetworkPolicy, `automountServiceAccountToken: false`, scheduled
+fuzzing, and the CIMD outbound fetch surface are implemented in the shipped
+manifests and workflows.
 Deploy provenance is a documented verify-before-rollout path
 (`docs/DEPLOYMENT.md`, `RUNBOOK.md`): the signed `ghcr.io` image verified with
 `cosign verify` and a recorded digest, with local builds kept only as a signed
@@ -95,11 +130,20 @@ fallback.
 
 ## Browser and intermediary caching
 
-- Every response except `/.well-known/…` metadata carries
+- Every response except `/.well-known/…` metadata and `GET /jwks.json` carries
   `Cache-Control: no-store` (+ `Pragma: no-cache` for legacy clients). An
   authorization page, consent page, token body, request handle, or error is
   therefore never reused by a browser, the back/forward cache, or a shared
   intermediary.
+- `GET /jwks.json` is deliberately cacheable with `public, max-age=300`: the
+  advertised members (`kid`, `kty`, `crv`, `x`, and `y` for EC, `use: "sig"`,
+  `alg`) are public verification coordinates, never private key material. The
+  five-minute window is a revocation trade-off: a rotated-out key stays
+  available for resource servers still checking old tokens, but caches drop it
+  ten times faster than the one-hour metadata document.
+- `/jwks.json` is a new public, unauthenticated path: it returns only public
+  key coordinates, and it is the only addition to the routing table for the
+  JWT feature. It does not reveal whether any client or token exists.
 - This covers the `GET /login` and `GET /consent` pages (which carry a
   session-bound CSRF token and the bound `request_id`), the `/authorize`
   redirects and error responses, the token endpoint (success and error), PAR,
@@ -138,6 +182,67 @@ fallback.
 
 See [REDIS.md](REDIS.md) for the barrier modes.
 
+## JWT access-token signing and revocation
+
+Reference: RFC 9068 §2.1, §2.2, §4; RFC 7517 §5.
+
+The signer is off by default and is separate from the pepper/TOTP/PII master
+chains. When `jwt_access_tokens_enabled` is on, exactly 32 bytes of raw key
+material (`jwt_signing_key` or its `*_file` mount) become a PKCS#8 signing key
+for `ES256` (P-256 scalar) or `EdDSA` (Ed25519 seed), chosen by
+`jwt_access_token_signing_alg`. There is no RSA path and no PEM handling. A
+probe signature is produced at load, so an all-zero or out-of-range scalar fails
+at boot rather than at the first mint, and enabling the feature without a key is
+a load-time error.
+
+Threat model for the signer:
+
+- The algorithm is pinned by configuration, never taken from the token. A JWT
+  whose header names any other `alg` is rejected, including `none` and HS
+  variants, so there is no algorithm-confusion path and no symmetric key to
+  confuse with a public one.
+- The header `typ` must be exactly `at+jwt`, and `kid` must name the current or
+  a retained previous key. An unknown `kid` fails with no fallback.
+- `iss` and `exp` are enforced with zero clock-skew leeway. `aud` containment is
+  applied when the caller supplies an expected audience.
+- Key material never appears in `Debug`, an error, a metric label, or a log.
+  `Config` and `JwtSigningKey` print only the key id and algorithm; `/jwks.json`
+  carries public coordinates only (`kid`, `kty`, `crv`, `x`, and `y` for EC).
+- Rotation is verify-only for the old key: the current key signs, every
+  `jwt_signing_previous_keys` entry verifies. Kids must be distinct, and a
+  previous key cannot be configured without a current key. Retain the old key at
+  least until every token it signed has reached `exp` (`ttl.access_secs`, 300 s
+  by default). The JWKS route advertises current first, then retained keys, and
+  intermediaries may cache that document for 300 s, so a removed key can remain
+  visible for up to that window.
+
+Revocation is an allowlist, not a denylist. Every minted JWT writes a
+`jwt:<jti-hash>` marker (fields `grant_id`, `client_id`, `subject`; TTL equal to
+the access TTL) in the same atomic script as its grant membership. A token is
+live only while its marker exists and, for introspection, while the marker's own
+grant set still contains it. `POST /introspect` also re-checks the current
+account status, so a disabled subject reports inactive. The server fails closed
+(`503`) if Redis or Postgres cannot answer that liveness question.
+
+The marker is torn down by every revocation path:
+
+- A grant cascade deletes `jwt:<hash>` markers as its third ARGV, alongside the
+  access records and grant set members.
+- `user disable` sweeps `jwt:` keys by their `subject` field before flipping
+  `users.disabled`.
+- `POST /revoke` accepts a JWT-shaped token directly: it verifies signature and
+  `iss`, requires both the signed `client_id` and the marker's `client_id` to
+  match the caller, then deletes marker, record, and grant membership with a
+  durability barrier. A failed ownership check is a uniform no-op (`200`).
+
+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
+accepts a revoked or disabled user's token until `exp`. That is the exp-bounded
+trade-off of a self-contained token. A resource server that needs immediate
+revocation must use `POST /introspect`; one that verifies locally must keep the
+access TTL short. The same trade-off is stated from the protocol side in
+[OAUTH.md](OAUTH.md#the-resource-server-trade-off).
+
 ## Sender constraints
 
 Reference: RFC 9449 (DPoP), RFC 8705 (mTLS).
@@ -146,9 +251,16 @@ Every sender-constrained grant requires a freshly verified proof on every use.
 
 | Client class | Required binding |
 | --- | --- |
-| Public | DPoP only. A missing proof fails with `invalid_grant`. |
+| Public, DPoP-bound (`dpop_bound_access_tokens: true`) | DPoP only. A missing proof fails with `invalid_grant`. |
+| Public, unbound (default) | None: the client mints `Bearer` tokens. A presented DPoP proof is still verified and recorded. |
 | Confidential | mTLS and/or DPoP. The confirmation records each proven component. |
 
+Rollout note: `dpop_bound_access_tokens` defaults to false, so upgrading
+makes an existing public client bearer-capable; a client that must stay
+DPoP-mandatory has to be registered (or updated via RFC 7592) with
+`dpop_bound_access_tokens: true`. A presented bad proof is still rejected for
+both classes, and scope and disabled-subject rules are unchanged.
+
 - `cnf` propagates to every newly minted token.
 - A stored `cnf` alone never mints: the proof is re-verified per use.
 - For token exchange, the proof must match the subject token's confirmation,
@@ -186,6 +298,77 @@ Reference: RFC 9126, RFC 9101.
   blocked address.
 - PAR credentials are read from the header and body only, never the query.
 
+## Client ID metadata documents (CIMD)
+
+Reference: `draft-ietf-oauth-client-id-metadata-document-02` §3 through §5,
+§8.6, §8.7.
+
+CIMD is a registration-free client class: an `https://` URL used as the
+`client_id` is fetched and validated, and the result is used as client metadata
+for that request. The trust model is remote-document trust with strict
+validation. The server does fetch, and it does not treat the client as trusted
+because "it registered"; there is no row and no initial-access token. The
+feature is off by default (`cimd_enabled`); with it off, an `https://`
+`client_id` with no registered row is still an unknown client.
+
+Fetch boundary:
+
+- One guarded fetch, the same fetcher used for `request_uri` and `jwks_uri`:
+  `https` only, redirects disabled (a 3xx fails closed), DNS pinned to the
+  validated addresses, proxy disabled, and special-use, private, and loopback
+  targets rejected before any connection.
+- The body is capped by `cimd_max_bytes` (default `5120`, allowed `1`-`5120`)
+  and the whole request by `cimd_fetch_timeout_secs` (default `5`, allowed
+  `1`-`10`). A non-2xx status or a non-JSON body fails closed.
+- A fetch or document-validation failure is a client error
+  (`invalid_client_metadata`, or `invalid_redirect_uri` for the redirect set),
+  never a fail-open success. A Redis or Postgres outage on the same path is a
+  fail-closed `503`.
+
+Threat notes:
+
+- **Document substitution.** The document must echo the fetched URL in
+  `client_id` byte for byte, so a document hosted at one URL cannot claim to be
+  another. The remaining trust is the origin itself: whoever controls the URL
+  controls the redirect set, scope bound, grant allowlist, and DPoP flag,
+  within the server's validation rules and the enabled scope catalog. Use only
+  URLs whose origin you control; the consent page shows the client and its
+  redirect target before approval.
+- **Cache poisoning window.** A validated document is cached for 15 minutes as
+  `cimd:<sha256(url)>` under the `{state}` keyspace. Only the raw document is
+  cached, and it is revalidated on every read, so a disabled scope or removed
+  catalog entry takes effect on the next request. A malformed cache entry is
+  deleted and treated as a miss. A document changed at its origin can still be
+  served from cache for up to the TTL, because a cache hit performs no fetch.
+  Redis write access is therefore equivalent to control of client metadata for
+  that window; treat Redis as trusted infrastructure exactly as for every other
+  security record.
+- **Inline-only `jwks`.** A CIMD `private_key_jwt` client must carry its key set
+  inline, and any `jwks_uri` member is rejected even alongside an inline
+  `jwks`. Client authentication therefore never triggers a second network
+  fetch on the token or assertion path, which removes a second SSRF surface and
+  a key-substitution race between the fetched document and a separately fetched
+  key set.
+- **No shared secret or mTLS.** Only `none` and `private_key_jwt` are accepted.
+  A remote document cannot hold a shared secret or attest a client certificate,
+  and CIMD §4.1 defines these clients as unable to do so. `private_key_jwt` is
+  authentication only and never a sender constraint.
+- **DPoP and Bearer.** `dpop_bound_access_tokens` absent or `false` produces
+  Bearer tokens; `true` requires a fresh DPoP proof at every gate. A CIMD
+  `none` client is therefore bearer-capable by default, the same as an unbound
+  registered public client, and a stolen Bearer token is usable until it
+  expires or is revoked. The document author chooses the flag, not the
+  operator. PKCE `S256` and exact redirect matching still bound the code path.
+- **No row, no operator controls.** A CIMD client has no `oauth_clients` row,
+  so it cannot be given JWT access tokens (`access_token_format` stays
+  `opaque`), attached to the redirect catalog, or edited through RFC 7592. A
+  CIMD `none` client can revoke its own token but cannot introspect; a CIMD
+  `private_key_jwt` client can do both.
+- **The DCR gate is unchanged.** CIMD adds a resolution fallback only when no
+  registered row exists. `POST /register` still requires a one-time
+  initial-access token unless `allow_open_registration` is set, and that gate
+  has no CIMD branch.
+
 ## Key ring and rotation
 
 - One versioned `KeyRing` holds three independent chains, newest first: the
@@ -200,8 +383,10 @@ Reference: RFC 9126, RFC 9101.
   (`totp rewrap`). See
   [OPERATIONS.md](OPERATIONS.md#pii-and-totp-key-rotation).
 - `Debug` prints key ids only, never key material.
-- There is no authorization-server JWT-signing key and no `jwks_uri`. The
-  `keygen` command emits keys for test fixtures and client-side DPoP only.
+- There is a separate, opt-in JWT access-token signer (`jwt_signing_key` plus
+  `jwt_signing_previous_keys`), not part of the three master chains. The
+  `keygen` command still emits ES256 keys only for test fixtures and
+  client-side DPoP; it does not manage the server's JWT signing key.
 
 ## PII field encryption
 
@@ -325,6 +510,17 @@ configure one as fail-open is a load-time error.
   There are no wildcards and no prefix matches. A private-use scheme must
   contain reverse-DNS dot notation (draft-16 §2.3.1): `com.example.app:/` is
   accepted, `myapp:/` is rejected; `https` and loopback are unaffected.
+- The loopback exception covers the literal hosts `127.0.0.1` and `[::1]`
+  unconditionally. The host `localhost` is covered only when
+  `allow_localhost_redirect` is set (default off). RFC 8252 §8.3 warns that a
+  hostname such as `localhost` is resolution-dependent: `/etc/hosts`, DNS, or
+  a search suffix can point it away from the loopback interface, so enabling
+  the flag widens the exception to whatever `localhost` resolves to on the
+  user agent. It is an explicit operator opt-in, never a default, and it does
+  not change the literal-IP behavior. OAuth clients such as MCP servers that
+  use `http://localhost:{port}/…` redirects rely on this flag; the consent
+  page shows the client and its redirect target, so a user can see where the
+  authorization code will be delivered before approving.
 - PKCE is `S256` only. A verifier cannot be omitted to downgrade.
 - Scope is intersected: `requested ∩ client-registered ∩ user-entitled ∩
   enabled`. Refresh re-checks the current `enabled` catalog, the client's

+ 9 - 3
fuzz/fuzz_targets/redirect_uri.rs

@@ -24,13 +24,19 @@ fuzz_target!(|data: &[u8]| {
     return;
   };
   let registered: Vec<&str> = lines.take(MAX_REGISTERED).collect();
-  let decision = match_redirect_uri(&registered, requested);
+  // Flag off is the default policy; flag on additionally admits the exact
+  // host `localhost` as a loopback host. Both must hold the same invariants.
+  let decision = match_redirect_uri(&registered, requested, false);
+  let localhost_decision = match_redirect_uri(&registered, requested, true);
   // An empty catalog can never match.
   if registered.is_empty() {
     debug_assert_eq!(decision, RedirectDecision::Reject);
+    debug_assert_eq!(localhost_decision, RedirectDecision::Reject);
   }
   // `Exact` fires only for a character-for-character registered member.
-  if decision == RedirectDecision::Exact {
-    debug_assert!(registered.contains(&requested));
+  for decision in [decision, localhost_decision] {
+    if decision == RedirectDecision::Exact {
+      debug_assert!(registered.contains(&requested));
+    }
   }
 });

+ 34 - 0
migrations/1791504000_resource_servers.sql

@@ -0,0 +1,34 @@
+-- RFC 8707 resource indicators and the per-client JWT access-token selector.
+--
+-- Forward-only: deterministic, no DROP / down path. This migration creates
+-- schema ONLY (the `resource_servers` catalog is created EMPTY and populated at
+-- runtime) and adds one additive column to `oauth_clients`.
+--
+-- There is deliberately NO grants/audience column here: grant, access-token,
+-- and refresh-token audience is security-ephemeral and lives in Redis, never in
+-- a Postgres row.
+
+-- Runtime-managed resource-server (resource indicator) catalog. `identifier`
+-- is the RFC 8707 resource identifier (`resource` parameter / `aud` target) and
+-- is globally unique, mirroring the `scopes` / `redirect_uris` catalogs.
+CREATE TABLE resource_servers (
+  id          uuid        NOT NULL,
+  identifier  text        NOT NULL,
+  description text        NOT NULL DEFAULT '',
+  enabled     bool        NOT NULL DEFAULT true,
+  created_at  timestamptz NOT NULL,
+  CONSTRAINT resource_servers_pkey PRIMARY KEY (id),
+  CONSTRAINT ux_resource_servers_identifier UNIQUE (identifier)
+);
+
+-- Per-client access-token format. `opaque` is the OAuth 2.1 baseline and the
+-- default, so every existing client backfills to it (the CHECK applied after
+-- the ADD COLUMN validates the defaulted rows); `jwt` is honored only while JWT
+-- access tokens are enabled by configuration. Additive, so old replicas ignore
+-- the column during the expand/contract rollout.
+ALTER TABLE oauth_clients
+  ADD COLUMN access_token_format text NOT NULL DEFAULT 'opaque';
+
+ALTER TABLE oauth_clients
+  ADD CONSTRAINT ck_oauth_clients_access_token_format
+  CHECK (access_token_format IN ('opaque', 'jwt'));

+ 51 - 0
src/cli.rs

@@ -31,6 +31,7 @@ mod context;
 mod ops;
 mod pii;
 mod redirect;
+mod resource;
 mod scope;
 mod secret;
 mod sync;
@@ -90,6 +91,12 @@ pub enum Command {
     #[command(subcommand)]
     command: RedirectUriCommand,
   },
+  /// Manage the runtime RFC 8707 resource-server (resource indicator) catalog.
+  ResourceServer {
+    /// Resource-server subcommand.
+    #[command(subcommand)]
+    command: ResourceServerCommand,
+  },
   /// Manage per-client redirect-URI attachments.
   Client {
     /// Client subcommand.
@@ -318,6 +325,45 @@ pub enum ClientCommand {
     #[arg(long)]
     client_id: String,
   },
+  /// Set a client's access-token format (operator-only opt-in to JWT).
+  SetAccessTokenFormat {
+    /// OAuth client identifier.
+    #[arg(long)]
+    client_id: String,
+    /// Access-token format: `opaque` (default) or `jwt`.
+    #[arg(long, value_parser = ["opaque", "jwt"])]
+    format: String,
+  },
+}
+
+/// `resource-server` subcommands (RFC 8707 resource indicator catalog).
+#[derive(Debug, Subcommand)]
+pub enum ResourceServerCommand {
+  /// Create a resource server (or update its description when it exists).
+  Create {
+    /// Exact RFC 8707 resource identifier.
+    identifier: String,
+    /// Optional human description.
+    #[arg(long)]
+    description: Option<String>,
+  },
+  /// List the resource-server catalog.
+  List,
+  /// Enable a resource server.
+  Enable {
+    /// Exact RFC 8707 resource identifier.
+    identifier: String,
+  },
+  /// Disable a resource server.
+  Disable {
+    /// Exact RFC 8707 resource identifier.
+    identifier: String,
+  },
+  /// Delete a resource server.
+  Delete {
+    /// Exact RFC 8707 resource identifier.
+    identifier: String,
+  },
 }
 
 /// `config` subcommands.
@@ -446,6 +492,11 @@ pub async fn run(cli: Cli) -> Result<i32, CliError> {
       redirect::run_uri(&context, command).await?;
       Ok(0)
     }
+    Command::ResourceServer { command } => {
+      let context = context::Context::from_env().await?;
+      resource::run(&context, command).await?;
+      Ok(0)
+    }
     Command::Client { command } => {
       let context = context::Context::from_env().await?;
       redirect::run_client(&context, command).await?;

+ 28 - 1
src/cli/redirect.rs

@@ -10,7 +10,7 @@ use super::{
 };
 use crate::{
   crypto::field::blind_index_candidates,
-  db::{NewRedirectUri, RedirectKind, UpsertOutcome, redirect_repo},
+  db::{NewRedirectUri, RedirectKind, UpsertOutcome, client_repo, redirect_repo},
 };
 
 /// Run a `redirect-uri` subcommand.
@@ -56,9 +56,36 @@ pub async fn run_client(context: &Context, command: ClientCommand) -> Result<(),
       kind,
     } => detach(context, &client_id, &uri, &kind).await,
     ClientCommand::RedirectUris { client_id } => list_for_client(context, &client_id).await,
+    ClientCommand::SetAccessTokenFormat { client_id, format } => {
+      set_access_token_format(context, &client_id, &format).await
+    }
   }
 }
 
+/// Set a client's access-token format (`opaque` or `jwt`).
+///
+/// This is the operator-only opt-in to JWT access tokens; dynamic registration
+/// can never set it. The format value is constrained by the CLI and the schema
+/// CHECK constraint.
+async fn set_access_token_format(
+  context: &Context,
+  client_id: &str,
+  format: &str,
+) -> Result<(), CliError> {
+  ensure_client_exists(context, client_id).await?;
+  let changed = client_repo::set_access_token_format(context.pool(), client_id, format).await?;
+  audit(
+    "client.set_access_token_format",
+    &format!("{client_id}:{format}"),
+  );
+  if changed {
+    println!("set access token format of {client_id} to {format}");
+  } else {
+    println!("client {client_id} already uses {format} access tokens");
+  }
+  Ok(())
+}
+
 async fn list(context: &Context) -> Result<(), CliError> {
   let uris = redirect_repo::list(context.pool(), &context.config().keys, true).await?;
   if uris.is_empty() {

+ 169 - 0
src/cli/resource.rs

@@ -0,0 +1,169 @@
+//! `resource-server` subcommands and the shared idempotent catalog upsert.
+//!
+//! The catalog is the RFC 8707 resource-indicator allowlist. Identifiers match
+//! exactly (no normalization, no sub-path, and no wildcard): `identifier` is a
+//! public URI, so it is safe to print, but `*` is rejected as a wildcard.
+
+use sqlx::Row as _;
+
+use super::{
+  ResourceServerCommand,
+  context::{CliError, Context, audit},
+  scope::Upsert,
+};
+use crate::db::resource_repo;
+
+/// Run a `resource-server` subcommand.
+///
+/// # Errors
+/// Returns [`CliError`] for an invalid identifier, a missing row, or a
+/// database failure.
+pub async fn run(context: &Context, command: ResourceServerCommand) -> Result<(), CliError> {
+  match command {
+    ResourceServerCommand::Create {
+      identifier,
+      description,
+    } => {
+      match upsert(context, &identifier, description.as_deref(), true).await? {
+        Upsert::Created => println!("created resource server {identifier}"),
+        Upsert::Updated => println!("updated resource server {identifier}"),
+        Upsert::Unchanged => println!("resource server {identifier} unchanged"),
+      }
+      audit("resource_server.create", &identifier);
+      Ok(())
+    }
+    ResourceServerCommand::List => list(context).await,
+    ResourceServerCommand::Enable { identifier } => set_enabled(context, &identifier, true).await,
+    ResourceServerCommand::Disable { identifier } => set_enabled(context, &identifier, false).await,
+    ResourceServerCommand::Delete { identifier } => delete(context, &identifier).await,
+  }
+}
+
+/// List the resource-server catalog (enabled and disabled).
+async fn list(context: &Context) -> Result<(), CliError> {
+  let rows = sqlx::query(
+    "SELECT identifier, description, enabled FROM resource_servers ORDER BY identifier",
+  )
+  .fetch_all(context.pool())
+  .await?;
+  if rows.is_empty() {
+    println!("resource server catalog is empty");
+    return Ok(());
+  }
+  for row in rows {
+    let identifier: String = row.try_get("identifier")?;
+    let description: String = row.try_get("description")?;
+    let enabled: bool = row.try_get("enabled")?;
+    let state = if enabled { "enabled" } else { "disabled" };
+    println!("{identifier}\t{state}\t{description}");
+  }
+  Ok(())
+}
+
+/// Enable or disable a resource server by its exact identifier.
+async fn set_enabled(context: &Context, identifier: &str, enabled: bool) -> Result<(), CliError> {
+  let affected = if enabled {
+    sqlx::query("UPDATE resource_servers SET enabled = true WHERE identifier = $1")
+      .bind(identifier)
+      .execute(context.pool())
+      .await?
+      .rows_affected()
+  } else {
+    u64::from(resource_repo::disable(context.pool(), identifier).await?)
+  };
+  if affected == 0 {
+    return Err(CliError::NotFound(format!("resource server {identifier}")));
+  }
+  let operation = if enabled {
+    "resource_server.enable"
+  } else {
+    "resource_server.disable"
+  };
+  audit(operation, identifier);
+  println!(
+    "{} resource server {identifier}",
+    if enabled { "enabled" } else { "disabled" }
+  );
+  Ok(())
+}
+
+/// Delete a resource server by its exact identifier.
+async fn delete(context: &Context, identifier: &str) -> Result<(), CliError> {
+  let affected = sqlx::query("DELETE FROM resource_servers WHERE identifier = $1")
+    .bind(identifier)
+    .execute(context.pool())
+    .await?
+    .rows_affected();
+  if affected == 0 {
+    return Err(CliError::NotFound(format!("resource server {identifier}")));
+  }
+  audit("resource_server.delete", identifier);
+  println!("deleted resource server {identifier}");
+  Ok(())
+}
+
+/// 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.
+///
+/// # Errors
+/// Returns [`CliError::Input`] when the identifier is empty or contains `*`.
+pub fn validate(identifier: &str) -> Result<(), CliError> {
+  if identifier.trim().is_empty() {
+    return Err(CliError::Input(
+      "resource identifier must not be empty".to_owned(),
+    ));
+  }
+  if identifier.contains('*') {
+    return Err(CliError::Input(
+      "resource identifier must not contain a wildcard '*'".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// Idempotently create or update a resource server, preserving `enabled`.
+///
+/// A second identical run writes nothing (`Unchanged`), so `config sync` stays
+/// a true no-op; an operator-disabled row is never silently re-enabled.
+///
+/// # Errors
+/// Returns [`CliError`] for an invalid identifier or a database failure.
+pub async fn upsert(
+  context: &Context,
+  identifier: &str,
+  description: Option<&str>,
+  enabled: bool,
+) -> Result<Upsert, CliError> {
+  validate(identifier)?;
+  let description = description.unwrap_or_default();
+  let existing =
+    sqlx::query("SELECT description, enabled FROM resource_servers WHERE identifier = $1")
+      .bind(identifier)
+      .fetch_optional(context.pool())
+      .await?;
+  let Some(row) = existing else {
+    resource_repo::upsert(context.pool(), identifier, description).await?;
+    if !enabled {
+      resource_repo::disable(context.pool(), identifier).await?;
+    }
+    return Ok(Upsert::Created);
+  };
+  let stored_description: String = row.try_get("description")?;
+  let stored_enabled: bool = row.try_get("enabled")?;
+  let mut changed = false;
+  if stored_description != description {
+    resource_repo::upsert(context.pool(), identifier, description).await?;
+    changed = true;
+  }
+  if !enabled && stored_enabled {
+    resource_repo::disable(context.pool(), identifier).await?;
+    changed = true;
+  }
+  Ok(if changed {
+    Upsert::Updated
+  } else {
+    Upsert::Unchanged
+  })
+}

+ 79 - 4
src/cli/sync.rs

@@ -10,9 +10,9 @@ use serde::Deserialize;
 
 use super::{
   context::{CliError, Context, audit},
-  redirect, scope,
+  redirect, resource, scope,
 };
-use crate::db::redirect_repo;
+use crate::db::{client_repo, redirect_repo};
 
 /// Top-level declarative file.
 #[derive(Debug, Default, Deserialize)]
@@ -23,12 +23,18 @@ pub struct SyncFile {
   /// Redirect-URI-catalog entries.
   #[serde(default)]
   pub redirect_uris: Vec<RedirectEntry>,
+  /// Resource-server (RFC 8707 resource indicator) catalog entries.
+  #[serde(default)]
+  pub resource_servers: Vec<ResourceServerEntry>,
   /// Per-user scope entitlements.
   #[serde(default)]
   pub user_scopes: Vec<EntitlementEntry>,
   /// Per-client redirect-URI attachments.
   #[serde(default)]
   pub client_redirect_uris: Vec<AttachmentEntry>,
+  /// Per-client access-token-format overrides (operator-only opt-in to JWT).
+  #[serde(default)]
+  pub client_access_token_formats: Vec<ClientTokenFormatEntry>,
 }
 
 /// A declarative scope.
@@ -69,6 +75,28 @@ pub struct EntitlementEntry {
   pub scope: String,
 }
 
+/// A declarative resource server (RFC 8707 resource indicator).
+#[derive(Debug, Deserialize)]
+pub struct ResourceServerEntry {
+  /// Exact RFC 8707 resource identifier.
+  pub identifier: String,
+  /// Optional description.
+  #[serde(default)]
+  pub description: Option<String>,
+  /// Whether the resource server is enabled (default true).
+  #[serde(default = "default_true")]
+  pub enabled: bool,
+}
+
+/// A declarative per-client access-token format.
+#[derive(Debug, Deserialize)]
+pub struct ClientTokenFormatEntry {
+  /// OAuth client identifier.
+  pub client_id: String,
+  /// Access-token format: `opaque` (default) or `jwt`.
+  pub access_token_format: String,
+}
+
 /// A declarative client attachment.
 #[derive(Debug, Deserialize)]
 pub struct AttachmentEntry {
@@ -93,10 +121,16 @@ pub struct SyncReport {
   pub redirects_created: u64,
   /// Redirect URIs updated.
   pub redirects_updated: u64,
+  /// Resource servers inserted.
+  pub resource_servers_created: u64,
+  /// Resource servers updated.
+  pub resource_servers_updated: u64,
   /// Entitlements inserted.
   pub entitlements_added: u64,
   /// Attachments inserted.
   pub attachments_added: u64,
+  /// Client access-token formats changed.
+  pub client_formats_set: u64,
 }
 
 impl SyncReport {
@@ -107,8 +141,11 @@ impl SyncReport {
       && self.scopes_updated == 0
       && self.redirects_created == 0
       && self.redirects_updated == 0
+      && self.resource_servers_created == 0
+      && self.resource_servers_updated == 0
       && self.entitlements_added == 0
       && self.attachments_added == 0
+      && self.client_formats_set == 0
   }
 }
 
@@ -116,13 +153,17 @@ impl fmt::Display for SyncReport {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     write!(
       f,
-      "config sync: scopes +{} ~{}; redirects +{} ~{}; entitlements +{}; attachments +{}",
+      "config sync: scopes +{} ~{}; redirects +{} ~{}; resource_servers +{} ~{}; \
+       entitlements +{}; attachments +{}; client_formats {}",
       self.scopes_created,
       self.scopes_updated,
       self.redirects_created,
       self.redirects_updated,
+      self.resource_servers_created,
+      self.resource_servers_updated,
       self.entitlements_added,
-      self.attachments_added
+      self.attachments_added,
+      self.client_formats_set
     )
   }
 }
@@ -180,6 +221,21 @@ pub async fn apply(context: &Context, file: &SyncFile) -> Result<SyncReport, Cli
     }
   }
 
+  for entry in &file.resource_servers {
+    match resource::upsert(
+      context,
+      &entry.identifier,
+      entry.description.as_deref(),
+      entry.enabled,
+    )
+    .await?
+    {
+      scope::Upsert::Created => report.resource_servers_created += 1,
+      scope::Upsert::Updated => report.resource_servers_updated += 1,
+      scope::Upsert::Unchanged => {}
+    }
+  }
+
   for entry in &file.user_scopes {
     let user_id = super::user::find_user_id(context, &entry.email).await?;
     report.entitlements_added += sqlx::query(
@@ -201,6 +257,25 @@ pub async fn apply(context: &Context, file: &SyncFile) -> Result<SyncReport, Cli
       u64::from(redirect_repo::attach(context.pool(), &entry.client_id, redirect_id, None).await?);
   }
 
+  for entry in &file.client_access_token_formats {
+    if !matches!(entry.access_token_format.as_str(), "opaque" | "jwt") {
+      return Err(CliError::Input(format!(
+        "invalid access_token_format {} for client {}",
+        entry.access_token_format, entry.client_id
+      )));
+    }
+    redirect::ensure_client_exists(context, &entry.client_id).await?;
+    if client_repo::set_access_token_format(
+      context.pool(),
+      &entry.client_id,
+      &entry.access_token_format,
+    )
+    .await?
+    {
+      report.client_formats_set += 1;
+    }
+  }
+
   audit("config.sync", &report.to_string());
   Ok(report)
 }

+ 183 - 4
src/cli/tests.rs

@@ -15,16 +15,16 @@ use sqlx::Row as _;
 use time::OffsetDateTime;
 
 use super::{
-  Cli, ClientCommand, Command, RedirectUriCommand, ScopeCommand, UserCommand, admin, ops, redirect,
-  scope, secret,
-  sync::{RedirectEntry, ScopeEntry, SyncFile},
+  Cli, CliError, ClientCommand, Command, RedirectUriCommand, ResourceServerCommand, ScopeCommand,
+  UserCommand, admin, ops, redirect, resource, scope, secret,
+  sync::{ClientTokenFormatEntry, RedirectEntry, ResourceServerEntry, ScopeEntry, SyncFile},
   test_support::{self, TestError},
   user,
 };
 use crate::{
   config::Config,
   crypto::totp,
-  db::{client_repo, redirect_repo, user_repo},
+  db::{client_repo, redirect_repo, resource_repo, user_repo},
   keys::{KeyId, KeyRing},
   state::{AppState, RedisConn},
   ui::login::{
@@ -491,6 +491,8 @@ async fn cli_config_sync_is_idempotent() -> Result<(), TestError> {
     }],
     user_scopes: Vec::new(),
     client_redirect_uris: Vec::new(),
+    resource_servers: Vec::new(),
+    client_access_token_formats: Vec::new(),
   };
 
   let first = super::sync::apply(&context, &file).await?;
@@ -541,6 +543,7 @@ async fn insert_redirect_client(
       contacts: None,
       software_id: None,
       dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
       registration_access_token_hash: None,
     },
   )
@@ -566,6 +569,8 @@ async fn config_sync_redirects() -> Result<(), TestError> {
     }],
     user_scopes: Vec::new(),
     client_redirect_uris: Vec::new(),
+    resource_servers: Vec::new(),
+    client_access_token_formats: Vec::new(),
   };
 
   let first = super::sync::apply(&context, &file).await?;
@@ -1837,3 +1842,177 @@ fn cli_password_file_trims_trailing_newline() -> Result<(), TestError> {
   );
   Ok(())
 }
+
+/// `resource-server` create/list/enable/disable/delete round-trips, rejects a
+/// wildcard identifier, and re-applies a `config sync` `resource_servers`
+/// array idempotently.
+#[tokio::test]
+async fn cli_resource_server_create() -> Result<(), TestError> {
+  let (_harness, context) = test_support::context().await?;
+  let identifier = format!("https://api-{}.example.test", uuid::Uuid::new_v4());
+
+  resource::run(
+    &context,
+    ResourceServerCommand::Create {
+      identifier: identifier.clone(),
+      description: Some("orders API".to_owned()),
+    },
+  )
+  .await?;
+  assert!(
+    resource_repo::find_enabled(context.pool(), &identifier)
+      .await?
+      .is_some(),
+    "a created resource server is enabled"
+  );
+
+  let wildcard = resource::run(
+    &context,
+    ResourceServerCommand::Create {
+      identifier: "https://*.example.test".to_owned(),
+      description: None,
+    },
+  )
+  .await;
+  assert!(
+    matches!(wildcard, Err(CliError::Input(_))),
+    "a wildcard identifier must be rejected"
+  );
+
+  resource::run(
+    &context,
+    ResourceServerCommand::Disable {
+      identifier: identifier.clone(),
+    },
+  )
+  .await?;
+  assert!(
+    resource_repo::find_enabled(context.pool(), &identifier)
+      .await?
+      .is_none(),
+    "a disabled resource server is not enabled"
+  );
+  resource::run(
+    &context,
+    ResourceServerCommand::Enable {
+      identifier: identifier.clone(),
+    },
+  )
+  .await?;
+
+  let synced = format!("https://synced-{}.example.test", uuid::Uuid::new_v4());
+  let file = SyncFile {
+    resource_servers: vec![ResourceServerEntry {
+      identifier: synced.clone(),
+      description: Some("synced".to_owned()),
+      enabled: true,
+    }],
+    ..SyncFile::default()
+  };
+  let first = super::sync::apply(&context, &file).await?;
+  assert_eq!(first.resource_servers_created, 1);
+  let second = super::sync::apply(&context, &file).await?;
+  assert!(second.is_noop(), "second sync must be a no-op: {second}");
+
+  resource::run(
+    &context,
+    ResourceServerCommand::Delete {
+      identifier: identifier.clone(),
+    },
+  )
+  .await?;
+  assert!(
+    resource_repo::find_enabled(context.pool(), &identifier)
+      .await?
+      .is_none(),
+    "a deleted resource server is gone"
+  );
+  Ok(())
+}
+
+/// `client set-access-token-format` writes `jwt` (operator opt-in), reads it
+/// back through the repository projection, rejects an unknown client, and
+/// refuses an invalid `--format` value at parse time.
+#[tokio::test]
+async fn cli_client_set_access_token_format() -> Result<(), TestError> {
+  let (_harness, context) = test_support::context().await?;
+  let client_id = format!("fmt-client-{}", uuid::Uuid::new_v4());
+  insert_redirect_client(context.pool(), &context.config().keys, &client_id).await?;
+
+  let initial: String =
+    sqlx::query_scalar("SELECT access_token_format FROM oauth_clients WHERE client_id = $1")
+      .bind(&client_id)
+      .fetch_one(context.pool())
+      .await?;
+  assert_eq!(initial, "opaque", "a new client defaults to opaque");
+
+  redirect::run_client(
+    &context,
+    ClientCommand::SetAccessTokenFormat {
+      client_id: client_id.clone(),
+      format: "jwt".to_owned(),
+    },
+  )
+  .await?;
+
+  let stored: String =
+    sqlx::query_scalar("SELECT access_token_format FROM oauth_clients WHERE client_id = $1")
+      .bind(&client_id)
+      .fetch_one(context.pool())
+      .await?;
+  assert_eq!(stored, "jwt");
+
+  let client =
+    client_repo::find_active_by_client_id(context.pool(), &context.config().keys, &client_id)
+      .await?
+      .ok_or(TestError::Missing("client after format change"))?;
+  assert_eq!(client.access_token_format, "jwt", "read-back must be jwt");
+
+  let second = format!("fmt-sync-{}", uuid::Uuid::new_v4());
+  insert_redirect_client(context.pool(), &context.config().keys, &second).await?;
+  let file = SyncFile {
+    client_access_token_formats: vec![ClientTokenFormatEntry {
+      client_id: second.clone(),
+      access_token_format: "jwt".to_owned(),
+    }],
+    ..SyncFile::default()
+  };
+  let first = super::sync::apply(&context, &file).await?;
+  assert_eq!(first.client_formats_set, 1);
+  let reapply = super::sync::apply(&context, &file).await?;
+  assert!(reapply.is_noop(), "second sync must be a no-op: {reapply}");
+  let synced: String =
+    sqlx::query_scalar("SELECT access_token_format FROM oauth_clients WHERE client_id = $1")
+      .bind(&second)
+      .fetch_one(context.pool())
+      .await?;
+  assert_eq!(synced, "jwt", "sync must apply the declared format");
+
+  let unknown = redirect::run_client(
+    &context,
+    ClientCommand::SetAccessTokenFormat {
+      client_id: "no-such-client".to_owned(),
+      format: "opaque".to_owned(),
+    },
+  )
+  .await;
+  assert!(
+    matches!(unknown, Err(CliError::NotFound(_))),
+    "an unknown client is not found"
+  );
+
+  let parsed = Cli::try_parse_from([
+    "d-oauth-server",
+    "client",
+    "set-access-token-format",
+    "--client-id",
+    &client_id,
+    "--format",
+    "bogus",
+  ]);
+  assert!(
+    parsed.is_err(),
+    "an invalid --format value must be rejected"
+  );
+  Ok(())
+}

+ 72 - 2
src/config.rs

@@ -28,6 +28,7 @@ use crate::keys::KeyRing;
 
 mod build;
 pub mod db;
+mod jwt;
 pub mod policy;
 mod raw;
 pub mod redis;
@@ -36,10 +37,10 @@ pub mod scope;
 mod tests;
 pub mod types;
 
-use raw::RawConfig;
-
 pub use db::DbConfig;
+pub use jwt::{JwtPublicJwk, JwtSigningAlg, JwtSigningKey};
 pub use policy::{FailMode, FailurePolicy, RetryableClass};
+use raw::RawConfig;
 pub use redis::{DurabilityMode, RedisConfig, RedisTopology};
 pub use scope::{ClientClass, GrantKind, ScopeDefinition, TokenExchangeRule};
 pub use types::{
@@ -107,6 +108,29 @@ pub struct Config {
   pub allow_token_exchange: bool,
   /// Explicit `(caller, subject, scopes)` allowlist for token exchange.
   pub token_exchange_allowlist: Vec<TokenExchangeRule>,
+  /// Whether RFC 8707 `resource` indicators are honored (default false).
+  pub resource_indicators_enabled: bool,
+  /// Whether Client ID Metadata Document lookup is enabled (default false).
+  pub cimd_enabled: bool,
+  /// Whether RFC 9728 `protected_resources` metadata is emitted (default false).
+  pub protected_resources_enabled: bool,
+  /// Explicit protected-resource identifiers advertised in metadata.
+  pub protected_resource_ids: Vec<String>,
+  /// Whether the RFC 8252 host `localhost` loopback redirect is accepted
+  /// (default false; `127.0.0.1`/`[::1]` remain loopback regardless).
+  pub allow_localhost_redirect: bool,
+  /// Global kill-switch for JWT access tokens (default false).
+  pub jwt_access_tokens_enabled: bool,
+  /// Active JWT signing key (absent unless configured).
+  pub jwt_signing_key: Option<JwtSigningKey>,
+  /// Rotated-out JWT signing keys retained for verification only.
+  pub jwt_signing_previous_keys: Vec<JwtSigningKey>,
+  /// Algorithm used to sign JWT access tokens (default `ES256`).
+  pub jwt_access_token_signing_alg: JwtSigningAlg,
+  /// CIMD document fetch timeout (default 5 s, allowed 1-10 s).
+  pub cimd_fetch_timeout: std::time::Duration,
+  /// CIMD document byte cap (default 5120, allowed 1-5120).
+  pub cimd_max_bytes: usize,
 }
 
 impl fmt::Debug for Config {
@@ -136,6 +160,29 @@ impl fmt::Debug for Config {
         "token_exchange_allowlist_len",
         &self.token_exchange_allowlist.len(),
       )
+      .field(
+        "resource_indicators_enabled",
+        &self.resource_indicators_enabled,
+      )
+      .field("cimd_enabled", &self.cimd_enabled)
+      .field(
+        "protected_resources_enabled",
+        &self.protected_resources_enabled,
+      )
+      .field("protected_resource_ids", &self.protected_resource_ids)
+      .field("allow_localhost_redirect", &self.allow_localhost_redirect)
+      .field("jwt_access_tokens_enabled", &self.jwt_access_tokens_enabled)
+      .field("jwt_signing_key", &self.jwt_signing_key)
+      .field(
+        "jwt_signing_previous_keys_len",
+        &self.jwt_signing_previous_keys.len(),
+      )
+      .field(
+        "jwt_access_token_signing_alg",
+        &self.jwt_access_token_signing_alg,
+      )
+      .field("cimd_fetch_timeout", &self.cimd_fetch_timeout)
+      .field("cimd_max_bytes", &self.cimd_max_bytes)
       .finish_non_exhaustive()
   }
 }
@@ -358,6 +405,29 @@ pub enum ConfigError {
     /// The configuration key that carried the weak material.
     key: &'static str,
   },
+  /// A configured JWT signing key could not be turned into a usable JOSE
+  /// signing key (all-zero or out-of-range EC scalar, malformed material).
+  #[error("invalid JWT signing key material for {key}")]
+  InvalidJwtSigningKey {
+    /// The configuration key that carried the invalid material.
+    key: &'static str,
+  },
+  /// JWT access tokens were enabled (or previous keys given) without an
+  /// active signing key.
+  #[error("jwt_access_tokens_enabled requires jwt_signing_key")]
+  MissingJwtSigningKey,
+  /// A `jwt_signing_previous_keys` entry reused an active or previous kid.
+  #[error("duplicate JWT signing key id: {kid}")]
+  DuplicateJwtSigningKid {
+    /// The duplicated key id.
+    kid: String,
+  },
+  /// The CIMD fetch timeout was outside the 1-10 second window.
+  #[error("cimd_fetch_timeout_secs must be between 1 and 10 seconds")]
+  InvalidCimdFetchTimeout,
+  /// The CIMD document byte cap was zero or above 5120 bytes.
+  #[error("cimd_max_bytes must be between 1 and 5120 bytes")]
+  InvalidCimdMaxBytes,
   /// Argon2 parameters were outside the safe range.
   #[error("invalid argon2 parameters")]
   InvalidArgon2Params,

+ 112 - 1
src/config/build.rs

@@ -3,13 +3,14 @@
 //! Kept in a submodule so the root `config.rs` stays focused on the public
 //! types and loader surface.
 
-use std::{net::SocketAddr, path::Path, str::FromStr};
+use std::{net::SocketAddr, path::Path, str::FromStr, time::Duration};
 
 use secrecy::{ExposeSecret, SecretString};
 
 use super::{
   Config, ConfigError, TlsConfig,
   db::DbConfig,
+  jwt::{JWT_KEY_BYTES, JwtSigningKey},
   policy::FailurePolicy,
   raw::{RawConfig, RawTls},
   read_secret_file,
@@ -36,6 +37,9 @@ impl Config {
       })?;
     let tls = TlsConfig::from_raw(&raw.tls)?;
     let keys = build_key_ring(&raw)?;
+    let (jwt_signing_key, jwt_signing_previous_keys) = build_jwt_signing(&raw)?;
+    let cimd_fetch_timeout = validate_cimd_fetch_timeout(raw.cimd_fetch_timeout_secs)?;
+    let cimd_max_bytes = validate_cimd_max_bytes(raw.cimd_max_bytes)?;
     let database = DbConfig::from_raw(raw.database, environment)?;
     let redis = RedisConfig::from_raw(raw.redis)?;
     let failure_policy = FailurePolicy::from_raw(raw.failure_policy)?;
@@ -81,6 +85,17 @@ impl Config {
       scope_registry,
       allow_token_exchange: raw.allow_token_exchange,
       token_exchange_allowlist,
+      resource_indicators_enabled: raw.resource_indicators_enabled,
+      cimd_enabled: raw.cimd_enabled,
+      protected_resources_enabled: raw.protected_resources_enabled,
+      protected_resource_ids: raw.protected_resource_ids,
+      allow_localhost_redirect: raw.allow_localhost_redirect,
+      jwt_access_tokens_enabled: raw.jwt_access_tokens_enabled,
+      jwt_signing_key,
+      jwt_signing_previous_keys,
+      jwt_access_token_signing_alg: raw.jwt_access_token_signing_alg,
+      cimd_fetch_timeout,
+      cimd_max_bytes,
     })
   }
 }
@@ -214,6 +229,102 @@ fn build_key_ring(raw: &RawConfig) -> Result<KeyRing, ConfigError> {
   KeyRing::from_parts(peppers, totp_keys, pii_keys).map_err(ConfigError::from)
 }
 
+/// Require the JWT signing key to be exactly [`JWT_KEY_BYTES`] bytes: short
+/// material cannot form a key and over-long material is refused (the pepper/
+/// TOTP/PII chains enforce only a minimum).
+fn require_exact_jwt_key_bytes(
+  secret: &SecretString,
+  key: &'static str,
+) -> Result<(), ConfigError> {
+  if secret.expose_secret().len() == JWT_KEY_BYTES {
+    Ok(())
+  } else {
+    Err(ConfigError::WeakKeyMaterial { key })
+  }
+}
+
+/// Build the optional active JWT signing key and its previous (verify-only)
+/// keys, validating length, kid uniqueness, and JOSE construction at load.
+fn build_jwt_signing(
+  raw: &RawConfig,
+) -> Result<(Option<JwtSigningKey>, Vec<JwtSigningKey>), ConfigError> {
+  let alg = raw.jwt_access_token_signing_alg;
+  let current = match non_blank(resolve_secret(
+    raw.jwt_signing_key.as_deref(),
+    raw.jwt_signing_key_file.as_deref(),
+  )?) {
+    Some(secret) => {
+      require_exact_jwt_key_bytes(&secret, "jwt_signing_key")?;
+      let kid = key_id_or_default(raw.jwt_signing_kid.as_deref())?;
+      Some(JwtSigningKey::from_raw(
+        kid,
+        alg,
+        &secret,
+        "jwt_signing_key",
+      )?)
+    }
+    None => None,
+  };
+  let mut previous = Vec::with_capacity(raw.jwt_signing_previous_keys.len());
+  for entry in &raw.jwt_signing_previous_keys {
+    let id_raw = entry
+      .kid
+      .as_deref()
+      .map(str::trim)
+      .filter(|value| !value.is_empty())
+      .ok_or(ConfigError::MissingPreviousKeyId)?;
+    let kid = validate_key_id(id_raw)?;
+    let secret = entry
+      .key
+      .as_deref()
+      .map(str::trim)
+      .filter(|value| !value.is_empty())
+      .ok_or(ConfigError::InvalidJwtSigningKey {
+        key: "jwt_signing_previous_keys",
+      })?;
+    let secret = SecretString::from(secret.to_owned());
+    require_exact_jwt_key_bytes(&secret, "jwt_signing_previous_keys")?;
+    previous.push(JwtSigningKey::from_raw(
+      kid,
+      alg,
+      &secret,
+      "jwt_signing_previous_keys",
+    )?);
+  }
+  if (raw.jwt_access_tokens_enabled || !previous.is_empty()) && current.is_none() {
+    return Err(ConfigError::MissingJwtSigningKey);
+  }
+  let active_kid = current.as_ref().map(|key| key.kid().clone());
+  for (index, key) in previous.iter().enumerate() {
+    let clashes_active = active_kid.as_ref().is_some_and(|id| id == key.kid());
+    let clashes_previous = previous[..index].iter().any(|seen| seen.kid() == key.kid());
+    if clashes_active || clashes_previous {
+      return Err(ConfigError::DuplicateJwtSigningKid {
+        kid: key.kid().as_str().to_owned(),
+      });
+    }
+  }
+  Ok((current, previous))
+}
+
+/// CIMD fetch timeout must sit inside the 1-10 second window.
+fn validate_cimd_fetch_timeout(secs: u64) -> Result<Duration, ConfigError> {
+  if (1..=10).contains(&secs) {
+    Ok(Duration::from_secs(secs))
+  } else {
+    Err(ConfigError::InvalidCimdFetchTimeout)
+  }
+}
+
+/// CIMD document byte cap must be positive and at most 5120 bytes.
+fn validate_cimd_max_bytes(bytes: usize) -> Result<usize, ConfigError> {
+  if (1..=5_120).contains(&bytes) {
+    Ok(bytes)
+  } else {
+    Err(ConfigError::InvalidCimdMaxBytes)
+  }
+}
+
 /// Build an active-first chain from the active key plus its retained previous
 /// key. A missing active key with no previous key yields an empty chain (the
 /// caller maps it to `missing_active`); a previous key without its active key

+ 234 - 0
src/config/jwt.rs

@@ -0,0 +1,234 @@
+//! JWT access-token signing configuration (`jwt_access_tokens_enabled`).
+//!
+//! The feature is a global kill-switch that defaults off. When a key is
+//! configured it must be exactly 32 bytes of raw material and is turned into a
+//! real JOSE signing key at load time: an out-of-range EC scalar fails at boot,
+//! not at first mint. Key material never appears in `Debug`.
+
+use std::fmt;
+
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, encode};
+use secrecy::{ExposeSecret, SecretString};
+use serde::Deserialize;
+
+use super::ConfigError;
+use crate::keys::KeyId;
+
+/// Exactly 32 bytes of raw key material: `ES256` scalar or `Ed25519` seed.
+pub(crate) const JWT_KEY_BYTES: usize = 32;
+
+/// Allowed JWT access-token signing algorithms.
+///
+/// Both use exactly-32-byte raw key material. RSA is deliberately absent, so
+/// there is no `RS256` branch and no PEM/modulus handling here.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
+pub enum JwtSigningAlg {
+  /// ECDSA P-256 with SHA-256.
+  #[default]
+  #[serde(rename = "ES256")]
+  Es256,
+  /// Ed25519.
+  #[serde(rename = "EdDSA")]
+  EdDsa,
+}
+
+impl JwtSigningAlg {
+  /// The JOSE `alg` name advertised in a public JWK (RFC 7517 §4.4).
+  #[must_use]
+  pub(crate) const fn as_str(self) -> &'static str {
+    match self {
+      Self::Es256 => "ES256",
+      Self::EdDsa => "EdDSA",
+    }
+  }
+
+  /// The `jsonwebtoken` algorithm this maps to.
+  #[must_use]
+  pub(crate) fn algorithm(self) -> Algorithm {
+    match self {
+      Self::Es256 => Algorithm::ES256,
+      Self::EdDsa => Algorithm::EdDSA,
+    }
+  }
+}
+
+/// Public JWK coordinates of a [`JwtSigningKey`].
+///
+/// Derived from the same 32-byte raw material as the signing key, so the
+/// `jwks_uri` document (task 28) can advertise exactly what this key verifies
+/// with. Coordinates are public by construction and safe to log.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct JwtPublicJwk {
+  /// JWK `kty`: `"EC"` for P-256, `"OKP"` for Ed25519.
+  pub kty: &'static str,
+  /// JWK `crv`: `"P-256"` for P-256, `"Ed25519"` for Ed25519.
+  pub crv: &'static str,
+  /// Base64url-no-pad `x` coordinate (the Ed25519 public key).
+  pub x: String,
+  /// Base64url-no-pad `y` coordinate; `None` for `OKP` keys.
+  pub y: Option<String>,
+}
+
+/// A validated JOSE signing key bound to a [`KeyId`].
+///
+/// The previous (rotated-out) keys use the same shape but are only ever used
+/// to verify. No key material is printable.
+#[derive(Clone)]
+pub struct JwtSigningKey {
+  kid: KeyId,
+  alg: JwtSigningAlg,
+  key: EncodingKey,
+  decoding: DecodingKey,
+  public: JwtPublicJwk,
+}
+
+impl JwtSigningKey {
+  /// Build and validate a signing key from exactly 32 bytes of raw material.
+  ///
+  /// The raw bytes become a PKCS#8 document (P-256 scalar or Ed25519 seed) and
+  /// a live [`EncodingKey`]. A probe signature is produced once so an
+  /// out-of-range EC scalar or malformed material fails at load.
+  ///
+  /// # Errors
+  /// Returns [`ConfigError::InvalidJwtSigningKey`] when the material cannot
+  /// sign under `alg`.
+  pub(crate) fn from_raw(
+    kid: KeyId,
+    alg: JwtSigningAlg,
+    secret: &SecretString,
+    field: &'static str,
+  ) -> Result<Self, ConfigError> {
+    let raw = secret.expose_secret().as_bytes();
+    let der = match alg {
+      JwtSigningAlg::Es256 => p256_pkcs8(raw),
+      JwtSigningAlg::EdDsa => ed25519_pkcs8(raw),
+    };
+    let key = match alg {
+      JwtSigningAlg::Es256 => EncodingKey::from_ec_der(&der),
+      JwtSigningAlg::EdDsa => EncodingKey::from_ed_der(&der),
+    };
+    encode(&Header::new(alg.algorithm()), &serde_json::json!({}), &key)
+      .map_err(|_| ConfigError::InvalidJwtSigningKey { key: field })?;
+    let (decoding, public) =
+      derive_public(alg, raw).map_err(|()| ConfigError::InvalidJwtSigningKey { key: field })?;
+    Ok(Self {
+      kid,
+      alg,
+      key,
+      decoding,
+      public,
+    })
+  }
+
+  /// The key id this key is stored under.
+  #[must_use]
+  pub fn kid(&self) -> &KeyId {
+    &self.kid
+  }
+
+  /// The algorithm this key signs/verifies under.
+  #[must_use]
+  pub fn alg(&self) -> JwtSigningAlg {
+    self.alg
+  }
+
+  /// The underlying JOSE signing key.
+  #[must_use]
+  pub fn encoding_key(&self) -> &EncodingKey {
+    &self.key
+  }
+
+  /// The JOSE verification key for the public half of this key.
+  #[must_use]
+  pub fn decoding_key(&self) -> &DecodingKey {
+    &self.decoding
+  }
+
+  /// The public JWK coordinates of this key (safe to advertise and log).
+  #[must_use]
+  pub fn public_jwk(&self) -> &JwtPublicJwk {
+    &self.public
+  }
+}
+
+impl fmt::Debug for JwtSigningKey {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("JwtSigningKey")
+      .field("kid", &self.kid)
+      .field("alg", &self.alg)
+      .field("key", &"[REDACTED]")
+      .finish()
+  }
+}
+
+/// Derive the public verification key and JWK coordinates from the same
+/// 32-byte raw material that built the signing key (`p256` scalar or
+/// Ed25519 seed). The caller guarantees the length; the curve rejects an
+/// out-of-range scalar.
+fn derive_public(alg: JwtSigningAlg, raw: &[u8]) -> Result<(DecodingKey, JwtPublicJwk), ()> {
+  match alg {
+    JwtSigningAlg::Es256 => {
+      use p256::elliptic_curve::sec1::ToEncodedPoint as _;
+      let secret = p256::SecretKey::from_slice(raw).map_err(|_| ())?;
+      let point = secret.public_key().to_encoded_point(false);
+      let x = URL_SAFE_NO_PAD.encode(point.x().ok_or(())?);
+      let y = URL_SAFE_NO_PAD.encode(point.y().ok_or(())?);
+      let decoding = DecodingKey::from_ec_components(&x, &y).map_err(|_| ())?;
+      let public = JwtPublicJwk {
+        kty: "EC",
+        crv: "P-256",
+        x,
+        y: Some(y),
+      };
+      Ok((decoding, public))
+    }
+    JwtSigningAlg::EdDsa => {
+      let seed: [u8; JWT_KEY_BYTES] = raw.try_into().map_err(|_| ())?;
+      let signing = ed25519_dalek::SigningKey::from_bytes(&seed);
+      let x = URL_SAFE_NO_PAD.encode(signing.verifying_key().to_bytes());
+      let decoding = DecodingKey::from_ed_components(&x).map_err(|_| ())?;
+      let public = JwtPublicJwk {
+        kty: "OKP",
+        crv: "Ed25519",
+        x,
+        y: None,
+      };
+      Ok((decoding, public))
+    }
+  }
+}
+
+/// Wrap a 32-byte P-256 scalar in an unencrypted PKCS#8 v1 document.
+fn p256_pkcs8(scalar: &[u8]) -> Vec<u8> {
+  const PREFIX: [u8; 35] = [
+    0x30, 0x41, // SEQUENCE (PrivateKeyInfo)
+    0x02, 0x01, 0x00, // version 0
+    0x30, 0x13, // AlgorithmIdentifier
+    0x06, 0x07, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, // id-ecPublicKey
+    0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, // prime256v1
+    0x04, 0x27, // privateKey OCTET STRING (39 bytes)
+    0x30, 0x25, // ECPrivateKey SEQUENCE (37 bytes)
+    0x02, 0x01, 0x01, // version 1
+    0x04, 0x20, // privateKey OCTET STRING (32 bytes)
+  ];
+  let mut der = Vec::with_capacity(PREFIX.len() + scalar.len());
+  der.extend_from_slice(&PREFIX);
+  der.extend_from_slice(scalar);
+  der
+}
+
+/// Wrap a 32-byte Ed25519 seed in an unencrypted PKCS#8 v1 document (RFC 8410).
+fn ed25519_pkcs8(seed: &[u8]) -> Vec<u8> {
+  const PREFIX: [u8; 16] = [
+    0x30, 0x2e, // SEQUENCE (PrivateKeyInfo)
+    0x02, 0x01, 0x00, // version 0
+    0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, // id-Ed25519
+    0x04, 0x22, // privateKey OCTET STRING (34 bytes)
+    0x04, 0x20, // inner seed OCTET STRING (32 bytes)
+  ];
+  let mut der = Vec::with_capacity(PREFIX.len() + seed.len());
+  der.extend_from_slice(&PREFIX);
+  der.extend_from_slice(seed);
+  der
+}

+ 34 - 0
src/config/raw.rs

@@ -9,12 +9,21 @@ use serde::Deserialize;
 
 use super::{
   db::RawDbConfig,
+  jwt::JwtSigningAlg,
   policy::RawFailurePolicy,
   redis::RawRedisConfig,
   scope::{RawScopeDefinition, RawTokenExchangeRule},
   types::{DcrRedirectPolicy, Environment, RawArgon2, RawRateLimit, RawTtlConfig},
 };
 
+/// Raw `{kid, key}` entry of `jwt_signing_previous_keys`.
+#[derive(Debug, Clone, Default, Deserialize)]
+#[serde(default)]
+pub(crate) struct RawJwtSigningKey {
+  pub kid: Option<String>,
+  pub key: Option<String>,
+}
+
 /// Raw `tls` block.
 #[derive(Debug, Clone, Default, Deserialize)]
 #[serde(default)]
@@ -67,4 +76,29 @@ pub(crate) struct RawConfig {
   pub scope_registry: Vec<RawScopeDefinition>,
   pub allow_token_exchange: bool,
   pub token_exchange_allowlist: Vec<RawTokenExchangeRule>,
+  pub resource_indicators_enabled: bool,
+  pub cimd_enabled: bool,
+  pub protected_resources_enabled: bool,
+  #[serde(default, deserialize_with = "crate::config::deserialize_string_or_vec")]
+  pub protected_resource_ids: Vec<String>,
+  pub allow_localhost_redirect: bool,
+  pub jwt_access_tokens_enabled: bool,
+  pub jwt_signing_key: Option<String>,
+  pub jwt_signing_key_file: Option<PathBuf>,
+  pub jwt_signing_kid: Option<String>,
+  pub jwt_signing_previous_keys: Vec<RawJwtSigningKey>,
+  #[serde(default)]
+  pub jwt_access_token_signing_alg: JwtSigningAlg,
+  #[serde(default = "default_cimd_fetch_timeout_secs")]
+  pub cimd_fetch_timeout_secs: u64,
+  #[serde(default = "default_cimd_max_bytes")]
+  pub cimd_max_bytes: usize,
+}
+
+fn default_cimd_fetch_timeout_secs() -> u64 {
+  5
+}
+
+fn default_cimd_max_bytes() -> usize {
+  5_120
 }

+ 209 - 0
src/config/tests.rs

@@ -661,6 +661,215 @@ fn read_secret_file_preserves_surrounding_spaces() -> Result<(), Box<dyn Error>>
 
 /// The trusted-proxy allowlist defaults to empty: headers are never honored
 /// unless the operator configures the proxy peers explicitly.
+/// A raw 32-byte ES256/EdDSA key with a deterministic, non-zero, in-range
+/// scalar (printable ASCII keeps the helper representable as a JSON string).
+fn jwt_key(seed: u8) -> String {
+  (0..32_u8)
+    .map(|index| char::from(b'a' + ((seed.wrapping_add(index)) % 26)))
+    .collect()
+}
+
+/// All optional feature flags are off by default: adding the keys must not
+/// change any default-on behavior.
+#[test]
+fn config_jwt_flags_default_off() -> Result<(), Box<dyn Error>> {
+  let cfg = try_load(&base())?;
+  assert!(!cfg.resource_indicators_enabled);
+  assert!(!cfg.cimd_enabled);
+  assert!(!cfg.protected_resources_enabled);
+  assert!(!cfg.jwt_access_tokens_enabled);
+  assert!(!cfg.allow_localhost_redirect);
+  assert!(cfg.protected_resource_ids.is_empty());
+  assert!(cfg.jwt_signing_key.is_none());
+  assert!(cfg.jwt_signing_previous_keys.is_empty());
+  assert_eq!(
+    cfg.jwt_access_token_signing_alg,
+    super::JwtSigningAlg::Es256
+  );
+  assert_eq!(cfg.cimd_fetch_timeout, Duration::from_secs(5));
+  assert_eq!(cfg.cimd_max_bytes, 5_120);
+  Ok(())
+}
+
+/// C5b: the `localhost` loopback exception is gated by a flag that defaults
+/// off, so `http://localhost` redirects stay rejected unless opted in.
+#[test]
+fn config_localhost_redirect_defaults_off() -> Result<(), Box<dyn Error>> {
+  let cfg = try_load(&base())?;
+  assert!(!cfg.allow_localhost_redirect);
+  Ok(())
+}
+
+/// A JWT signing key that is not exactly 32 bytes is rejected at load with the
+/// offending key named — both short and over-long material.
+#[test]
+fn config_rejects_weak_jwt_key() {
+  for len in [16_usize, 31, 33, 64] {
+    let mut map = base();
+    map.insert("jwt_signing_key".to_owned(), Value::String("k".repeat(len)));
+    assert!(
+      matches!(
+        try_load(&map),
+        Err(ConfigError::WeakKeyMaterial {
+          key: "jwt_signing_key"
+        })
+      ),
+      "jwt_signing_key of {len} bytes must be rejected"
+    );
+  }
+  let mut previous = base();
+  previous.insert("jwt_signing_key".to_owned(), Value::String(jwt_key(1)));
+  previous.insert(
+    "jwt_signing_previous_keys".to_owned(),
+    json!([{ "kid": "v0", "key": "short-previous-jwt-key" }]),
+  );
+  assert!(matches!(
+    try_load(&previous),
+    Err(ConfigError::WeakKeyMaterial {
+      key: "jwt_signing_previous_keys"
+    })
+  ));
+}
+
+/// A 32-byte current key plus a rotated-out previous key loads; key material
+/// never appears in `Debug`.
+#[test]
+fn config_accepts_jwt_signing_key_and_previous_keys() -> Result<(), Box<dyn Error>> {
+  let current = jwt_key(1);
+  let previous = jwt_key(9);
+  let mut map = base();
+  map.insert("jwt_signing_key".to_owned(), Value::String(current.clone()));
+  map.insert("jwt_signing_kid".to_owned(), Value::String("v2".to_owned()));
+  map.insert(
+    "jwt_signing_previous_keys".to_owned(),
+    json!([{ "kid": "v0", "key": previous }]),
+  );
+  map.insert("jwt_access_tokens_enabled".to_owned(), Value::Bool(true));
+  let cfg = try_load(&map)?;
+  let active = cfg
+    .jwt_signing_key
+    .as_ref()
+    .ok_or("missing active jwt key")?;
+  assert_eq!(active.kid().as_str(), "v2");
+  assert_eq!(active.alg(), super::JwtSigningAlg::Es256);
+  assert_eq!(cfg.jwt_signing_previous_keys.len(), 1);
+  assert_eq!(cfg.jwt_signing_previous_keys[0].kid().as_str(), "v0");
+  let rendered = format!("{cfg:?}");
+  assert!(!rendered.contains(&current));
+  assert!(!rendered.contains("k".repeat(32).as_str()));
+  Ok(())
+}
+
+/// An all-zero scalar is not a valid EC private key: construction/validation
+/// happens at config load, so the process fails at boot, not at first mint.
+#[test]
+fn config_rejects_zero_scalar_jwt_key() {
+  let mut map = base();
+  map.insert(
+    "jwt_signing_key".to_owned(),
+    Value::String("\u{0}".repeat(32)),
+  );
+  assert!(matches!(
+    try_load(&map),
+    Err(ConfigError::InvalidJwtSigningKey {
+      key: "jwt_signing_key"
+    })
+  ));
+}
+
+/// Enabling JWT access tokens without a signing key fails closed.
+#[test]
+fn config_rejects_jwt_enabled_without_key() {
+  let mut map = base();
+  map.insert("jwt_access_tokens_enabled".to_owned(), Value::Bool(true));
+  assert!(matches!(
+    try_load(&map),
+    Err(ConfigError::MissingJwtSigningKey)
+  ));
+}
+
+/// A duplicated previous kid (or one matching the active kid) is rejected.
+#[test]
+fn config_rejects_duplicate_jwt_signing_kids() {
+  let mut map = base();
+  map.insert("jwt_signing_key".to_owned(), Value::String(jwt_key(1)));
+  map.insert("jwt_signing_kid".to_owned(), Value::String("v1".to_owned()));
+  map.insert(
+    "jwt_signing_previous_keys".to_owned(),
+    json!([
+      { "kid": "v0", "key": jwt_key(2) },
+      { "kid": "v0", "key": jwt_key(3) }
+    ]),
+  );
+  assert!(matches!(
+    try_load(&map),
+    Err(ConfigError::DuplicateJwtSigningKid { .. })
+  ));
+
+  let mut clash = base();
+  clash.insert("jwt_signing_key".to_owned(), Value::String(jwt_key(1)));
+  clash.insert("jwt_signing_kid".to_owned(), Value::String("v1".to_owned()));
+  clash.insert(
+    "jwt_signing_previous_keys".to_owned(),
+    json!([{ "kid": "v1", "key": jwt_key(2) }]),
+  );
+  assert!(matches!(
+    try_load(&clash),
+    Err(ConfigError::DuplicateJwtSigningKid { .. })
+  ));
+}
+
+/// `EdDSA` is an allowed JWT signing algorithm; an unknown value fails to
+/// deserialize rather than silently defaulting.
+#[test]
+fn config_accepts_eddsa_jwt_alg() -> Result<(), Box<dyn Error>> {
+  let mut map = base();
+  map.insert("jwt_signing_key".to_owned(), Value::String(jwt_key(1)));
+  map.insert(
+    "jwt_access_token_signing_alg".to_owned(),
+    Value::String("EdDSA".to_owned()),
+  );
+  let cfg = try_load(&map)?;
+  assert_eq!(
+    cfg.jwt_access_token_signing_alg,
+    super::JwtSigningAlg::EdDsa
+  );
+  let key = cfg
+    .jwt_signing_key
+    .as_ref()
+    .ok_or("missing active jwt key")?;
+  assert_eq!(key.alg(), super::JwtSigningAlg::EdDsa);
+  Ok(())
+}
+
+/// CIMD fetch bounds: the timeout ladder and byte cap are validated at load.
+#[test]
+fn config_validates_cimd_limits() -> Result<(), Box<dyn Error>> {
+  for secs in [0_u64, 11, 60] {
+    let mut map = base();
+    map.insert("cimd_fetch_timeout_secs".to_owned(), json!(secs));
+    assert!(
+      matches!(try_load(&map), Err(ConfigError::InvalidCimdFetchTimeout)),
+      "timeout {secs} must be rejected"
+    );
+  }
+  for bytes in [0_u64, 5_121, 20_000] {
+    let mut map = base();
+    map.insert("cimd_max_bytes".to_owned(), json!(bytes));
+    assert!(
+      matches!(try_load(&map), Err(ConfigError::InvalidCimdMaxBytes)),
+      "max bytes {bytes} must be rejected"
+    );
+  }
+  let mut ok = base();
+  ok.insert("cimd_fetch_timeout_secs".to_owned(), json!(10));
+  ok.insert("cimd_max_bytes".to_owned(), json!(1_024));
+  let cfg = try_load(&ok)?;
+  assert_eq!(cfg.cimd_fetch_timeout, Duration::from_secs(10));
+  assert_eq!(cfg.cimd_max_bytes, 1_024);
+  Ok(())
+}
+
 #[test]
 fn config_trusted_proxies_default_empty() -> Result<(), Box<dyn Error>> {
   let cfg = try_load(&base())?;

+ 2 - 1
src/crypto/tokens.rs

@@ -248,9 +248,10 @@ pub const fn redact_token(_token: &str) -> &'static str {
 
 #[cfg(test)]
 mod crypto_tests {
-  use super::*;
   use std::collections::HashSet;
 
+  use super::*;
+
   fn is_base64url(s: &str) -> bool {
     s.bytes()
       .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')

+ 39 - 9
src/db/client_repo.rs

@@ -23,10 +23,9 @@
 mod tests;
 mod types;
 
-pub use types::{Client, ClientMetadata, ClientRepoError, NewClient, TokenEndpointAuthMethod};
-
 use sqlx::PgPool;
 use time::OffsetDateTime;
+pub use types::{Client, ClientMetadata, ClientRepoError, NewClient, TokenEndpointAuthMethod};
 use uuid::Uuid;
 
 use crate::crypto::field::{
@@ -127,15 +126,15 @@ pub async fn insert(
        tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, \
        tls_client_auth_cert_thumbprint_index, request_uris, request_uris_enc, contacts, \
        contacts_enc, software_id, software_id_enc, registration_access_token_hash, \
-       dpop_bound_access_tokens, created_at, updated_at) \
+       dpop_bound_access_tokens, access_token_format, created_at, updated_at) \
      VALUES ($1, $2, $3, $4, now(), $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, \
-       $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, now(), now()) \
+       $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, now(), now()) \
      RETURNING 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, active, created_at, updated_at",
+       dpop_bound_access_tokens, access_token_format, active, created_at, updated_at",
     new.id,
     &new.client_id,
     new.client_secret_hash.as_deref(),
@@ -163,6 +162,7 @@ pub async fn insert(
     sealed.software_enc,
     new.registration_access_token_hash.as_deref(),
     new.dpop_bound_access_tokens,
+    &new.access_token_format,
   )
   .fetch_one(pool)
   .await?;
@@ -192,6 +192,7 @@ pub async fn insert(
     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,
@@ -215,7 +216,7 @@ pub async fn find_active_by_client_id(
        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, active, created_at, updated_at \
+       dpop_bound_access_tokens, access_token_format, active, created_at, updated_at \
      FROM oauth_clients WHERE client_id = $1 AND active = true",
     client_id,
   )
@@ -249,6 +250,7 @@ pub async fn find_active_by_client_id(
         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,
@@ -274,7 +276,7 @@ pub async fn find_by_id(
        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, active, created_at, updated_at \
+       dpop_bound_access_tokens, access_token_format, active, created_at, updated_at \
      FROM oauth_clients WHERE id = $1",
     id,
   )
@@ -308,6 +310,7 @@ pub async fn find_by_id(
         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,
@@ -333,7 +336,7 @@ pub async fn list(
        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, active, created_at, updated_at \
+       dpop_bound_access_tokens, access_token_format, active, created_at, updated_at \
      FROM oauth_clients WHERE ($1 OR active = true) ORDER BY created_at, client_id",
     include_inactive,
   )
@@ -368,6 +371,7 @@ pub async fn list(
         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,
@@ -482,7 +486,7 @@ pub async fn update_metadata(
        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, active, created_at, updated_at",
+       dpop_bound_access_tokens, access_token_format, active, created_at, updated_at",
     current.id,
     name_plain,
     sealed.name_enc,
@@ -537,6 +541,7 @@ pub async fn update_metadata(
         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,
@@ -587,6 +592,31 @@ pub async fn rotate_secret_hash(
   Ok(result.rows_affected() > 0)
 }
 
+/// Set an active client's access-token format (`opaque` or `jwt`).
+///
+/// Returns `true` only when the stored value actually changed, so a
+/// declarative `config sync` re-run stays a true no-op. The operator CLI is
+/// the only caller; dynamic registration always inserts `opaque`.
+///
+/// # Errors
+///
+/// Returns a database error if the update fails.
+pub async fn set_access_token_format(
+  pool: &PgPool,
+  client_id: &str,
+  format: &str,
+) -> Result<bool, sqlx::Error> {
+  let result = sqlx::query!(
+    "UPDATE oauth_clients SET access_token_format = $2, updated_at = now() \
+     WHERE client_id = $1 AND active = true AND access_token_format IS DISTINCT FROM $2",
+    client_id,
+    format,
+  )
+  .execute(pool)
+  .await?;
+  Ok(result.rows_affected() > 0)
+}
+
 /// Verify a presented client secret against the stored Argon2id hash.
 ///
 /// Returns `None` when no active client has that `client_id` or the client has

+ 1 - 0
src/db/client_repo/tests.rs

@@ -60,6 +60,7 @@ fn confidential(client_id: &str) -> NewClient {
     contacts: None,
     software_id: None,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
   }
 }

+ 7 - 0
src/db/client_repo/types.rs

@@ -73,6 +73,9 @@ pub struct Client {
   pub software_id: Option<String>,
   /// Whether the client registered DPoP-bound access tokens (RFC 9449 §5).
   pub dpop_bound_access_tokens: bool,
+  /// Access-token format: `opaque` (the OAuth 2.1 baseline and default) or
+  /// `jwt`. Operator-only; dynamic registration always stores `opaque`.
+  pub access_token_format: String,
   /// Whether the client is active (soft-delete flag).
   pub active: bool,
   /// Row creation timestamp.
@@ -104,6 +107,7 @@ impl fmt::Debug for Client {
       .field("contacts", &"[REDACTED]")
       .field("software_id", &"[REDACTED]")
       .field("dpop_bound_access_tokens", &self.dpop_bound_access_tokens)
+      .field("access_token_format", &self.access_token_format)
       .field("active", &self.active)
       .field("created_at", &self.created_at)
       .field("updated_at", &self.updated_at)
@@ -223,6 +227,9 @@ pub struct NewClient {
   pub software_id: Option<String>,
   /// Whether the client registers DPoP-bound access tokens (RFC 9449 §5).
   pub dpop_bound_access_tokens: bool,
+  /// Access-token format: `opaque` (default) or `jwt`. Dynamic registration
+  /// always inserts `opaque`; only an operator may opt a client into `jwt`.
+  pub access_token_format: String,
   /// Keyed hash of the RFC 7592 registration access token, if issued.
   pub registration_access_token_hash: Option<String>,
 }

+ 1 - 0
src/db/mod.rs

@@ -6,6 +6,7 @@
 
 pub mod client_repo;
 pub mod redirect_repo;
+pub mod resource_repo;
 pub mod scope_repo;
 pub mod user_repo;
 pub mod user_scope_repo;

+ 1 - 0
src/db/redirect_repo/tests.rs

@@ -69,6 +69,7 @@ async fn insert_client(pool: &PgPool, keys: &KeyRing, client_id: &str) -> Result
       contacts: None,
       software_id: None,
       dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
       registration_access_token_hash: None,
     },
   )

+ 113 - 0
src/db/resource_repo.rs

@@ -0,0 +1,113 @@
+//! `resource_servers` catalog repository (RFC 8707 resource indicators).
+//!
+//! Runtime-managed and starts empty, mirroring the `scopes` catalog: rows are
+//! inserted via `upsert` and only ever disabled, never widened by matching.
+//! `identifier` is the exact RFC 8707 resource identifier — lookups use plain
+//! equality, with no prefix, suffix, URI normalization, or wildcard matching.
+//!
+//! Every query is a parameterized `sqlx` macro call. A database outage maps to
+//! the crate-wide fail-closed `503 temporarily_unavailable` via
+//! [`crate::error::AppError`].
+
+use sqlx::PgPool;
+use time::OffsetDateTime;
+use uuid::Uuid;
+
+use crate::error::AppError;
+
+/// A catalog resource server (RFC 8707 resource indicator).
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ResourceServer {
+  /// Surrogate primary key.
+  pub id: Uuid,
+  /// Exact RFC 8707 resource identifier (unique in the catalog).
+  pub identifier: String,
+  /// Human-readable description.
+  pub description: String,
+  /// Whether the identifier may be requested and is advertised.
+  pub enabled: bool,
+  /// Row creation timestamp.
+  pub created_at: OffsetDateTime,
+}
+
+/// Insert a resource server or update its description, preserving its
+/// `enabled` state and surrogate id.
+///
+/// Idempotent, so configuration sync may run repeatedly: a resource server
+/// disabled by an operator is not silently re-enabled by a later sync.
+///
+/// # Errors
+/// Returns an error on any database failure.
+pub async fn upsert(
+  pool: &PgPool,
+  identifier: &str,
+  description: &str,
+) -> Result<ResourceServer, AppError> {
+  let record = sqlx::query_as!(
+    ResourceServer,
+    r#"INSERT INTO resource_servers (id, identifier, description, enabled, created_at)
+       VALUES ($1, $2, $3, true, now())
+       ON CONFLICT (identifier) DO UPDATE
+         SET description = EXCLUDED.description
+       RETURNING id, identifier, description, enabled, created_at"#,
+    Uuid::new_v4(),
+    identifier,
+    description,
+  )
+  .fetch_one(pool)
+  .await?;
+  Ok(record)
+}
+
+/// Look up an enabled resource server by its exact identifier; `None` when it
+/// is unknown or disabled.
+///
+/// # Errors
+/// Returns an error on any database failure.
+pub async fn find_enabled(
+  pool: &PgPool,
+  identifier: &str,
+) -> Result<Option<ResourceServer>, AppError> {
+  let record = sqlx::query_as!(
+    ResourceServer,
+    r#"SELECT id, identifier, description, enabled, created_at
+         FROM resource_servers WHERE identifier = $1 AND enabled"#,
+    identifier,
+  )
+  .fetch_optional(pool)
+  .await?;
+  Ok(record)
+}
+
+/// List every enabled resource server, ordered by identifier.
+///
+/// # Errors
+/// Returns an error on any database failure.
+pub async fn list_enabled(pool: &PgPool) -> Result<Vec<ResourceServer>, AppError> {
+  let records = sqlx::query_as!(
+    ResourceServer,
+    r#"SELECT id, identifier, description, enabled, created_at
+         FROM resource_servers WHERE enabled ORDER BY identifier"#,
+  )
+  .fetch_all(pool)
+  .await?;
+  Ok(records)
+}
+
+/// Disable a resource server by its exact identifier. Returns whether a row was
+/// updated; an unknown identifier is a no-op.
+///
+/// # Errors
+/// Returns an error on any database failure.
+pub async fn disable(pool: &PgPool, identifier: &str) -> Result<bool, AppError> {
+  let result = sqlx::query!(
+    r#"UPDATE resource_servers SET enabled = false WHERE identifier = $1"#,
+    identifier,
+  )
+  .execute(pool)
+  .await?;
+  Ok(result.rows_affected() == 1)
+}
+
+#[cfg(test)]
+mod tests;

+ 130 - 0
src/db/resource_repo/tests.rs

@@ -0,0 +1,130 @@
+//! `test(resource_repo_enabled_filter)` — real-Postgres tests for
+//! `db::resource_repo`.
+//!
+//! Covers exact-match lookup, the enabled filter on every read path, idempotent
+//! upsert preserving the operator's disabled state, and fail-closed `503` when
+//! the database is unavailable. Every test gets a fresh migrated database from
+//! [`crate::db::test_support`].
+
+use axum::http::StatusCode;
+
+use super::{disable, find_enabled, list_enabled, upsert};
+use crate::db::test_support::{TestError, migrated_pool};
+use crate::error::AppError;
+
+/// Await a fallible repository call that must fail, returning its error.
+async fn expect_err<T>(result: Result<T, AppError>, what: &str) -> Result<AppError, TestError> {
+  match result {
+    Err(error) => Ok(error),
+    Ok(_) => Err(format!("{what} unexpectedly succeeded").into()),
+  }
+}
+
+/// Only an enabled row resolves, matching the identifier exactly; the catalog
+/// never normalizes case or a trailing slash.
+#[tokio::test]
+async fn resource_repo_enabled_filter_resolves_only_enabled() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let identifier = "https://api.example.test";
+
+  let created = upsert(&pool, identifier, "Example API").await?;
+  assert_eq!(created.identifier, identifier);
+  assert_eq!(created.description, "Example API");
+  assert!(created.enabled, "a new resource server is enabled");
+
+  let found = find_enabled(&pool, identifier).await?;
+  assert_eq!(
+    found.as_ref().map(|row| row.id),
+    Some(created.id),
+    "the enabled identifier resolves"
+  );
+
+  // Exact match only: no case folding, no trailing-slash normalization, no
+  // prefix/suffix/wildcard behavior.
+  for variant in [
+    "https://API.example.test",
+    "https://api.example.test/",
+    "https://api.example.test/v1",
+    "https://api.example",
+    "https://*.example.test",
+  ] {
+    assert!(
+      find_enabled(&pool, variant).await?.is_none(),
+      "variant {variant:?} must not resolve"
+    );
+  }
+
+  let listed = list_enabled(&pool).await?;
+  assert_eq!(listed.len(), 1, "the enabled row is listed");
+  assert_eq!(listed[0].identifier, identifier);
+
+  // Disabled and unknown identifiers are not found.
+  assert!(disable(&pool, identifier).await?, "disable updates the row");
+  assert!(
+    find_enabled(&pool, identifier).await?.is_none(),
+    "a disabled identifier does not resolve"
+  );
+  assert!(
+    list_enabled(&pool).await?.is_empty(),
+    "a disabled row is not listed"
+  );
+  assert!(
+    find_enabled(&pool, "https://unknown.example.test")
+      .await?
+      .is_none(),
+    "an unknown identifier does not resolve"
+  );
+  assert!(
+    !disable(&pool, "https://unknown.example.test").await?,
+    "disabling an unknown identifier is a no-op"
+  );
+
+  // Re-upserting updates the description but preserves the disabled state and
+  // the existing row id.
+  let updated = upsert(&pool, identifier, "Updated API").await?;
+  assert_eq!(updated.id, created.id, "upsert keeps the existing row id");
+  assert_eq!(updated.description, "Updated API");
+  assert!(
+    !updated.enabled,
+    "upsert must not silently re-enable a disabled resource server"
+  );
+  assert!(
+    find_enabled(&pool, identifier).await?.is_none(),
+    "the disabled identifier stays invisible after upsert"
+  );
+
+  Ok(())
+}
+
+/// A database outage on any read path fails closed as `503`, never a `500`.
+#[tokio::test]
+async fn resource_repo_enabled_filter_db_outage_is_503() -> Result<(), TestError> {
+  let (_db, pool) = migrated_pool().await?;
+  let identifier = upsert(&pool, "https://outage.example.test", "")
+    .await?
+    .identifier;
+
+  // Closing the pool makes every subsequent acquire fail with the same
+  // connectivity class (`PoolClosed`) a real outage produces; `AppError` maps
+  // that to `temporarily_unavailable`.
+  pool.close().await;
+
+  for (name, result) in [
+    (
+      "find_enabled",
+      find_enabled(&pool, &identifier).await.map(|_| ()),
+    ),
+    ("list_enabled", list_enabled(&pool).await.map(|_| ())),
+    ("upsert", upsert(&pool, &identifier, "x").await.map(|_| ())),
+    ("disable", disable(&pool, &identifier).await.map(|_| ())),
+  ] {
+    let error = expect_err(result, name).await?;
+    assert_eq!(
+      error.status(),
+      StatusCode::SERVICE_UNAVAILABLE,
+      "{name} must fail closed as 503"
+    );
+  }
+
+  Ok(())
+}

+ 1 - 2
src/db/user_repo/lockout.rs

@@ -16,13 +16,12 @@
 
 use std::{net::IpAddr, time::Duration};
 
+use super::normalize_email;
 use crate::{
   state::RedisConn,
   store::{self, Store, StoreError, sessions},
 };
 
-use super::normalize_email;
-
 /// Login lockout policy: after `threshold` failures from one IP for one
 /// account within `lock_duration`, that IP is locked out for the account;
 /// after `account_threshold` failures from any IPs in the window, the account

+ 2 - 2
src/db/user_repo/tests.rs

@@ -7,14 +7,14 @@
 //! must never fail a live token. Every test gets a fresh migrated database
 //! from [`crate::db::test_support`].
 
+use secrecy::SecretString;
+use sqlx::PgPool;
 use uuid::Uuid;
 
 use super::{NewUser, SubjectAccountStatus, create_user, disable_user, subject_account_status};
 use crate::crypto::secret::{Argon2Params, hash_secret};
 use crate::db::test_support::{TestError, migrated_pool};
 use crate::keys::{KeyId, KeyRing};
-use secrecy::SecretString;
-use sqlx::PgPool;
 
 /// Test PII key ring (the blind-index candidates must derive from it).
 fn test_keys() -> Result<KeyRing, TestError> {

+ 2 - 1
src/error.rs

@@ -569,12 +569,13 @@ impl From<crate::crypto::field::FieldError> for AppError {
 
 #[cfg(test)]
 mod tests {
-  use super::*;
   use axum::body::Bytes;
   use axum::http::{HeaderMap, header};
   use http_body_util::BodyExt as _;
   use serde_json::Value;
 
+  use super::*;
+
   /// Drive an `AppError` through `IntoResponse` and collect its status,
   /// headers, and parsed JSON body.
   async fn render(err: AppError) -> (StatusCode, HeaderMap, Value) {

+ 15 - 4
src/jose/jar.rs

@@ -26,12 +26,14 @@ mod verify;
 
 use std::{collections::HashMap, time::Duration};
 
-use crate::{db::client_repo::Client, error::AppError, state::RedisConn, store::Store};
-
 use merge::merge_claims;
 use request_uri::resolve_uri;
 use verify::verify_request_object;
 
+pub use merge::MergedClaims;
+
+use crate::{db::client_repo::Client, error::AppError, state::RedisConn, store::Store};
+
 /// PAR `request_uri` URN prefix (RFC 9126 §3); the suffix is the PAR id.
 pub const PAR_REQUEST_URI_PREFIX: &str = "urn:ietf:params:oauth:request_uri:";
 /// Maximum fetched request-object body (SSRF size cap).
@@ -61,6 +63,10 @@ pub struct JarRequest<'a> {
 /// neither, a public client falls back to the raw query while a
 /// confidential client is rejected (see [`require_par_or_jar`]).
 ///
+/// The repeated `resource` indicator (RFC 8707) rides out-of-band in
+/// [`MergedClaims::resource`]: the single-valued `params` map never carries
+/// it (see [`merge::merge_claims`]).
+///
 /// # Errors
 ///
 /// Returns `invalid_request_object` for malformed, mis-bound, or
@@ -71,7 +77,7 @@ pub async fn resolve(
   conn: &mut RedisConn,
   store: &Store,
   input: &JarRequest<'_>,
-) -> Result<HashMap<String, String>, AppError> {
+) -> Result<MergedClaims, AppError> {
   match (input.request, input.request_uri) {
     (Some(_), Some(_)) => Err(AppError::invalid_request_object(
       "request and request_uri must not both be present",
@@ -85,7 +91,12 @@ pub async fn resolve(
     }
     (None, None) => {
       require_par_or_jar(input.client)?;
-      Ok(input.query.clone())
+      let mut params = input.query.clone();
+      params.remove("resource");
+      Ok(MergedClaims {
+        params,
+        resource: Vec::new(),
+      })
     }
   }
 }

+ 1 - 2
src/jose/jar/fetch.rs

@@ -5,13 +5,12 @@
 //! statuses only) and then enforces the request-object body cap of
 //! [`MAX_REQUEST_OBJECT_BYTES`] with a UTF-8 requirement.
 
+use super::MAX_REQUEST_OBJECT_BYTES;
 use crate::{
   error::AppError,
   protocol::ssrf::{self, SsrfError},
 };
 
-use super::MAX_REQUEST_OBJECT_BYTES;
-
 /// Fetch a pre-registered request object behind the SSRF guard.
 ///
 /// # Errors

+ 139 - 2
src/jose/jar/merge.rs

@@ -17,6 +17,20 @@ const JWT_METADATA_CLAIMS: [&str; 6] = ["iss", "aud", "exp", "nbf", "iat", "jti"
 
 /// Merge verified claims over the query parameters (RFC 9101 §5.2).
 ///
+/// The repeated `resource` indicator (RFC 8707) is carried OUT-OF-BAND in
+/// [`MergedClaims::resource`]: a `Value::Array` maps to one element per
+/// entry and a plain `Value::String` to a single element (any other shape,
+/// including `null`, is `invalid_request_object`). Values are never
+/// delimiter-joined, so a comma-bearing URI cannot collide. The merged map
+/// never carries a `resource` key — a single-valued map cannot hold repeats,
+/// and the old `claim_to_string` collapse of an array into one JSON string
+/// must never become an audience.
+///
+/// A `resource` present in BOTH the query map and the claims is
+/// `invalid_request_object`: the single-valued query side cannot represent
+/// repeats, so a cross-transport duplication is unresolvable fail-closed —
+/// the client puts the indicator in exactly one transport.
+///
 /// # Errors
 ///
 /// Returns `invalid_request_object` on any conflict or forbidden member.
@@ -24,7 +38,7 @@ pub(crate) fn merge_claims(
   query: &HashMap<String, String>,
   claims: &serde_json::Map<String, Value>,
   client_id: &str,
-) -> Result<HashMap<String, String>, AppError> {
+) -> Result<MergedClaims, AppError> {
   if let Some(Value::String(claimed)) = claims.get("client_id") {
     if claimed != client_id {
       return Err(AppError::invalid_request_object(
@@ -37,6 +51,9 @@ pub(crate) fn merge_claims(
     ));
   }
   let mut merged = query.clone();
+  merged.remove("resource");
+  let query_had_resource = query.contains_key("resource");
+  let mut resource = Vec::new();
   for (name, value) in claims {
     if name == "request" || name == "request_uri" {
       return Err(AppError::invalid_request_object(
@@ -46,6 +63,10 @@ pub(crate) fn merge_claims(
     if JWT_METADATA_CLAIMS.contains(&name.as_str()) {
       continue;
     }
+    if name == "resource" {
+      resource = jar_resources(value)?;
+      continue;
+    }
     let rendered = claim_to_string(value)?;
     match merged.get(name) {
       Some(seen) if seen != &rendered => {
@@ -58,7 +79,45 @@ pub(crate) fn merge_claims(
       }
     }
   }
-  Ok(merged)
+  if query_had_resource && claims.contains_key("resource") {
+    return Err(AppError::invalid_request_object(
+      "request object repeats a resource indicator present in the query",
+    ));
+  }
+  Ok(MergedClaims {
+    params: merged,
+    resource,
+  })
+}
+
+/// Merged authorize parameters: the single-valued map plus the repeated
+/// `resource` indicator carried out-of-band (see [`merge_claims`]).
+pub struct MergedClaims {
+  /// Effective single-valued parameters (never carries `resource`).
+  pub params: HashMap<String, String>,
+  /// Repeated `resource` values from a JAR `resource` claim, in order.
+  pub resource: Vec<String>,
+}
+
+/// Extract a JAR `resource` claim out-of-band: an array maps to one element
+/// per entry, a plain string to a single element; anything else (including
+/// `null`) is `invalid_request_object`.
+fn jar_resources(value: &Value) -> Result<Vec<String>, AppError> {
+  match value {
+    Value::String(text) => Ok(vec![text.clone()]),
+    Value::Array(items) => items
+      .iter()
+      .map(|item| match item {
+        Value::String(text) => Ok(text.clone()),
+        _ => Err(AppError::invalid_request_object(
+          "request object resource array must contain only strings",
+        )),
+      })
+      .collect(),
+    _ => Err(AppError::invalid_request_object(
+      "request object resource must be a string or an array of strings",
+    )),
+  }
 }
 
 /// Render one claim value as an authorize parameter string.
@@ -74,3 +133,81 @@ fn claim_to_string(value: &Value) -> Result<String, AppError> {
     )),
   }
 }
+
+#[cfg(test)]
+mod tests {
+  use super::merge_claims;
+
+  /// A JAR `resource` JSON array survives the merge out-of-band: one element
+  /// per entry, in order, and never as a joined string in the params map.
+  #[test]
+  fn jar_resource_array_survives_request_object() -> Result<(), String> {
+    let query = std::collections::HashMap::from([("client_id".to_owned(), "client-a".to_owned())]);
+    let mut claims = serde_json::Map::new();
+    claims.insert(
+      "client_id".to_owned(),
+      serde_json::Value::String("client-a".to_owned()),
+    );
+    claims.insert(
+      "resource".to_owned(),
+      serde_json::json!(["https://b.example.test/", "https://a.example.test/",]),
+    );
+    let merged = merge_claims(&query, &claims, "client-a")
+      .map_err(|error| format!("array resource must merge: {}", error.code()))?;
+    if merged.resource
+      != vec![
+        "https://b.example.test/".to_owned(),
+        "https://a.example.test/".to_owned(),
+      ]
+    {
+      return Err(format!(
+        "array resource lost or reordered: {:?}",
+        merged.resource
+      ));
+    }
+    if merged.params.contains_key("resource") {
+      return Err("merged params must never carry resource".to_owned());
+    }
+    // A plain string is a single element.
+    let mut single = serde_json::Map::new();
+    single.insert(
+      "client_id".to_owned(),
+      serde_json::Value::String("client-a".to_owned()),
+    );
+    single.insert(
+      "resource".to_owned(),
+      serde_json::Value::String("https://a.example.test/".to_owned()),
+    );
+    let merged = merge_claims(&query, &single, "client-a")
+      .map_err(|error| format!("string resource must merge: {}", error.code()))?;
+    if merged.resource != vec!["https://a.example.test/".to_owned()] {
+      return Err(format!("string resource lost: {:?}", merged.resource));
+    }
+    // A non-string array member, a non-string claim, and a null are rejected.
+    for bad in [
+      serde_json::json!([7]),
+      serde_json::json!({"aud": "x"}),
+      serde_json::Value::Null,
+    ] {
+      let mut claims = serde_json::Map::new();
+      claims.insert(
+        "client_id".to_owned(),
+        serde_json::Value::String("client-a".to_owned()),
+      );
+      claims.insert("resource".to_owned(), bad);
+      if merge_claims(&query, &claims, "client-a").is_ok() {
+        return Err("non-string resource must be invalid_request_object".to_owned());
+      }
+    }
+    // A `resource` in both the query and the claims is unresolvable
+    // fail-closed: the single-valued side cannot represent repeats.
+    let both_query = std::collections::HashMap::from([
+      ("client_id".to_owned(), "client-a".to_owned()),
+      ("resource".to_owned(), "https://a.example.test/".to_owned()),
+    ]);
+    if merge_claims(&both_query, &single, "client-a").is_ok() {
+      return Err("duplicated resource across transports must be rejected".to_owned());
+    }
+    Ok(())
+  }
+}

+ 12 - 6
src/jose/jar/request_uri.rs

@@ -9,6 +9,9 @@ use std::collections::HashMap;
 
 use serde_json::Value;
 
+use super::merge::merge_claims;
+use super::verify::verify_request_object;
+use super::{PAR_REQUEST_URI_PREFIX, fetch::fetch_request_object, merge::MergedClaims};
 use crate::{
   db::client_repo::Client,
   error::AppError,
@@ -16,9 +19,6 @@ use crate::{
   store::{Store, par},
 };
 
-use super::verify::verify_request_object;
-use super::{PAR_REQUEST_URI_PREFIX, fetch::fetch_request_object, merge::merge_claims};
-
 /// Resolve a `request_uri`: PAR URN or pre-registered URI.
 ///
 /// # Errors
@@ -32,7 +32,7 @@ pub(crate) async fn resolve_uri(
   issuer: &str,
   query: &HashMap<String, String>,
   uri: &str,
-) -> Result<HashMap<String, String>, AppError> {
+) -> Result<MergedClaims, AppError> {
   if let Some(id) = uri.strip_prefix(PAR_REQUEST_URI_PREFIX) {
     return resolve_par(conn, store, client, query, id).await;
   }
@@ -72,7 +72,7 @@ async fn resolve_par(
   client: &Client,
   query: &HashMap<String, String>,
   id: &str,
-) -> Result<HashMap<String, String>, AppError> {
+) -> Result<MergedClaims, AppError> {
   if id.is_empty() {
     return Err(AppError::invalid_request("request_uri is malformed"));
   }
@@ -96,7 +96,12 @@ async fn resolve_par(
       "stored PAR record is not a JSON object",
     )));
   };
-  merge_claims(query, object, &client.client_id)
+  // The PAR-pinned audience rides out-of-band (record `aud`, validated at
+  // push and re-validated by the authorize caller); the stored JSON object
+  // itself carries no `resource` (stripped at push, stripped by the merge).
+  let mut merged = merge_claims(query, object, &client.client_id)?;
+  merged.resource = record.aud;
+  Ok(merged)
 }
 
 #[cfg(test)]
@@ -122,6 +127,7 @@ mod tests {
       contacts: None,
       software_id: None,
       dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
       active: true,
       created_at: time::OffsetDateTime::UNIX_EPOCH,
       updated_at: time::OffsetDateTime::UNIX_EPOCH,

+ 3 - 1
src/jose/jar/verify.rs

@@ -108,9 +108,10 @@ fn key_alg_compatible(key: &Jwk, alg: Algorithm) -> bool {
 
 #[cfg(test)]
 mod tests {
+  use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+
   use super::*;
   use crate::error::OAuthErrorCode;
-  use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
 
   /// Test body result (no `unwrap`/`expect` under the deny lints).
   type TestResult = Result<(), TestError>;
@@ -145,6 +146,7 @@ mod tests {
       contacts: None,
       software_id: None,
       dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
       active: true,
       created_at: time::OffsetDateTime::UNIX_EPOCH,
       updated_at: time::OffsetDateTime::UNIX_EPOCH,

+ 478 - 0
src/jose/jwt_access.rs

@@ -0,0 +1,478 @@
+//! JWT access tokens: a profile subset of RFC 9068 minted by this AS.
+//!
+//! The AS signs access tokens with the configured [`JwtSigningKey`] (current
+//! key signs, `jwt_signing_previous_keys` verify only) when the
+//! `jwt_access_tokens_enabled` kill-switch is on. JOSE header carries `kid`
+//! plus `typ: "at+jwt"` (RFC 9068 §2.1, §4); claims carry `iss`/`sub`/`aud`/
+//! `scope`/`exp`/`iat`/`jti`/`client_id` (`client_id` is REQUIRED, §2.2),
+//! optional `cnf` (§2.2) and optional `act` (RFC 8693 §4.1, carried only when
+//! an exchange delegated authority).
+//!
+//! This is a profile subset, not full RFC 9068 conformance: no claim beyond
+//! the set above is emitted or required, and resource-server validation
+//! beyond `aud` containment is out of scope.
+//!
+//! Verification is fail-closed: the header `alg` must equal the configured
+//! algorithm (never trusted from the token), `typ` must be exactly `at+jwt`,
+//! `kid` must name the current or a previous key, and `iss`/`exp`/`aud` are
+//! enforced with zero clock-skew leeway (matching `jar::verify`). A token is
+//! additionally live only while its `jwt:<jti-hash>` allowlist marker exists
+//! and its subject account is not disabled.
+//!
+//! Key material never appears in [`fmt::Debug`], [`JwtError`], or logs.
+
+#[cfg(test)]
+mod tests;
+
+use std::fmt;
+
+use jsonwebtoken::{Algorithm, Header, Validation, decode, decode_header, encode};
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use crate::{
+  config::{Config, JwtPublicJwk, JwtSigningAlg, JwtSigningKey},
+  db::user_repo::{SubjectAccountStatus, subject_account_status},
+  keys::{KeyId, KeyRing},
+  protocol::token::Cnf,
+  state::RedisConn,
+  store::{self, Store},
+};
+
+/// Required JOSE header `typ` for AS-minted access tokens (RFC 9068 §2.1).
+pub const JWT_TYP: &str = "at+jwt";
+
+/// `exp`/`iat` clock-skew leeway in seconds. Zero: strict expiry, matching
+/// `jar::verify`; the access-token TTL is short, so no grace is granted.
+const LEEWAY_SECS: u64 = 0;
+
+/// Access-token claims: the RFC 9068 §2.2 profile subset this AS emits.
+///
+/// `scope` is the space-joined granted scope; `aud` is always an array (this
+/// AS never emits the string form). `sub` is empty for client-credentials
+/// tokens. `client_id` is REQUIRED (§2.2): a token without it is malformed.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct JwtClaims {
+  /// Token issuer: the AS `issuer`.
+  pub iss: String,
+  /// Token subject: the user email/user-id, or empty for client-credentials.
+  pub sub: String,
+  /// Intended audiences (RFC 8707 resource indicators at mint).
+  pub aud: Vec<String>,
+  /// Space-joined granted scope.
+  pub scope: String,
+  /// Expiry, seconds since the Unix epoch.
+  pub exp: i64,
+  /// Issued-at, seconds since the Unix epoch.
+  pub iat: i64,
+  /// Unique token id (allowlist marker key material, hashed at rest).
+  pub jti: String,
+  /// The client the token was issued to (REQUIRED, RFC 9068 §2.2).
+  pub client_id: String,
+  /// Sender-constraint confirmation, absent for unbound bearer tokens.
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub cnf: Option<Cnf>,
+  /// Delegation actor (RFC 8693 §4.1), present only on exchanged tokens.
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub act: Option<ActClaim>,
+}
+
+/// The `act` (actor) claim on an exchanged access token (RFC 8693 §4.1).
+///
+/// `sub` names the party the token acts as. The exchange grant stores the
+/// same shape as a sidecar; the JWT carries it inline.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ActClaim {
+  /// The subject token's client: the impersonated/delegated party.
+  pub sub: String,
+}
+
+/// A verified access token: signature, claims, marker, and account checks all
+/// passed.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct VerifiedJwt {
+  /// The verified claim set.
+  pub claims: JwtClaims,
+}
+
+/// Typed access-token verification failures. Variants carry no token, key, or
+/// claim bytes: only the failure class.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
+pub enum JwtError {
+  /// JOSE header `typ` is not `at+jwt`.
+  #[error("access token has an unexpected JOSE header type")]
+  WrongTyp,
+  /// JOSE header `alg` is not the configured algorithm.
+  #[error("access token uses an unexpected JOSE algorithm")]
+  WrongAlg,
+  /// JOSE header `kid` names no current or previous key.
+  #[error("access token key id is unknown")]
+  UnknownKid,
+  /// The signature does not verify under the selected key.
+  #[error("access token signature is invalid")]
+  Signature,
+  /// `exp` is in the past (zero leeway).
+  #[error("access token is expired")]
+  Expired,
+  /// `iss` does not equal the configured issuer.
+  #[error("access token issuer is unexpected")]
+  WrongIssuer,
+  /// `aud` lacks a required audience value.
+  #[error("access token audience is insufficient")]
+  WrongAudience,
+  /// The token is not a well-formed JWT for this claim shape.
+  #[error("access token is malformed")]
+  Malformed,
+  /// The `jwt:<jti-hash>` allowlist marker is absent (revoked or purged).
+  #[error("access token allowlist marker is absent")]
+  MarkerAbsent,
+  /// The subject maps to a disabled account.
+  #[error("access token subject is disabled")]
+  SubjectDisabled,
+  /// No JWT signing key is configured.
+  #[error("no JWT signing key is configured")]
+  NoSigningKey,
+  /// Signing a token failed (keys are validated at load; unreachable).
+  #[error("access token signing failed")]
+  Encode,
+  /// Redis or Postgres was unavailable; the token stays unusable.
+  #[error("token verification backend is unavailable")]
+  Backend,
+}
+
+/// The AS access-token signer: the current signing key plus verify-only
+/// previous keys selected by header `kid`.
+///
+/// `Debug` prints `kid`/`alg`/public coordinates only; key material is
+/// redacted by [`JwtSigningKey`]'s own `Debug`.
+#[derive(Clone)]
+pub struct JwtAccessSigner {
+  issuer: String,
+  alg: JwtSigningAlg,
+  current: JwtSigningKey,
+  previous: Vec<JwtSigningKey>,
+}
+
+impl JwtAccessSigner {
+  /// Build the signer from the validated [`Config`]: the active
+  /// `jwt_signing_key` signs, every `jwt_signing_previous_keys` entry verifies
+  /// only. All keys share the configured `jwt_access_token_signing_alg`.
+  ///
+  /// # Errors
+  ///
+  /// Returns [`JwtError::NoSigningKey`] when no active key is configured.
+  pub fn from_config(config: &Config) -> Result<Self, JwtError> {
+    let current = config
+      .jwt_signing_key
+      .clone()
+      .ok_or(JwtError::NoSigningKey)?;
+    Ok(Self::new(
+      config.issuer.clone(),
+      current,
+      config.jwt_signing_previous_keys.clone(),
+    ))
+  }
+
+  /// Build a signer from parts: `issuer` the tokens assert, `current` the
+  /// signing key, `previous` the verify-only rotated-out keys.
+  #[must_use]
+  pub fn new(issuer: String, current: JwtSigningKey, previous: Vec<JwtSigningKey>) -> Self {
+    let alg = current.alg();
+    Self {
+      issuer,
+      alg,
+      current,
+      previous,
+    }
+  }
+
+  /// The algorithm this signer mints and verifies under.
+  #[must_use]
+  pub fn algorithm(&self) -> Algorithm {
+    self.alg.algorithm()
+  }
+
+  /// The issuer minted tokens assert (RFC 9068 §2.2 `iss`).
+  #[must_use]
+  pub fn issuer(&self) -> &str {
+    &self.issuer
+  }
+
+  /// The current key first, then every retained verify-only key, each with
+  /// its `kid`, algorithm, and public JWK coordinates (RFC 7517 §4/§5).
+  /// Ordering is the configured order and therefore deterministic.
+  pub fn public_keys(&self) -> impl Iterator<Item = (&KeyId, JwtSigningAlg, &JwtPublicJwk)> {
+    std::iter::once((
+      self.current.kid(),
+      self.current.alg(),
+      self.current.public_jwk(),
+    ))
+    .chain(
+      self
+        .previous
+        .iter()
+        .map(|key| (key.kid(), key.alg(), key.public_jwk())),
+    )
+  }
+
+  /// Mint an access token: JOSE header `kid` + `typ: "at+jwt"` + the
+  /// configured `alg` (never `none`, never HS, never caller-chosen).
+  ///
+  /// # Errors
+  ///
+  /// Returns [`JwtError::Encode`] when JOSE encoding fails.
+  pub fn sign(&self, claims: &JwtClaims) -> Result<String, JwtError> {
+    let mut header = Header::new(self.algorithm());
+    header.kid = Some(self.current.kid().as_str().to_owned());
+    header.typ = Some(JWT_TYP.to_owned());
+    encode(&header, claims, self.current.encoding_key()).map_err(|_| JwtError::Encode)
+  }
+
+  /// Verify signature and claims without touching Redis or Postgres.
+  ///
+  /// Checks, in order: `typ == "at+jwt"`, `alg` equals the configured
+  /// algorithm, `kid` selects the current or a previous key (unknown `kid`
+  /// fails with no fallback), signature, `iss`, `exp` (zero leeway), then
+  /// `aud` containment: when `expected_aud` is non-empty every expected value
+  /// must appear in the token `aud` (the same containment semantics the
+  /// audience-binding grants use). An empty `expected_aud` skips the check.
+  ///
+  /// Marker and account liveness are NOT checked here; use [`Self::verify_live`].
+  ///
+  /// # Errors
+  ///
+  /// Returns the matching [`JwtError`] class for the first failing check.
+  pub fn verify(
+    &self,
+    token: &str,
+    expected_aud: Option<&[String]>,
+  ) -> Result<VerifiedJwt, JwtError> {
+    let header = decode_header(token).map_err(|_| JwtError::Malformed)?;
+    if header.typ.as_deref() != Some(JWT_TYP) {
+      return Err(JwtError::WrongTyp);
+    }
+    if header.alg != self.algorithm() {
+      return Err(JwtError::WrongAlg);
+    }
+    let kid = header.kid.as_deref().ok_or(JwtError::UnknownKid)?;
+    let key = self
+      .current
+      .kid()
+      .as_str()
+      .eq(kid)
+      .then_some(&self.current)
+      .or_else(|| {
+        self
+          .previous
+          .iter()
+          .find(|candidate| candidate.kid().as_str() == kid)
+      })
+      .ok_or(JwtError::UnknownKid)?;
+    let mut validation = Validation::new(self.algorithm());
+    validation.set_issuer(&[self.issuer.as_str()]);
+    validation.set_required_spec_claims(&["exp", "iss"]);
+    validation.leeway = LEEWAY_SECS;
+    validation.validate_exp = true;
+    // The library audience gate stays off: `aud` containment against the
+    // caller-supplied expected set is enforced manually below, so an empty
+    // `expected_aud` skips the check instead of failing it.
+    validation.validate_aud = false;
+    let claims = decode::<JwtClaims>(token, key.decoding_key(), &validation)
+      .map_err(|error| {
+        use jsonwebtoken::errors::ErrorKind as Kind;
+        match error.kind() {
+          Kind::ExpiredSignature => JwtError::Expired,
+          Kind::InvalidIssuer => JwtError::WrongIssuer,
+          Kind::InvalidSignature => JwtError::Signature,
+          Kind::InvalidAlgorithm => JwtError::WrongAlg,
+          _ => JwtError::Malformed,
+        }
+      })?
+      .claims;
+    if let Some(expected) = expected_aud {
+      let missing = expected
+        .iter()
+        .any(|value| !claims.aud.iter().any(|got| got == value));
+      if missing {
+        return Err(JwtError::WrongAudience);
+      }
+    }
+    Ok(VerifiedJwt { claims })
+  }
+
+  /// Full liveness verification: [`Self::verify`] plus the allowlist marker
+  /// and the disabled-subject re-check (same `subject_account_status` call
+  /// introspection uses).
+  ///
+  /// # Errors
+  ///
+  /// Returns [`JwtError::MarkerAbsent`] when the `jwt:<jti-hash>` marker hash
+  /// does not exist, [`JwtError::SubjectDisabled`] when `sub` maps to a
+  /// disabled account, [`JwtError::Backend`] on a Redis/Postgres outage, or
+  /// the [`Self::verify`] class for a signature/claim failure.
+  pub async fn verify_live(
+    &self,
+    conn: &mut RedisConn,
+    store: &Store,
+    pool: &sqlx::PgPool,
+    keys: &KeyRing,
+    token: &str,
+    expected_aud: Option<&[String]>,
+  ) -> Result<VerifiedJwt, JwtError> {
+    let verified = self.verify(token, expected_aud)?;
+    if !marker_live(conn, store, &jti_hash(&verified.claims.jti)).await? {
+      return Err(JwtError::MarkerAbsent);
+    }
+    if !subject_active(pool, keys, &verified.claims.sub).await? {
+      return Err(JwtError::SubjectDisabled);
+    }
+    Ok(verified)
+  }
+}
+
+impl fmt::Debug for JwtAccessSigner {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("JwtAccessSigner")
+      .field("issuer", &self.issuer)
+      .field("alg", &self.alg)
+      .field("current_kid", &self.current.kid())
+      .field("current_public", self.current.public_jwk())
+      .field(
+        "previous",
+        &self
+          .previous
+          .iter()
+          .map(|key| (key.kid(), key.public_jwk()))
+          .collect::<Vec<_>>(),
+      )
+      .finish()
+  }
+}
+
+/// Whether the presented token has JWT shape: three non-empty dot-separated
+/// base64url segments (`<b64url>.<b64url>.<b64url>`).
+///
+/// Opaque tokens are fixed-length base64url-no-pad with no dots (see the
+/// `is_well_formed_token` gate in [`crate::protocol::token::TokenService`],
+/// which rejects any dotted value before hashing or Redis work), so the dot
+/// test discriminates the two families without consulting the store. Shape
+/// alone never authorizes anything: both the revocation and the introspection
+/// JWT paths still verify signature plus `iss` and enforce ownership before
+/// acting.
+#[must_use]
+pub fn is_jwt_shape(token: &str) -> bool {
+  let mut segments = token.split('.');
+  let shaped = matches!(
+    (segments.next(), segments.next(), segments.next(), segments.next()),
+    (Some(first), Some(second), Some(third), None)
+      if !first.is_empty() && !second.is_empty() && !third.is_empty()
+  );
+  shaped
+    && token
+      .bytes()
+      .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
+}
+
+/// Hash a `jti` for its allowlist marker key: the store's existing SHA-256
+/// helper, the same convention `dpop`/`client_auth` use for `jti` replay
+/// markers. Never the raw `jti`.
+#[must_use]
+pub fn jti_hash(jti: &str) -> String {
+  store::sha256_hex(jti.as_bytes())
+}
+
+/// Whether the `jwt:<jti-hash>` allowlist marker hash exists. Absent means
+/// revoked, expired, or purged (`user disable`): the token is unusable.
+///
+/// # Errors
+///
+/// Returns [`JwtError::Backend`] when Redis is unavailable (fail closed).
+pub async fn marker_live(
+  conn: &mut RedisConn,
+  store: &Store,
+  jti_hash: &str,
+) -> Result<bool, JwtError> {
+  let key = store.jwt_marker_key(jti_hash);
+  let present: bool = redis::cmd("EXISTS")
+    .arg(&key)
+    .query_async(conn)
+    .await
+    .map_err(|_| JwtError::Backend)?;
+  Ok(present)
+}
+
+/// Whether the `jwt:<jti-hash>` marker is bound to a live grant member.
+///
+/// Strict allowlist liveness (Todo 26 race rule, RFC 9068 §4): the marker
+/// hash must exist AND the `jwt:<jti-hash>` member must still sit in the
+/// grant's token set named by the marker's own `grant_id` field. The atomic
+/// mint writes marker and membership in one script, so a fresh mint is
+/// strictly live; a marker written late (after a cascade revoke wiped the
+/// set, or after `discard_orphan` removed the member) reports inactive even
+/// though the marker hash itself exists. The marker-only [`marker_live`]
+/// stays untouched for the Todo 24 callers; Todo 27 (cascade marker purge)
+/// and Todo 29 (introspection) consume this bound check.
+///
+/// # Errors
+///
+/// Returns [`JwtError::Backend`] when Redis is unavailable (fail closed: a
+/// missing marker or membership reports `Ok(false)`, never an error).
+pub async fn marker_live_strict(
+  conn: &mut RedisConn,
+  store: &Store,
+  jti_hash: &str,
+) -> Result<bool, JwtError> {
+  let key = store.jwt_marker_key(jti_hash);
+  let flat: Vec<String> = redis::cmd("HGETALL")
+    .arg(&key)
+    .query_async(conn)
+    .await
+    .map_err(|_| JwtError::Backend)?;
+  if flat.is_empty() {
+    return Ok(false);
+  }
+  let mut grant_id = "";
+  let mut fields = flat.as_slice();
+  while let [field, value, rest @ ..] = fields {
+    if field == "grant_id" {
+      grant_id = value;
+    }
+    fields = rest;
+  }
+  if grant_id.is_empty() {
+    return Ok(false);
+  }
+  let member = format!("jwt:{jti_hash}");
+  let bound: bool = redis::cmd("SISMEMBER")
+    .arg(store.grant_tokens_key(grant_id))
+    .arg(&member)
+    .query_async(conn)
+    .await
+    .map_err(|_| JwtError::Backend)?;
+  Ok(bound)
+}
+
+/// Whether the token subject is live: an empty subject (client-credentials)
+/// and unmapped subjects stay live; a subject mapping to a disabled account
+/// does not. Same `subject_account_status` call introspection uses.
+///
+/// # Errors
+///
+/// Returns [`JwtError::Backend`] on a database failure (fail closed: the
+/// caller must treat the token as unusable, never as active).
+pub async fn subject_active(
+  pool: &sqlx::PgPool,
+  keys: &KeyRing,
+  subject: &str,
+) -> Result<bool, JwtError> {
+  if subject.trim().is_empty() {
+    return Ok(true);
+  }
+  match subject_account_status(pool, keys, subject)
+    .await
+    .map_err(|_| JwtError::Backend)?
+  {
+    SubjectAccountStatus::Disabled => Ok(false),
+    SubjectAccountStatus::Enabled | SubjectAccountStatus::Unmapped => Ok(true),
+  }
+}

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

@@ -0,0 +1,459 @@
+//! `test(jwt_)` suite: mint/verify round-trips, header discipline, rotation,
+//! allowlist markers, and disabled-subject rejection for AS-minted JWT
+//! access tokens (RFC 9068 profile subset).
+
+use std::sync::atomic::{AtomicU64, Ordering};
+
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use jsonwebtoken::{Algorithm, Header, decode_header, encode};
+use secrecy::SecretString;
+use time::OffsetDateTime;
+
+use super::{
+  ActClaim, JwtAccessSigner, JwtClaims, JwtError, VerifiedJwt, jti_hash, marker_live,
+  subject_active,
+};
+use crate::{
+  config::{DurabilityMode, JwtSigningAlg, JwtSigningKey},
+  crypto::secret::{Argon2Params, hash_secret},
+  db::{
+    test_support::migrated_pool,
+    user_repo::{NewUser, create_user, disable_user},
+  },
+  keys::{KeyId, KeyRing},
+  protocol::token::Cnf,
+  state::RedisConn,
+  store::Store,
+};
+
+/// Test body result (no `unwrap`/`expect` under the deny lints).
+type TestError = Box<dyn std::error::Error + Send + Sync>;
+type TestResult = Result<(), TestError>;
+
+/// Issuer every test signer asserts.
+const TEST_ISSUER: &str = "https://as.example.test";
+/// Access-token TTL stand-in: the round-trip pins `exp == iat + TTL`.
+const TEST_TTL: i64 = 3_600;
+
+static JTI_NONCE: AtomicU64 = AtomicU64::new(1);
+
+/// A `jti` no other test uses, so allowlist markers never collide.
+fn unique_jti() -> String {
+  let n = JTI_NONCE.fetch_add(1, Ordering::Relaxed);
+  format!("jwt-test-jti-{n}")
+}
+
+/// Current time as seconds since the Unix epoch.
+fn now_unix() -> i64 {
+  OffsetDateTime::now_utc().unix_timestamp()
+}
+
+/// A validated signing key from a fixed 32-byte printable scalar.
+fn signing_key(kid: &str, seed: &str, alg: JwtSigningAlg) -> Result<JwtSigningKey, TestError> {
+  let raw = SecretString::from(seed.repeat(32));
+  Ok(JwtSigningKey::from_raw(
+    KeyId::new(kid),
+    alg,
+    &raw,
+    "jwt_signing_key",
+  )?)
+}
+
+/// An ES256 signer with no previous keys.
+fn test_signer() -> Result<(JwtAccessSigner, JwtSigningKey), TestError> {
+  let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
+  let signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), current.clone(), Vec::new());
+  Ok((signer, current))
+}
+
+/// A well-formed claim set; tests override fields via struct-update syntax.
+fn valid_claims(now: i64) -> JwtClaims {
+  JwtClaims {
+    iss: TEST_ISSUER.to_owned(),
+    sub: "subject@example.test".to_owned(),
+    aud: vec!["https://rs.example.test".to_owned()],
+    scope: "orders:read orders:write".to_owned(),
+    exp: now + TEST_TTL,
+    iat: now,
+    jti: unique_jti(),
+    client_id: "test-client".to_owned(),
+    cnf: None,
+    act: None,
+  }
+}
+
+/// Fresh connection plus store handle against the shared compose Redis.
+async fn test_redis() -> Result<(RedisConn, Store), TestError> {
+  let manager = crate::test_support::redis_manager().await?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(state_prefix, ratelimit_prefix, DurabilityMode::WaitAof);
+  Ok((RedisConn::Single(manager), store))
+}
+
+/// Test PII key ring for the disabled-subject re-check.
+fn test_keys() -> Result<KeyRing, TestError> {
+  Ok(KeyRing::from_parts(
+    vec![(KeyId::new("v1"), SecretString::from("jwt-test-pepper"))],
+    vec![(KeyId::new("v1"), SecretString::from("jwt-test-totp"))],
+    vec![(KeyId::new("v1"), SecretString::from("jwt-test-pii"))],
+  )?)
+}
+
+/// Write the `jwt:<jti-hash>` allowlist marker hash for `jti`.
+async fn write_marker(
+  conn: &mut RedisConn,
+  store: &Store,
+  jti: &str,
+  subject: &str,
+) -> Result<(), TestError> {
+  let key = store.jwt_marker_key(&jti_hash(jti));
+  redis::cmd("HSET")
+    .arg(&key)
+    .arg("grant_id")
+    .arg("grant-1")
+    .arg("client_id")
+    .arg("test-client")
+    .arg("subject")
+    .arg(subject)
+    .query_async::<()>(conn)
+    .await?;
+  Ok(())
+}
+
+/// Assert a verification outcome is exactly the expected `JwtError`.
+fn assert_jwt_error(result: Result<VerifiedJwt, JwtError>, want: JwtError) -> TestResult {
+  match result {
+    Ok(_) => Err(format!("expected {want:?}, token was accepted").into()),
+    Err(got) => {
+      if got == want {
+        Ok(())
+      } else {
+        Err(format!("expected {want:?}, got {got:?}").into())
+      }
+    }
+  }
+}
+
+/// Sign with the configured key but a forged `typ: "JWT"` header: the
+/// signature is valid, yet verification must reject the type.
+#[test]
+fn jwt_verify_rejects_wrong_typ() -> TestResult {
+  let (signer, key) = test_signer()?;
+  let claims = valid_claims(now_unix());
+  let mut header = Header::new(Algorithm::ES256);
+  header.kid = Some(key.kid().as_str().to_owned());
+  header.typ = Some("JWT".to_owned());
+  let forged = encode(&header, &claims, key.encoding_key())?;
+  assert_jwt_error(signer.verify(&forged, None), JwtError::WrongTyp)
+}
+
+/// A token whose `aud` lacks a required value is rejected, even when the
+/// signature and every other claim verify.
+#[test]
+fn jwt_verify_rejects_wrong_aud() -> TestResult {
+  let (signer, _) = test_signer()?;
+  let claims = valid_claims(now_unix());
+  let token = signer.sign(&claims)?;
+  assert_jwt_error(
+    signer.verify(&token, Some(&["https://other.example.test".to_owned()])),
+    JwtError::WrongAudience,
+  )
+}
+
+/// Signature and claims valid, but no marker hash in Redis: the token is
+/// unusable (revoked, expired, or purged).
+#[tokio::test]
+async fn jwt_verify_rejects_unknown_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)?;
+  if marker_live(&mut conn, &store, &jti_hash(&claims.jti)).await? {
+    return Err("marker exists for a fresh jti".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("markerless token was accepted".into()),
+  }
+}
+
+/// A token with no `cnf` (unbound bearer, e.g. client-credentials) verifies,
+/// and `cnf` round-trips to `None` on the wire.
+#[test]
+fn jwt_verify_accepts_absent_cnf() -> TestResult {
+  let (signer, _) = test_signer()?;
+  let claims = valid_claims(now_unix());
+  let token = signer.sign(&claims)?;
+  let verified = signer.verify(&token, None)?;
+  if verified.claims.cnf.is_some() {
+    return Err("absent cnf did not round-trip to None".into());
+  }
+  let segments: Vec<&str> = token.split('.').collect();
+  if segments.len() != 3 {
+    return Err("minted token is not three segments".into());
+  }
+  let payload: serde_json::Value = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(segments[1])?)?;
+  if payload.get("cnf").is_some() {
+    return Err("absent cnf was serialized onto the wire".into());
+  }
+  let bound = JwtClaims {
+    cnf: Some(Cnf {
+      jkt: Some("test-thumbprint".to_owned()),
+      x5t_s256: None,
+    }),
+    ..valid_claims(now_unix())
+  };
+  let bound_verified = signer.verify(&signer.sign(&bound)?, None)?;
+  if bound_verified.claims.cnf != bound.cnf {
+    return Err("present cnf did not survive the round-trip".into());
+  }
+  Ok(())
+}
+
+/// Mint-to-verify round-trip: header `typ`/`kid`/`alg`, `client_id`, `scope`,
+/// `aud`, `jti`, `act`, and `iat`/`exp` pinned to now plus the access TTL.
+#[test]
+fn jwt_sign_verify_round_trip() -> TestResult {
+  let (signer, key) = test_signer()?;
+  let now = now_unix();
+  let claims = JwtClaims {
+    act: Some(ActClaim {
+      sub: "subject-client".to_owned(),
+    }),
+    ..valid_claims(now)
+  };
+  let token = signer.sign(&claims)?;
+  let header = decode_header(&token)?;
+  if header.typ.as_deref() != Some("at+jwt") {
+    return Err("header typ is not at+jwt".into());
+  }
+  if header.kid.as_deref() != Some(key.kid().as_str()) {
+    return Err("header kid is not the current key".into());
+  }
+  if header.alg != Algorithm::ES256 {
+    return Err("header alg is not the configured algorithm".into());
+  }
+  let verified = signer.verify(
+    &token,
+    Some(&[
+      "https://rs.example.test".to_owned(),
+      "https://rs.example.test".to_owned(),
+    ]),
+  )?;
+  if verified.claims != claims {
+    return Err("claims did not survive the round-trip".into());
+  }
+  if verified.claims.iat != now || verified.claims.exp != now + TEST_TTL {
+    return Err("iat/exp are not now plus the access TTL".into());
+  }
+  if verified.claims.client_id != "test-client" {
+    return Err("client_id did not survive the round-trip".into());
+  }
+  Ok(())
+}
+
+/// Rotation: a token minted under the previous kid verifies once that kid is
+/// retained verify-only; the current kid still signs.
+#[test]
+fn jwt_verify_rotation_previous_kid_accepted() -> TestResult {
+  let old = signing_key("v1", "P", JwtSigningAlg::Es256)?;
+  let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
+  let old_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), old.clone(), Vec::new());
+  let rotated = JwtAccessSigner::new(TEST_ISSUER.to_owned(), current.clone(), vec![old.clone()]);
+  let token = old_signer.sign(&valid_claims(now_unix()))?;
+  let verified = rotated.verify(&token, None)?;
+  if decode_header(&token)?.kid.as_deref() != Some("v1") {
+    return Err("rotated token does not carry the previous kid".into());
+  }
+  if verified.claims.jti.is_empty() {
+    return Err("rotated token claims are empty".into());
+  }
+  assert_jwt_error(
+    JwtAccessSigner::new(TEST_ISSUER.to_owned(), current, Vec::new()).verify(&token, None),
+    JwtError::UnknownKid,
+  )
+}
+
+/// A single flipped signature character rejects with `Signature`.
+#[test]
+fn jwt_verify_rejects_tampered_signature() -> TestResult {
+  let (signer, _) = test_signer()?;
+  let token = signer.sign(&valid_claims(now_unix()))?;
+  assert_jwt_error(
+    signer.verify(&tamper_signature(&token)?, None),
+    JwtError::Signature,
+  )
+}
+
+/// An expired token is rejected with zero leeway.
+#[test]
+fn jwt_verify_rejects_expired() -> TestResult {
+  let (signer, _) = test_signer()?;
+  let now = now_unix();
+  let claims = JwtClaims {
+    iat: now - TEST_TTL - 20,
+    exp: now - 10,
+    ..valid_claims(now)
+  };
+  assert_jwt_error(
+    signer.verify(&signer.sign(&claims)?, None),
+    JwtError::Expired,
+  )
+}
+
+/// Flip the first signature character: every base64url symbol there carries
+/// full data bits, so the token still parses and only the signature breaks.
+fn tamper_signature(token: &str) -> Result<String, TestError> {
+  let segments: Vec<&str> = token.split('.').collect();
+  if segments.len() != 3 {
+    return Err("minted token is not three segments".into());
+  }
+  let mut sig = segments[2].to_owned();
+  let first = sig.remove(0);
+  sig.insert(0, if first == 'a' { 'b' } else { 'a' });
+  Ok(format!("{}.{}.{}", segments[0], segments[1], sig))
+}
+
+/// A token minted for another issuer is rejected.
+#[test]
+fn jwt_verify_rejects_wrong_issuer() -> TestResult {
+  let (signer, _) = test_signer()?;
+  let claims = JwtClaims {
+    iss: "https://other.example.test".to_owned(),
+    ..valid_claims(now_unix())
+  };
+  assert_jwt_error(
+    signer.verify(&signer.sign(&claims)?, None),
+    JwtError::WrongIssuer,
+  )
+}
+
+/// A header `kid` naming no current or previous key fails with no fallback.
+#[test]
+fn jwt_verify_rejects_unknown_kid() -> TestResult {
+  let (signer, _) = test_signer()?;
+  let stranger = signing_key("v9", "S", JwtSigningAlg::Es256)?;
+  let stranger_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), stranger, Vec::new());
+  let token = stranger_signer.sign(&valid_claims(now_unix()))?;
+  assert_jwt_error(signer.verify(&token, None), JwtError::UnknownKid)
+}
+
+/// A token signed under another algorithm is rejected even with a valid
+/// signature for that algorithm: the header never picks the algorithm.
+#[test]
+fn jwt_verify_rejects_wrong_alg() -> TestResult {
+  let (signer, _) = test_signer()?;
+  let ed_key = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
+  let ed_signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), ed_key, Vec::new());
+  let token = ed_signer.sign(&valid_claims(now_unix()))?;
+  assert_jwt_error(signer.verify(&token, None), JwtError::WrongAlg)
+}
+
+/// EdDSA keys mint and verify under the same header discipline.
+#[test]
+fn jwt_eddsa_sign_verify_round_trip() -> TestResult {
+  let key = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
+  let signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), key.clone(), Vec::new());
+  let token = signer.sign(&valid_claims(now_unix()))?;
+  let header = decode_header(&token)?;
+  if header.alg != Algorithm::EdDSA || header.typ.as_deref() != Some("at+jwt") {
+    return Err("eddsa header does not carry alg EdDSA and typ at+jwt".into());
+  }
+  signer.verify(&token, None)?;
+  Ok(())
+}
+
+/// Marker present, subject enabled: live. After `disable_user`: the same
+/// token reports `SubjectDisabled`. Empty and unmapped subjects stay live.
+#[tokio::test]
+async fn jwt_verify_live_rejects_disabled_subject() -> TestResult {
+  let (signer, _) = test_signer()?;
+  let (mut conn, store) = test_redis().await?;
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  let params = Argon2Params::new(8, 1, 1, 32)?;
+  let email = format!("jwt-subject-{}@example.test", now_unix());
+  let record = create_user(
+    &pool,
+    &keys,
+    false,
+    &NewUser {
+      email: email.clone(),
+      password_hash: hash_secret("correct-horse-01", &params)?,
+      is_admin: false,
+      must_change_password: false,
+    },
+  )
+  .await?;
+  let claims = JwtClaims {
+    sub: email.clone(),
+    ..valid_claims(now_unix())
+  };
+  let token = signer.sign(&claims)?;
+  write_marker(&mut conn, &store, &claims.jti, &email).await?;
+  signer
+    .verify_live(&mut conn, &store, &pool, &keys, &token, None)
+    .await?;
+  if !subject_active(&pool, &keys, "").await? {
+    return Err("empty subject must stay live".into());
+  }
+  if !subject_active(&pool, &keys, "synthetic-subject").await? {
+    return Err("unmapped subject must stay live".into());
+  }
+  if !disable_user(&pool, record.id).await? {
+    return Err("disable_user reported no change".into());
+  }
+  match signer
+    .verify_live(&mut conn, &store, &pool, &keys, &token, None)
+    .await
+  {
+    Err(JwtError::SubjectDisabled) => Ok(()),
+    Err(got) => Err(format!("expected SubjectDisabled, got {got:?}").into()),
+    Ok(_) => Err("disabled subject token was accepted".into()),
+  }
+}
+
+/// `Debug` on the signer and on every key shape carries no key material: the
+/// raw 32-byte scalars appear nowhere; only `kid`/`alg`/public coordinates.
+#[test]
+fn jwt_debug_redacts_key_material() -> TestResult {
+  let current = signing_key("v2", "C", JwtSigningAlg::Es256)?;
+  let previous = signing_key("v1", "P", JwtSigningAlg::Es256)?;
+  let ed = signing_key("ek1", "E", JwtSigningAlg::EdDsa)?;
+  let signer = JwtAccessSigner::new(
+    TEST_ISSUER.to_owned(),
+    current.clone(),
+    vec![previous.clone()],
+  );
+  for raw in ["C".repeat(32), "P".repeat(32), "E".repeat(32)] {
+    for rendered in [
+      format!("{signer:?}"),
+      format!("{current:?}"),
+      format!("{previous:?}"),
+      format!("{ed:?}"),
+    ] {
+      if rendered.contains(&raw) {
+        return Err("key material leaked into Debug".into());
+      }
+    }
+  }
+  let rendered = format!("{signer:?}");
+  for marker in ["v2", "v1", "Es256"] {
+    if !rendered.contains(marker) {
+      return Err(format!("signer Debug lost {marker}").into());
+    }
+  }
+  if !rendered.contains(current.public_jwk().x.as_str()) {
+    return Err("signer Debug lost the public coordinates".into());
+  }
+  Ok(())
+}

+ 7 - 2
src/jose/mod.rs

@@ -1,10 +1,15 @@
-//! JOSE verification: `DPoP`, JAR, `private_key_jwt`, JWK thumbprints.
+//! JOSE verification: `DPoP`, JAR, `private_key_jwt`, JWK thumbprints, and
+//! AS-minted JWT access tokens.
 //!
 //! Single verifier (`jsonwebtoken`) for every spec-signed artifact. The AS
-//! signs nothing itself, so there is no AS signing key and no `jwks_uri`.
+//! signs nothing except JWT access tokens: when the
+//! `jwt_access_tokens_enabled` kill-switch is on, [`jwt_access`] mints and
+//! verifies `at+jwt` tokens under the configured `JwtSigningKey` ring, and
+//! task 28 advertises the ring's public halves as `jwks_uri`.
 //!
 //! TODO(task 20): wire re-exports for `dpop`/`jar`/`jwk`.
 
 pub mod dpop;
 pub mod jar;
 pub mod jwk;
+pub mod jwt_access;

+ 1 - 1
src/middleware/ratelimit.rs

@@ -34,13 +34,13 @@ use std::time::Duration;
 use axum::body::Body;
 use axum::http::{HeaderValue, Request, Response, StatusCode, header};
 use axum::response::IntoResponse;
+use identity::{client_bucket_raw, client_ip, counter_key, extract_client_id};
 use redis::Script;
 use serde::Serialize;
 use tower::{Layer, Service};
 
 use crate::config::RateLimitConfig;
 use crate::state::AppState;
-use identity::{client_bucket_raw, client_ip, counter_key, extract_client_id};
 
 #[cfg(test)]
 mod test_support;

+ 2 - 1
src/middleware/redaction.rs

@@ -139,10 +139,11 @@ fn redact_pair(pair: &str) -> String {
 
 #[cfg(test)]
 mod redaction_tests {
-  use super::*;
   use axum::http::{HeaderName, HeaderValue};
   use serde_json::json;
 
+  use super::*;
+
   #[test]
   fn redaction_header_map_masks_authorization_cookie_and_dpop() {
     let mut headers = HeaderMap::new();

+ 55 - 33
src/middleware/security_headers.rs

@@ -10,11 +10,12 @@
 //! when the router serves the TLS public listener (`hsts: true`); the
 //! plaintext health listener never claims HSTS.
 //!
-//! Every response that is not OAuth metadata discovery is marked
-//! `Cache-Control: no-store` (+ `Pragma: no-cache` for legacy clients), so an
-//! authorization page, consent page, token body, or error is never reused by a
-//! browser, the back/forward cache, or an intermediary. `/.well-known/…`
-//! metadata stays cacheable.
+//! Every response that is not OAuth metadata discovery or the public JWK Set
+//! is marked `Cache-Control: no-store` (+ `Pragma: no-cache` for legacy
+//! clients), so an authorization page, consent page, token body, or error is
+//! never reused by a browser, the back/forward cache, or an intermediary.
+//! `/.well-known/…` metadata stays cacheable, and `/jwks.json` gets its own
+//! shorter window: a rotated-out key must leave intermediary caches quickly.
 //!
 //! [`cors_layer`] builds the explicit browser-facing CORS policy from
 //! `config.cors_allowed_origins`: an empty allowlist yields `None` (no CORS
@@ -52,6 +53,14 @@ pub const NO_CACHE_VALUE: &str = "no-cache";
 /// so caching it is intentional and safe. The explicit directive replaces
 /// browser heuristic freshness with a tunable, reviewable policy.
 pub const METADATA_CACHE_VALUE: &str = "public, max-age=3600";
+/// Positive `Cache-Control` for the public JWK Set (`GET /jwks.json`).
+///
+/// Shorter than [`METADATA_CACHE_VALUE`] on purpose: a rotated-out key is
+/// retained in the JWK Set for resource servers still checking old tokens, so
+/// the cache window bounds how long a revoked key can linger in an
+/// intermediary cache. Five minutes keeps JWKS fetch traffic low without
+/// pinning a dead key for an hour.
+pub const JWKS_CACHE_VALUE: &str = "public, max-age=300";
 
 /// Whether `path` serves a server-rendered page (strict script policy).
 ///
@@ -63,14 +72,21 @@ pub fn is_page_path(path: &str) -> bool {
   matches!(first, "login" | "consent" | "device")
 }
 
-/// Whether `path`'s response may be cached: OAuth metadata discovery only.
+/// The exact `Cache-Control` value for `path`.
 ///
-/// Every other response is marked `no-store`, so credentials, tokens, request
-/// handles, and server-rendered pages never survive in a browser or an
-/// intermediary cache.
+/// The mapping is per-path, not a boolean: OAuth metadata discovery keeps the
+/// one-hour value, the public JWK Set gets a shorter window, and everything
+/// else forbids storage entirely. A single "cacheable" flag would force JWKS
+/// onto the metadata window and keep a rotated-out key cached for an hour.
 #[must_use]
-pub fn is_cacheable_path(path: &str) -> bool {
-  path.starts_with("/.well-known/")
+pub fn cache_control_value(path: &str) -> &'static str {
+  if path == "/jwks.json" {
+    JWKS_CACHE_VALUE
+  } else if path.starts_with("/.well-known/") {
+    METADATA_CACHE_VALUE
+  } else {
+    NO_STORE_VALUE
+  }
 }
 
 /// Tower layer decorating responses with the security-header set.
@@ -130,20 +146,25 @@ where
 
   fn call(&mut self, request: Request<B>) -> Self::Future {
     let page = is_page_path(request.uri().path());
-    let cacheable = is_cacheable_path(request.uri().path());
+    let cache = cache_control_value(request.uri().path());
     let hsts = self.hsts;
     let mut inner = self.inner.clone();
     Box::pin(async move {
       let mut response = inner.call(request).await?;
-      insert_headers(response.headers_mut(), page, hsts, cacheable);
+      insert_headers(response.headers_mut(), page, hsts, cache);
       Ok(response)
     })
   }
 }
 
 /// Insert the header set on an outbound response (`page` selects the CSP;
-/// `cacheable` is true only for metadata, so everything else is `no-store`).
-fn insert_headers(headers: &mut axum::http::HeaderMap, page: bool, hsts: bool, cacheable: bool) {
+/// `cache` is the exact `Cache-Control` value for the request path).
+fn insert_headers(
+  headers: &mut axum::http::HeaderMap,
+  page: bool,
+  hsts: bool,
+  cache: &'static str,
+) {
   let csp = if page { PAGE_CSP_VALUE } else { API_CSP_VALUE };
   for (name, value) in [
     (
@@ -161,19 +182,10 @@ fn insert_headers(headers: &mut axum::http::HeaderMap, page: bool, hsts: bool, c
   ] {
     headers.insert(name, value);
   }
-  if !cacheable {
-    headers.insert(
-      header::CACHE_CONTROL,
-      HeaderValue::from_static(NO_STORE_VALUE),
-    );
+  headers.insert(header::CACHE_CONTROL, HeaderValue::from_static(cache));
+  if cache == NO_STORE_VALUE {
     headers.insert(header::PRAGMA, HeaderValue::from_static(NO_CACHE_VALUE));
   }
-  if cacheable {
-    headers.insert(
-      header::CACHE_CONTROL,
-      HeaderValue::from_static(METADATA_CACHE_VALUE),
-    );
-  }
   if hsts {
     headers.insert(
       header::STRICT_TRANSPORT_SECURITY,
@@ -224,8 +236,8 @@ mod tests {
   use axum::http::header;
 
   use super::{
-    HSTS_VALUE, METADATA_CACHE_VALUE, NO_CACHE_VALUE, NO_STORE_VALUE, insert_headers,
-    is_cacheable_path, is_page_path,
+    HSTS_VALUE, JWKS_CACHE_VALUE, METADATA_CACHE_VALUE, NO_CACHE_VALUE, NO_STORE_VALUE,
+    cache_control_value, insert_headers, is_page_path,
   };
 
   #[test]
@@ -239,17 +251,27 @@ mod tests {
   }
 
   #[test]
-  fn only_metadata_is_cacheable() {
-    assert!(is_cacheable_path("/.well-known/oauth-authorization-server"));
+  fn cache_control_is_per_path() {
+    assert_eq!(
+      cache_control_value("/.well-known/oauth-authorization-server"),
+      METADATA_CACHE_VALUE
+    );
+    assert_eq!(cache_control_value("/jwks.json"), JWKS_CACHE_VALUE);
+    assert_ne!(JWKS_CACHE_VALUE, METADATA_CACHE_VALUE);
+    assert_ne!(JWKS_CACHE_VALUE, NO_STORE_VALUE);
     for path in ["/login", "/authorize", "/token", "/par", "/health/live"] {
-      assert!(!is_cacheable_path(path), "{path} must not be cacheable");
+      assert_eq!(
+        cache_control_value(path),
+        NO_STORE_VALUE,
+        "{path} must not be cacheable"
+      );
     }
   }
 
   #[test]
   fn non_cacheable_response_forbids_storage() {
     let mut headers = axum::http::HeaderMap::new();
-    insert_headers(&mut headers, true, true, false);
+    insert_headers(&mut headers, true, true, NO_STORE_VALUE);
     let value = |name: header::HeaderName| {
       headers
         .get(name)
@@ -265,7 +287,7 @@ mod tests {
   #[test]
   fn cacheable_metadata_sets_positive_cache_control() {
     let mut headers = axum::http::HeaderMap::new();
-    insert_headers(&mut headers, false, false, true);
+    insert_headers(&mut headers, false, false, METADATA_CACHE_VALUE);
     let cache_control = headers
       .get(header::CACHE_CONTROL)
       .and_then(|value| value.to_str().ok())

+ 188 - 22
src/protocol/authorize.rs

@@ -16,7 +16,7 @@
 //! failure redirects to the validated `redirect_uri` with `error` (+ `error_description`, + `state`, + RFC 9207
 //! `iss`).
 
-use std::collections::HashMap;
+use std::{collections::HashMap, time::Duration};
 
 use axum::{
   body::Body,
@@ -31,11 +31,27 @@ use crate::{
   db::{client_repo, redirect_repo, scope_repo},
   error::{AppError, OAuthErrorCode},
   jose::jar::{self, JarRequest},
-  protocol::{redirect_repo as redirect_match, scope},
+  protocol::{cimd, redirect_repo as redirect_match, scope},
   state::RedisConn,
   store::Store,
 };
 
+/// Client ID Metadata Document (CIMD) resolution settings for a request path.
+///
+/// Present only when `config.cimd_enabled`; a `None` value makes a CIMD URL
+/// `client_id` take the existing unknown-client path, so enabling the feature
+/// is the sole switch (CIMD draft §3/§4.1).
+#[derive(Debug, Clone, Copy)]
+pub struct CimdRequest<'a> {
+  /// Token keyspace prefix carrying the `{state}` hash tag, for the
+  /// `cimd:<sha256(url)>` document cache.
+  pub token_prefix: &'a str,
+  /// Maximum accepted document size (`config.cimd_max_bytes`).
+  pub max_bytes: usize,
+  /// Total fetch budget (`config.cimd_fetch_timeout`).
+  pub timeout: Duration,
+}
+
 /// Inputs to [`validate`]: live stores plus the raw authorize query.
 pub struct AuthorizeInput<'a> {
   /// Postgres pool for the client, redirect, and scope catalog reads.
@@ -48,8 +64,19 @@ pub struct AuthorizeInput<'a> {
   pub store: &'a Store,
   /// Server issuer; the `aud` of JAR request objects.
   pub issuer: &'a str,
-  /// Raw authorize query parameters as received.
+  /// Config `allow_localhost_redirect`: treat the exact host `localhost` as
+  /// an RFC 8252 §7.3 loopback redirect host.
+  pub allow_localhost_redirect: bool,
+  /// CIMD resolution settings; `None` when `config.cimd_enabled` is off.
+  pub cimd: Option<CimdRequest<'a>>,
+  /// Raw query parameters as received.
   pub query: &'a HashMap<String, String>,
+  /// Repeated `resource` indicators (RFC 8707) in wire order, captured from
+  /// the raw query by the `RawQuery` extractor (see `protocol::params`).
+  pub resources: Vec<String>,
+  /// Config `resource_indicators_enabled`: off + any `resource` present is
+  /// `invalid_target` (fail-closed, no silent drop).
+  pub resource_indicators_enabled: bool,
 }
 
 /// An authorize request that passed every validation step.
@@ -70,6 +97,9 @@ pub struct ValidatedRequest {
   pub code_challenge: String,
   /// The `state` value to echo in the authorization response, if supplied.
   pub state: Option<String>,
+  /// Validated resource audiences (RFC 8707), sorted and deduped; empty
+  /// when unbound. Pinned into the pending record at the route layer.
+  pub aud: Vec<String>,
   /// The client's single registered post-logout return URI, when it has one.
   /// Resolved during validation so a later logout can return the browser to
   /// the client without accepting any redirect target from a request.
@@ -139,6 +169,37 @@ impl AuthorizeFailure {
   }
 }
 
+/// The resolved authorize client: a registered row, or a registration-free
+/// Client ID Metadata Document (CIMD draft §3/§4.1).
+///
+/// The CIMD arm is purely in-memory: it never writes a row, never attaches a
+/// catalog redirect, and never consumes an initial access token.
+enum ResolvedClient {
+  /// An active `oauth_clients` row.
+  Registered(Box<client_repo::Client>),
+  /// A validated document fetched from an `https://` `client_id`.
+  Cimd(cimd::ValidatedCimd),
+}
+
+impl ResolvedClient {
+  /// The public `client_id` (the document URL for the CIMD arm).
+  fn client_id(&self) -> &str {
+    match self {
+      Self::Registered(client) => &client.client_id,
+      Self::Cimd(validated) => &validated.client_id,
+    }
+  }
+
+  /// The scope bound used as the client-registered set for the intersect: a
+  /// registered client's stored `scope`, or the document's `scope`.
+  fn registered_scope(&self) -> &str {
+    match self {
+      Self::Registered(client) => &client.scope,
+      Self::Cimd(validated) => &validated.scope,
+    }
+  }
+}
+
 /// Validate an authorize request following the mandated order.
 ///
 /// Returns the normalized request when every check passes. Returns
@@ -148,28 +209,61 @@ impl AuthorizeFailure {
 /// # Errors
 ///
 /// Returns [`AuthorizeFailure`] as described by the error-delivery matrix.
-pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, AuthorizeFailure> {
+pub async fn validate(mut input: AuthorizeInput<'_>) -> Result<ValidatedRequest, AuthorizeFailure> {
   let client_id = present(input.query.get("client_id"))
     .ok_or_else(|| page(AppError::invalid_request("client_id is required")))?;
-  let client = client_repo::find_active_by_client_id(input.pool, input.keys, client_id)
+
+  // A registered row always wins over CIMD; an `https://` `client_id` with no
+  // active row is a CIMD client only while the feature is enabled (CIMD
+  // draft §3). With `cimd` absent the unknown-client page is unchanged.
+  let resolution = match client_repo::find_active_by_client_id(input.pool, input.keys, client_id)
     .await
     .map_err(AuthorizeFailure::Page)?
-    .ok_or_else(|| page(AppError::invalid_client_non_basic()))?;
+  {
+    Some(client) => ResolvedClient::Registered(Box::new(client)),
+    None => match resolve_cimd(&mut input, client_id).await? {
+      Some(validated) => ResolvedClient::Cimd(validated),
+      None => return Err(page(AppError::invalid_client_non_basic())),
+    },
+  };
 
+  // A registration-free CIMD client resolves through the same JAR entry
+  // point as a registered row, but against a synthetic principal
+  // materialized from the validated document (CIMD draft §4.1/§5): its
+  // inline `jwks` verifies request objects, `require_par_or_jar` keeps the
+  // PAR/JAR matrix (a `private_key_jwt` document must use PAR or JAR, a
+  // `none` document may use the plain query), and a PAR-issued `request_uri`
+  // binds to the document URL exactly as a row id would.
+  let synthetic;
+  let jar_client: &client_repo::Client = match &resolution {
+    ResolvedClient::Registered(client) => client,
+    ResolvedClient::Cimd(validated) => {
+      synthetic = validated.to_client();
+      &synthetic
+    }
+  };
   let jar_input = JarRequest {
-    client: &client,
+    client: jar_client,
     issuer: input.issuer,
     query: input.query,
     request: present(input.query.get("request")).map(String::as_str),
     request_uri: present(input.query.get("request_uri")).map(String::as_str),
   };
-  let merged = jar::resolve(input.conn, input.store, &jar_input)
+  let merged_claims = jar::resolve(input.conn, input.store, &jar_input)
     .await
     .map_err(AuthorizeFailure::Page)?;
+  // Query-presented indicators union with JAR/PAR-pinned ones (both are
+  // catalog-validated below, so the union cannot smuggle anything past the
+  // catalog). A `resource` in both transports is already rejected by the
+  // merge as `invalid_request_object`.
+  let mut resources = input.resources.clone();
+  resources.extend(merged_claims.resource);
+  let merged = &merged_claims.params;
 
+  let expected_client_id = resolution.client_id();
   if merged
     .get("client_id")
-    .is_some_and(|seen| seen != &client.client_id)
+    .is_some_and(|seen| seen.as_str() != expected_client_id)
   {
     return Err(page(AppError::invalid_request(
       "client_id binding mismatch",
@@ -178,11 +272,26 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
 
   let redirect_uri = present(merged.get("redirect_uri"))
     .ok_or_else(|| page(AppError::invalid_request("redirect_uri is required")))?;
-  let effective = redirect_repo::effective_for_client(input.pool, input.keys, &client.client_id)
-    .await
-    .map_err(AuthorizeFailure::Page)?;
-  let registered: Vec<&str> = effective.iter().map(|entry| entry.uri.as_str()).collect();
-  match redirect_match::match_redirect_uri(&registered, redirect_uri) {
+  // A registered client matches against its effective catalog set; a CIMD
+  // client against its own document `redirect_uris` only — the catalog is
+  // never consulted for a registration-free client (CIMD draft §5).
+  let registered_uris: Vec<String> = match &resolution {
+    ResolvedClient::Registered(client) => {
+      redirect_repo::effective_for_client(input.pool, input.keys, &client.client_id)
+        .await
+        .map_err(AuthorizeFailure::Page)?
+        .into_iter()
+        .map(|entry| entry.uri)
+        .collect()
+    }
+    ResolvedClient::Cimd(validated) => validated.redirect_uris.clone(),
+  };
+  let registered: Vec<&str> = registered_uris.iter().map(String::as_str).collect();
+  match redirect_match::match_redirect_uri(
+    &registered,
+    redirect_uri,
+    input.allow_localhost_redirect,
+  ) {
     redirect_match::RedirectDecision::Exact | redirect_match::RedirectDecision::Loopback => {}
     redirect_match::RedirectDecision::Reject => {
       return Err(page(AppError::invalid_request(
@@ -200,6 +309,15 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
     iss: input.issuer.to_owned(),
   };
 
+  // Resource indicators (RFC 8707) validate after the redirect target, so a
+  // bad audience redirects (never a page): unknown, non-https, or flag-off
+  // `resource` is `invalid_target`, and the sorted deduped set pins into
+  // `ValidatedRequest.aud` for the pending record.
+  let aud =
+    super::params::validate_resources(input.pool, input.resource_indicators_enabled, &resources)
+      .await
+      .map_err(redirect_err)?;
+
   match present(merged.get("response_type")) {
     Some(response_type) if response_type == "code" => {}
     Some(_) => {
@@ -218,9 +336,9 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
     .await
     .map_err(redirect_err)?;
   let enabled_names: Vec<String> = enabled.iter().map(|record| record.name.clone()).collect();
-  let enabled_set = scope_set_of(&enabled_names, &redirect, &merged, input.issuer)?;
-  let client_registered =
-    scope::ScopeSet::parse(&client.scope).map_err(|_| redirect_err(AppError::server_error()))?;
+  let enabled_set = scope_set_of(&enabled_names, &redirect, merged, input.issuer)?;
+  let client_registered = scope::ScopeSet::parse(resolution.registered_scope())
+    .map_err(|_| redirect_err(AppError::server_error()))?;
   let scope_input = scope::ScopeInput {
     requested: present(merged.get("scope")).map(String::as_str),
     client_registered: &client_registered,
@@ -249,21 +367,69 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
   verify_method(S256_METHOD).map_err(|_| redirect_err(AppError::server_error()))?;
   check_challenge_shape(challenge).map_err(redirect_err)?;
 
-  let post_logout_uri =
-    redirect_repo::post_logout_for_client(input.pool, input.keys, &client.client_id)
-      .await
-      .map_err(redirect_err)?;
+  // A registration-free client has no catalog post-logout target; only a
+  // registered row is consulted.
+  let post_logout_uri = match &resolution {
+    ResolvedClient::Registered(client) => {
+      redirect_repo::post_logout_for_client(input.pool, input.keys, &client.client_id)
+        .await
+        .map_err(redirect_err)?
+    }
+    ResolvedClient::Cimd(_) => None,
+  };
 
   Ok(ValidatedRequest {
-    client_id: client.client_id,
+    client_id: resolution.client_id().to_owned(),
     redirect_uri: redirect,
     scope: effective_scope,
     code_challenge: challenge.clone(),
     state: merged.get("state").cloned(),
+    aud,
     post_logout_uri,
   })
 }
 
+/// Resolve a Client ID Metadata Document for an `https://` `client_id` when
+/// the feature is enabled and no registered row matched.
+///
+/// Returns `Ok(None)` when CIMD is disabled or `client_id` is not an
+/// `https://` URL, so the caller keeps the existing unknown-client behavior.
+/// A document that fails validation maps to its client-safe error
+/// (`invalid_client_metadata`/`invalid_redirect_uri`; a dependency outage is
+/// a fail-closed `503`).
+///
+/// # Errors
+///
+/// Returns [`AuthorizeFailure::Page`] for any fetch/validation/backend
+/// [`cimd::CimdError`].
+async fn resolve_cimd(
+  input: &mut AuthorizeInput<'_>,
+  client_id: &str,
+) -> Result<Option<cimd::ValidatedCimd>, AuthorizeFailure> {
+  let Some(settings) = input.cimd else {
+    return Ok(None);
+  };
+  // CIMD draft §3: only an `https://` URL names a metadata document.
+  if !client_id.starts_with("https://") {
+    return Ok(None);
+  }
+  let limits = cimd::CimdLimits {
+    max_bytes: settings.max_bytes,
+    timeout: settings.timeout,
+  };
+  let validated = cimd::resolve_with_catalog(
+    input.conn,
+    settings.token_prefix,
+    input.pool,
+    client_id,
+    input.allow_localhost_redirect,
+    limits,
+  )
+  .await
+  .map_err(|error| page(AppError::from(error)))?;
+  Ok(Some(validated))
+}
+
 /// Treat `None` and `""` alike: an absent parameter.
 fn present(value: Option<&String>) -> Option<&String> {
   value.filter(|text| !text.is_empty())

+ 350 - 2
src/protocol/authorize/tests.rs

@@ -3,19 +3,24 @@
 use std::{collections::HashMap, time::Duration};
 
 use axum::http::{StatusCode, header};
+use secrecy::SecretString;
 use uuid::Uuid;
 
 use super::{AuthorizeFailure, AuthorizeInput, validate};
 use crate::{
   config::DurabilityMode,
   crypto::pkce::challenge_from_verifier,
-  db::{client_repo::NewClient, redirect_repo, scope_repo, test_support::migrated_pool},
+  db::{
+    client_repo, client_repo::NewClient, redirect_repo, resource_repo, scope_repo,
+    test_support::migrated_pool,
+  },
   error::{AppError, OAuthErrorCode},
   keys::{KeyId, KeyRing},
+  protocol::cimd,
   state::RedisConn,
   store::Store,
+  test_support,
 };
-use secrecy::SecretString;
 
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
 type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
@@ -56,6 +61,7 @@ fn fixture(client_id: &str, public: bool) -> NewClient {
     contacts: None,
     software_id: None,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
   }
 }
@@ -161,7 +167,11 @@ async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   .map_err(|failure| format!("valid request rejected: {}", failure.code()))?;
@@ -190,7 +200,11 @@ async fn authorize_validate_unknown_client_shows_page() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -225,7 +239,11 @@ async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -258,7 +276,11 @@ async fn authorize_validate_token_response_type_redirects() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -317,7 +339,11 @@ async fn authorize_validate_unknown_scope_redirects() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -376,7 +402,11 @@ async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -409,7 +439,11 @@ async fn authorize_redirect_exact_match() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   .map_err(|failure| format!("exact redirect rejected: {}", failure.code()))?;
@@ -427,7 +461,11 @@ async fn authorize_redirect_exact_match() -> TestResult {
       conn: &mut conn,
       store: &store,
       issuer: ISSUER,
+      allow_localhost_redirect: false,
+      cimd: None,
       query: &params,
+      resources: Vec::new(),
+      resource_indicators_enabled: false,
     })
     .await
     {
@@ -453,7 +491,11 @@ async fn authorize_redirect_exact_match() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -491,7 +533,11 @@ async fn authorize_loopback_allows_port() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   .map_err(|failure| format!("loopback port rejected: {}", failure.code()))?;
@@ -507,7 +553,11 @@ async fn authorize_loopback_allows_port() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -537,7 +587,11 @@ async fn authorize_validate_missing_challenge_redirects() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -624,7 +678,11 @@ async fn authorize_post_logout_outage_redirects() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -664,7 +722,11 @@ async fn authorize_error_iss() -> TestResult {
     conn: &mut conn,
     store: &store,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   .await
   {
@@ -734,7 +796,11 @@ async fn authorize_validate_missing_response_type_redirects() -> TestResult {
       conn: &mut conn,
       store: &store,
       issuer: ISSUER,
+      allow_localhost_redirect: false,
+      cimd: None,
       query: &params,
+      resources: Vec::new(),
+      resource_indicators_enabled: false,
     })
     .await
     {
@@ -778,3 +844,285 @@ async fn authorize_validate_missing_response_type_redirects() -> TestResult {
   }
   Ok(())
 }
+
+/// Seed the CIMD document cache for `url`, so a resolve is served from Redis
+/// without a network fetch (a cache miss would fail against `.invalid`).
+async fn seed_cimd(
+  conn: &mut RedisConn,
+  prefix: &str,
+  url: &str,
+  document: &cimd::CimdDocument,
+) -> TestResult {
+  let payload = serde_json::to_string(document)?;
+  redis::cmd("SET")
+    .arg(cimd::cache_key(prefix, url))
+    .arg(payload)
+    .arg("EX")
+    .arg(900)
+    .query_async::<()>(conn)
+    .await?;
+  Ok(())
+}
+
+/// A minimal `none`-method CIMD document (CIMD draft §4.1).
+fn cimd_document(url: &str, redirect_uris: &[&str], scope: &str) -> cimd::CimdDocument {
+  cimd::CimdDocument {
+    client_id: url.to_owned(),
+    redirect_uris: redirect_uris.iter().map(|uri| (*uri).to_owned()).collect(),
+    token_endpoint_auth_method: Some("none".to_owned()),
+    grant_types: Some(vec!["authorization_code".to_owned()]),
+    scope: Some(scope.to_owned()),
+    jwks: None,
+    jwks_uri: None,
+    client_name: None,
+    dpop_bound_access_tokens: None,
+  }
+}
+
+/// A shared-Redis connection plus its unique `{state}`/`{rl}` prefix pair.
+async fn shared_redis() -> TestResult<(RedisConn, String, String)> {
+  let manager = test_support::redis_manager()
+    .await
+    .map_err(|error| format!("shared redis: {error}"))?;
+  let (token_prefix, ratelimit_prefix) = test_support::unique_prefixes();
+  Ok((RedisConn::Single(manager), token_prefix, ratelimit_prefix))
+}
+
+/// CIMD settings pointing at this test's cache prefix.
+fn cimd_settings(prefix: &str) -> super::CimdRequest<'_> {
+  super::CimdRequest {
+    token_prefix: prefix,
+    max_bytes: 5_120,
+    timeout: Duration::from_secs(2),
+  }
+}
+
+/// A client metadata document client completes `/authorize` with no row and
+/// no catalog redirect; a redirect outside the document and a shared-secret
+/// document both fail closed (CIMD draft §4.1/§5).
+#[tokio::test]
+async fn authorize_accepts_cimd_public() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  scope_repo::upsert(&pool, "api:read", "read api").await?;
+  let (mut conn, prefix, ratelimit) = shared_redis().await?;
+  let store = Store::from_parts(prefix.clone(), ratelimit, DurabilityMode::WaitAof);
+
+  let url = "https://client.invalid/cimd.json";
+  seed_cimd(
+    &mut conn,
+    &prefix,
+    url,
+    &cimd_document(url, &["https://app.example.test/cb"], "api:read"),
+  )
+  .await?;
+
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let params = query(&[
+    ("client_id", url),
+    ("redirect_uri", "https://app.example.test/cb"),
+    ("response_type", "code"),
+    ("scope", "api:read"),
+    ("code_challenge", challenge.as_str()),
+    ("code_challenge_method", "S256"),
+    ("state", "xyz-state"),
+  ]);
+
+  let validated = validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: Some(cimd_settings(&prefix)),
+    query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
+  })
+  .await
+  .map_err(|failure| format!("CIMD request rejected: {}", failure.code()))?;
+  assert_eq!(validated.client_id, url);
+  assert_eq!(validated.redirect_uri, "https://app.example.test/cb");
+  assert_eq!(validated.scope.render(), "api:read");
+  assert_eq!(validated.state.as_deref(), Some("xyz-state"));
+
+  // A registration-free client writes no row and attaches no catalog redirect.
+  assert!(
+    client_repo::find_active_by_client_id(&pool, &keys, url)
+      .await?
+      .is_none(),
+    "CIMD must not persist a client row"
+  );
+
+  // A redirect outside the document is refused (untrusted: page, no Location).
+  let mut mismatch = params.clone();
+  mismatch.insert(
+    "redirect_uri".to_owned(),
+    "https://evil.example.test/cb".to_owned(),
+  );
+  match validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: Some(cimd_settings(&prefix)),
+    query: &mismatch,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
+  })
+  .await
+  {
+    Ok(_) => return Err("document redirect mismatch was accepted".into()),
+    Err(failure) => assert_eq!(failure.code(), OAuthErrorCode::InvalidRequest),
+  }
+
+  // A document declaring a shared-secret method is invalid metadata.
+  let shared_url = "https://client.invalid/secret.json";
+  let mut shared = cimd_document(shared_url, &["https://app.example.test/cb"], "api:read");
+  shared.token_endpoint_auth_method = Some("client_secret_basic".to_owned());
+  seed_cimd(&mut conn, &prefix, shared_url, &shared).await?;
+  let mut shared_params = params.clone();
+  shared_params.insert("client_id".to_owned(), shared_url.to_owned());
+  match validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: Some(cimd_settings(&prefix)),
+    query: &shared_params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
+  })
+  .await
+  {
+    Ok(_) => Err("shared-secret CIMD document was accepted".into()),
+    Err(failure) => {
+      assert_eq!(failure.code(), OAuthErrorCode::InvalidClientMetadata);
+      Ok(())
+    }
+  }
+}
+
+/// A document `http://localhost` redirect is accepted only while
+/// `allow_localhost_redirect` is on; the request's random port is matched by
+/// the RFC 8252 §7.3 loopback rule. With the flag off the document itself is
+/// invalid, because `http://localhost` is not a loopback host.
+#[tokio::test]
+async fn authorize_accepts_cimd_localhost_when_flag_on() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  scope_repo::upsert(&pool, "api:read", "read api").await?;
+  let (mut conn, prefix, ratelimit) = shared_redis().await?;
+  let store = Store::from_parts(prefix.clone(), ratelimit, DurabilityMode::WaitAof);
+
+  let url = "https://client.invalid/loopback.json";
+  seed_cimd(
+    &mut conn,
+    &prefix,
+    url,
+    &cimd_document(url, &["http://localhost/cb"], "api:read"),
+  )
+  .await?;
+
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let params = query(&[
+    ("client_id", url),
+    ("redirect_uri", "http://localhost:51234/cb"),
+    ("response_type", "code"),
+    ("scope", "api:read"),
+    ("code_challenge", challenge.as_str()),
+    ("code_challenge_method", "S256"),
+  ]);
+
+  let validated = validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    allow_localhost_redirect: true,
+    cimd: Some(cimd_settings(&prefix)),
+    query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
+  })
+  .await
+  .map_err(|failure| {
+    format!(
+      "CIMD loopback rejected with the flag on: {}",
+      failure.code()
+    )
+  })?;
+  assert_eq!(validated.redirect_uri, "http://localhost:51234/cb");
+  assert_eq!(validated.scope.render(), "api:read");
+
+  match validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: Some(cimd_settings(&prefix)),
+    query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
+  })
+  .await
+  {
+    Ok(_) => Err("CIMD localhost redirect was accepted with the flag off".into()),
+    Err(failure) => {
+      assert_eq!(failure.code(), OAuthErrorCode::InvalidRedirectUri);
+      Ok(())
+    }
+  }
+}
+
+/// Two catalog resources presented as repeated query indicators validate and
+/// pin as a sorted deduped audience set on the validated request.
+#[tokio::test]
+async fn authorize_accepts_repeated_resource() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let keys = test_keys()?;
+  provision(&pool, &keys, "authz-resource", true).await?;
+  resource_repo::upsert(&pool, "https://b.example.test/", "b").await?;
+  resource_repo::upsert(&pool, "https://a.example.test/", "a").await?;
+  let challenge = challenge_from_verifier(VERIFIER).map_err(|_| "bad fixture verifier")?;
+  let params = valid_query("authz-resource", &challenge);
+  let mut conn = offline_redis()?;
+  let store = test_store();
+
+  let validated = validate(AuthorizeInput {
+    pool: &pool,
+    keys: &keys,
+    conn: &mut conn,
+    store: &store,
+    issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
+    query: &params,
+    resources: vec![
+      "https://b.example.test/".to_owned(),
+      "https://a.example.test/".to_owned(),
+      "https://b.example.test/".to_owned(),
+    ],
+    resource_indicators_enabled: true,
+  })
+  .await
+  .map_err(|failure| format!("repeated resource rejected: {}", failure.code()))?;
+
+  if validated.aud
+    != vec![
+      "https://a.example.test/".to_owned(),
+      "https://b.example.test/".to_owned(),
+    ]
+  {
+    return Err(format!("aud not pinned sorted deduped: {:?}", validated.aud).into());
+  }
+  Ok(())
+}

+ 654 - 0
src/protocol/cimd.rs

@@ -0,0 +1,654 @@
+//! Client ID Metadata Documents (CIMD) — fetch, validate, and cache.
+//!
+//! A registration-free client identifies itself with an `https://` URL as its
+//! `client_id`; the authorization server fetches that URL and validates the
+//! returned JSON document before treating it as client metadata. This module
+//! owns the fetch and the strict validation rules; the request-path wiring
+//! (authorize/token/client auth) lives in the callers.
+//!
+//! Strict rules (CIMD draft §4.1/§5/§8.6/§8.7):
+//!
+//! - The document URL is fetched through the single guarded fetcher
+//!   [`ssrf::fetch_guarded_bytes_with`]: `https` only, no redirects, special-
+//!   use/private/loopback targets rejected, the configured timeout, and the
+//!   configured byte cap.
+//! - The response MUST be a JSON object whose `client_id` is byte-for-byte the
+//!   fetched URL.
+//! - `token_endpoint_auth_method` MUST be `none` (the default when absent) or
+//!   `private_key_jwt`. Every shared-secret and mTLS method is rejected
+//!   (CIMD §4.1: metadata documents describe clients that cannot hold a
+//!   shared secret).
+//! - `private_key_jwt` REQUIRES an INLINE `jwks` object with a non-empty
+//!   `keys` array. A document that supplies only `jwks_uri` is rejected: the
+//!   decision here is inline-only so client authentication never triggers a
+//!   second network fetch on the token path.
+//! - `redirect_uris` MUST be a non-empty array. Every URI must be `https`, or
+//!   plaintext `http` to an RFC 8252 §7.3 loopback host (`127.0.0.1`, `[::1]`,
+//!   and `localhost` only while `allow_localhost` is set). The checks delegate
+//!   to [`redirect_repo::match_redirect_uri`] so CIMD and registered clients
+//!   share exactly one loopback rule. Wildcards are never accepted.
+//! - `scope` MUST be a subset of the enabled catalog and `grant_types` a
+//!   subset of `{authorization_code, refresh_token}`.
+//!
+//! A validated document is cached for 15 minutes under `{state}` as
+//! `cimd:<sha256(url)>`. The cache stores the raw document; it is revalidated
+//! on every read so a scope disabled during the TTL takes effect immediately.
+//!
+//! This module never writes an `/register` row and never consumes an initial
+//! access token; it only resolves metadata.
+
+#[cfg(test)]
+mod tests;
+#[cfg(test)]
+mod tests_principal;
+
+use std::{collections::HashSet, time::Duration};
+
+use serde::{Deserialize, Serialize};
+use sqlx::PgPool;
+
+use crate::{
+  db::{client_repo, scope_repo},
+  error::AppError,
+  keys::KeyRing,
+  protocol::{
+    redirect_repo::{self, RedirectDecision},
+    ssrf,
+  },
+  state::RedisConn,
+  store::{StoreError, sha256_hex},
+};
+
+/// How long a validated CIMD document stays cached (seconds).
+const CACHE_TTL_SECS: u64 = 15 * 60;
+
+/// Grant types a CIMD client may declare (CIMD §4.1; no device or
+/// client-credentials grants in a registration-free document).
+const ALLOWED_GRANTS: [&str; 2] = ["authorization_code", "refresh_token"];
+
+/// A CIMD document as returned by the `client_id` URL.
+///
+/// Unknown members are ignored, so a document may carry additional metadata
+/// (logos, contacts, policy URIs) without failing validation. Only the members
+/// the server acts on are modeled here.
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct CimdDocument {
+  /// The client identifier; MUST equal the fetched URL.
+  pub client_id: String,
+  /// Registered redirect URIs.
+  pub redirect_uris: Vec<String>,
+  /// Token-endpoint authentication method (`none` when absent).
+  #[serde(default)]
+  pub token_endpoint_auth_method: Option<String>,
+  /// Requested grant types (default `["authorization_code"]`).
+  #[serde(default)]
+  pub grant_types: Option<Vec<String>>,
+  /// Space-delimited scope bound for the client.
+  #[serde(default)]
+  pub scope: Option<String>,
+  /// Inline JWKS (required for `private_key_jwt`).
+  #[serde(default)]
+  pub jwks: Option<serde_json::Value>,
+  /// Remote JWKS URI. Rejected: inline-only by decision (no second fetch).
+  #[serde(default)]
+  pub jwks_uri: Option<String>,
+  /// Human-readable client name.
+  #[serde(default)]
+  pub client_name: Option<String>,
+  /// Whether the client opts into DPoP-bound access tokens.
+  #[serde(default)]
+  pub dpop_bound_access_tokens: Option<bool>,
+}
+
+/// Client authentication method accepted from a CIMD document.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum CimdAuthMethod {
+  /// Public client (the registration-free default).
+  None,
+  /// `private_key_jwt` with an inline JWKS.
+  PrivateKeyJwt,
+}
+
+impl CimdAuthMethod {
+  /// The wire value stored in the synthetic client's
+  /// `token_endpoint_auth_method`.
+  #[must_use]
+  pub const fn as_str(self) -> &'static str {
+    match self {
+      Self::None => "none",
+      Self::PrivateKeyJwt => "private_key_jwt",
+    }
+  }
+}
+
+/// The validated subset of a CIMD document, ready for request handling.
+#[derive(Debug, Clone)]
+pub struct ValidatedCimd {
+  /// The client identifier (equals the fetched URL).
+  pub client_id: String,
+  /// Redirect URIs declared by the document.
+  pub redirect_uris: Vec<String>,
+  /// Accepted client authentication method.
+  pub token_endpoint_auth_method: CimdAuthMethod,
+  /// Deduplicated grant types.
+  pub grant_types: Vec<String>,
+  /// Space-joined scope, already checked against the enabled catalog.
+  pub scope: String,
+  /// Inline JWKS for `private_key_jwt`, if any.
+  pub jwks: Option<serde_json::Value>,
+  /// Human-readable client name.
+  pub client_name: Option<String>,
+  /// Whether the client opts into DPoP-bound access tokens.
+  pub dpop_bound_access_tokens: bool,
+}
+
+impl ValidatedCimd {
+  /// Materialize the registration-free principal as a synthetic
+  /// [`client_repo::Client`] so every existing code path works unchanged.
+  ///
+  /// Field mapping: `client_id` is the document URL; the auth method, grant
+  /// allowlist, scope bound, DPoP flag, and inline `jwks` come from the
+  /// validated document; secrets, mTLS pins, `jwks_uri`, pre-registered
+  /// `request_uris`, contacts, and PII are empty; `access_token_format` is
+  /// always `opaque` (a document can never opt into JWT); the row is active
+  /// with fresh timestamps and a random surrogate id. No row is written: the
+  /// value is in-memory only, and the next resolution revalidates the cached
+  /// document, so a scope or catalog change takes effect immediately.
+  #[must_use]
+  pub fn to_client(&self) -> client_repo::Client {
+    let now = time::OffsetDateTime::now_utc();
+    client_repo::Client {
+      id: uuid::Uuid::new_v4(),
+      client_id: self.client_id.clone(),
+      client_secret_expires_at: None,
+      client_id_issued_at: now,
+      client_name: self
+        .client_name
+        .clone()
+        .unwrap_or_else(|| self.client_id.clone()),
+      grant_types: self.grant_types.clone(),
+      response_types: vec!["code".to_owned()],
+      scope: self.scope.clone(),
+      token_endpoint_auth_method: self.token_endpoint_auth_method.as_str().to_owned(),
+      jwks: self.jwks.clone(),
+      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: self.dpop_bound_access_tokens,
+      access_token_format: "opaque".to_owned(),
+      active: true,
+      created_at: now,
+      updated_at: now,
+    }
+  }
+}
+
+/// Per-fetch limits from the validated configuration.
+#[derive(Debug, Clone, Copy)]
+pub struct CimdLimits {
+  /// Maximum accepted document size in bytes (`cimd_max_bytes`).
+  pub max_bytes: usize,
+  /// Total fetch budget (`cimd_fetch_timeout`).
+  pub timeout: Duration,
+}
+
+/// Validation inputs resolved by the caller (keeps this module pure).
+pub struct CimdValidation<'a> {
+  /// Every enabled scope name (`scope_repo::list_enabled`).
+  pub enabled_scopes: &'a HashSet<String>,
+  /// Config `allow_localhost_redirect`: whether the host `localhost` joins
+  /// the RFC 8252 §7.3 loopback exception.
+  pub allow_localhost: bool,
+}
+
+/// Fail-closed CIMD failures.
+///
+/// Validation failures map to `invalid_client_metadata` (or
+/// `invalid_redirect_uri` for a bad redirect set) via [`From<CimdError>`].
+#[derive(Debug, thiserror::Error)]
+pub enum CimdError {
+  /// The guarded fetch failed (policy, DNS, status, timeout, or size cap).
+  #[error("client metadata document fetch failed")]
+  Fetch,
+  /// The fetched body was not JSON or not the expected document shape.
+  #[error("client metadata document is not valid JSON")]
+  InvalidJson,
+  /// The document `client_id` does not equal the fetched URL.
+  #[error("client metadata document client_id does not match its URL")]
+  ClientIdMismatch,
+  /// `token_endpoint_auth_method` was absent-with-invalid-value or a
+  /// shared-secret/mTLS method.
+  #[error("unsupported token_endpoint_auth_method")]
+  UnsupportedAuthMethod,
+  /// `private_key_jwt` was declared without an inline JWKS.
+  #[error("private_key_jwt requires an inline jwks")]
+  JwksRequired,
+  /// The document supplied `jwks_uri`; inline-only by decision.
+  #[error("jwks_uri is not allowed in a client metadata document")]
+  JwksUriNotAllowed,
+  /// A redirect URI was missing, wildcarded, non-https, or non-loopback
+  /// plaintext `http`.
+  #[error("invalid redirect_uris")]
+  InvalidRedirectUri,
+  /// A grant type outside `{authorization_code, refresh_token}`.
+  #[error("unsupported grant_type")]
+  UnsupportedGrantType,
+  /// A scope token is not in the enabled catalog.
+  #[error("scope is not a subset of the enabled catalog")]
+  UnknownScope,
+  /// The cache or scope catalog could not be read (fail closed, `503`).
+  #[error("client metadata backend failure")]
+  Backend(#[source] AppError),
+  /// The registered-row lookup failed. Pass-through: the inner error is
+  /// returned unchanged so typed row failures (e.g. a corrupt sealed PII
+  /// field surfacing as `PiiDecrypt`) are never flattened into a `503`.
+  /// A database outage still surfaces as `temporarily_unavailable` through
+  /// the existing `From<sqlx::Error> for AppError`.
+  #[error("client row lookup failed")]
+  Lookup(#[source] AppError),
+}
+
+impl From<CimdError> for AppError {
+  /// Map a CIMD failure to a client-safe OAuth error. Bad redirects use
+  /// `invalid_redirect_uri`; every other validation failure uses
+  /// `invalid_client_metadata`; a dependency failure is a fail-closed `503`.
+  /// No document contents or fetch internals are included.
+  fn from(error: CimdError) -> Self {
+    match error {
+      CimdError::InvalidRedirectUri => {
+        Self::invalid_redirect_uri("invalid redirect_uri in client metadata document")
+      }
+      CimdError::Backend(_) => Self::temporarily_unavailable(),
+      CimdError::Lookup(inner) => inner,
+      CimdError::Fetch
+      | CimdError::InvalidJson
+      | CimdError::ClientIdMismatch
+      | CimdError::UnsupportedAuthMethod
+      | CimdError::JwksRequired
+      | CimdError::JwksUriNotAllowed
+      | CimdError::UnsupportedGrantType
+      | CimdError::UnknownScope => {
+        Self::invalid_client_metadata("invalid client metadata document")
+      }
+    }
+  }
+}
+
+/// The cache key for a validated document: `{state}`-prefixed
+/// `cimd:<sha256(url)>`.
+///
+/// `token_prefix` is the configured token keyspace prefix (which carries the
+/// `{state}` hash tag); the key therefore never lands in the `{rl}` keyspace.
+#[must_use]
+pub fn cache_key(token_prefix: &str, client_id_url: &str) -> String {
+  format!(
+    "{token_prefix}cimd:{}",
+    sha256_hex(client_id_url.as_bytes())
+  )
+}
+
+/// Per-request CIMD resolution settings for a client-auth consumer.
+///
+/// `None` at a call site means CIMD is disabled there (`config.cimd_enabled`
+/// is off): an `https://` `client_id` with no registered row keeps the
+/// existing unknown-client behavior. `Some` enables the registered-row-wins
+/// fallback for that consumer.
+#[derive(Debug, Clone, Copy)]
+pub struct CimdSettings<'a> {
+  /// Token keyspace prefix carrying the `{state}` hash tag, for the
+  /// `cimd:<sha256(url)>` document cache.
+  pub token_prefix: &'a str,
+  /// Maximum accepted document size (`config.cimd_max_bytes`).
+  pub max_bytes: usize,
+  /// Total fetch budget (`config.cimd_fetch_timeout`).
+  pub timeout: Duration,
+  /// Config `allow_localhost_redirect`: whether the host `localhost` joins
+  /// the RFC 8252 §7.3 loopback exception.
+  pub allow_localhost: bool,
+}
+
+/// The single client-principal resolution for every client-auth consumer
+/// (token dispatch, `client_auth`, `/revoke`, `/introspect`, authorize/JAR,
+/// PAR, device poll).
+///
+/// A registered row always wins: when `find_active_by_client_id` names a row
+/// — including a row whose `client_id` happens to be an `https://` URL — that
+/// row is returned and no document is fetched. Otherwise, when `settings` is
+/// `Some` and the id parses as `https://`, the document is resolved through
+/// [`resolve_with_catalog`] (cache, else guarded fetch + strict validation)
+/// and materialized via [`ValidatedCimd::to_client`]. Any other id, or a
+/// disabled feature, resolves to `None` so the caller keeps its existing
+/// unknown-client error.
+///
+/// Only `none` and `private_key_jwt` documents can resolve here: validation
+/// rejects every shared-secret and mTLS method before a principal exists
+/// (CIMD draft §4.1), and a `private_key_jwt` document resolves only with an
+/// inline `jwks` (no second fetch on the auth path).
+///
+/// # Errors
+///
+/// Returns the [`CimdError`] of a failed fetch or validation (mapped by the
+/// caller per [`From<CimdError>`]: `invalid_client_metadata`,
+/// `invalid_redirect_uri`, or fail-closed `503`), a pass-through
+/// [`CimdError::Lookup`] preserving the row lookup's own error, or a
+/// [`CimdError::Backend`] when the scope catalog or the cache fails.
+pub async fn resolve_client(
+  pool: &PgPool,
+  keys: &KeyRing,
+  conn: &mut RedisConn,
+  settings: Option<CimdSettings<'_>>,
+  client_id: &str,
+) -> Result<Option<client_repo::Client>, CimdError> {
+  let row = client_repo::find_active_by_client_id(pool, keys, client_id)
+    .await
+    .map_err(CimdError::Lookup)?;
+  if let Some(client) = row {
+    return Ok(Some(client));
+  }
+  let Some(settings) = settings else {
+    return Ok(None);
+  };
+  // CIMD draft §3: only an `https://` URL names a metadata document.
+  if !client_id.starts_with("https://") {
+    return Ok(None);
+  }
+  let limits = CimdLimits {
+    max_bytes: settings.max_bytes,
+    timeout: settings.timeout,
+  };
+  let validated = resolve_with_catalog(
+    conn,
+    settings.token_prefix,
+    pool,
+    client_id,
+    settings.allow_localhost,
+    limits,
+  )
+  .await?;
+  Ok(Some(validated.to_client()))
+}
+/// Fetch a CIMD document by URL through the guarded fetcher.
+///
+/// Uses exactly one fetcher ([`ssrf::fetch_guarded_bytes_with`]): `https`
+/// only, no redirects, special-use/private addresses rejected, `limits`
+/// enforced. Non-success statuses and oversize bodies fail closed as
+/// [`CimdError::Fetch`].
+///
+/// # Errors
+///
+/// Returns [`CimdError::Fetch`] on any guard/fetch/status/size failure and
+/// [`CimdError::InvalidJson`] when the body is not a document-shaped JSON
+/// object.
+pub async fn fetch_document(
+  client_id_url: &str,
+  limits: CimdLimits,
+) -> Result<CimdDocument, CimdError> {
+  let bytes = ssrf::fetch_guarded_bytes_with(client_id_url, limits.max_bytes, limits.timeout)
+    .await
+    .map_err(|_| CimdError::Fetch)?;
+  serde_json::from_slice(&bytes).map_err(|_| CimdError::InvalidJson)
+}
+
+/// Validate a document against the strict CIMD rules.
+///
+/// # Errors
+///
+/// Returns the specific [`CimdError`] for the first violated rule. The
+/// checks are ordered cheapest/most-specific first: identifier echo, auth
+/// method and JWKS policy, redirect set, scope subset, grant subset.
+pub fn validate_document(
+  client_id_url: &str,
+  document: &CimdDocument,
+  validation: &CimdValidation<'_>,
+) -> Result<ValidatedCimd, CimdError> {
+  // CIMD §4.1: the document must identify itself as the URL it was fetched
+  // from, exactly. No normalization is performed.
+  if document.client_id != client_id_url {
+    return Err(CimdError::ClientIdMismatch);
+  }
+
+  // CIMD §4.1: only registration-free-safe methods. Absent defaults to
+  // `none`; shared-secret and mTLS methods are rejected.
+  let token_endpoint_auth_method = match document.token_endpoint_auth_method.as_deref() {
+    None | Some("none") => CimdAuthMethod::None,
+    Some("private_key_jwt") => CimdAuthMethod::PrivateKeyJwt,
+    Some(_) => return Err(CimdError::UnsupportedAuthMethod),
+  };
+
+  // Inline-only decision: never fetch a second URL on the auth path.
+  if document.jwks_uri.is_some() {
+    return Err(CimdError::JwksUriNotAllowed);
+  }
+  let jwks = match token_endpoint_auth_method {
+    CimdAuthMethod::PrivateKeyJwt => {
+      let Some(value) = document.jwks.as_ref() else {
+        return Err(CimdError::JwksRequired);
+      };
+      validate_jwks_shape(value)?;
+      Some(value.clone())
+    }
+    CimdAuthMethod::None => None,
+  };
+
+  validate_redirect_uris(&document.redirect_uris, validation.allow_localhost)?;
+
+  let scope = validate_scope(document.scope.as_deref(), validation.enabled_scopes)?;
+  let grant_types = validate_grants(document.grant_types.as_ref())?;
+
+  Ok(ValidatedCimd {
+    client_id: document.client_id.clone(),
+    redirect_uris: document.redirect_uris.clone(),
+    token_endpoint_auth_method,
+    grant_types,
+    scope,
+    jwks,
+    client_name: document.client_name.clone(),
+    dpop_bound_access_tokens: document.dpop_bound_access_tokens.unwrap_or(false),
+  })
+}
+
+/// Fetch and validate in one step (no cache).
+///
+/// # Errors
+///
+/// Returns a fetch, parse, or validation [`CimdError`].
+pub async fn fetch_and_validate(
+  client_id_url: &str,
+  validation: &CimdValidation<'_>,
+  limits: CimdLimits,
+) -> Result<ValidatedCimd, CimdError> {
+  let document = fetch_document(client_id_url, limits).await?;
+  validate_document(client_id_url, &document, validation)
+}
+
+/// Resolve a CIMD client: cache hit, else fetch + validate + cache.
+///
+/// A cached document is revalidated so catalog changes are honored during the
+/// 15-minute TTL. `token_prefix` must carry the `{state}` hash tag.
+///
+/// # Errors
+///
+/// Returns any fetch/parse/validation [`CimdError`], or
+/// [`CimdError::Backend`] when Redis cannot be read or written (fail closed).
+pub async fn resolve(
+  conn: &mut RedisConn,
+  token_prefix: &str,
+  client_id_url: &str,
+  validation: &CimdValidation<'_>,
+  limits: CimdLimits,
+) -> Result<ValidatedCimd, CimdError> {
+  let key = cache_key(token_prefix, client_id_url);
+  if let Some(document) = get_cached(conn, &key).await? {
+    return validate_document(client_id_url, &document, validation);
+  }
+
+  let document = fetch_document(client_id_url, limits).await?;
+  let validated = validate_document(client_id_url, &document, validation)?;
+  put_cached(conn, &key, &document).await?;
+  Ok(validated)
+}
+
+/// Resolve a CIMD client while loading the enabled scope catalog from
+/// Postgres. Convenience wrapper for request handlers.
+///
+/// # Errors
+///
+/// Returns [`CimdError::Backend`] when the catalog read fails, plus every
+/// [`resolve`] failure.
+pub async fn resolve_with_catalog(
+  conn: &mut RedisConn,
+  token_prefix: &str,
+  pool: &PgPool,
+  client_id_url: &str,
+  allow_localhost: bool,
+  limits: CimdLimits,
+) -> Result<ValidatedCimd, CimdError> {
+  let enabled_scopes: HashSet<String> = scope_repo::list_enabled(pool)
+    .await
+    .map_err(CimdError::Backend)?
+    .into_iter()
+    .map(|record| record.name)
+    .collect();
+  resolve(
+    conn,
+    token_prefix,
+    client_id_url,
+    &CimdValidation {
+      enabled_scopes: &enabled_scopes,
+      allow_localhost,
+    },
+    limits,
+  )
+  .await
+}
+
+/// Read a cached document. Malformed cache entries are deleted and treated as
+/// a miss; a Redis failure is fail-closed [`CimdError::Backend`].
+async fn get_cached(conn: &mut RedisConn, key: &str) -> Result<Option<CimdDocument>, CimdError> {
+  let raw: Option<String> = redis::cmd("GET")
+    .arg(key)
+    .query_async(conn)
+    .await
+    .map_err(cache_backend)?;
+  let Some(raw) = raw else {
+    return Ok(None);
+  };
+  match serde_json::from_str(&raw) {
+    Ok(document) => Ok(Some(document)),
+    Err(_) => {
+      // A corrupt entry is not trusted: drop it and fetch afresh.
+      let _: Result<i64, _> = redis::cmd("DEL").arg(key).query_async(conn).await;
+      Ok(None)
+    }
+  }
+}
+
+/// Cache a validated document for [`CACHE_TTL_SECS`].
+async fn put_cached(
+  conn: &mut RedisConn,
+  key: &str,
+  document: &CimdDocument,
+) -> Result<(), CimdError> {
+  let payload = serde_json::to_string(document).map_err(|_| CimdError::InvalidJson)?;
+  redis::cmd("SET")
+    .arg(key)
+    .arg(payload)
+    .arg("EX")
+    .arg(CACHE_TTL_SECS)
+    .query_async::<()>(conn)
+    .await
+    .map_err(cache_backend)
+}
+
+/// Map a Redis error to a fail-closed backend error.
+fn cache_backend(error: redis::RedisError) -> CimdError {
+  CimdError::Backend(StoreError::Redis(error).into())
+}
+
+/// Redirect URIs are `https`, or plaintext `http` only for an RFC 8252 §7.3
+/// loopback host. The loopback decision (including `localhost` behind
+/// `allow_localhost`) reuses [`redirect_repo::match_redirect_uri`] so CIMD and
+/// registered clients can never disagree. Wildcards are rejected outright.
+fn validate_redirect_uris(uris: &[String], allow_localhost: bool) -> Result<(), CimdError> {
+  if uris.is_empty() {
+    return Err(CimdError::InvalidRedirectUri);
+  }
+  for uri in uris {
+    if uri.contains('*') {
+      return Err(CimdError::InvalidRedirectUri);
+    }
+    let parsed = reqwest::Url::parse(uri).map_err(|_| CimdError::InvalidRedirectUri)?;
+    if !matches!(parsed.scheme(), "https" | "http")
+      || parsed.host_str().is_none_or(str::is_empty)
+      || !parsed.username().is_empty()
+      || parsed.password().is_some()
+    {
+      return Err(CimdError::InvalidRedirectUri);
+    }
+    // `match_redirect_uri` is the single source of truth for the loopback
+    // exception and rejects plaintext `http` for every non-loopback host.
+    if redirect_repo::match_redirect_uri(uris, uri, allow_localhost) == RedirectDecision::Reject {
+      return Err(CimdError::InvalidRedirectUri);
+    }
+  }
+  Ok(())
+}
+
+/// Check every requested scope token against the enabled catalog.
+fn validate_scope(
+  scope: Option<&str>,
+  enabled_scopes: &HashSet<String>,
+) -> Result<String, CimdError> {
+  let mut accepted = Vec::new();
+  for token in scope.unwrap_or_default().split_whitespace() {
+    if !enabled_scopes.contains(token) {
+      return Err(CimdError::UnknownScope);
+    }
+    accepted.push(token);
+  }
+  Ok(accepted.join(" "))
+}
+
+/// Require a non-empty grant set, each member in [`ALLOWED_GRANTS`]; default
+/// to `["authorization_code"]` when absent. Duplicates collapse in first-seen
+/// order.
+fn validate_grants(grants: Option<&Vec<String>>) -> Result<Vec<String>, CimdError> {
+  let requested: &[String] = grants.map_or(&[], Vec::as_slice);
+  if grants.is_some() && requested.is_empty() {
+    return Err(CimdError::UnsupportedGrantType);
+  }
+  let source: Vec<String> = if requested.is_empty() {
+    vec!["authorization_code".to_owned()]
+  } else {
+    requested.to_vec()
+  };
+  let mut seen = HashSet::with_capacity(source.len());
+  let mut out = Vec::with_capacity(source.len());
+  for grant in source {
+    if !ALLOWED_GRANTS.contains(&grant.as_str()) {
+      return Err(CimdError::UnsupportedGrantType);
+    }
+    if seen.insert(grant.clone()) {
+      out.push(grant);
+    }
+  }
+  Ok(out)
+}
+
+/// Require an inline JWKS document to carry a non-empty `keys` array. Mirrors
+/// the DCR shape check; signature validation belongs to the assertion
+/// verifier.
+fn validate_jwks_shape(value: &serde_json::Value) -> Result<(), CimdError> {
+  let has_keys = value
+    .get("keys")
+    .and_then(|keys| keys.as_array())
+    .is_some_and(|keys| !keys.is_empty());
+  if has_keys {
+    Ok(())
+  } else {
+    Err(CimdError::JwksRequired)
+  }
+}

+ 351 - 0
src/protocol/cimd/tests.rs

@@ -0,0 +1,351 @@
+//! `test(cimd_)` suite: strict CIMD fetch/validation rules (offline except
+//! the cache round-trip, which uses the shared compose Redis).
+
+use std::{collections::HashSet, time::Duration};
+
+use serde_json::{Value, json};
+
+use super::{
+  CimdAuthMethod, CimdDocument, CimdError, CimdLimits, CimdValidation, ValidatedCimd, cache_key,
+  fetch_document, get_cached, put_cached, resolve, validate_document,
+};
+use crate::state::RedisConn;
+
+/// This suite's error type (no `unwrap`/`expect` under the deny lints).
+type TestResult = Result<(), TestError>;
+
+/// Test scaffolding failures.
+#[derive(Debug, thiserror::Error)]
+enum TestError {
+  /// An assertion observed an unexpected value.
+  #[error("unexpected test value: {0}")]
+  Unexpected(String),
+}
+
+/// The enabled scope catalog used by the validation tests.
+fn scopes() -> HashSet<String> {
+  ["profile", "email"]
+    .into_iter()
+    .map(str::to_owned)
+    .collect()
+}
+
+/// Build a document value; callers override one member per test.
+fn document_value(client_id: &str, redirect_uris: &[&str]) -> Value {
+  json!({
+    "client_id": client_id,
+    "redirect_uris": redirect_uris,
+    "token_endpoint_auth_method": "none",
+    "grant_types": ["authorization_code"],
+    "scope": "profile",
+  })
+}
+
+/// Parse a test document.
+fn parse(value: Value) -> Result<CimdDocument, TestError> {
+  serde_json::from_value(value)
+    .map_err(|error| TestError::Unexpected(format!("document parse: {error}")))
+}
+
+/// Parse and validate one document, requiring acceptance.
+fn expect_ok(
+  client_id: &str,
+  value: Value,
+  enabled_scopes: &HashSet<String>,
+  allow_localhost: bool,
+) -> Result<ValidatedCimd, TestError> {
+  let document = parse(value)?;
+  validate_document(
+    client_id,
+    &document,
+    &CimdValidation {
+      enabled_scopes,
+      allow_localhost,
+    },
+  )
+  .map_err(|error| TestError::Unexpected(format!("{client_id}: rejected ({error})")))
+}
+
+/// Parse and validate one document, requiring rejection; returns the error.
+fn expect_err(
+  client_id: &str,
+  value: Value,
+  enabled_scopes: &HashSet<String>,
+  allow_localhost: bool,
+) -> Result<CimdError, TestError> {
+  let document = parse(value)?;
+  match validate_document(
+    client_id,
+    &document,
+    &CimdValidation {
+      enabled_scopes,
+      allow_localhost,
+    },
+  ) {
+    Ok(_) => Err(TestError::Unexpected(format!("{client_id}: accepted"))),
+    Err(error) => Ok(error),
+  }
+}
+
+/// The guarded fetcher rejects special-use and private IP literals before any
+/// connection is attempted (no DNS, no network I/O).
+#[tokio::test]
+async fn cimd_rejects_special_use_ip() -> TestResult {
+  let limits = CimdLimits {
+    max_bytes: 5_120,
+    timeout: Duration::from_secs(1),
+  };
+  for url in [
+    "https://127.0.0.1/cimd.json",
+    "https://10.0.0.1/cimd.json",
+    "https://169.254.169.254/latest/meta-data",
+    "https://192.168.1.10/cimd.json",
+    "https://[::1]/cimd.json",
+  ] {
+    match fetch_document(url, limits).await {
+      Err(CimdError::Fetch) => {}
+      Err(other) => {
+        return Err(TestError::Unexpected(format!("{url}: wrong error {other}")));
+      }
+      Ok(_) => {
+        return Err(TestError::Unexpected(format!(
+          "{url}: fetched a blocked target"
+        )));
+      }
+    }
+  }
+  Ok(())
+}
+
+/// Empty, wildcarded, non-http(s), and query-bearing redirect URIs are all
+/// rejected; the strict redirect matcher is the single authority.
+#[test]
+fn cimd_rejects_redirect() -> TestResult {
+  let enabled = scopes();
+  let client_id = "https://client.example/cimd.json";
+  for redirects in [
+    Vec::<&str>::new(),
+    vec!["https://client.example/*"],
+    vec!["https://*.client.example/cb"],
+    vec!["com.example.app:/cb"],
+    vec!["https://client.example/cb?next=1"],
+  ] {
+    match expect_err(
+      client_id,
+      document_value(client_id, &redirects),
+      &enabled,
+      false,
+    )? {
+      CimdError::InvalidRedirectUri => {}
+      other => {
+        return Err(TestError::Unexpected(format!(
+          "{redirects:?}: wrong error {other}"
+        )));
+      }
+    }
+  }
+  Ok(())
+}
+
+/// CIMD §4.1: every shared-secret and mTLS method is rejected; only `none`
+/// and `private_key_jwt` are registration-free-safe.
+#[test]
+fn cimd_rejects_shared_secret_method() -> TestResult {
+  let enabled = scopes();
+  let client_id = "https://client.example/cimd.json";
+  for method in [
+    "client_secret_basic",
+    "client_secret_post",
+    "client_secret_jwt",
+    "tls_client_auth",
+    "self_signed_tls_client_auth",
+  ] {
+    let mut value = document_value(client_id, &["https://client.example/cb"]);
+    value["token_endpoint_auth_method"] = json!(method);
+    match expect_err(client_id, value, &enabled, false)? {
+      CimdError::UnsupportedAuthMethod => {}
+      other => {
+        return Err(TestError::Unexpected(format!(
+          "{method}: wrong error {other}"
+        )));
+      }
+    }
+  }
+  Ok(())
+}
+
+/// `private_key_jwt` requires an INLINE `jwks`; a `jwks_uri`-only document is
+/// rejected so no second fetch ever happens on the auth path.
+#[test]
+fn cimd_rejects_jwks_uri_only() -> TestResult {
+  let enabled = scopes();
+  let client_id = "https://client.example/cimd.json";
+
+  let mut remote = document_value(client_id, &["https://client.example/cb"]);
+  remote["token_endpoint_auth_method"] = json!("private_key_jwt");
+  remote["jwks_uri"] = json!("https://client.example/jwks.json");
+  match expect_err(client_id, remote, &enabled, false)? {
+    CimdError::JwksUriNotAllowed => {}
+    other => {
+      return Err(TestError::Unexpected(format!(
+        "jwks_uri-only: wrong error {other}"
+      )));
+    }
+  }
+
+  let mut missing = document_value(client_id, &["https://client.example/cb"]);
+  missing["token_endpoint_auth_method"] = json!("private_key_jwt");
+  match expect_err(client_id, missing, &enabled, false)? {
+    CimdError::JwksRequired => {}
+    other => {
+      return Err(TestError::Unexpected(format!(
+        "no jwks: wrong error {other}"
+      )));
+    }
+  }
+
+  let mut inline = document_value(client_id, &["https://client.example/cb"]);
+  inline["token_endpoint_auth_method"] = json!("private_key_jwt");
+  inline["jwks"] = json!({ "keys": [{ "kty": "EC", "crv": "P-256", "x": "x", "y": "y" }] });
+  let validated = expect_ok(client_id, inline, &enabled, false)?;
+  if validated.token_endpoint_auth_method != CimdAuthMethod::PrivateKeyJwt {
+    return Err(TestError::Unexpected(
+      "inline jwks: wrong auth method".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// Plaintext `http` is accepted only for a loopback host; a non-loopback host
+/// (and `localhost` with the flag off) is rejected while `https` passes.
+#[test]
+fn cimd_rejects_non_loopback_http() -> TestResult {
+  let enabled = scopes();
+  let client_id = "https://client.example/cimd.json";
+  for uri in ["http://example.com/cb", "http://localhost/cb"] {
+    match expect_err(
+      client_id,
+      document_value(client_id, &[uri]),
+      &enabled,
+      false,
+    )? {
+      CimdError::InvalidRedirectUri => {}
+      other => {
+        return Err(TestError::Unexpected(format!("{uri}: wrong error {other}")));
+      }
+    }
+  }
+  // https is always acceptable, and a loopback literal is acceptable even
+  // with the `localhost` flag off.
+  for uri in [
+    "https://client.example/cb",
+    "http://127.0.0.1/cb",
+    "http://[::1]/cb",
+  ] {
+    expect_ok(
+      client_id,
+      document_value(client_id, &[uri]),
+      &enabled,
+      false,
+    )?;
+  }
+  Ok(())
+}
+
+/// RFC 8252 §7.3 loopback: plaintext `http` to `127.0.0.1`/`[::1]` is always
+/// accepted; the exact host `localhost` only while `allow_localhost` is on.
+#[test]
+fn cimd_accepts_loopback_redirect() -> TestResult {
+  let enabled = scopes();
+  let client_id = "https://client.example/cimd.json";
+  for uri in [
+    "http://127.0.0.1/cb",
+    "http://127.0.0.1:8080/cb",
+    "http://[::1]/cb",
+    "http://[::1]:9000/cb",
+    "http://localhost/cb",
+    "http://localhost:3000/cb",
+  ] {
+    expect_ok(client_id, document_value(client_id, &[uri]), &enabled, true)?;
+  }
+  for uri in ["http://localhost/cb", "http://localhost:3000/cb"] {
+    match expect_err(
+      client_id,
+      document_value(client_id, &[uri]),
+      &enabled,
+      false,
+    )? {
+      CimdError::InvalidRedirectUri => {}
+      other => {
+        return Err(TestError::Unexpected(format!("{uri}: wrong error {other}")));
+      }
+    }
+  }
+  Ok(())
+}
+
+/// The document must echo the fetched URL exactly.
+#[test]
+fn cimd_rejects_client_id_mismatch() -> TestResult {
+  let enabled = scopes();
+  let fetched = "https://client.example/cimd.json";
+  let document = document_value(
+    "https://other.example/cimd.json",
+    &["https://other.example/cb"],
+  );
+  match expect_err(fetched, document, &enabled, false)? {
+    CimdError::ClientIdMismatch => Ok(()),
+    other => Err(TestError::Unexpected(format!("wrong error {other}"))),
+  }
+}
+
+/// A validated document is cached under `{state}` `cimd:<sha256>` for 15
+/// minutes; `resolve` serves the cached copy without fetching.
+#[tokio::test]
+async fn cimd_cache_round_trip() -> 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` never resolves, so a cache miss would fail the fetch.
+  let url = "https://client.invalid/cimd.json";
+  let document = parse(document_value(url, &["https://client.invalid/cb"]))?;
+
+  let key = cache_key(&prefix, url);
+  if !key.starts_with(&prefix) || !key.contains("{state}") || !key.contains("cimd:") {
+    return Err(TestError::Unexpected(format!("bad cache key {key}")));
+  }
+  put_cached(&mut conn, &key, &document)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("cache write: {error}")))?;
+  match get_cached(&mut conn, &key)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("cache read: {error}")))?
+  {
+    Some(cached) if cached.client_id == url => {}
+    Some(_) => return Err(TestError::Unexpected("cached wrong document".to_owned())),
+    None => return Err(TestError::Unexpected("cache miss".to_owned())),
+  }
+
+  let enabled = scopes();
+  let resolved = resolve(
+    &mut conn,
+    &prefix,
+    url,
+    &CimdValidation {
+      enabled_scopes: &enabled,
+      allow_localhost: false,
+    },
+    CimdLimits {
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(1),
+    },
+  )
+  .await
+  .map_err(|error| TestError::Unexpected(format!("resolve: {error}")))?;
+  if resolved.client_id != url {
+    return Err(TestError::Unexpected("resolved wrong client".to_owned()));
+  }
+  Ok(())
+}

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

@@ -0,0 +1,1163 @@
+//! Registration-free principal tests: the synthetic `Client` threaded
+//! through token dispatch, `client_auth`, `/revoke`, `/introspect`,
+//! authorize/JAR, and PAR (CIMD draft §3/§4.1/§5, RFC 7009 §2.1/§2.2,
+//! RFC 7662 §2.3, RFC 9449).
+//!
+//! Every test seeds the CIMD document cache directly under
+//! `cimd::cache_key` (a miss against `.invalid` can never pass), so no test
+//! performs a network fetch. A registered row always wins; `cimd: None`
+//! keeps the exact pre-CIMD unknown-client behavior.
+
+use std::collections::HashMap;
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Duration;
+
+use base64::Engine as _;
+use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
+use ring::rand::SystemRandom;
+use ring::signature::{Ed25519KeyPair, KeyPair as _};
+use secrecy::SecretString;
+use serde::{Deserialize, Serialize};
+use time::OffsetDateTime;
+
+use super::{CimdDocument, CimdSettings, cache_key, resolve_client};
+use crate::config::{DurabilityMode, TtlConfig};
+use crate::crypto::secret::{Argon2Params, hash_secret};
+use crate::db::{NewClient, TokenEndpointAuthMethod, client_repo, scope_repo};
+use crate::error::{AppError, OAuthErrorCode};
+use crate::keys::{KeyId, KeyRing};
+use crate::protocol::authorize::{AuthorizeInput, CimdRequest, validate};
+use crate::protocol::client_auth::{
+  AuthContext, AuthStores, CLIENT_ASSERTION_TYPE_JWT_BEARER, ClientAuthRequest,
+  authenticate_client, token_endpoint,
+};
+use crate::protocol::code::{CodeClientKind, CodeIssueRequest, CodeIssuer, IssuedCode};
+use crate::protocol::introspect::{
+  IntrospectContext, IntrospectHttpInputs, IntrospectStores, introspect,
+};
+use crate::protocol::par::{ParContext, ParStores, push_authorization_request};
+use crate::protocol::revoke::{
+  RevokeContext, RevokeOutcome, RevokeRequest, RevokeStores, revoke_tokens,
+};
+use crate::protocol::scope::ScopeSet;
+use crate::protocol::token::grants::authorization_code::{
+  CodeGrantInputs, CodeGrantState, redeem_code,
+};
+use crate::protocol::token::grants::refresh_token::{RefreshInput, refresh};
+use crate::protocol::token::{ClientKind, SenderProof, TokenFormat, TokenService};
+use crate::state::RedisConn;
+use crate::store::Store;
+
+/// Test body result (no `unwrap`/`expect` under the deny lints).
+type TestResult = Result<(), TestError>;
+
+/// Errors raised by the CIMD principal test scaffolding itself.
+#[derive(Debug, thiserror::Error)]
+enum TestError {
+  /// A Redis command failed.
+  #[error("redis error: {0}")]
+  Redis(#[from] redis::RedisError),
+  /// A database operation failed.
+  #[error("database error: {0}")]
+  Database(#[from] sqlx::Error),
+  /// A store operation failed.
+  #[error("store error: {0}")]
+  Store(#[from] crate::store::StoreError),
+  /// The token service failed.
+  #[error("service error: {0}")]
+  Service(#[from] crate::protocol::token::TokenServiceError),
+  /// A key operation failed.
+  #[error("keys error: {0}")]
+  Keys(#[from] crate::keys::KeyRingError),
+  /// A code issuance failed.
+  #[error("issuance error: {0}")]
+  Issue(#[from] crate::protocol::code::CodeIssueError),
+  /// A JWT operation failed.
+  #[error("jwt error: {0}")]
+  Jwt(#[from] jsonwebtoken::errors::Error),
+  /// An application error from a helper under test.
+  #[error("app error: {0}")]
+  App(#[from] AppError),
+  /// A test assertion observed an unexpected value.
+  #[error("unexpected test value: {0}")]
+  Unexpected(String),
+}
+
+static NONCE: AtomicU64 = AtomicU64::new(900_000);
+
+fn unique(label: &str) -> String {
+  let n = NONCE.fetch_add(1, Ordering::Relaxed);
+  format!("https://client-{n}-{label}.invalid/cimd.json")
+}
+
+const ISSUER: &str = "https://auth.example.test";
+const REDIRECT: &str = "https://app.example.test/cb";
+const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+const CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+/// Live handles for one principal test.
+struct Harness {
+  _db: crate::test_guard::Lease<crate::db::test_support::Db>,
+  pool: sqlx::PgPool,
+  conn: RedisConn,
+  store: Store,
+  token_prefix: String,
+  ring: KeyRing,
+  ttl: TtlConfig,
+  endpoint: String,
+}
+
+async fn harness() -> Result<Harness, TestError> {
+  let (_db, pool) = crate::db::test_support::migrated_pool()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("pg: {error}")))?;
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (token_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let ring = KeyRing::from_parts(
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("cimd-principal-test-pepper"),
+    )],
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("cimd-principal-test-totp"),
+    )],
+    vec![(
+      KeyId::new("v1"),
+      SecretString::from("cimd-principal-test-pii"),
+    )],
+  )
+  .map_err(|error| TestError::Unexpected(format!("keys: {error:?}")))?;
+  scope_repo::upsert(&pool, "api:read", "read api").await?;
+  Ok(Harness {
+    _db,
+    pool,
+    conn: RedisConn::Single(manager),
+    store: Store::from_parts(
+      token_prefix.clone(),
+      ratelimit_prefix,
+      DurabilityMode::WaitAof,
+    ),
+    token_prefix,
+    ring,
+    ttl: TtlConfig::default(),
+    endpoint: token_endpoint(ISSUER),
+  })
+}
+
+fn auth_stores(h: &mut Harness) -> AuthStores<'_> {
+  AuthStores {
+    pool: &h.pool,
+    keys: &h.ring,
+    conn: &mut h.conn,
+    store: &h.store,
+  }
+}
+
+fn auth_ctx_for<'a>(endpoint: &'a str, token_prefix: &'a str) -> AuthContext<'a> {
+  AuthContext {
+    token_endpoint: endpoint,
+    now: OffsetDateTime::now_utc(),
+    cimd: Some(CimdSettings {
+      token_prefix,
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(2),
+      allow_localhost: false,
+    }),
+  }
+}
+
+/// Seed the document cache for `url` (a miss would fail against `.invalid`).
+async fn seed_cimd(h: &mut Harness, url: &str, document: &CimdDocument) -> TestResult {
+  let payload =
+    serde_json::to_string(document).map_err(|error| TestError::Unexpected(format!("{error}")))?;
+  redis::cmd("SET")
+    .arg(cache_key(&h.token_prefix, url))
+    .arg(payload)
+    .arg("EX")
+    .arg(900)
+    .query_async::<()>(&mut h.conn)
+    .await?;
+  Ok(())
+}
+
+fn document(
+  url: &str,
+  method: Option<&str>,
+  grants: &[&str],
+  scope: &str,
+  jwks: Option<serde_json::Value>,
+  dpop: bool,
+) -> CimdDocument {
+  CimdDocument {
+    client_id: url.to_owned(),
+    redirect_uris: vec![REDIRECT.to_owned()],
+    token_endpoint_auth_method: method.map(str::to_owned),
+    grant_types: Some(grants.iter().map(|grant| (*grant).to_owned()).collect()),
+    scope: Some(scope.to_owned()),
+    jwks,
+    jwks_uri: None,
+    client_name: None,
+    dpop_bound_access_tokens: Some(dpop),
+  }
+}
+
+/// An Ed25519 signer plus the matching inline JWKS for one document.
+struct DocKey {
+  encoding: EncodingKey,
+  jwks: serde_json::Value,
+}
+
+impl DocKey {
+  fn generate(kid: &str) -> Result<Self, TestError> {
+    let rng = SystemRandom::new();
+    let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
+      .map_err(|_| TestError::Unexpected("ed25519 keygen failed".to_owned()))?;
+    let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
+      .map_err(|_| TestError::Unexpected("ed25519 reload failed".to_owned()))?;
+    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 }]
+      }),
+    })
+  }
+
+  fn assertion(
+    &self,
+    kid: &str,
+    client_id: &str,
+    audience: &str,
+    jti: &str,
+  ) -> Result<String, TestError> {
+    let now = OffsetDateTime::now_utc().unix_timestamp();
+    let claims = AssertionClaims {
+      iss: client_id.to_owned(),
+      sub: client_id.to_owned(),
+      aud: audience.to_owned(),
+      jti: jti.to_owned(),
+      exp: now + 300,
+      iat: now,
+    };
+    let mut header = Header::new(Algorithm::EdDSA);
+    header.kid = Some(kid.to_owned());
+    Ok(encode(&header, &claims, &self.encoding)?)
+  }
+
+  fn request_object(
+    &self,
+    kid: &str,
+    client_id: &str,
+    issuer: &str,
+    extra: serde_json::Value,
+  ) -> Result<String, TestError> {
+    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_else(|| TestError::Unexpected("claims must be an object".to_owned()))?;
+    let extra_object = extra
+      .as_object()
+      .ok_or_else(|| TestError::Unexpected("extra must be an object".to_owned()))?;
+    for (key, value) in extra_object {
+      object.insert(key.clone(), value.clone());
+    }
+    let mut header = Header::new(Algorithm::EdDSA);
+    header.kid = Some(kid.to_owned());
+    Ok(encode(&header, &claims, &self.encoding)?)
+  }
+}
+
+/// `private_key_jwt` assertion claims (RFC 7523 §3).
+#[derive(Debug, Serialize, Deserialize)]
+struct AssertionClaims {
+  iss: String,
+  sub: String,
+  aud: String,
+  jti: String,
+  exp: i64,
+  iat: i64,
+}
+
+fn assertion_request<'a>(client_id: &'a str, assertion: &'a str) -> ClientAuthRequest<'a> {
+  ClientAuthRequest {
+    basic_header: None,
+    client_id: Some(client_id),
+    body_secret: None,
+    assertion_type: Some(CLIENT_ASSERTION_TYPE_JWT_BEARER),
+    assertion: Some(assertion),
+    peer_certs: None,
+  }
+}
+
+fn bare_request(client_id: &str) -> ClientAuthRequest<'_> {
+  ClientAuthRequest {
+    basic_header: None,
+    client_id: Some(client_id),
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    peer_certs: None,
+  }
+}
+
+/// Issue a code for a CIMD `client_id` through the real issuer.
+async fn issue_code(
+  h: &mut Harness,
+  client_id: &str,
+  kind: CodeClientKind,
+  dpop_required: bool,
+  dpop_jkt: Option<&str>,
+) -> Result<IssuedCode, TestError> {
+  let issuer = CodeIssuer::new(&h.ttl, &h.ring)?;
+  let approved = ScopeSet::parse("api:read")?;
+  let entitled = ScopeSet::parse("api:read")?;
+  let request = CodeIssueRequest {
+    client_id,
+    client_kind: kind,
+    dpop_required,
+    user_id: "user-1",
+    redirect_uri: REDIRECT,
+    approved_scope: &approved,
+    user_entitled: &entitled,
+    code_challenge: CHALLENGE,
+    dpop_jkt,
+    state: None,
+    aud: &[],
+    issuer: ISSUER,
+  };
+  Ok(issuer.issue(&mut h.conn, &h.store, &request).await?)
+}
+
+/// Redeem a code as the named CIMD client.
+async fn redeem(
+  h: &mut Harness,
+  code: &str,
+  client_id: &str,
+  assertion: Option<&str>,
+  now: i64,
+) -> Result<crate::protocol::token::grants::authorization_code::CodeTokenResponse, AppError> {
+  let inputs = CodeGrantInputs {
+    code,
+    redirect_uri: REDIRECT,
+    code_verifier: Some(VERIFIER),
+    client_id: Some(client_id),
+    basic_header: None,
+    body_secret: None,
+    assertion_type: assertion.map(|_| CLIENT_ASSERTION_TYPE_JWT_BEARER),
+    assertion,
+    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,
+    token_format: TokenFormat::Opaque,
+    jwt_signer: None,
+    cimd: Some(CimdSettings {
+      token_prefix: &h.token_prefix,
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(2),
+      allow_localhost: false,
+    }),
+    now_unix: now,
+  };
+  redeem_code(&mut state, &inputs).await
+}
+
+fn now_unix() -> i64 {
+  OffsetDateTime::now_utc().unix_timestamp()
+}
+
+/// A `private_key_jwt` document redeems a code through the token grant: the
+/// assertion verifies against the doc-inline `jwks` and the minted token is
+/// returned (CIMD draft §4.1).
+#[tokio::test]
+async fn token_accepts_cimd_private_key_jwt() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("jwt");
+  let key = DocKey::generate("doc-key-1")?;
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("private_key_jwt"),
+      &["authorization_code", "refresh_token"],
+      "api:read",
+      Some(key.jwks.clone()),
+      false,
+    ),
+  )
+  .await?;
+  let issued = issue_code(&mut h, &url, CodeClientKind::Confidential, false, None).await?;
+  let assertion = key.assertion("doc-key-1", &url, &h.endpoint.clone(), "cimd-jti-accept-1")?;
+  let tokens = redeem(
+    &mut h,
+    issued.code.as_str(),
+    &url,
+    Some(&assertion),
+    now_unix(),
+  )
+  .await
+  .map_err(|error| TestError::Unexpected(format!("cimd redemption rejected: {error}")))?;
+  if tokens.access_token.is_empty() || tokens.refresh_token.is_none() {
+    return Err(TestError::Unexpected(
+      "cimd redemption must mint access and refresh tokens".to_owned(),
+    ));
+  }
+  if tokens.token_type != "Bearer" {
+    return Err(TestError::Unexpected(format!(
+      "unbound cimd client must get Bearer, got {}",
+      tokens.token_type
+    )));
+  }
+  if tokens.scope != "api:read" {
+    return Err(TestError::Unexpected(format!(
+      "unexpected scope: {}",
+      tokens.scope
+    )));
+  }
+  Ok(())
+}
+
+/// A document declaring a shared-secret method never resolves: validation
+/// rejects it before any principal exists (CIMD draft §4.1).
+#[tokio::test]
+async fn token_rejects_cimd_secret_method() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("secret");
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("client_secret_basic"),
+      &["authorization_code"],
+      "api:read",
+      None,
+      false,
+    ),
+  )
+  .await?;
+  let req = ClientAuthRequest {
+    basic_header: None,
+    client_id: Some(&url),
+    body_secret: Some(SecretString::from("whatever")),
+    assertion_type: None,
+    assertion: None,
+    peer_certs: None,
+  };
+  let endpoint = h.endpoint.clone();
+  let token_prefix = h.token_prefix.clone();
+  let ctx = auth_ctx_for(&endpoint, &token_prefix);
+  match authenticate_client(&mut auth_stores(&mut h), &ctx, &req).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidClientMetadata => Ok(()),
+    Err(error) => Err(TestError::Unexpected(format!(
+      "secret-method doc must be invalid_client_metadata, got {}",
+      error.code()
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "secret-method doc was accepted".to_owned(),
+    )),
+  }
+}
+
+/// A document with `dpop_bound_access_tokens=true` requires a fresh DPoP
+/// proof at every gate: redemption without one fails (RFC 9449).
+#[tokio::test]
+async fn cimd_client_bound_doc_requires_dpop() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("bound");
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("none"),
+      &["authorization_code"],
+      "api:read",
+      None,
+      true,
+    ),
+  )
+  .await?;
+  let issued = issue_code(
+    &mut h,
+    &url,
+    CodeClientKind::Public,
+    true,
+    Some("bound-test-jkt"),
+  )
+  .await?;
+  match redeem(&mut h, issued.code.as_str(), &url, None, now_unix()).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidDpopProof => Ok(()),
+    Err(error) => Err(TestError::Unexpected(format!(
+      "bound cimd doc must demand DPoP, got {}",
+      error.code()
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "bound cimd doc redeemed without DPoP".to_owned(),
+    )),
+  }
+}
+
+/// A `none` document that leaves `dpop_bound_access_tokens` false mints
+/// `Bearer` and the pair refreshes without a proof.
+#[tokio::test]
+async fn cimd_none_unbound_gets_bearer() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("unbound");
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("none"),
+      &["authorization_code", "refresh_token"],
+      "api:read",
+      None,
+      false,
+    ),
+  )
+  .await?;
+  let issued = issue_code(&mut h, &url, CodeClientKind::Public, false, None).await?;
+  let tokens = redeem(&mut h, issued.code.as_str(), &url, None, now_unix())
+    .await
+    .map_err(|error| TestError::Unexpected(format!("unbound cimd redemption rejected: {error}")))?;
+  if tokens.token_type != "Bearer" {
+    return Err(TestError::Unexpected(format!(
+      "unbound cimd client must get Bearer, got {}",
+      tokens.token_type
+    )));
+  }
+  let Some(refresh_token) = tokens.refresh_token else {
+    return Err(TestError::Unexpected(
+      "unbound cimd must mint a refresh token".to_owned(),
+    ));
+  };
+  let service = TokenService::new(&h.ttl, &h.ring)?;
+  let enabled = ScopeSet::parse("api:read")?;
+  let registered = ScopeSet::parse("api:read")?;
+  let entitled = ScopeSet::empty();
+  let proof = SenderProof {
+    dpop_jkt: None,
+    mtls_x5t_s256: None,
+  };
+  let refreshed = refresh(
+    &mut h.conn,
+    &h.store,
+    &service,
+    &RefreshInput {
+      client_id: &url,
+      client_kind: ClientKind::Public,
+      dpop_required: false,
+      refresh_token: &refresh_token,
+      requested_scope: None,
+      requested_resources: &[],
+      proof: &proof,
+      enabled: &enabled,
+      client_registered: &registered,
+      user_entitled: &entitled,
+      token_format: TokenFormat::Opaque,
+      now_unix: now_unix(),
+    },
+  )
+  .await
+  .map_err(|error| TestError::Unexpected(format!("cimd refresh rejected: {error}")))?;
+  if refreshed.token_type != "Bearer" {
+    return Err(TestError::Unexpected(format!(
+      "refreshed cimd token must stay Bearer, got {}",
+      refreshed.token_type
+    )));
+  }
+  Ok(())
+}
+
+/// A document that omits the grant is not authorized for it: redeeming a code
+/// with a `refresh_token`-only document fails `unauthorized_client`.
+#[tokio::test]
+async fn cimd_client_enforces_grant_allowlist() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("grants");
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("none"),
+      &["refresh_token"],
+      "api:read",
+      None,
+      false,
+    ),
+  )
+  .await?;
+  let issued = issue_code(&mut h, &url, CodeClientKind::Public, false, None).await?;
+  match redeem(&mut h, issued.code.as_str(), &url, None, now_unix()).await {
+    Err(error) if error.code() == OAuthErrorCode::UnauthorizedClient => Ok(()),
+    Err(error) => Err(TestError::Unexpected(format!(
+      "grant allowlist must be unauthorized_client, got {}",
+      error.code()
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "code redeemed without the authorization_code grant".to_owned(),
+    )),
+  }
+}
+
+/// A `private_key_jwt` CIMD client uses PAR and JAR with its document
+/// redirect: a pushed request authorizes, and a signed request object
+/// verifies against the inline `jwks` (CIMD draft §5).
+#[tokio::test]
+async fn cimd_client_jar_or_par() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("parjar");
+  let key = DocKey::generate("doc-key-par")?;
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("private_key_jwt"),
+      &["authorization_code"],
+      "api:read",
+      Some(key.jwks.clone()),
+      false,
+    ),
+  )
+  .await?;
+
+  // PAR: the client authenticates with its assertion and pushes with the
+  // document redirect; the returned URN then authorizes.
+  let assertion = key.assertion("doc-key-par", &url, &h.endpoint.clone(), "cimd-jti-par-1")?;
+  let auth = assertion_request(&url, &assertion);
+  let mut params = HashMap::from([
+    ("client_id".to_owned(), url.clone()),
+    ("response_type".to_owned(), "code".to_owned()),
+    ("redirect_uri".to_owned(), REDIRECT.to_owned()),
+    ("scope".to_owned(), "api:read".to_owned()),
+    ("code_challenge".to_owned(), CHALLENGE.to_owned()),
+    ("code_challenge_method".to_owned(), "S256".to_owned()),
+    ("state".to_owned(), "par-state".to_owned()),
+  ]);
+  let endpoint = h.endpoint.clone();
+  let token_prefix = h.token_prefix.clone();
+  let cimd_request = CimdRequest {
+    token_prefix: &token_prefix,
+    max_bytes: 5_120,
+    timeout: Duration::from_secs(2),
+  };
+  let pushed = {
+    let mut stores = ParStores {
+      pool: &h.pool,
+      keys: &h.ring,
+      conn: &mut h.conn,
+      store: &h.store,
+    };
+    let ctx = ParContext {
+      token_endpoint: &endpoint,
+      now: OffsetDateTime::now_utc(),
+      par_ttl: Duration::from_secs(90),
+      allow_localhost_redirect: false,
+      cimd: Some(cimd_request),
+      resource_indicators_enabled: false,
+    };
+    push_authorization_request(&mut stores, &ctx, &auth, &params, &[])
+      .await
+      .map_err(|error| TestError::Unexpected(format!("cimd PAR push rejected: {error}")))?
+  };
+  if !pushed
+    .request_uri
+    .starts_with("urn:ietf:params:oauth:request_uri:")
+  {
+    return Err(TestError::Unexpected(format!(
+      "bad request_uri: {}",
+      pushed.request_uri
+    )));
+  }
+  let par_query = HashMap::from([
+    ("client_id".to_owned(), url.clone()),
+    ("request_uri".to_owned(), pushed.request_uri.clone()),
+  ]);
+  let validated = validate(AuthorizeInput {
+    pool: &h.pool,
+    keys: &h.ring,
+    conn: &mut h.conn,
+    store: &h.store,
+    issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: Some(CimdRequest {
+      token_prefix: &h.token_prefix,
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(2),
+    }),
+    query: &par_query,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
+  })
+  .await
+  .map_err(|failure| {
+    TestError::Unexpected(format!("cimd PAR authorize rejected: {}", failure.code()))
+  })?;
+  if validated.client_id != url || validated.redirect_uri != REDIRECT {
+    return Err(TestError::Unexpected(
+      "PAR authorize bound the wrong client".to_owned(),
+    ));
+  }
+
+  // JAR: an inline request object signed by the document key authorizes
+  // with only `client_id` beside it in the query.
+  let request = key.request_object(
+    "doc-key-par",
+    &url,
+    ISSUER,
+    serde_json::json!({
+      "client_id": url,
+      "response_type": "code",
+      "redirect_uri": REDIRECT,
+      "scope": "api:read",
+      "code_challenge": CHALLENGE,
+      "code_challenge_method": "S256",
+      "state": "jar-state",
+    }),
+  )?;
+  params.insert("request".to_owned(), request);
+  params.remove("request_uri");
+  let mut jar_query = HashMap::from([("client_id".to_owned(), url.clone())]);
+  jar_query.insert("request".to_owned(), params["request"].clone());
+  let validated = validate(AuthorizeInput {
+    pool: &h.pool,
+    keys: &h.ring,
+    conn: &mut h.conn,
+    store: &h.store,
+    issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: Some(CimdRequest {
+      token_prefix: &h.token_prefix,
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(2),
+    }),
+    query: &jar_query,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
+  })
+  .await
+  .map_err(|failure| {
+    TestError::Unexpected(format!("cimd JAR authorize rejected: {}", failure.code()))
+  })?;
+  if validated.client_id != url {
+    return Err(TestError::Unexpected(
+      "JAR authorize bound the wrong client".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// A registration-free client revokes its own token (RFC 7009 §2.1); a
+/// foreign client's token is a uniform no-op and stays live (RFC 7009 §2.2).
+#[tokio::test]
+async fn cimd_client_can_revoke() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("revoke");
+  let key = DocKey::generate("doc-key-revoke")?;
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("private_key_jwt"),
+      &["authorization_code", "refresh_token"],
+      "api:read",
+      Some(key.jwks.clone()),
+      false,
+    ),
+  )
+  .await?;
+  let first = issue_code(&mut h, &url, CodeClientKind::Confidential, false, None).await?;
+  let assertion = key.assertion(
+    "doc-key-revoke",
+    &url,
+    &h.endpoint.clone(),
+    "cimd-jti-revoke-1",
+  )?;
+  let tokens = redeem(
+    &mut h,
+    first.code.as_str(),
+    &url,
+    Some(&assertion),
+    now_unix(),
+  )
+  .await
+  .map_err(|error| TestError::Unexpected(format!("cimd redemption rejected: {error}")))?;
+  let second = issue_code(&mut h, &url, CodeClientKind::Confidential, false, None).await?;
+  let assertion_two = key.assertion(
+    "doc-key-revoke",
+    &url,
+    &h.endpoint.clone(),
+    "cimd-jti-revoke-2",
+  )?;
+  let other = redeem(
+    &mut h,
+    second.code.as_str(),
+    &url,
+    Some(&assertion_two),
+    now_unix(),
+  )
+  .await
+  .map_err(|error| TestError::Unexpected(format!("second cimd redemption rejected: {error}")))?;
+
+  let service = TokenService::new(&h.ttl, &h.ring)?;
+  let endpoint = h.endpoint.clone();
+  let token_prefix = h.token_prefix.clone();
+  let ctx = RevokeContext {
+    token_endpoint: &endpoint,
+    now: OffsetDateTime::now_utc(),
+    cimd: Some(CimdSettings {
+      token_prefix: &token_prefix,
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(2),
+      allow_localhost: false,
+    }),
+    request_id: None,
+  };
+  // A foreign registration-free client learns nothing and revokes nothing.
+  let foreign_url = unique("foreign");
+  seed_cimd(
+    &mut h,
+    &foreign_url,
+    &document(
+      &foreign_url,
+      Some("none"),
+      &["authorization_code"],
+      "api:read",
+      None,
+      false,
+    ),
+  )
+  .await?;
+  let assertion_three = key.assertion(
+    "doc-key-revoke",
+    &url,
+    &h.endpoint.clone(),
+    "cimd-jti-revoke-3",
+  )?;
+  let auth = assertion_request(&url, &assertion_three);
+  let request = RevokeRequest {
+    token: &tokens.access_token,
+    token_type_hint: None,
+    auth: &auth,
+  };
+  let outcome = {
+    let mut stores = RevokeStores {
+      pool: &h.pool,
+      keys: &h.ring,
+      conn: &mut h.conn,
+      store: &h.store,
+      service: &service,
+    };
+    revoke_tokens(&mut stores, &ctx, &request, None).await?
+  };
+  if outcome != RevokeOutcome::Revoked {
+    return Err(TestError::Unexpected(format!(
+      "owner revoke must succeed, got {outcome:?}"
+    )));
+  }
+  if service
+    .find_access(&mut h.conn, &h.store, &tokens.access_token)
+    .await?
+    .is_some()
+  {
+    return Err(TestError::Unexpected(
+      "revoked cimd token still resolves".to_owned(),
+    ));
+  }
+
+  let foreign_auth = bare_request(&foreign_url);
+  let foreign = RevokeRequest {
+    token: &other.access_token,
+    token_type_hint: None,
+    auth: &foreign_auth,
+  };
+  let outcome = {
+    let mut stores = RevokeStores {
+      pool: &h.pool,
+      keys: &h.ring,
+      conn: &mut h.conn,
+      store: &h.store,
+      service: &service,
+    };
+    revoke_tokens(&mut stores, &ctx, &foreign, None).await?
+  };
+  if outcome != RevokeOutcome::NoOp {
+    return Err(TestError::Unexpected(format!(
+      "foreign revoke must be a no-op, got {outcome:?}"
+    )));
+  }
+  if service
+    .find_access(&mut h.conn, &h.store, &other.access_token)
+    .await?
+    .is_none()
+  {
+    return Err(TestError::Unexpected(
+      "foreign revoke burned the victim token".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// Public clients do not introspect (RFC 7662 §2.3): a `none` CIMD document
+/// is rejected with `invalid_client` (HTTP 401 challenge semantics).
+#[tokio::test]
+async fn cimd_none_cannot_introspect() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("nointrospect");
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("none"),
+      &["authorization_code"],
+      "api:read",
+      None,
+      false,
+    ),
+  )
+  .await?;
+  let service = TokenService::new(&h.ttl, &h.ring)?;
+  let token_prefix = h.token_prefix.clone();
+  let mut stores = IntrospectStores {
+    pool: &h.pool,
+    keys: &h.ring,
+    conn: &mut h.conn,
+    store: &h.store,
+  };
+  let ctx = IntrospectContext {
+    issuer: ISSUER,
+    now_unix: now_unix(),
+    cimd: Some(CimdSettings {
+      token_prefix: &token_prefix,
+      max_bytes: 5_120,
+      timeout: Duration::from_secs(2),
+      allow_localhost: false,
+    }),
+    service: &service,
+    jwt_signer: None,
+  };
+  let inputs = IntrospectHttpInputs {
+    token: "opaque-token-that-can-never-resolve",
+    token_type_hint: None,
+    client_id: Some(&url),
+    basic_header: None,
+    body_secret: None,
+    assertion_type: None,
+    assertion: None,
+    peer_certs: None,
+    resource: &[],
+  };
+  match introspect(&mut stores, &ctx, &inputs).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidClient => {
+      if error.status().as_u16() != 401 {
+        return Err(TestError::Unexpected(format!(
+          "cimd none introspection must be 401, got {}",
+          error.status()
+        )));
+      }
+      Ok(())
+    }
+    Err(error) => Err(TestError::Unexpected(format!(
+      "cimd none introspection must be invalid_client, got {}",
+      error.code()
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "cimd none client introspected successfully".to_owned(),
+    )),
+  }
+}
+
+/// A registered row whose `client_id` is an `https://` URL always wins: the
+/// cached document is never consulted, even when CIMD is enabled.
+#[tokio::test]
+async fn cimd_registered_row_wins_over_document() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("rowwins");
+  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: "row wins".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?;
+  // A conflicting document: different method, different scope. The row must
+  // still win.
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("none"),
+      &["authorization_code"],
+      "other:scope",
+      None,
+      false,
+    ),
+  )
+  .await?;
+  let req = ClientAuthRequest {
+    basic_header: None,
+    client_id: Some(&url),
+    body_secret: Some(SecretString::from("row-secret")),
+    assertion_type: None,
+    assertion: None,
+    peer_certs: None,
+  };
+  let endpoint = h.endpoint.clone();
+  let token_prefix = h.token_prefix.clone();
+  let ctx = auth_ctx_for(&endpoint, &token_prefix);
+  let authenticated = authenticate_client(&mut auth_stores(&mut h), &ctx, &req)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("registered row rejected: {error}")))?;
+  if authenticated.method != TokenEndpointAuthMethod::ClientSecretPost {
+    return Err(TestError::Unexpected(format!(
+      "row method must win, got {:?}",
+      authenticated.method
+    )));
+  }
+  if authenticated.client.scope != "api:read" {
+    return Err(TestError::Unexpected(format!(
+      "row scope must win, got {}",
+      authenticated.client.scope
+    )));
+  }
+  Ok(())
+}
+
+/// A replayed assertion `jti` is single-use: the second presentation of the
+/// same CIMD assertion fails `invalid_client`.
+#[tokio::test]
+async fn cimd_replayed_assertion_jti_rejected() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("replay");
+  let key = DocKey::generate("doc-key-replay")?;
+  seed_cimd(
+    &mut h,
+    &url,
+    &document(
+      &url,
+      Some("private_key_jwt"),
+      &["authorization_code"],
+      "api:read",
+      Some(key.jwks.clone()),
+      false,
+    ),
+  )
+  .await?;
+  let assertion = key.assertion(
+    "doc-key-replay",
+    &url,
+    &h.endpoint.clone(),
+    "cimd-jti-replay-9",
+  )?;
+  let endpoint = h.endpoint.clone();
+  let token_prefix = h.token_prefix.clone();
+  let ctx = auth_ctx_for(&endpoint, &token_prefix);
+  let req = assertion_request(&url, &assertion);
+  authenticate_client(&mut auth_stores(&mut h), &ctx, &req)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("first assertion rejected: {error}")))?;
+  let req = assertion_request(&url, &assertion);
+  match authenticate_client(&mut auth_stores(&mut h), &ctx, &req).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidClient => Ok(()),
+    Err(error) => Err(TestError::Unexpected(format!(
+      "replay must be invalid_client, got {}",
+      error.code()
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "replayed cimd assertion accepted".to_owned(),
+    )),
+  }
+}
+
+/// Fetch and validation failures map fail-closed: an unfetchable document is
+/// `invalid_client_metadata`, and a disabled feature keeps an `https://` id
+/// an unknown client.
+#[tokio::test]
+async fn cimd_fetch_failure_maps_fail_closed() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("missing");
+  let token_prefix = h.token_prefix.clone();
+  let fetch_settings = CimdSettings {
+    token_prefix: &token_prefix,
+    max_bytes: 5_120,
+    timeout: Duration::from_secs(2),
+    allow_localhost: false,
+  };
+  // No cache seed: `.invalid` can never fetch, so this is a pure Fetch
+  // failure (CIMD draft §5, fail-closed per Todo 19's mapping).
+  match resolve_client(&h.pool, &h.ring, &mut h.conn, Some(fetch_settings), &url).await {
+    Err(error) => {
+      let app = AppError::from(error);
+      if app.code() != OAuthErrorCode::InvalidClientMetadata {
+        return Err(TestError::Unexpected(format!(
+          "fetch failure must be invalid_client_metadata, got {}",
+          app.code()
+        )));
+      }
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "unfetchable document resolved".to_owned(),
+      ));
+    }
+  }
+  // Disabled feature: the same id is an unknown client, exactly as today.
+  match resolve_client(&h.pool, &h.ring, &mut h.conn, None, &url).await {
+    Ok(None) => Ok(()),
+    Ok(Some(_)) => Err(TestError::Unexpected(
+      "disabled cimd must not resolve".to_owned(),
+    )),
+    Err(error) => Err(TestError::Unexpected(format!(
+      "disabled cimd must be Ok(None), got {error}"
+    ))),
+  }
+}

+ 21 - 11
src/protocol/client_auth.rs

@@ -27,7 +27,7 @@ use crate::{
   db::{Client, TokenEndpointAuthMethod, client_repo},
   error::AppError,
   keys::KeyRing,
-  protocol::mtls,
+  protocol::{cimd, mtls},
   state::RedisConn,
   store::Store,
   tls::PeerCertificates,
@@ -143,6 +143,10 @@ pub struct AuthContext<'a> {
   pub token_endpoint: &'a str,
   /// Current time for secret-expiry enforcement.
   pub now: OffsetDateTime,
+  /// CIMD resolution settings for a registration-free fallback; `None` when
+  /// `config.cimd_enabled` is off, so an `https://` `client_id` with no
+  /// registered row keeps the existing unknown-client behavior.
+  pub cimd: Option<cimd::CimdSettings<'a>>,
 }
 
 /// Authenticate a token-request client.
@@ -186,7 +190,7 @@ pub async fn authenticate_client(
     {
       return Err(invalid_client_for(true));
     }
-    let client = lookup_client(stores.pool, stores.keys, &creds.client_id, true).await?;
+    let client = lookup_client(stores, ctx.cimd, &creds.client_id, true).await?;
     require_method(&client, TokenEndpointAuthMethod::ClientSecretBasic, true)?;
     authenticate_secret(
       stores.pool,
@@ -200,7 +204,7 @@ pub async fn authenticate_client(
     let Some(client_id) = req.client_id else {
       return Err(invalid_client_for(false));
     };
-    let client = lookup_client(stores.pool, stores.keys, client_id, false).await?;
+    let client = lookup_client(stores, ctx.cimd, client_id, false).await?;
     require_method(&client, TokenEndpointAuthMethod::ClientSecretPost, false)?;
     authenticate_secret(stores.pool, &client, secret.expose_secret(), ctx.now, false).await
   } else if let Some(assertion_str) = req.assertion {
@@ -213,11 +217,11 @@ pub async fn authenticate_client(
       (None, Some(iss)) => iss,
       (None, None) => return Err(invalid_client_for(false)),
     };
-    let client = lookup_client(stores.pool, stores.keys, &client_id, false).await?;
+    let client = lookup_client(stores, ctx.cimd, &client_id, false).await?;
     require_method(&client, TokenEndpointAuthMethod::PrivateKeyJwt, false)?;
     assertion::authenticate(stores, &client, assertion_str, ctx).await
   } else if let Some(client_id) = req.client_id {
-    let client = lookup_client(stores.pool, stores.keys, client_id, false).await?;
+    let client = lookup_client(stores, ctx.cimd, client_id, false).await?;
     match client.auth_method() {
       Ok(TokenEndpointAuthMethod::None) => Ok(AuthenticatedClient {
         client,
@@ -242,18 +246,24 @@ fn invalid_client_for(used_basic: bool) -> AppError {
   }
 }
 
-/// Load an active client or fail closed as `invalid_client` (never reveal
-/// whether the id exists).
+/// Load the client principal: a registered row wins, else a validated CIMD
+/// document materialized as a synthetic client (CIMD draft §3/§4.1), else
+/// `invalid_client` (never reveal whether the id exists).
+///
+/// A CIMD fetch or validation failure maps per [`cimd::CimdError`]
+/// (`invalid_client_metadata`, `invalid_redirect_uri`, or fail-closed `503`),
+/// not to `invalid_client`: the id named a document, but the document itself
+/// is unusable.
 async fn lookup_client(
-  pool: &PgPool,
-  keys: &KeyRing,
+  stores: &mut AuthStores<'_>,
+  cimd: Option<cimd::CimdSettings<'_>>,
   client_id: &str,
   used_basic: bool,
 ) -> Result<Client, AppError> {
-  match client_repo::find_active_by_client_id(pool, keys, client_id).await {
+  match cimd::resolve_client(stores.pool, stores.keys, stores.conn, cimd, client_id).await {
     Ok(Some(client)) => Ok(client),
     Ok(None) => Err(invalid_client_for(used_basic)),
-    Err(error) => Err(error),
+    Err(error) => Err(AppError::from(error)),
   }
 }
 

+ 2 - 0
src/protocol/client_auth/tests_containers.rs

@@ -122,6 +122,7 @@ fn ctx_of(endpoint: &str) -> AuthContext<'_> {
   AuthContext {
     token_endpoint: endpoint,
     now: OffsetDateTime::now_utc(),
+    cimd: None,
   }
 }
 
@@ -171,6 +172,7 @@ async fn insert_client(
     contacts: None,
     software_id: None,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
   };
   client_repo::insert(pool, &keys, false, &new).await?;

+ 21 - 6
src/protocol/code.rs

@@ -40,7 +40,8 @@ pub const CODE_TTL_CAP: Duration = Duration::from_secs(60);
 /// Whether the code is issued to a public or a confidential client.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum CodeClientKind {
-  /// No client secret: a verified DPoP `jkt` is mandatory.
+  /// No client secret: a verified DPoP `jkt` is mandatory only when the
+  /// client is DPoP-bound; unbound public clients use Bearer.
   Public,
   /// Authenticated client: the DPoP `jkt` is recorded when proven.
   Confidential,
@@ -53,6 +54,10 @@ pub struct CodeIssueRequest<'a> {
   pub client_id: &'a str,
   /// Public or confidential: selects the DPoP matrix.
   pub client_kind: CodeClientKind,
+  /// Whether a public client must present a DPoP proof: mirrors the
+  /// client's `dpop_bound_access_tokens` registration (unbound public
+  /// clients are issued bearer-capable codes). Ignored for confidential.
+  pub dpop_required: bool,
   /// Resource-owner subject the code is authorized for.
   pub user_id: &'a str,
   /// Redirect URI the code is bound to.
@@ -67,6 +72,9 @@ pub struct CodeIssueRequest<'a> {
   pub dpop_jkt: Option<&'a str>,
   /// `state` value to echo in the success redirect, if supplied.
   pub state: Option<&'a str>,
+  /// Validated resource audiences (RFC 8707) pinned from the pending
+  /// request; empty when the authorize request was unbound.
+  pub aud: &'a [String],
   /// Server issuer echoed as the RFC 9207 `iss` parameter.
   pub issuer: &'a str,
 }
@@ -243,7 +251,7 @@ impl CodeIssuer {
     if !request.approved_scope.is_subset_of(request.user_entitled) {
       return Err(CodeIssueError::UnentitledScope);
     }
-    let cnf = resolve_cnf(request.client_kind, request.dpop_jkt)?;
+    let cnf = resolve_cnf(request.client_kind, request.dpop_jkt, request.dpop_required)?;
     let raw = crypto_tokens::mint_token()?;
     let active = self.keys.first().ok_or(CodeIssueError::NoPepper)?;
     let code_hash = crypto_tokens::token_hash(&active.generation, active.secret.as_slice(), &raw)
@@ -257,6 +265,7 @@ impl CodeIssuer {
       subject: request.user_id.to_owned(),
       grant_id: grant_id.clone(),
       cnf,
+      aud: request.aud.to_vec(),
     };
     codes::issue(conn, store, &code_hash, &record, self.ttl).await?;
     let location = success_redirect(request.redirect_uri, &raw, request.state, request.issuer);
@@ -270,14 +279,20 @@ impl CodeIssuer {
   }
 }
 
-/// Apply the DPoP matrix: public clients REQUIRE a `jkt`; confidential
-/// clients record it when proven, else stay unconstrained (empty `cnf`).
-fn resolve_cnf(kind: CodeClientKind, jkt: Option<&str>) -> Result<String, CodeIssueError> {
+/// Apply the DPoP matrix: DPoP-bound public clients REQUIRE a `jkt`;
+/// unbound public clients are issued bearer-capable codes (an empty `cnf`);
+/// confidential clients record the `jkt` when proven, else stay unconstrained.
+fn resolve_cnf(
+  kind: CodeClientKind,
+  jkt: Option<&str>,
+  dpop_required: bool,
+) -> Result<String, CodeIssueError> {
   let bound = jkt.filter(|value| !value.is_empty());
   match kind {
-    CodeClientKind::Public => bound
+    CodeClientKind::Public if dpop_required => bound
       .map(str::to_owned)
       .ok_or(CodeIssueError::MissingDpopBinding),
+    CodeClientKind::Public => Ok(bound.unwrap_or_default().to_owned()),
     CodeClientKind::Confidential => Ok(bound.unwrap_or_default().to_owned()),
   }
 }

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

@@ -88,6 +88,7 @@ fn request<'a>(
   CodeIssueRequest {
     client_id: "client-abc",
     client_kind: kind,
+    dpop_required: kind == CodeClientKind::Public,
     user_id: "user-1",
     redirect_uri: REDIRECT,
     approved_scope: approved,
@@ -95,6 +96,7 @@ fn request<'a>(
     code_challenge: CHALLENGE,
     dpop_jkt: jkt,
     state,
+    aud: &[],
     issuer: ISSUER,
   }
 }
@@ -209,6 +211,52 @@ async fn code_issue_redirect_carries_code_state_iss() -> TestResult {
   Ok(())
 }
 
+/// An unbound public client is issued a bearer-capable code with no DPoP
+/// proof: issuance succeeds and the stored binding is empty.
+#[tokio::test]
+async fn public_unbound_code_issuance_succeeds() -> TestResult {
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let (mut conn, store) = direct_store(
+    crate::test_support::redis_manager()
+      .await
+      .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?,
+    &state_prefix,
+    &ratelimit_prefix,
+  )
+  .await?;
+  let issuer = CodeIssuer::new(&TtlConfig::default(), &test_ring()?)?;
+  let approved = ScopeSet::parse("api:read")?;
+  let entitled = ScopeSet::parse("api:read api:admin")?;
+
+  let issued = issuer
+    .issue(
+      &mut conn,
+      &store,
+      &CodeIssueRequest {
+        client_id: "client-abc",
+        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
+    .map_err(|error| TestError::Unexpected(format!("unbound issuance rejected: {error}")))?;
+  if issued.code.is_empty() {
+    return Err(TestError::Unexpected(
+      "unbound issuance must return a code".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn code_issue_stores_only_hash_with_bounded_ttl() -> TestResult {
   let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();

+ 45 - 13
src/protocol/dcr.rs

@@ -74,8 +74,10 @@ pub struct RegisterContext<'a> {
 
 /// RFC 7591 §2 registration request body.
 ///
-/// Unknown fields are ignored; client-asserted secrets, ids, or timestamps
-/// are never honored (the server mints every credential it returns).
+/// Unknown fields are ignored, except `access_token_format`: it is
+/// operator-only and a body carrying it is rejected (see
+/// [`parse_register_request`]). Client-asserted secrets, ids, or timestamps are
+/// never honored (the server mints every credential it returns).
 #[derive(Debug, Clone, Deserialize)]
 pub struct RegisterRequest {
   /// Requested redirect URIs (required, non-empty).
@@ -168,6 +170,23 @@ struct ValidatedRegistration {
   redirect_uris: Vec<String>,
 }
 
+/// Deserialize a registration body, rejecting operator-only fields.
+///
+/// RFC 7591 registration is self-service, so it must never let a registrant
+/// opt into JWT access tokens (an operator-only choice exposed solely through
+/// `client set-access-token-format`). A body carrying `access_token_format`
+/// fails closed with `invalid_client_metadata` instead of being silently
+/// ignored; every other DCR field keeps its existing behavior.
+fn parse_register_request(body: serde_json::Value) -> Result<RegisterRequest, AppError> {
+  if body.get("access_token_format").is_some() {
+    return Err(AppError::invalid_client_metadata(
+      "access_token_format is operator-only and cannot be set through dynamic client registration",
+    ));
+  }
+  serde_json::from_value(body)
+    .map_err(|_| AppError::invalid_client_metadata("invalid client metadata"))
+}
+
 /// Axum entry point for `POST /register` (mounted by the router in task 40).
 ///
 /// # Errors
@@ -180,8 +199,9 @@ struct ValidatedRegistration {
 pub async fn register_handler(
   State(state): State<AppState>,
   headers: HeaderMap,
-  Json(request): Json<RegisterRequest>,
+  Json(body): Json<serde_json::Value>,
 ) -> Result<(StatusCode, Json<RegisterResponse>), AppError> {
+  let request = parse_register_request(body)?;
   let token = bearer_token(&headers);
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
@@ -250,6 +270,7 @@ pub async fn register_client(
     contacts: validated.contacts.clone(),
     software_id: validated.software_id.clone(),
     dpop_bound_access_tokens: validated.dpop_bound,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: Some(registration_hash),
   };
   let row = client_repo::insert(
@@ -491,6 +512,15 @@ async fn validate_scope(pool: &PgPool, scope: Option<&str>) -> Result<String, Ap
 /// anything else follows `dcr_redirect_policy` (`open` and dynamic loopback
 /// URIs are upserted into the catalog, then attached). An upsert that lands
 /// on an admin-disabled row is rejected rather than resurrected.
+///
+/// C5c runbook coupling: a DCR client can register
+/// `http://localhost:{port}/cb` only with open registration
+/// (`allow_open_registration=true`) OR an IAT-gated registration (RFC 7591
+/// §2), AND a non-`catalog` `dcr_redirect_policy` (`catalog_loopback` or
+/// `open`), AND `allow_localhost_redirect=true` (RFC 8252 §7.3 makes
+/// `localhost` a loopback host only behind that flag). The default `catalog`
+/// policy rejects every non-cataloged redirect, so it rejects this URI even
+/// when the other two conditions hold.
 async fn resolve_redirects(
   pool: &PgPool,
   config: &Config,
@@ -510,14 +540,14 @@ async fn resolve_redirects(
         ));
       }
       DcrRedirectPolicy::CatalogLoopback => {
-        if !is_loopback_uri(uri) {
+        if !is_loopback_uri(uri, config.allow_localhost_redirect) {
           return Err(AppError::invalid_redirect_uri(
             "redirect URI is not in the catalog or a loopback URI",
           ));
         }
       }
       DcrRedirectPolicy::Open => {
-        if !is_open_redirect_uri(uri) {
+        if !is_open_redirect_uri(uri, config.allow_localhost_redirect) {
           return Err(AppError::invalid_redirect_uri(
             "redirect URI must be an absolute https URI or a loopback URI",
           ));
@@ -548,9 +578,10 @@ async fn resolve_redirects(
 }
 
 /// Whether `uri` is an RFC 8252 §7.3 loopback URI: plaintext `http` to a
-/// loopback literal (`127.0.0.1` or `[::1]`, any port) with no userinfo,
-/// query, or fragment. `localhost` is never loopback here.
-fn is_loopback_uri(uri: &str) -> bool {
+/// loopback host (`127.0.0.1` or `[::1]` at any port, plus the exact host
+/// `localhost` when `allow_localhost` is set) with no userinfo, query, or
+/// fragment. `localhost` is not loopback otherwise.
+fn is_loopback_uri(uri: &str, allow_localhost: bool) -> bool {
   let (before_fragment, fragment) = match uri.split_once('#') {
     Some((before, fragment)) => (before, Some(fragment)),
     None => (uri, None),
@@ -589,9 +620,10 @@ fn is_loopback_uri(uri: &str) -> bool {
       None => false,
     }
   } else {
+    let is_loopback = |host: &str| host == "127.0.0.1" || (allow_localhost && host == "localhost");
     match authority.split_once(':') {
-      Some((host, _)) => host == "127.0.0.1",
-      None => authority == "127.0.0.1",
+      Some((host, _)) => is_loopback(host),
+      None => is_loopback(authority),
     }
   }
 }
@@ -600,9 +632,9 @@ fn is_loopback_uri(uri: &str) -> bool {
 /// RFC 8252 loopback URI, or an absolute `https` URI with a non-empty host
 /// and no userinfo, fragment, or whitespace. Accepting an arbitrary absolute
 /// URI here would turn the opt-in `open` policy into an open-redirect
-/// factory.
-fn is_open_redirect_uri(uri: &str) -> bool {
-  if is_loopback_uri(uri) {
+/// factory. `allow_localhost` is forwarded to [`is_loopback_uri`].
+fn is_open_redirect_uri(uri: &str, allow_localhost: bool) -> bool {
+  if is_loopback_uri(uri, allow_localhost) {
     return true;
   }
   if uri.contains('#') || uri.chars().any(char::is_whitespace) {

+ 11 - 3
src/protocol/dcr/manage/tests_delete.rs

@@ -1,13 +1,12 @@
 //! `test(dcr_manage)` delete suite: cross-client denial, DELETE, revocation,
 //! and Redis-loss survival.
 
-use uuid::Uuid;
-
 use testcontainers::{
   GenericImage, ImageExt as _,
   core::{IntoContainerPort, WaitFor},
   runners::AsyncRunner,
 };
+use uuid::Uuid;
 
 use super::delete::delete_client;
 use super::read_client;
@@ -17,7 +16,7 @@ use super::support::{
 };
 use crate::db::client_repo::{self, NewClient};
 use crate::error::OAuthErrorCode;
-use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenService};
+use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService};
 
 #[tokio::test]
 async fn dcr_manage_cross_client_forbidden() -> TestResult {
@@ -164,10 +163,14 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
         &MintRequest {
           client_id: registered.client_id.clone(),
           client_kind: ClientKind::Confidential,
+          dpop_required: false,
           subject: String::new(),
           scope: String::new(),
           grant_id: Uuid::new_v4().to_string(),
           proof: SenderProof::none(),
+          aud: Vec::new(),
+          refresh_aud: Vec::new(),
+          format: TokenFormat::Opaque,
         },
         now,
       )
@@ -183,10 +186,14 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
       &MintRequest {
         client_id: other.client_id.clone(),
         client_kind: ClientKind::Confidential,
+        dpop_required: false,
         subject: String::new(),
         scope: String::new(),
         grant_id: Uuid::new_v4().to_string(),
         proof: SenderProof::none(),
+        aud: Vec::new(),
+        refresh_aud: Vec::new(),
+        format: TokenFormat::Opaque,
       },
       now,
     )
@@ -363,6 +370,7 @@ async fn insert_thumbprint_client(
     contacts: None,
     software_id: None,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: Some(registration_token_hash.to_owned()),
   };
   client_repo::insert(

+ 323 - 31
src/protocol/dcr/tests.rs

@@ -1,6 +1,15 @@
 //! `test(dcr_register)` container suite: `POST /register` against real
 //! Postgres and Redis (IAT enforcement, metadata validation, the
 //! confidential sender-constraint rule, and hash-only persistence).
+//!
+//! C5c runbook coupling pinned here: a DCR client can register
+//! `http://localhost:{port}/cb` only with open registration
+//! (`allow_open_registration=true`), or an IAT-gated registration, AND a
+//! non-`catalog` `dcr_redirect_policy` (`catalog_loopback` or `open`), AND
+//! `allow_localhost_redirect=true`. The `catalog` policy rejects any
+//! non-cataloged redirect, so it rejects `localhost` even with the other two
+//! on; `localhost` is not an RFC 8252 §7.3 loopback host unless the flag is
+//! set. Enabling CIMD never changes any of this (CIMD is not DCR).
 
 use std::time::Duration;
 
@@ -65,6 +74,18 @@ struct Harness {
 /// The Postgres container is owned by the shared `db` lease; Redis comes from
 /// the shared compose service, isolated per test by [`unique_prefixes`].
 async fn harness(open: bool, policy: &str) -> Result<Harness, TestError> {
+  harness_with(open, policy, false, false).await
+}
+
+/// Like [`harness`], with the RFC 8252 §7.3 `localhost` flag and the CIMD
+/// feature flag pinned. CIMD is irrelevant to the `/register` gate; it is a
+/// parameter so the no-regression tests can prove that independence.
+async fn harness_with(
+  open: bool,
+  policy: &str,
+  allow_localhost: bool,
+  cimd: bool,
+) -> Result<Harness, TestError> {
   let (_db, pool) = crate::db::test_support::migrated_pool()
     .await
     .map_err(|error| TestError::Unexpected(format!("{error}")))?;
@@ -79,6 +100,8 @@ async fn harness(open: bool, policy: &str) -> Result<Harness, TestError> {
     "redis://127.0.0.1:1",
     open,
     policy,
+    allow_localhost,
+    cimd,
   )?;
   let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
   let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
@@ -97,6 +120,8 @@ fn test_config(
   redis_url: &str,
   open: bool,
   policy: &str,
+  allow_localhost: bool,
+  cimd: bool,
 ) -> Result<Config, TestError> {
   let fixture = serde_json::json!({
     "issuer": "https://auth.example.test",
@@ -110,6 +135,8 @@ fn test_config(
     "argon2": {"memory_kib": 8192, "iterations": 1, "parallelism": 1},
     "allow_open_registration": open,
     "dcr_redirect_policy": policy,
+    "allow_localhost_redirect": allow_localhost,
+    "cimd_enabled": cimd,
   });
   let source = ::config::Config::try_from(&fixture)?;
   Ok(Config::from_source(source)?)
@@ -177,6 +204,15 @@ async fn issue_iat(harness: &mut Harness, uses: u64) -> Result<String, TestError
   Ok(token)
 }
 
+/// Number of client rows in this test's fresh database.
+async fn client_count(pool: &PgPool) -> Result<i64, TestError> {
+  Ok(
+    sqlx::query_scalar("SELECT COUNT(*) FROM oauth_clients")
+      .fetch_one(pool)
+      .await?,
+  )
+}
+
 /// A registration request against `redirect_uri` with DPoP binding.
 fn dpop_request(redirect_uri: &str, scope: Option<&str>) -> RegisterRequest {
   RegisterRequest {
@@ -491,6 +527,186 @@ async fn dcr_register_invalid_iat_401() -> TestResult {
   }
 }
 
+/// CIMD is not DCR: enabling it must not touch the RFC 7591 §2 gate. With
+/// `allow_open_registration=false` an anonymous call is still
+/// `invalid_client` (401) and writes no client row, with CIMD off or on; the
+/// open flag still admits an anonymous caller; an IAT still registers exactly
+/// as before.
+#[tokio::test]
+async fn dcr_gate_unchanged_with_cimd_on() -> TestResult {
+  let redirect = "https://app.example.test/cb";
+  let request = dpop_request(redirect, None);
+  let mut closed_off = harness_with(false, "catalog", false, false).await?;
+  let mut closed_on = harness_with(false, "catalog", false, true).await?;
+
+  for (label, harness) in [("cimd off", &mut closed_off), ("cimd on", &mut closed_on)] {
+    {
+      let (mut stores, ctx) = parts_of(harness);
+      match register_client(&mut stores, &ctx, None, &request).await {
+        Ok(_) => {
+          return Err(TestError::Unexpected(format!(
+            "anonymous registration succeeded while closed ({label})"
+          )));
+        }
+        Err(error) => expect_error(error, OAuthErrorCode::InvalidClient, 401)?,
+      }
+    }
+    if client_count(&harness.pool).await? != 0 {
+      return Err(TestError::Unexpected(format!(
+        "rejected anonymous registration wrote a client row ({label})"
+      )));
+    }
+  }
+
+  // The open flag still admits an anonymous caller with CIMD on.
+  let mut open_on = harness_with(true, "catalog", false, true).await?;
+  seed_redirect(&open_on.pool, &open_on.config.keys, redirect).await?;
+  {
+    let (mut stores, ctx) = parts_of(&mut open_on);
+    register_client(&mut stores, &ctx, None, &request)
+      .await
+      .map_err(|error| {
+        TestError::Unexpected(format!("open anonymous registration failed: {error}"))
+      })?;
+  }
+  if client_count(&open_on.pool).await? != 1 {
+    return Err(TestError::Unexpected(
+      "open anonymous registration did not write exactly one client".to_owned(),
+    ));
+  }
+
+  // An IAT still registers in both CIMD states.
+  for (label, harness) in [("cimd off", &mut closed_off), ("cimd on", &mut closed_on)] {
+    seed_redirect(&harness.pool, &harness.config.keys, redirect).await?;
+    let iat = issue_iat(harness, 3).await?;
+    {
+      let (mut stores, ctx) = parts_of(harness);
+      register_client(&mut stores, &ctx, Some(&iat), &request)
+        .await
+        .map_err(|error| {
+          TestError::Unexpected(format!("IAT registration failed ({label}): {error}"))
+        })?;
+    }
+    if client_count(&harness.pool).await? != 1 {
+      return Err(TestError::Unexpected(format!(
+        "IAT registration wrote an unexpected client count ({label})"
+      )));
+    }
+  }
+  Ok(())
+}
+
+/// C5c triple: `http://localhost:{port}/cb` registers ONLY with open
+/// registration OR an IAT, AND a non-`catalog` `dcr_redirect_policy`, AND
+/// `allow_localhost_redirect=true`, even with CIMD enabled. Each negative
+/// asserts the exact error and that no client row was created.
+#[tokio::test]
+async fn dcr_open_registration_localhost_when_flag_on() -> TestResult {
+  let loopback = "http://localhost:6420/cb";
+  let request = dpop_request(loopback, None);
+
+  // All three on: open registration admits it.
+  let mut open_ok = harness_with(true, "catalog_loopback", true, true).await?;
+  let response = {
+    let (mut stores, ctx) = parts_of(&mut open_ok);
+    register_client(&mut stores, &ctx, None, &request)
+      .await
+      .map_err(|error| {
+        TestError::Unexpected(format!("localhost open registration failed: {error}"))
+      })?
+  };
+  let effective =
+    redirect_repo::effective_for_client(&open_ok.pool, &open_ok.config.keys, &response.client_id)
+      .await?;
+  if effective.len() != 1 || effective[0].uri != loopback {
+    return Err(TestError::Unexpected(format!(
+      "localhost URI not attached: {:?}",
+      effective.iter().map(|row| &row.uri).collect::<Vec<_>>()
+    )));
+  }
+
+  // The `open` policy also admits it (non-`catalog` policy class).
+  let mut open_policy = harness_with(true, "open", true, true).await?;
+  {
+    let (mut stores, ctx) = parts_of(&mut open_policy);
+    register_client(&mut stores, &ctx, None, &request)
+      .await
+      .map_err(|error| {
+        TestError::Unexpected(format!("localhost under open policy failed: {error}"))
+      })?;
+  }
+
+  // IAT-gated (open registration off) is the other admissible path.
+  let mut iat_ok = harness_with(false, "catalog_loopback", true, true).await?;
+  let iat = issue_iat(&mut iat_ok, 3).await?;
+  {
+    let (mut stores, ctx) = parts_of(&mut iat_ok);
+    register_client(&mut stores, &ctx, Some(&iat), &request)
+      .await
+      .map_err(|error| {
+        TestError::Unexpected(format!("localhost IAT registration failed: {error}"))
+      })?;
+  }
+
+  // Negative: open registration off and no IAT.
+  let mut closed = harness_with(false, "catalog_loopback", true, true).await?;
+  {
+    let (mut stores, ctx) = parts_of(&mut closed);
+    match register_client(&mut stores, &ctx, None, &request).await {
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "localhost registered with open registration off".to_owned(),
+        ));
+      }
+      Err(error) => expect_error(error, OAuthErrorCode::InvalidClient, 401)?,
+    }
+  }
+  if client_count(&closed.pool).await? != 0 {
+    return Err(TestError::Unexpected(
+      "rejected localhost registration wrote a client row".to_owned(),
+    ));
+  }
+
+  // Negative: default `catalog` policy rejects any non-cataloged redirect.
+  let mut catalog = harness_with(true, "catalog", true, true).await?;
+  {
+    let (mut stores, ctx) = parts_of(&mut catalog);
+    match register_client(&mut stores, &ctx, None, &request).await {
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "localhost registered under the catalog policy".to_owned(),
+        ));
+      }
+      Err(error) => expect_error(error, OAuthErrorCode::InvalidRedirectUri, 400)?,
+    }
+  }
+  if client_count(&catalog.pool).await? != 0 {
+    return Err(TestError::Unexpected(
+      "catalog-policy rejection wrote a client row".to_owned(),
+    ));
+  }
+
+  // Negative: `localhost` is not loopback without the flag.
+  let mut no_host = harness_with(true, "catalog_loopback", false, true).await?;
+  {
+    let (mut stores, ctx) = parts_of(&mut no_host);
+    match register_client(&mut stores, &ctx, None, &request).await {
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "localhost registered without allow_localhost_redirect".to_owned(),
+        ));
+      }
+      Err(error) => expect_error(error, OAuthErrorCode::InvalidRedirectUri, 400)?,
+    }
+  }
+  if client_count(&no_host.pool).await? != 0 {
+    return Err(TestError::Unexpected(
+      "localhost-flag rejection wrote a client row".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
 #[tokio::test]
 async fn dcr_register_bad_redirect_rejected() -> TestResult {
   let mut harness = harness(false, "catalog").await?;
@@ -504,7 +720,6 @@ async fn dcr_register_bad_redirect_rejected() -> TestResult {
     Err(error) => expect_error(error, OAuthErrorCode::InvalidRedirectUri, 400),
   }
 }
-
 #[tokio::test]
 async fn dcr_register_bad_auth_method_rejected() -> TestResult {
   let mut harness = harness(false, "catalog").await?;
@@ -836,40 +1051,65 @@ async fn dcr_register_jwks_xor_rejected() -> TestResult {
 
 #[test]
 fn dcr_register_loopback_shapes() {
-  assert!(is_loopback_uri("http://127.0.0.1/cb"));
-  assert!(is_loopback_uri("http://127.0.0.1:6420/cb"));
-  assert!(is_loopback_uri("http://[::1]/cb"));
-  assert!(is_loopback_uri("http://[::1]:8080/cb"));
-  assert!(!is_loopback_uri("https://127.0.0.1/cb"));
-  assert!(!is_loopback_uri("http://localhost/cb"));
-  assert!(!is_loopback_uri("http://127.0.0.1/cb?x=1"));
-  assert!(!is_loopback_uri("http://127.0.0.1/cb#frag"));
-  assert!(!is_loopback_uri("http://user@127.0.0.1/cb"));
-  assert!(!is_loopback_uri("http://192.168.1.1/cb"));
-  assert!(!is_loopback_uri("not a uri"));
+  assert!(is_loopback_uri("http://127.0.0.1/cb", false));
+  assert!(is_loopback_uri("http://127.0.0.1:6420/cb", false));
+  assert!(is_loopback_uri("http://[::1]/cb", false));
+  assert!(is_loopback_uri("http://[::1]:8080/cb", false));
+  assert!(!is_loopback_uri("https://127.0.0.1/cb", false));
+  assert!(!is_loopback_uri("http://localhost/cb", false));
+  assert!(!is_loopback_uri("http://127.0.0.1/cb?x=1", false));
+  assert!(!is_loopback_uri("http://127.0.0.1/cb#frag", false));
+  assert!(!is_loopback_uri("http://user@127.0.0.1/cb", false));
+  assert!(!is_loopback_uri("http://192.168.1.1/cb", false));
+  assert!(!is_loopback_uri("not a uri", false));
+
+  // The flag widens the exception to the exact host `localhost` only.
+  assert!(is_loopback_uri("http://localhost/cb", true));
+  assert!(is_loopback_uri("http://localhost:5173/cb", true));
+  assert!(!is_loopback_uri("http://localhost.evil.example/cb", true));
+  assert!(!is_loopback_uri("http://localhost/cb?x=1", true));
+  assert!(!is_loopback_uri("http://user@localhost/cb", true));
 }
 
 #[test]
 fn dcr_register_open_redirect_shapes() {
-  assert!(is_open_redirect_uri("https://app.example.com/cb"));
-  assert!(is_open_redirect_uri("https://app.example.com/cb?x=1"));
-  assert!(is_open_redirect_uri("https://app.example.com:8443/cb"));
-  assert!(is_open_redirect_uri("HTTPS://APP.EXAMPLE.COM/cb"));
-  assert!(is_open_redirect_uri("https://[2001:db8::1]/cb"));
-  assert!(is_open_redirect_uri("http://127.0.0.1:1234/cb"));
-  assert!(is_open_redirect_uri("http://[::1]:1234/cb"));
-  assert!(!is_open_redirect_uri("http://evil.example.com/cb"));
-  assert!(!is_open_redirect_uri("myapp://cb"));
-  assert!(!is_open_redirect_uri("//app.example.com/cb"));
-  assert!(!is_open_redirect_uri("https://app.example.com/cb?x=1#frag"));
-  assert!(!is_open_redirect_uri("https://app.example.com/cb#frag"));
-  assert!(!is_open_redirect_uri("https://user@app.example.com/cb"));
-  assert!(!is_open_redirect_uri("https:///cb"));
-  assert!(!is_open_redirect_uri("https://"));
-  assert!(!is_open_redirect_uri("https:app.example.com/cb"));
-  assert!(!is_open_redirect_uri("https://[]/cb"));
-  assert!(!is_open_redirect_uri("https://app.example.com/cb "));
-  assert!(!is_open_redirect_uri("not a uri"));
+  assert!(is_open_redirect_uri("https://app.example.com/cb", false));
+  assert!(is_open_redirect_uri(
+    "https://app.example.com/cb?x=1",
+    false
+  ));
+  assert!(is_open_redirect_uri(
+    "https://app.example.com:8443/cb",
+    false
+  ));
+  assert!(is_open_redirect_uri("HTTPS://APP.EXAMPLE.COM/cb", false));
+  assert!(is_open_redirect_uri("https://[2001:db8::1]/cb", false));
+  assert!(is_open_redirect_uri("http://127.0.0.1:1234/cb", false));
+  assert!(is_open_redirect_uri("http://[::1]:1234/cb", false));
+  assert!(!is_open_redirect_uri("http://evil.example.com/cb", false));
+  assert!(!is_open_redirect_uri("myapp://cb", false));
+  assert!(!is_open_redirect_uri("//app.example.com/cb", false));
+  assert!(!is_open_redirect_uri(
+    "https://app.example.com/cb?x=1#frag",
+    false
+  ));
+  assert!(!is_open_redirect_uri(
+    "https://app.example.com/cb#frag",
+    false
+  ));
+  assert!(!is_open_redirect_uri(
+    "https://user@app.example.com/cb",
+    false
+  ));
+  assert!(!is_open_redirect_uri("https:///cb", false));
+  assert!(!is_open_redirect_uri("https://", false));
+  assert!(!is_open_redirect_uri("https:app.example.com/cb", false));
+  assert!(!is_open_redirect_uri("https://[]/cb", false));
+  assert!(!is_open_redirect_uri("https://app.example.com/cb ", false));
+  assert!(!is_open_redirect_uri("not a uri", false));
+
+  assert!(!is_open_redirect_uri("http://localhost/cb", false));
+  assert!(is_open_redirect_uri("http://localhost/cb", true));
 }
 
 #[test]
@@ -877,3 +1117,55 @@ fn dcr_register_bearer_exposes_nothing_secret() {
   let exposed = format!("{:?}", AppError::invalid_client_bearer());
   assert!(!exposed.to_ascii_lowercase().contains("pepper"));
 }
+
+/// Dynamic registration is self-service: a body carrying `access_token_format`
+/// fails closed with `invalid_client_metadata` (400), and a registered client
+/// always stores `opaque` (the column default), never `jwt`.
+#[tokio::test]
+async fn dcr_cannot_set_jwt_format() -> TestResult {
+  let mut harness = harness(false, "catalog").await?;
+  seed_redirect(
+    &harness.pool,
+    &harness.config.keys,
+    "https://app.example.test/cb",
+  )
+  .await?;
+  let iat = issue_iat(&mut harness, 3).await?;
+
+  let body = serde_json::json!({
+    "redirect_uris": ["https://app.example.test/cb"],
+    "client_name": "jwt wannabe",
+    "token_endpoint_auth_method": "client_secret_basic",
+    "grant_types": ["authorization_code"],
+    "response_types": ["code"],
+    "dpop_bound_access_tokens": true,
+    "access_token_format": "jwt",
+  });
+  match super::parse_register_request(body) {
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "registration body with access_token_format was accepted".to_owned(),
+      ));
+    }
+    Err(error) => expect_error(error, OAuthErrorCode::InvalidClientMetadata, 400)?,
+  }
+
+  let request = dpop_request("https://app.example.test/cb", None);
+  let response = {
+    let (mut stores, ctx) = parts_of(&mut harness);
+    register_client(&mut stores, &ctx, Some(&iat), &request)
+      .await
+      .map_err(|error| TestError::Unexpected(format!("register failed: {error}")))?
+  };
+  let stored: String =
+    sqlx::query_scalar("SELECT access_token_format FROM oauth_clients WHERE client_id = $1")
+      .bind(&response.client_id)
+      .fetch_one(&harness.pool)
+      .await?;
+  if stored != "opaque" {
+    return Err(TestError::Unexpected(format!(
+      "registered client format is {stored}, want opaque"
+    )));
+  }
+  Ok(())
+}

+ 100 - 11
src/protocol/device.rs

@@ -24,7 +24,7 @@ mod tests;
 
 use std::time::Duration;
 
-use axum::{Extension, Form, extract::State, http::HeaderMap, response::Json};
+use axum::{Extension, extract::State, http::HeaderMap};
 use rand::{TryRng as _, rngs::SysRng};
 use secrecy::{ExposeSecret as _, SecretString};
 use serde::{Deserialize, Serialize};
@@ -72,6 +72,34 @@ pub struct DeviceAuthorizationForm {
   pub client_assertion: Option<String>,
 }
 
+impl DeviceAuthorizationForm {
+  /// Fields the extractor recognizes: a repeat of any of these is a
+  /// duplicate-field rejection. Unknown fields (including a repeated
+  /// `resource`) are silently absorbed, as `serde_urlencoded` absorbed them.
+  const KNOWN_FIELDS: [&str; 5] = [
+    "client_id",
+    "scope",
+    "client_secret",
+    "client_assertion_type",
+    "client_assertion",
+  ];
+
+  /// Build the form from a strict single-valued map (the `Bytes` extractor
+  /// replacement for `Form<DeviceAuthorizationForm>`). A missing
+  /// `client_id` is `None` so the handler renders the extractor's 400,
+  /// never a core error.
+  fn from_map(map: &std::collections::HashMap<String, String>) -> Option<Self> {
+    let client_id = map.get("client_id").cloned()?;
+    Some(Self {
+      client_id,
+      scope: map.get("scope").cloned(),
+      client_secret: map.get("client_secret").cloned(),
+      client_assertion_type: map.get("client_assertion_type").cloned(),
+      client_assertion: map.get("client_assertion").cloned(),
+    })
+  }
+}
+
 /// Successful `POST /device_authorization` body (RFC 8628 §3.2).
 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
 pub struct DeviceAuthorizationResponse {
@@ -106,6 +134,9 @@ pub struct DeviceHttpInputs<'a> {
   pub assertion: Option<&'a str>,
   /// Raw `DPoP` header (proof JWT), when present.
   pub dpop_proof: Option<&'a str>,
+  /// Repeated `resource` indicators (RFC 8707) in wire order, captured from
+  /// the raw body by the `Bytes` extractor (see `protocol::params`).
+  pub resource: &'a [String],
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
 }
@@ -130,6 +161,9 @@ pub struct DeviceContext<'a> {
   pub device_ttl: Duration,
   /// Current time as seconds since the Unix epoch (DPoP freshness).
   pub now_unix: i64,
+  /// Config `resource_indicators_enabled`: off + any `resource` present is
+  /// `invalid_target` (fail-closed, no silent drop).
+  pub resource_indicators_enabled: bool,
 }
 /// Sender-constraint binding captured at authorization time.
 ///
@@ -231,6 +265,15 @@ pub async fn authorize_device(
   if inputs.client_id.trim().is_empty() {
     return Err(AppError::invalid_request("client_id is required"));
   }
+  // Resource indicators (RFC 8707) validate fail-closed before any state is
+  // written: off + present is `invalid_target`, and every value must be an
+  // absolute `https` URI with an exact enabled-catalog match.
+  let aud = crate::protocol::params::validate_resources(
+    stores.pool,
+    ctx.resource_indicators_enabled,
+    inputs.resource,
+  )
+  .await?;
   let used_basic = inputs.basic_header.is_some();
   let unknown_client = || {
     if used_basic {
@@ -296,10 +339,14 @@ pub async fn authorize_device(
     }
   };
 
-  // A public client with no DPoP binding would mint an unbound grant: the
-  // sender-constraint matrix forbids it, so issuance fails here, before any
-  // state is written.
-  if method == TokenEndpointAuthMethod::None && binding.jkt.is_none() {
+  // A DPoP-bound public client with no DPoP binding would mint an unbound
+  // grant: the sender-constraint matrix forbids it, so issuance fails here,
+  // before any state is written. Unbound public clients start bearer-capable
+  // device flows.
+  if method == TokenEndpointAuthMethod::None
+    && binding.jkt.is_none()
+    && client.dpop_bound_access_tokens
+  {
     return Err(AppError::invalid_dpop_proof("DPoP proof is required"));
   }
 
@@ -321,6 +368,7 @@ pub async fn authorize_device(
     status: store::device::DeviceStatus::Pending,
     subject: String::new(),
     grant_id: String::new(),
+    aud,
   };
   store::device::put(
     stores.conn,
@@ -379,6 +427,9 @@ async fn authenticate_confidential(
     token_endpoint: endpoint.as_str(),
     now: time::OffsetDateTime::from_unix_timestamp(ctx.now_unix)
       .unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
+    // The device-authorization endpoint serves registered clients only: a
+    // CIMD document can never declare the device grant, so no fallback.
+    cimd: None,
   };
   client_auth::authenticate_client(&mut auth, &auth_ctx, &request).await?;
   Ok(())
@@ -456,14 +507,48 @@ async fn put_binding(
 
 /// Axum handler for `POST /device_authorization` (wired by task 40).
 ///
-/// Thin glue: extracts the form, the auth headers, the optional DPoP proof,
-/// and the injected peer chain, then delegates to [`authorize_device`].
+/// Thin glue: `Bytes` + a manual parse replaces `Form<...>` (the extractor
+/// consumed the raw body, hiding repeats), then delegates to
+/// [`authorize_device`]. The explicit content-type gate preserves the 415
+/// `Form` rendered (`Bytes` never fails on content-type), a malformed body
+/// or a repeated known field is the same 400, and the repeated `resource`
+/// indicator (RFC 8707) is captured in order by
+/// `params::repeated_values`.
 pub async fn handler(
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
-  Form(form): Form<DeviceAuthorizationForm>,
-) -> Result<Json<DeviceAuthorizationResponse>, AppError> {
+  body: axum::body::Bytes,
+) -> axum::response::Response {
+  use axum::response::IntoResponse as _;
+  if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
+    return reject.into_response();
+  }
+  let pairs = match crate::protocol::params::form_pairs(&body) {
+    Ok(pairs) => pairs,
+    Err(reject) => return reject.into_response(),
+  };
+  if crate::protocol::params::has_duplicate_field(&pairs, &DeviceAuthorizationForm::KNOWN_FIELDS) {
+    return (
+      axum::http::StatusCode::BAD_REQUEST,
+      "Failed to deserialize form",
+    )
+      .into_response();
+  }
+  let map = crate::protocol::params::single_valued_map(&pairs);
+  let Some(form) = DeviceAuthorizationForm::from_map(&map) else {
+    return (
+      axum::http::StatusCode::BAD_REQUEST,
+      "Failed to deserialize form",
+    )
+      .into_response();
+  };
+  let text = match crate::protocol::params::form_text(&body) {
+    Ok(text) => text,
+    Err(reject) => return reject.into_response(),
+  };
+  let resources =
+    crate::protocol::params::repeated_values(text, crate::protocol::params::RESOURCE_PARAM);
   let dpop_proof = headers.get("dpop").and_then(|value| value.to_str().ok());
   let basic_header = headers
     .get(axum::http::header::AUTHORIZATION)
@@ -476,6 +561,7 @@ pub async fn handler(
     assertion_type: form.client_assertion_type.as_deref(),
     assertion: form.client_assertion.as_deref(),
     dpop_proof,
+    resource: &resources,
     peer_certs: peer_certs.as_deref(),
   };
   let mut conn = state.redis.clone();
@@ -490,7 +576,10 @@ pub async fn handler(
     issuer: &state.config.issuer,
     device_ttl: state.config.ttl.device,
     now_unix: state.clock.now().unix_timestamp(),
+    resource_indicators_enabled: state.config.resource_indicators_enabled,
   };
-  let response = authorize_device(&mut stores, &ctx, &inputs).await?;
-  Ok(Json(response))
+  match authorize_device(&mut stores, &ctx, &inputs).await {
+    Ok(response) => axum::Json(response).into_response(),
+    Err(error) => error.into_response(),
+  }
 }

+ 21 - 1
src/protocol/device/tests.rs

@@ -127,6 +127,7 @@ async fn insert_client(
   method: TokenEndpointAuthMethod,
   secret: Option<&str>,
   scope: &str,
+  dpop_bound: bool,
 ) -> TestResult {
   let params =
     Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::KeyGen(format!("{error:?}")))?;
@@ -151,7 +152,8 @@ async fn insert_client(
     request_uris: None,
     contacts: None,
     software_id: None,
-    dpop_bound_access_tokens: false,
+    dpop_bound_access_tokens: dpop_bound,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
   };
   client_repo::insert(pool, keys, false, &new).await?;
@@ -230,6 +232,7 @@ async fn issue(
     issuer: &h.config.issuer,
     device_ttl: h.config.ttl.device,
     now_unix: now_unix(),
+    resource_indicators_enabled: false,
   };
   authorize_device(&mut stores, &ctx, inputs).await
 }
@@ -245,6 +248,7 @@ async fn test_device_authorize_public_client_issues_code_and_interval() -> TestR
     TokenEndpointAuthMethod::None,
     None,
     "orders:read",
+    false,
   )
   .await?;
 
@@ -259,6 +263,7 @@ async fn test_device_authorize_public_client_issues_code_and_interval() -> TestR
     assertion_type: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
   };
   let ttl = h.config.ttl.device;
@@ -346,6 +351,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
     TokenEndpointAuthMethod::ClientSecretPost,
     Some("post-secret"),
     "orders:read",
+    false,
   )
   .await?;
 
@@ -358,6 +364,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
     assertion_type: None,
     assertion: None,
     dpop_proof: None,
+    resource: &[],
     peer_certs: None,
   };
   let response = issue(&mut h, &inputs)
@@ -382,6 +389,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
     assertion_type: None,
     assertion: None,
     dpop_proof: None,
+    resource: &[],
     peer_certs: None,
   };
   match issue(&mut h, &bad).await {
@@ -405,6 +413,7 @@ async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResu
     TokenEndpointAuthMethod::None,
     None,
     "orders:read",
+    true,
   )
   .await?;
 
@@ -416,6 +425,7 @@ async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResu
     assertion_type: None,
     assertion: None,
     dpop_proof: None,
+    resource: &[],
     peer_certs: None,
   };
   match issue(&mut h, &inputs).await {
@@ -441,6 +451,7 @@ async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
     TokenEndpointAuthMethod::None,
     None,
     "orders:read",
+    false,
   )
   .await?;
 
@@ -455,6 +466,7 @@ async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
     assertion_type: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
   };
   match issue(&mut h, &inputs).await {
@@ -480,6 +492,7 @@ async fn test_device_authorize_approval_transitions_state() -> TestResult {
     TokenEndpointAuthMethod::None,
     None,
     "orders:read",
+    false,
   )
   .await?;
 
@@ -494,6 +507,7 @@ async fn test_device_authorize_approval_transitions_state() -> TestResult {
     assertion_type: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
   };
   let response = issue(&mut h, &inputs)
@@ -561,6 +575,7 @@ async fn test_device_authorize_unknown_and_expired_user_code_rejected() -> TestR
     TokenEndpointAuthMethod::None,
     None,
     "",
+    false,
   )
   .await?;
   let key = fresh_key()?;
@@ -574,6 +589,7 @@ async fn test_device_authorize_unknown_and_expired_user_code_rejected() -> TestR
     assertion_type: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
   };
   let response = issue(&mut h, &inputs)
@@ -639,6 +655,7 @@ async fn test_device_authorize_verification_uri_complete_carries_user_code() ->
     TokenEndpointAuthMethod::None,
     None,
     "orders:read",
+    false,
   )
   .await?;
 
@@ -653,6 +670,7 @@ async fn test_device_authorize_verification_uri_complete_carries_user_code() ->
     assertion_type: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
   };
   let response = issue(&mut h, &inputs)
@@ -728,6 +746,7 @@ async fn test_device_authorize_user_code_misses_are_generic_and_normalized() ->
     TokenEndpointAuthMethod::None,
     None,
     "orders:read",
+    false,
   )
   .await?;
   let key = fresh_key()?;
@@ -741,6 +760,7 @@ async fn test_device_authorize_user_code_misses_are_generic_and_normalized() ->
     assertion_type: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
   };
   let response = issue(&mut h, &inputs)

+ 262 - 10
src/protocol/introspect.rs

@@ -21,10 +21,12 @@ mod tests;
 
 use std::time::Duration;
 
-use axum::{Extension, Form, extract::State, http::HeaderMap, response::Json};
+use axum::{Extension, extract::State, http::HeaderMap};
 use secrecy::SecretString;
 use serde::{Deserialize, Serialize};
+use sha2::{Digest as _, Sha256};
 use sqlx::PgPool;
+use subtle::ConstantTimeEq as _;
 use time::OffsetDateTime;
 
 use super::{
@@ -34,6 +36,7 @@ use super::{
 use crate::{
   db::{Client, TokenEndpointAuthMethod, user_repo},
   error::{AppError, OAuthErrorCode},
+  jose::jwt_access::{JwtAccessSigner, is_jwt_shape, jti_hash, marker_live_strict, subject_active},
   state::{AppState, RedisConn},
   store::{Store, StoreError},
   tls::PeerCertificates,
@@ -70,6 +73,35 @@ pub struct IntrospectForm {
   pub client_assertion: Option<String>,
 }
 
+impl IntrospectForm {
+  /// Fields the extractor recognizes: a repeat of any of these is the
+  /// extractor's 400. Unknown fields (including a repeated `resource`) 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",
+  ];
+
+  /// Build the form from a strict single-valued map (the `Bytes` extractor
+  /// replacement for `Form<IntrospectForm>`). A missing `token` defaults to
+  /// `""` so the core renders the RFC 7662 §2.3 `invalid_request`, exactly
+  /// as the derived `Default` did.
+  fn from_map(map: &std::collections::HashMap<String, String>) -> Self {
+    Self {
+      token: map.get("token").cloned().unwrap_or_default(),
+      token_type_hint: map.get("token_type_hint").cloned(),
+      client_id: map.get("client_id").cloned(),
+      client_secret: map.get("client_secret").cloned(),
+      client_assertion_type: map.get("client_assertion_type").cloned(),
+      client_assertion: map.get("client_assertion").cloned(),
+    }
+  }
+}
+
 /// RFC 7662 §2.2 introspection response.
 ///
 /// Inactive responses serialize to exactly `{"active": false}`: every other
@@ -103,6 +135,14 @@ pub struct IntrospectionResponse {
   /// RFC 8693 actor claim, for token-exchange tokens.
   #[serde(skip_serializing_if = "Option::is_none")]
   pub act: Option<serde_json::Value>,
+  /// Resource audiences bound to the token (RFC 8707), when non-empty.
+  ///
+  /// RFC 7662 §2.2 documents `aud` as a string, but this server follows the
+  /// RFC 9068 §2.2 array shape: the exact stored set is emitted as a JSON
+  /// array, including for a single audience. An empty set omits the member
+  /// entirely, and an inactive response never carries it.
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub aud: Option<Vec<String>>,
 }
 
 impl IntrospectionResponse {
@@ -119,6 +159,7 @@ impl IntrospectionResponse {
       sub: None,
       cnf: None,
       act: None,
+      aud: None,
     }
   }
 }
@@ -142,6 +183,15 @@ pub struct IntrospectHttpInputs<'a> {
   pub assertion: Option<&'a str>,
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
+  /// Validated, sorted, deduped `resource` values from the repeated form
+  /// parameter.
+  ///
+  /// This is a LOCAL EXTENSION to RFC 7662: the RFC defines no `resource`
+  /// parameter for introspection. When non-empty, the located token's `aud`
+  /// must include every requested member (see [`audience_includes`]) or the
+  /// response collapses to the inactive shape. An empty slice keeps
+  /// introspection audience-agnostic, exactly as RFC 7662 specifies.
+  pub resource: &'a [String],
 }
 
 /// Datastore handles needed by [`introspect`].
@@ -162,8 +212,18 @@ pub struct IntrospectContext<'a> {
   pub issuer: &'a str,
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
+  /// CIMD resolution settings for a registration-free fallback; `None` when
+  /// `config.cimd_enabled` is off. Only `private_key_jwt` documents may
+  /// introspect (RFC 7662 §2.3): a `none` principal is still rejected in
+  /// [`authenticate`].
+  pub cimd: Option<crate::protocol::cimd::CimdSettings<'a>>,
   /// Token service: pepper-retried lookup plus configured lifetimes.
   pub service: &'a TokenService,
+  /// AS access-token signer for the JWT path, built per request from the
+  /// config (rotation takes effect immediately). `None` covers both the
+  /// feature switch off and a missing key: a JWT-shaped token then
+  /// introspects inactive, never an error.
+  pub jwt_signer: Option<&'a JwtAccessSigner>,
 }
 
 /// Which store record a raw token resolved to.
@@ -187,12 +247,20 @@ struct Located {
   subject: String,
   /// Stored `cnf` (empty when unbound).
   cnf: String,
+  /// Resource audiences bound to the record (RFC 8707).
+  aud: Vec<String>,
   /// Access or refresh record.
   kind: TokenKind,
 }
 
-/// Introspect one token: authenticate the caller, look the token up, enforce
-/// ownership, and report the RFC 7662 §2.2 shape.
+/// Introspect one token: authenticate the caller, route by shape, enforce
+/// ownership and liveness, and report the RFC 7662 §2.2 shape.
+///
+/// A JWT-shaped token (`<b64url>.<b64url>.<b64url>`) takes the JWT path
+/// ([`introspect_jwt`]: RFC 9068 §4 allowlist verification plus the same
+/// request-`resource` filter as the opaque path); anything else takes the
+/// opaque record lookup. `token_type_hint` (RFC 7662 §2.1) only reorders the
+/// opaque lookup and never changes the JWT verdict.
 ///
 /// # Errors
 ///
@@ -200,6 +268,8 @@ struct Located {
 /// uses the public `none` method, `invalid_request` for an empty `token`, and
 /// a masked `5xx` when a datastore fails. Unknown, expired, revoked, foreign,
 /// and inactive tokens are never errors: they return the inactive response.
+/// A backend outage is never masked as `active: false` (that would silently
+/// revoke); it propagates as the 503.
 pub async fn introspect(
   stores: &mut IntrospectStores<'_>,
   ctx: &IntrospectContext<'_>,
@@ -211,6 +281,9 @@ pub async fn introspect(
   }
   let client = authenticate(stores, ctx, inputs).await?;
 
+  if is_jwt_shape(raw) {
+    return introspect_jwt(stores, ctx, inputs, &client, raw).await;
+  }
   let prefer_refresh = inputs.token_type_hint == Some(REFRESH_TOKEN_HINT);
   let located = locate(ctx.service, stores.conn, stores.store, raw, prefer_refresh)
     .await
@@ -241,9 +314,115 @@ pub async fn introspect(
   {
     return Ok(IntrospectionResponse::inactive());
   }
+  // LOCAL EXTENSION to RFC 7662 (which defines no `resource` parameter): a
+  // requested audience set asks whether the token is bound to every member.
+  // A pinned superset stays active; a missing member is indistinguishable
+  // from an unknown token, so it collapses to the §2.2 inactive shape rather
+  // than an error.
+  if !inputs.resource.is_empty() && !audience_includes(&located.aud, inputs.resource) {
+    return Ok(IntrospectionResponse::inactive());
+  }
   active_response(stores, ctx, located).await
 }
 
+/// Whether `bound` includes every member of `requested`.
+///
+/// This is set INCLUSION, not equality, so a token pinned to a superset
+/// satisfies a narrower request (unlike
+/// [`crate::protocol::params::audience_binds`]). Members are SHA-256 hashed
+/// and compared through [`subtle::ConstantTimeEq`]; the scan never exits
+/// early on the data and accumulates a [`subtle::Choice`] per requested
+/// member. Cardinality is public metadata for RFC 8707 audiences, so the
+/// empty-set gating is not secret-dependent.
+fn audience_includes(bound: &[String], requested: &[String]) -> bool {
+  let mut included = subtle::Choice::from(1u8);
+  for wanted in requested {
+    let wanted = Sha256::digest(wanted.as_bytes());
+    let mut found = subtle::Choice::from(0u8);
+    for candidate in bound {
+      let candidate = Sha256::digest(candidate.as_bytes());
+      found |= candidate.as_slice().ct_eq(wanted.as_slice());
+    }
+    included &= found;
+  }
+  bool::from(included)
+}
+
+/// Introspect a JWT-shaped access token (RFC 9068 §4 allowlist model).
+///
+/// Checks, in order: the signer is present (the feature switch off or no key
+/// configured renders inactive, never an error and never a 4xx); signature
+/// plus `iss`/`exp` verify; the verified `client_id` equals the caller
+/// (indistinguishable inactive otherwise, as RFC 7662 §2.2 requires);
+/// liveness holds under the strict rule (allowlist marker exists AND its
+/// `jwt:<jti-hash>` member is still in the marker's own grant set, plus the
+/// disabled-subject re-check); the optional request `resource` set is
+/// contained in the `aud` claim (the same [`audience_includes`] filter as the
+/// opaque path — a LOCAL EXTENSION to RFC 7662, which defines no `resource`
+/// parameter).
+///
+/// The response is shaped from the claims: `token_type: Bearer` (RFC 6749
+/// §5.1), `aud` in the RFC 9068 §2.2 array form, `exp`/`iat` straight from the
+/// claims. JWTs carry no stored PTTL, so no `token_window` read applies here;
+/// likewise there is no `act` sidecar on this path (an exchanged JWT carries
+/// its actor inline in the `act` claim). `token_type_hint` (RFC 7009 §2.1
+/// names the revocation hints; RFC 7662 §2.1 the introspection hint) is
+/// ignored: it only reorders the opaque lookup.
+///
+/// Every verification or liveness miss collapses to the inactive response. A
+/// Redis/Postgres outage propagates as the masked 503, never `active: false`
+/// (that would be a silent revocation) and never a 500 or panic.
+async fn introspect_jwt(
+  stores: &mut IntrospectStores<'_>,
+  ctx: &IntrospectContext<'_>,
+  inputs: &IntrospectHttpInputs<'_>,
+  client: &Client,
+  raw: &str,
+) -> Result<IntrospectionResponse, AppError> {
+  let signer = match ctx.jwt_signer {
+    Some(signer) => signer,
+    None => return Ok(IntrospectionResponse::inactive()),
+  };
+  // `verify` is signature plus `iss`/`exp` only (store-free, so it cannot
+  // fail with a backend error): `aud` containment runs through
+  // `audience_includes` below for exact parity with the opaque path.
+  let verified = match signer.verify(raw, None) {
+    Ok(verified) => verified,
+    Err(_) => return Ok(IntrospectionResponse::inactive()),
+  };
+  let claims = verified.claims;
+  if claims.client_id != client.client_id {
+    return Ok(IntrospectionResponse::inactive());
+  }
+  let live = marker_live_strict(stores.conn, stores.store, &jti_hash(&claims.jti))
+    .await
+    .map_err(|_| AppError::temporarily_unavailable())?;
+  if !live {
+    return Ok(IntrospectionResponse::inactive());
+  }
+  let subject_live = subject_active(stores.pool, stores.keys, &claims.sub)
+    .await
+    .map_err(|_| AppError::temporarily_unavailable())?;
+  if !subject_live {
+    return Ok(IntrospectionResponse::inactive());
+  }
+  if !inputs.resource.is_empty() && !audience_includes(&claims.aud, inputs.resource) {
+    return Ok(IntrospectionResponse::inactive());
+  }
+  Ok(IntrospectionResponse {
+    active: true,
+    scope: Some(claims.scope),
+    client_id: Some(claims.client_id),
+    token_type: Some(BEARER.to_owned()),
+    exp: Some(claims.exp),
+    iat: Some(claims.iat),
+    sub: (!claims.sub.is_empty()).then_some(claims.sub),
+    cnf: claims.cnf,
+    act: claims.act.map(|act| serde_json::json!({"sub": act.sub})),
+    aud: (!claims.aud.is_empty()).then_some(claims.aud),
+  })
+}
+
 /// Authenticate the introspection caller, rejecting the public `none` method.
 ///
 /// RFC 7662 §2.3 requires HTTP 401 for invalid client credentials, so every
@@ -272,6 +451,7 @@ async fn authenticate(
   let auth_ctx = AuthContext {
     token_endpoint: &endpoint,
     now,
+    cimd: ctx.cimd,
   };
   let authenticated = client_auth::authenticate_client(&mut auth_stores, &auth_ctx, &request)
     .await
@@ -317,6 +497,7 @@ async fn locate(
           scope: found.record.scope,
           subject: found.record.subject,
           cnf: found.record.cnf,
+          aud: found.record.aud,
           kind: TokenKind::Access,
         }),
       TokenKind::Refresh => service
@@ -328,6 +509,7 @@ async fn locate(
           scope: found.record.scope,
           subject: found.record.subject,
           cnf: found.record.cnf,
+          aud: found.record.aud,
           kind: TokenKind::Refresh,
         }),
     };
@@ -374,6 +556,7 @@ async fn active_response(
     sub: (!located.subject.is_empty()).then_some(located.subject),
     cnf: cnf.is_bound().then_some(cnf),
     act,
+    aud: (!located.aud.is_empty()).then_some(located.aud),
   })
 }
 
@@ -464,18 +647,65 @@ pub async fn read_act(
 
 /// Axum handler for `POST /introspect` (wired by task 40).
 ///
-/// Thin glue: extracts the form, auth headers, and injected peer chain, builds
-/// the token service, then delegates to [`introspect`].
+/// 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
+/// (`invalid_target`) before any lookup runs.
+///
+/// RFC 7662 defines no `resource` parameter. This endpoint accepts it as a
+/// LOCAL EXTENSION: the validated set is handed to [`introspect`], which
+/// reports a token inactive unless its `aud` includes every requested value.
+/// `revoke` and the token-exchange subject-token lookup stay
+/// audience-agnostic.
 pub async fn handler(
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
-  Form(form): Form<IntrospectForm>,
-) -> Result<Json<IntrospectionResponse>, AppError> {
+  body: axum::body::Bytes,
+) -> axum::response::Response {
+  use axum::response::IntoResponse as _;
+  if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
+    return reject.into_response();
+  }
+  let pairs = match crate::protocol::params::form_pairs(&body) {
+    Ok(pairs) => pairs,
+    Err(reject) => return reject.into_response(),
+  };
+  if crate::protocol::params::has_duplicate_field(&pairs, &IntrospectForm::KNOWN_FIELDS) {
+    return (
+      axum::http::StatusCode::BAD_REQUEST,
+      "Failed to deserialize form",
+    )
+      .into_response();
+  }
+  let map = crate::protocol::params::single_valued_map(&pairs);
+  let form = IntrospectForm::from_map(&map);
+  let text = match crate::protocol::params::form_text(&body) {
+    Ok(text) => text,
+    Err(reject) => return reject.into_response(),
+  };
+  let resources =
+    crate::protocol::params::repeated_values(text, crate::protocol::params::RESOURCE_PARAM);
+  let resources = match crate::protocol::params::validate_resources(
+    &state.pool,
+    state.config.resource_indicators_enabled,
+    &resources,
+  )
+  .await
+  {
+    Ok(resources) => resources,
+    Err(error) => return error.into_response(),
+  };
   let basic_header = headers
     .get(axum::http::header::AUTHORIZATION)
     .and_then(|value| value.to_str().ok());
-  let service = TokenService::new(&state.config.ttl, &state.keys)?;
+  let service = match TokenService::new(&state.config.ttl, &state.keys) {
+    Ok(service) => service,
+    Err(error) => return AppError::from(error).into_response(),
+  };
   let store = Store::from_config(&state.config.redis);
   let mut conn = state.redis.clone();
   let mut stores = IntrospectStores {
@@ -484,10 +714,28 @@ pub async fn handler(
     conn: &mut conn,
     store: &store,
   };
+  // The signer is built only for introspection when the operator switch is
+  // on; a missing key (or a disabled switch) leaves a JWT-shaped token
+  // inactive, never an error.
+  let jwt_signer = if state.config.jwt_access_tokens_enabled {
+    JwtAccessSigner::from_config(&state.config).ok()
+  } else {
+    None
+  };
   let ctx = IntrospectContext {
     issuer: &state.config.issuer,
     now_unix: state.clock.now().unix_timestamp(),
+    cimd: state
+      .config
+      .cimd_enabled
+      .then(|| crate::protocol::cimd::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,
+      }),
     service: &service,
+    jwt_signer: jwt_signer.as_ref(),
   };
   let inputs = IntrospectHttpInputs {
     token: form.token.as_str(),
@@ -498,7 +746,11 @@ pub async fn handler(
     assertion_type: form.client_assertion_type.as_deref(),
     assertion: form.client_assertion.as_deref(),
     peer_certs: peer_certs.as_deref(),
+    resource: &resources,
+  };
+  let response = match introspect(&mut stores, &ctx, &inputs).await {
+    Ok(response) => response,
+    Err(error) => return error.into_response(),
   };
-  let response = introspect(&mut stores, &ctx, &inputs).await?;
-  Ok(Json(response))
+  axum::Json(response).into_response()
 }

+ 603 - 3
src/protocol/introspect/tests.rs

@@ -33,14 +33,15 @@ use crate::{
   config::Config,
   crypto::secret::{Argon2Params, hash_secret},
   db::{
-    NewClient, TokenEndpointAuthMethod, client_repo,
+    NewClient, TokenEndpointAuthMethod, client_repo, resource_repo,
     test_support::migrated_pool,
     user_repo::{self, NewUser, UserRecord},
   },
+  jose::jwt_access::{JwtAccessSigner, JwtClaims},
   protocol::{
     client_auth::ClientAuthRequest,
     revoke::{RevokeContext, RevokeOutcome, RevokeRequest, RevokeStores, revoke_tokens},
-    token::{ClientKind, MintRequest, SenderProof, TokenService},
+    token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService},
   },
   state::{AppState, Clock, RedisConn},
   store::Store,
@@ -94,6 +95,9 @@ async fn harness() -> TestResult<Harness> {
     "token_pepper": "introspect-test-pepper-0123456789abcdef",
     "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "introspect-test-pii-key-0123456789abcdef",
+    "resource_indicators_enabled": true,
+    "jwt_access_tokens_enabled": true,
+    "jwt_signing_key": "bcdefghijklmnopqrstuvwxyzabcdefg",
   });
   let source = ::config::Config::try_from(&fixture)?;
   let config = Config::from_source(source)?;
@@ -135,6 +139,7 @@ async fn insert_client(
     contacts: None,
     software_id: None,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
   };
   client_repo::insert(pool, keys, false, &new).await?;
@@ -160,10 +165,14 @@ async fn mint_bound(
         &MintRequest {
           client_id: client_id.to_owned(),
           client_kind: ClientKind::Confidential,
+          dpop_required: false,
           subject: "user-1".to_owned(),
           scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           proof,
+          aud: Vec::new(),
+          refresh_aud: Vec::new(),
+          format: TokenFormat::Opaque,
         },
         now_unix(),
       )
@@ -194,8 +203,40 @@ async fn run_core(
   hint: Option<&str>,
   client_id: &str,
   secret: &str,
+) -> TestResult<IntrospectionResponse> {
+  run_core_with_resources(h, token, hint, client_id, secret, &[]).await
+}
+
+/// Drive the core with an explicit validated `resource` set, mirroring the
+/// handler: the JWT signer is built from the harness config (never cached, so
+/// rotation takes effect).
+async fn run_core_with_resources(
+  h: &mut Harness,
+  token: &str,
+  hint: Option<&str>,
+  client_id: &str,
+  secret: &str,
+  resource: &[String],
+) -> TestResult<IntrospectionResponse> {
+  run_core_full(h, token, hint, client_id, secret, resource, true).await
+}
+
+/// Core driver with an explicit JWT-signer switch: `with_signer = false`
+/// reproduces the feature-flag-off handler (signer absent, so a JWT-shaped
+/// token introspects inactive).
+async fn run_core_full(
+  h: &mut Harness,
+  token: &str,
+  hint: Option<&str>,
+  client_id: &str,
+  secret: &str,
+  resource: &[String],
+  with_signer: bool,
 ) -> TestResult<IntrospectionResponse> {
   let service = token_service(h)?;
+  let signer = with_signer
+    .then(|| JwtAccessSigner::from_config(&h.config))
+    .transpose()?;
   let mut stores = IntrospectStores {
     pool: &h.pool,
     keys: &h.config.keys,
@@ -205,7 +246,9 @@ async fn run_core(
   let ctx = IntrospectContext {
     issuer: &h.config.issuer,
     now_unix: now_unix(),
+    cimd: None,
     service: &service,
+    jwt_signer: signer.as_ref(),
   };
   let inputs = IntrospectHttpInputs {
     token,
@@ -216,6 +259,7 @@ async fn run_core(
     assertion_type: None,
     assertion: None,
     peer_certs: None,
+    resource,
   };
   Ok(introspect(&mut stores, &ctx, &inputs).await?)
 }
@@ -793,6 +837,7 @@ async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
     let ctx = RevokeContext {
       token_endpoint: "https://auth.example.test/token",
       now: OffsetDateTime::now_utc(),
+      cimd: None,
       request_id: Some("req-introspect-revoke"),
     };
     let request = RevokeRequest {
@@ -800,7 +845,7 @@ async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
       token_type_hint: None,
       auth: &auth,
     };
-    revoke_tokens(&mut stores, &ctx, &request).await?
+    revoke_tokens(&mut stores, &ctx, &request, None).await?
   };
   assert_eq!(
     outcome,
@@ -823,6 +868,48 @@ async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
   Ok(())
 }
 
+/// Mint one token pair owned by `client_id` with an explicit audience.
+async fn mint_audience(
+  h: &mut Harness,
+  client_id: &str,
+  aud: &[&str],
+) -> TestResult<crate::protocol::token::MintedTokens> {
+  mint_audience_subject(h, client_id, aud, "user-1").await
+}
+
+/// Mint one token pair owned by `client_id` with an explicit audience and
+/// subject.
+async fn mint_audience_subject(
+  h: &mut Harness,
+  client_id: &str,
+  aud: &[&str],
+  subject: &str,
+) -> TestResult<crate::protocol::token::MintedTokens> {
+  let service = token_service(h)?;
+  let aud: Vec<String> = aud.iter().map(|value| (*value).to_owned()).collect();
+  Ok(
+    service
+      .mint(
+        &mut h.conn,
+        &h.store,
+        &MintRequest {
+          client_id: client_id.to_owned(),
+          client_kind: ClientKind::Confidential,
+          dpop_required: false,
+          subject: subject.to_owned(),
+          scope: "orders:read".to_owned(),
+          grant_id: unique("grant"),
+          proof: SenderProof::none(),
+          aud: aud.clone(),
+          refresh_aud: aud,
+          format: TokenFormat::Opaque,
+        },
+        now_unix(),
+      )
+      .await?,
+  )
+}
+
 /// Mint one token pair owned by `client_id` with an explicit subject.
 async fn mint_subject(
   h: &mut Harness,
@@ -838,10 +925,14 @@ async fn mint_subject(
         &MintRequest {
           client_id: client_id.to_owned(),
           client_kind: ClientKind::Confidential,
+          dpop_required: false,
           subject: subject.to_owned(),
           scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           proof: SenderProof::none(),
+          aud: Vec::new(),
+          refresh_aud: Vec::new(),
+          format: TokenFormat::Opaque,
         },
         now_unix(),
       )
@@ -1009,3 +1100,512 @@ async fn test_introspect_empty_and_unknown_subjects_stay_active() -> TestResult
   assert_eq!(response.sub.as_deref(), Some("synthetic-subject-9"));
   Ok(())
 }
+
+#[tokio::test]
+async fn test_introspect_enforces_requested_resource() -> TestResult {
+  let mut h = harness().await?;
+  let client_id = unique("resource-filter");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretBasic,
+    Some("resource-secret"),
+  )
+  .await?;
+  let audience_a = "https://a.introspect.test/";
+  let audience_b = "https://b.introspect.test/";
+  let audience_c = "https://c.introspect.test/";
+  for identifier in [audience_a, audience_b, audience_c] {
+    resource_repo::upsert(&h.pool, identifier, "introspect audience").await?;
+  }
+  let minted = mint_audience(&mut h, &client_id, &[audience_a, audience_b]).await?;
+  let auth = basic_header(&client_id, "resource-secret");
+  let access = &minted.access_token;
+
+  // RFC 7662 defines no `resource` parameter; this server accepts it as a
+  // local extension. A subset of the token audience stays active.
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!("token={access}&resource={audience_a}"),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(
+    body.get("active").and_then(Value::as_bool),
+    Some(true),
+    "a requested audience contained in the token aud must stay active"
+  );
+
+  // Repeated values are a set: both members are present, order-insensitive.
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!("token={access}&resource={audience_a}&resource={audience_b}"),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(
+    body.get("active").and_then(Value::as_bool),
+    Some(true),
+    "the complete repeated set must stay active"
+  );
+
+  // A requested audience the token is not bound to collapses to the inactive
+  // shape: no scope, client_id, sub, cnf, or expiry may leak.
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!("token={access}&resource={audience_c}"),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(
+    body,
+    json!({"active": false}),
+    "a mismatched audience must not report token metadata"
+  );
+
+  // Absent `resource` keeps RFC 7662 introspection audience-agnostic.
+  let (status, body) = post_form(router(&h), Some(&auth), &format!("token={access}")).await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(
+    body.get("active").and_then(Value::as_bool),
+    Some(true),
+    "no resource parameter must preserve today's behavior"
+  );
+  assert_eq!(
+    body.get("scope").and_then(Value::as_str),
+    Some("orders:read"),
+    "an unfiltered active response keeps its metadata"
+  );
+
+  // The filter follows the token record, not the hint: the access token
+  // resolves through the refresh hint (RFC 7662 §2.1 fallback) and stays
+  // filtered, and the refresh token is filtered under its own hint.
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!("token={access}&token_type_hint=refresh_token&resource={audience_c}"),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(
+    body,
+    json!({"active": false}),
+    "hint ordering must not bypass the audience filter"
+  );
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!(
+      "token={}&token_type_hint=refresh_token&resource={audience_a}",
+      minted.refresh_token
+    ),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(
+    body.get("active").and_then(Value::as_bool),
+    Some(true),
+    "a refresh token bound to the requested audience must stay active"
+  );
+
+  // An unbound token (empty aud) cannot satisfy any requested audience.
+  let empty = mint_audience(&mut h, &client_id, &[]).await?;
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!("token={}&resource={audience_a}", empty.access_token),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(
+    body,
+    json!({"active": false}),
+    "an empty token audience must not satisfy a requested resource"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn test_introspect_reports_aud() -> TestResult {
+  let mut h = harness().await?;
+  let client_id = unique("aud-report");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretBasic,
+    Some("secret-au"),
+  )
+  .await?;
+  let audience_a = "https://a.aud-introspect.test/";
+  let audience_b = "https://b.aud-introspect.test/";
+  for identifier in [audience_a, audience_b] {
+    resource_repo::upsert(&h.pool, identifier, "introspect audience").await?;
+  }
+  let auth = basic_header(&client_id, "secret-au");
+
+  // RFC 9068 §2.2 array shape: an aud-bound token reports the exact stored
+  // set as a JSON array, even though RFC 7662 §2.2 documents a string.
+  let minted = mint_audience(&mut h, &client_id, &[audience_a, audience_b]).await?;
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!("token={}", minted.access_token),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(body.get("active").and_then(Value::as_bool), Some(true));
+  assert_eq!(
+    body.get("aud"),
+    Some(&json!([audience_a, audience_b])),
+    "the access token must report its exact stored audience set"
+  );
+
+  // A single audience is still a one-element array, never a bare string.
+  let single = mint_audience(&mut h, &client_id, &[audience_a]).await?;
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!("token={}", single.access_token),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  let aud = body.get("aud").ok_or("aud member missing")?;
+  assert!(aud.is_array(), "aud must be a JSON array, not a string");
+  assert_eq!(aud, &json!([audience_a]));
+
+  // The same rule applies to a refresh record that carries an audience.
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!(
+      "token={}&token_type_hint=refresh_token",
+      minted.refresh_token
+    ),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(body.get("active").and_then(Value::as_bool), Some(true));
+  assert_eq!(
+    body.get("aud"),
+    Some(&json!([audience_a, audience_b])),
+    "a refresh record carrying an audience must report it"
+  );
+
+  // An unbound token omits the member entirely.
+  let unbound = mint_audience(&mut h, &client_id, &[]).await?;
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!("token={}", unbound.access_token),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert!(body.get("aud").is_none(), "an unbound token must omit aud");
+
+  // A disabled subject collapses to the RFC 7662 §2.2 inactive shape with no
+  // audience leak, even when the record carries one.
+  let user = provision_user(&h, "aud-disabled@example.test").await?;
+  let disabled = mint_audience_subject(&mut h, &client_id, &[audience_a], &user.email).await?;
+  assert!(user_repo::disable_user(&h.pool, user.id).await?);
+  let (status, body) = post_form(
+    router(&h),
+    Some(&auth),
+    &format!("token={}", disabled.access_token),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(body, json!({"active": false}));
+  assert!(
+    body.get("aud").is_none(),
+    "an inactive response must not leak aud"
+  );
+
+  // An unknown token is inactive and carries no aud.
+  let (status, body) = post_form(router(&h), Some(&auth), "token=not-a-real-aud-token").await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(body, json!({"active": false}));
+  Ok(())
+}
+
+/// Mint one JWT access token owned by `client_id` through the configured
+/// signer (Todo 26 branch): the returned token verifies under the harness
+/// config, so the introspection JWT path must accept it.
+async fn mint_jwt(
+  h: &mut Harness,
+  client_id: &str,
+  aud: &[&str],
+  subject: &str,
+  proof: SenderProof,
+) -> TestResult<String> {
+  let signer = JwtAccessSigner::from_config(&h.config)?;
+  let service = token_service(h)?.with_jwt_signer(signer);
+  let aud: Vec<String> = aud.iter().map(|value| (*value).to_owned()).collect();
+  let minted = service
+    .mint(
+      &mut h.conn,
+      &h.store,
+      &MintRequest {
+        client_id: client_id.to_owned(),
+        client_kind: ClientKind::Confidential,
+        dpop_required: false,
+        subject: subject.to_owned(),
+        scope: "orders:read".to_owned(),
+        grant_id: unique("grant"),
+        proof,
+        aud: aud.clone(),
+        refresh_aud: aud,
+        format: TokenFormat::Jwt,
+      },
+      now_unix(),
+    )
+    .await?;
+  if minted.access_token.split('.').count() != 3 {
+    return Err("a jwt mint must return three JOSE segments".into());
+  }
+  Ok(minted.access_token)
+}
+
+/// Flip the first signature character so the token still parses but the
+/// signature breaks (flipping the last char trips base64 padding instead).
+fn tamper_signature(token: &str) -> String {
+  let mut parts: Vec<String> = token.split('.').map(str::to_owned).collect();
+  let sig = parts[2].clone();
+  let first = sig.chars().next().unwrap_or('a');
+  let replacement = if first == 'a' { 'b' } else { 'a' };
+  parts[2] = format!("{replacement}{}", &sig[1..]);
+  parts.join(".")
+}
+
+#[tokio::test]
+async fn test_introspect_dual_path() -> TestResult {
+  let mut h = harness().await?;
+  let client_id = unique("dual-owner");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &client_id,
+    TokenEndpointAuthMethod::ClientSecretPost,
+    Some("secret-dual"),
+  )
+  .await?;
+  let foreign = unique("dual-foreign");
+  insert_client(
+    &h.pool,
+    &h.config.keys,
+    &foreign,
+    TokenEndpointAuthMethod::ClientSecretPost,
+    Some("secret-foreign"),
+  )
+  .await?;
+  let audience_a = "https://a.dual-introspect.test/";
+  let audience_b = "https://b.dual-introspect.test/";
+  let audience_c = "https://c.dual-introspect.test/";
+  for identifier in [audience_a, audience_b, audience_c] {
+    resource_repo::upsert(&h.pool, identifier, "dual audience").await?;
+  }
+
+  // Opaque regression: an opaque access token stays active with its stored
+  // aud, and a refresh hint still falls back to the access record.
+  let opaque = mint_audience_subject(&mut h, &client_id, &[audience_a], "opaque-sub").await?;
+  let response = run_core_full(
+    &mut h,
+    &opaque.access_token,
+    None,
+    &client_id,
+    "secret-dual",
+    &[],
+    true,
+  )
+  .await?;
+  assert!(response.active, "an opaque token must stay active");
+  assert_eq!(response.token_type.as_deref(), Some("Bearer"));
+  assert_eq!(response.aud, Some(vec![audience_a.to_owned()]));
+  let hinted = run_core_full(
+    &mut h,
+    &opaque.access_token,
+    Some("refresh_token"),
+    &client_id,
+    "secret-dual",
+    &[],
+    true,
+  )
+  .await?;
+  assert!(
+    hinted.active,
+    "an opaque token under a refresh hint must still resolve"
+  );
+  assert_eq!(hinted.token_type.as_deref(), Some("Bearer"));
+
+  // JWT: active with aud, token_type, client_id, sub, scope, exp/iat, cnf,
+  // and no leaked JOSE internals.
+  let jwt = mint_jwt(
+    &mut h,
+    &client_id,
+    &[audience_a, audience_b],
+    "jwt-subject-1",
+    SenderProof {
+      dpop_jkt: Some("dual-jkt".to_owned()),
+      mtls_x5t_s256: None,
+    },
+  )
+  .await?;
+  let before = now_unix();
+  let response = run_core_full(&mut h, &jwt, None, &client_id, "secret-dual", &[], true).await?;
+  assert!(response.active, "a live JWT must introspect active");
+  assert_eq!(response.client_id.as_deref(), Some(client_id.as_str()));
+  assert_eq!(response.scope.as_deref(), Some("orders:read"));
+  assert_eq!(response.sub.as_deref(), Some("jwt-subject-1"));
+  assert_eq!(response.token_type.as_deref(), Some("Bearer"));
+  assert_eq!(
+    response.aud,
+    Some(vec![audience_a.to_owned(), audience_b.to_owned()])
+  );
+  let cnf = response.cnf.as_ref().ok_or("cnf missing")?;
+  assert_eq!(cnf.jkt.as_deref(), Some("dual-jkt"));
+  let iat = response.iat.ok_or("iat missing")?;
+  let exp = response.exp.ok_or("exp missing")?;
+  assert!(exp > before, "exp must be in the future");
+  let ttl = i64::try_from(h.config.ttl.access.as_secs())?;
+  assert_eq!(exp - iat, ttl, "the JWT window rides the claims");
+  assert_eq!(response.act, None, "a JWT carries no sidecar act");
+  let json = serde_json::to_value(&response)?;
+  assert!(
+    json.get("jti").is_none(),
+    "the allowlist jti must never leak into the response"
+  );
+  assert!(
+    !serde_json::to_string(&json)?.contains(&jwt),
+    "the presented JWT must never be echoed"
+  );
+
+  // The hint is advisory for opaque lookups and irrelevant for JWTs: a
+  // refresh hint must not change the JWT verdict.
+  let hinted_jwt = run_core_full(
+    &mut h,
+    &jwt,
+    Some("refresh_token"),
+    &client_id,
+    "secret-dual",
+    &[],
+    true,
+  )
+  .await?;
+  assert!(
+    hinted_jwt.active,
+    "a token_type_hint must not change the JWT path"
+  );
+
+  // A JWT owned by another client collapses to the same inactive shape as
+  // an unknown token (RFC 7662 §2.2 indistinguishability).
+  let foreign_response =
+    run_core_full(&mut h, &jwt, None, &foreign, "secret-foreign", &[], true).await?;
+  assert!(
+    !foreign_response.active,
+    "a cross-client JWT must be inactive"
+  );
+  assert_eq!(foreign_response, IntrospectionResponse::inactive());
+
+  // A tampered signature, an unknown marker (signed but never minted), and
+  // an expired JWT are all inactive, never errors.
+  let tampered = tamper_signature(&jwt);
+  let response = run_core_full(
+    &mut h,
+    &tampered,
+    None,
+    &client_id,
+    "secret-dual",
+    &[],
+    true,
+  )
+  .await?;
+  assert!(!response.active, "a tampered JWT must be inactive");
+  let signer = JwtAccessSigner::from_config(&h.config)?;
+  let ghost = signer.sign(&JwtClaims {
+    iss: h.config.issuer.clone(),
+    sub: "ghost-subject".to_owned(),
+    aud: vec![audience_a.to_owned()],
+    scope: "orders:read".to_owned(),
+    exp: now_unix() + 3_600,
+    iat: now_unix(),
+    jti: unique("ghost-jti"),
+    client_id: client_id.clone(),
+    cnf: None,
+    act: None,
+  })?;
+  let response = run_core_full(&mut h, &ghost, None, &client_id, "secret-dual", &[], true).await?;
+  assert!(!response.active, "a JWT with no marker must be inactive");
+  let stale = signer.sign(&JwtClaims {
+    iss: h.config.issuer.clone(),
+    sub: "ghost-subject".to_owned(),
+    aud: vec![audience_a.to_owned()],
+    scope: "orders:read".to_owned(),
+    exp: now_unix() - 10,
+    iat: now_unix() - 3_610,
+    jti: unique("stale-jti"),
+    client_id: client_id.clone(),
+    cnf: None,
+    act: None,
+  })?;
+  let response = run_core_full(&mut h, &stale, None, &client_id, "secret-dual", &[], true).await?;
+  assert!(!response.active, "an expired JWT must be inactive");
+
+  // A disabled subject renders the live JWT inactive with no aud leak.
+  let user = provision_user(&h, "dual-disabled@example.test").await?;
+  let bound = mint_jwt(
+    &mut h,
+    &client_id,
+    &[audience_a],
+    &user.email,
+    SenderProof::none(),
+  )
+  .await?;
+  assert!(user_repo::disable_user(&h.pool, user.id).await?);
+  let response = run_core_full(&mut h, &bound, None, &client_id, "secret-dual", &[], true).await?;
+  assert!(
+    !response.active,
+    "a disabled subject's JWT must be inactive"
+  );
+  assert_eq!(response, IntrospectionResponse::inactive());
+
+  // Flag off: the same live JWT introspects inactive (never 5xx). The core
+  // returns `Ok`, so reaching this line already proves no error was raised.
+  let response = run_core_full(&mut h, &jwt, None, &client_id, "secret-dual", &[], false).await?;
+  assert!(!response.active, "a JWT with the flag off must be inactive");
+  assert_eq!(response, IntrospectionResponse::inactive());
+
+  // Request-`resource` parity over HTTP (handler validation included): a
+  // requested audience the JWT lacks is inactive; one it has stays active.
+  // The fixture client is `ClientSecretPost`, so the HTTP calls carry the
+  // credentials in the body (a Basic header against a post client is 401).
+  let credentials = format!("client_id={client_id}&client_secret=secret-dual");
+  let (status, body) = post_form(
+    router(&h),
+    None,
+    &format!("token={jwt}&resource={audience_c}&{credentials}"),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(
+    body,
+    json!({"active": false}),
+    "a JWT missing the requested resource must be inactive"
+  );
+  let (status, body) = post_form(
+    router(&h),
+    None,
+    &format!("token={jwt}&resource={audience_a}&{credentials}"),
+  )
+  .await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(
+    body.get("active").and_then(Value::as_bool),
+    Some(true),
+    "a JWT carrying the requested resource must stay active"
+  );
+  Ok(())
+}

+ 164 - 0
src/protocol/jwks.rs

@@ -0,0 +1,164 @@
+//! `GET /jwks.json`: the public JWK Set for JWT access-token verification.
+//!
+//! Resource servers verify AS-minted `at+jwt` tokens (RFC 9068 §3) against the
+//! public halves of the configured signing ring. The document is a JWK Set
+//! (RFC 7517 §5): `{"keys": [...]}` with the current key first, then every
+//! retained verify-only key. Each JWK carries `kid`, `kty`, `crv`, `x` (and
+//! `y` for EC per RFC 7518 §6.2.1; OKP carries only `x` per RFC 8037 §2),
+//! `use: "sig"`, and the configured `alg` (RFC 7517 §4.4). No private member
+//! (`d`, its CRT parameters, or the symmetric `k`) is ever rendered.
+//!
+//! The route is public and read-only: these coordinates are public by
+//! definition, and RFC 9068 §4 has resource servers fetch them without
+//! credentials. Ordering is the configured key order, so the same ring yields
+//! a byte-stable document an intermediary can reuse.
+//!
+//! With `jwt_access_tokens_enabled` off (or no key configured) the endpoint
+//! still answers `200` with `{"keys":[]}`. An empty key array is a valid JWK
+//! Set (RFC 7517 §5), it discloses nothing the disabled feature would not, and
+//! it avoids a cacheable `404`: the security-headers middleware assigns the
+//! short JWKS window by path, so a `404` would pin "not found" for five
+//! minutes after the operator enables the feature.
+
+use axum::extract::State;
+use axum::http::{StatusCode, header};
+use axum::response::{IntoResponse, Response};
+use serde_json::{Map, Value, json};
+
+use crate::jose::jwt_access::JwtAccessSigner;
+use crate::state::AppState;
+
+/// Media type registered for a JWK Set (RFC 7517 §8.5.1).
+pub const JWKS_CONTENT_TYPE: &str = "application/jwk-set+json";
+
+/// Build the JWK Set document from the signer's current and retained keys.
+#[must_use]
+pub fn key_set(signer: &JwtAccessSigner) -> Value {
+  let keys: Vec<Value> = signer
+    .public_keys()
+    .map(|(kid, alg, public)| {
+      let mut jwk = Map::new();
+      jwk.insert("kty".to_owned(), json!(public.kty));
+      jwk.insert("crv".to_owned(), json!(public.crv));
+      jwk.insert("x".to_owned(), json!(public.x));
+      if let Some(y) = &public.y {
+        jwk.insert("y".to_owned(), json!(y));
+      }
+      jwk.insert("use".to_owned(), json!("sig"));
+      jwk.insert("alg".to_owned(), json!(alg.as_str()));
+      jwk.insert("kid".to_owned(), json!(kid.as_str()));
+      Value::Object(jwk)
+    })
+    .collect();
+  json!({ "keys": keys })
+}
+
+/// `GET /jwks.json`: render the public JWK Set.
+pub async fn handler(State(state): State<AppState>) -> Response {
+  let document = if state.config.jwt_access_tokens_enabled {
+    match JwtAccessSigner::from_config(&state.config) {
+      Ok(signer) => key_set(&signer),
+      Err(_) => empty_set(),
+    }
+  } else {
+    empty_set()
+  };
+  (
+    StatusCode::OK,
+    [(header::CONTENT_TYPE, JWKS_CONTENT_TYPE)],
+    document.to_string(),
+  )
+    .into_response()
+}
+
+/// The deterministic no-keys document (see the module note on feature-off).
+fn empty_set() -> Value {
+  json!({ "keys": [] })
+}
+
+#[cfg(test)]
+mod tests {
+  use secrecy::SecretString;
+  use serde_json::Value;
+
+  use super::key_set;
+  use crate::{
+    config::{JwtSigningAlg, JwtSigningKey},
+    jose::jwt_access::JwtAccessSigner,
+    keys::KeyId,
+  };
+
+  type TestError = Box<dyn std::error::Error + Send + Sync>;
+
+  /// A validated signing key from a repeated printable byte.
+  fn signing_key(kid: &str, seed: char, alg: JwtSigningAlg) -> Result<JwtSigningKey, TestError> {
+    let raw = SecretString::from(seed.to_string().repeat(32));
+    Ok(JwtSigningKey::from_raw(
+      KeyId::new(kid),
+      alg,
+      &raw,
+      "jwt_signing_key",
+    )?)
+  }
+
+  /// Current key first, retained key second, public members only.
+  #[test]
+  fn key_set_lists_current_then_retained() -> Result<(), TestError> {
+    let current = signing_key("v2", 'c', JwtSigningAlg::Es256)?;
+    let previous = signing_key("v1", 'd', JwtSigningAlg::Es256)?;
+    let signer = JwtAccessSigner::new(
+      "https://as.example.test".to_owned(),
+      current,
+      vec![previous],
+    );
+    let set = key_set(&signer);
+    let keys = set["keys"].as_array().ok_or("JWK Set lacks keys")?;
+    assert_eq!(keys.len(), 2);
+    assert_eq!(keys[0]["kid"], "v2");
+    assert_eq!(keys[1]["kid"], "v1");
+    for key in keys {
+      assert_eq!(key["use"], "sig");
+      assert_eq!(key["alg"], "ES256");
+      assert_eq!(key["kty"], "EC");
+      assert_eq!(key["crv"], "P-256");
+      for coordinate in ["x", "y"] {
+        assert!(
+          key[coordinate]
+            .as_str()
+            .is_some_and(|value| !value.is_empty()),
+          "EC JWK must carry a non-empty {coordinate}"
+        );
+      }
+      assert_no_private_members(key);
+    }
+    Ok(())
+  }
+
+  /// An Ed25519 ring renders `OKP`/`Ed25519` with `x` only (RFC 8037 §2).
+  #[test]
+  fn key_set_emits_okp_without_y() -> Result<(), TestError> {
+    let current = signing_key("v1", 'e', JwtSigningAlg::EdDsa)?;
+    let signer = JwtAccessSigner::new("https://as.example.test".to_owned(), current, Vec::new());
+    let set = key_set(&signer);
+    let keys = set["keys"].as_array().ok_or("JWK Set lacks keys")?;
+    assert_eq!(keys.len(), 1);
+    assert_eq!(keys[0]["kty"], "OKP");
+    assert_eq!(keys[0]["crv"], "Ed25519");
+    assert_eq!(keys[0]["alg"], "EdDSA");
+    assert_eq!(keys[0]["use"], "sig");
+    assert!(keys[0]["x"].as_str().is_some_and(|value| !value.is_empty()));
+    assert!(keys[0].get("y").is_none());
+    assert_no_private_members(&keys[0]);
+    Ok(())
+  }
+
+  /// RFC 7518 §6.2.1 private coordinates and the symmetric `k` never appear.
+  fn assert_no_private_members(key: &Value) {
+    for private in ["d", "p", "q", "dp", "dq", "qi", "oth", "k"] {
+      assert!(
+        key.get(private).is_none(),
+        "private member {private} leaked"
+      );
+    }
+  }
+}

+ 77 - 4
src/protocol/metadata.rs

@@ -9,14 +9,21 @@
 //! no restart. When Postgres is unavailable the endpoint fails closed with `503
 //! temporarily_unavailable` rather than advertising an unknown scope list.
 //!
-//! No `jwks_uri`: the AS signs nothing (there is no AS JWT-signing key), and
-//! `code_challenge_methods_supported` advertises `S256` only — `plain` is
+//! The JWT access-token feature adds two conditional members: `jwks_uri`
+//! (RFC 8414 §2), pointing at `GET /jwks.json`, and
+//! `access_token_signing_alg_values_supported` (RFC 9068 §2.1), whose list is
+//! exactly the configured `jwt_access_token_signing_alg`. Both are ABSENT
+//! unless `jwt_access_tokens_enabled` is set, so a client never sees a key or
+//! an algorithm the signer would not honor. There is no RSA branch: the
+//! signer produces `ES256` or `EdDSA` only.
+//!
+//! `code_challenge_methods_supported` advertises `S256` only; `plain` is
 //! never offered.
 
 use axum::{Json, extract::State};
 use serde::Serialize;
 
-use crate::{db::scope_repo, error::AppError, state::AppState};
+use crate::{config::JwtSigningAlg, db::scope_repo, error::AppError, state::AppState};
 
 /// The five grant types this server supports (RFC 6749, RFC 8628, RFC 8693).
 pub const GRANT_TYPES_SUPPORTED: [&str; 5] = [
@@ -103,6 +110,45 @@ pub struct AuthorizationServerMetadata {
   pub authorization_response_iss_parameter_supported: bool,
   /// Enabled scope catalog (single source of truth: the `scopes` table).
   pub scopes_supported: Vec<String>,
+  /// RFC 9728 §4 resource identifiers this AS can mint tokens for. Omitted
+  /// unless `protected_resources_enabled` is set. `resource_metadata` is a
+  /// resource-server `WWW-Authenticate` parameter (§5.1), never an AS member.
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub protected_resources: Option<Vec<String>>,
+  /// Whether the RFC 8707 `resource` request parameter is honored.
+  ///
+  /// A vetted LOCAL EXTENSION: RFC 8707 registers only the `resource`
+  /// parameter (RFC 8707 §5.1) and the `invalid_target` error (§5.2); it
+  /// defines no authorization-server metadata member. The member is emitted
+  /// as `true` only when `resource_indicators_enabled` is set and is ABSENT
+  /// (not `false`) otherwise, so a client never sees a capability the server
+  /// would reject with `invalid_target`.
+  #[serde(skip_serializing_if = "std::ops::Not::not")]
+  pub resource_indicators_supported: bool,
+  /// Whether a `client_id` URL may name a Client ID Metadata Document.
+  ///
+  /// The CIMD draft's "Authorization Server Metadata" section
+  /// (`draft-ietf-oauth-client-id-metadata-document-02` §6) registers this
+  /// Boolean in the RFC 8414 metadata document; it is emitted as `true` only
+  /// when `cimd_enabled` is set and is ABSENT (not `false`) otherwise. Client
+  /// interoperability requires it TOGETHER with `none` in
+  /// [`Self::token_endpoint_auth_methods_supported`]: mainstream MCP clients
+  /// select CIMD only when both are present (the CIMD client is a public
+  /// client unless its document publishes a key, §4.1).
+  #[serde(skip_serializing_if = "std::ops::Not::not")]
+  pub client_id_metadata_document_supported: bool,
+  /// Public JWK Set (RFC 7517 §5) for verifying the AS's own signed access
+  /// tokens (RFC 9068 §4). Omitted unless `jwt_access_tokens_enabled` is set.
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub jwks_uri: Option<String>,
+  /// JWS algorithms the access-token signer can produce (RFC 9068 §2.1).
+  ///
+  /// Exactly the configured `jwt_access_token_signing_alg` (`ES256` or
+  /// `EdDSA`), never a broader list: the signer has no RSA branch, so
+  /// advertising `RS256` would name an algorithm no key can produce. Omitted
+  /// unless `jwt_access_tokens_enabled` is set.
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub access_token_signing_alg_values_supported: Option<Vec<String>>,
 }
 
 impl AuthorizationServerMetadata {
@@ -111,7 +157,17 @@ impl AuthorizationServerMetadata {
   /// `issuer` is validated elsewhere to be absolute `https` with no trailing
   /// slash, so endpoint paths append directly.
   #[must_use]
-  pub fn for_issuer(issuer: &str, scopes_supported: Vec<String>) -> Self {
+  pub fn for_issuer(
+    issuer: &str,
+    scopes_supported: Vec<String>,
+    protected_resources: Option<Vec<String>>,
+    resource_indicators_supported: bool,
+    client_id_metadata_document_supported: bool,
+    jwt_signing_alg: Option<JwtSigningAlg>,
+  ) -> Self {
+    let jwks_uri = jwt_signing_alg.map(|_| endpoint(issuer, "/jwks.json"));
+    let access_token_signing_alg_values_supported =
+      jwt_signing_alg.map(|alg| vec![alg.as_str().to_owned()]);
     Self {
       issuer: issuer.to_owned(),
       authorization_endpoint: endpoint(issuer, "/authorize"),
@@ -132,6 +188,11 @@ impl AuthorizationServerMetadata {
       tls_client_certificate_bound_access_tokens: true,
       authorization_response_iss_parameter_supported: true,
       scopes_supported,
+      protected_resources,
+      resource_indicators_supported,
+      client_id_metadata_document_supported,
+      jwks_uri,
+      access_token_signing_alg_values_supported,
     }
   }
 }
@@ -152,9 +213,21 @@ pub async fn metadata(
   let rows = scope_repo::list_enabled(&state.pool).await?;
   let mut scopes: Vec<String> = rows.into_iter().map(|row| row.name).collect();
   scopes.sort();
+  let protected_resources = state
+    .config
+    .protected_resources_enabled
+    .then(|| state.config.protected_resource_ids.clone());
+  let jwt_signing_alg = state
+    .config
+    .jwt_access_tokens_enabled
+    .then_some(state.config.jwt_access_token_signing_alg);
   Ok(Json(AuthorizationServerMetadata::for_issuer(
     &state.config.issuer,
     scopes,
+    protected_resources,
+    state.config.resource_indicators_enabled,
+    state.config.cimd_enabled,
+    jwt_signing_alg,
   )))
 }
 

+ 9 - 0
src/protocol/metadata/snapshots/metadata_document.snap

@@ -63,5 +63,14 @@ expression: rendered
   "scopes_supported": [
     "api:read",
     "orders:write"
+  ],
+  "protected_resources": [
+    "https://api.example.test"
+  ],
+  "resource_indicators_supported": true,
+  "client_id_metadata_document_supported": true,
+  "jwks_uri": "https://auth.example.test/jwks.json",
+  "access_token_signing_alg_values_supported": [
+    "ES256"
   ]
 }

+ 426 - 12
src/protocol/metadata/tests.rs

@@ -1,6 +1,6 @@
 //! `test(metadata)` suite: RFC 8414 document + scope-catalog read semantics.
 
-use std::{sync::Arc, time::Duration};
+use std::{collections::BTreeSet, sync::Arc, time::Duration};
 
 use axum::{
   Router,
@@ -14,7 +14,7 @@ use tower::ServiceExt as _;
 
 use super::{AuthorizationServerMetadata, metadata};
 use crate::{
-  config::{Config, ConfigError},
+  config::{Config, ConfigError, JwtSigningAlg},
   db::{scope_repo, test_support::migrated_pool},
   state::{AppState, Clock, RedisConn},
 };
@@ -44,10 +44,107 @@ fn test_config() -> Result<Config, ConfigError> {
   Config::from_source(source)
 }
 
+/// A validated config with RFC 9728 `protected_resources` enabled and the given
+/// identifiers. The metadata handler serves these only when the flag is on.
+fn test_config_protected(ids: &[&str]) -> Result<Config, ConfigError> {
+  let fixture = serde_json::json!({
+    "issuer": ISSUER,
+    "environment": "development",
+    "tls": {
+      "cert_path": "/tmp/metadata-test-cert.pem",
+      "key_path": "/tmp/metadata-test-key.pem"
+    },
+    "database": {"url": "postgres://127.0.0.1:1/metadata"},
+    "redis": {"url": "redis://127.0.0.1:1", "durability": "waitaof"},
+    "token_pepper": "metadata-test-pepper-0123456789abcdef",
+    "totp_encryption_key": "metadata-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "metadata-test-pii-key-0123456789abcdef",
+    "protected_resources_enabled": true,
+    "protected_resource_ids": ids,
+  });
+  let source = ::config::Config::try_from(&fixture)?;
+  Config::from_source(source)
+}
+
+/// A validated config with the RFC 8707 `resource_indicators_enabled` flag
+/// pinned. The metadata handler advertises the capability only when it is set.
+fn test_config_resource_indicators(enabled: bool) -> Result<Config, ConfigError> {
+  let fixture = serde_json::json!({
+    "issuer": ISSUER,
+    "environment": "development",
+    "tls": {
+      "cert_path": "/tmp/metadata-test-cert.pem",
+      "key_path": "/tmp/metadata-test-key.pem"
+    },
+    "database": {"url": "postgres://127.0.0.1:1/metadata"},
+    "redis": {"url": "redis://127.0.0.1:1", "durability": "waitaof"},
+    "token_pepper": "metadata-test-pepper-0123456789abcdef",
+    "totp_encryption_key": "metadata-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "metadata-test-pii-key-0123456789abcdef",
+    "resource_indicators_enabled": enabled,
+  });
+  let source = ::config::Config::try_from(&fixture)?;
+  Config::from_source(source)
+}
+
+/// A validated config with the CIMD `cimd_enabled` flag pinned. The metadata
+/// handler advertises `client_id_metadata_document_supported` only when it is
+/// set (CIMD draft §6).
+fn test_config_cimd(enabled: bool) -> Result<Config, ConfigError> {
+  let fixture = serde_json::json!({
+    "issuer": ISSUER,
+    "environment": "development",
+    "tls": {
+      "cert_path": "/tmp/metadata-test-cert.pem",
+      "key_path": "/tmp/metadata-test-key.pem"
+    },
+    "database": {"url": "postgres://127.0.0.1:1/metadata"},
+    "redis": {"url": "redis://127.0.0.1:1", "durability": "waitaof"},
+    "token_pepper": "metadata-test-pepper-0123456789abcdef",
+    "totp_encryption_key": "metadata-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "metadata-test-pii-key-0123456789abcdef",
+    "cimd_enabled": enabled,
+  });
+  let source = ::config::Config::try_from(&fixture)?;
+  Config::from_source(source)
+}
+
+/// Exactly 32 ASCII bytes: a valid non-zero P-256 scalar and Ed25519 seed.
+const TEST_JWT_KEY: &str = "metadata-jwt-signing-key-32-byte";
+
+/// A validated config with the JWT access-token switch in the requested state
+/// and `alg` configured. The metadata handler advertises `jwks_uri` and the
+/// algorithm list only when the switch is on.
+fn test_config_jwt(enabled: bool, alg: &str) -> Result<Config, ConfigError> {
+  let fixture = serde_json::json!({
+    "issuer": ISSUER,
+    "environment": "development",
+    "tls": {
+      "cert_path": "/tmp/metadata-test-cert.pem",
+      "key_path": "/tmp/metadata-test-key.pem"
+    },
+    "database": {"url": "postgres://127.0.0.1:1/metadata"},
+    "redis": {"url": "redis://127.0.0.1:1", "durability": "waitaof"},
+    "token_pepper": "metadata-test-pepper-0123456789abcdef",
+    "totp_encryption_key": "metadata-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "metadata-test-pii-key-0123456789abcdef",
+    "jwt_access_tokens_enabled": enabled,
+    "jwt_signing_key": TEST_JWT_KEY,
+    "jwt_access_token_signing_alg": alg,
+  });
+  let source = ::config::Config::try_from(&fixture)?;
+  Config::from_source(source)
+}
+
 /// State around a live Postgres pool and a lazy, never-dialed Redis (the
-/// handler touches only `pool` + `config.issuer`).
+/// handler touches only `pool` + `config`).
 fn test_state(pool: &sqlx::PgPool) -> TestResult<AppState> {
-  let config = Arc::new(test_config()?);
+  test_state_with_config(pool, Arc::new(test_config()?))
+}
+
+/// State around `pool` and an explicit config (used to toggle the RFC 9728
+/// `protected_resources` flag without touching the default fixture).
+fn test_state_with_config(pool: &sqlx::PgPool, config: Arc<Config>) -> TestResult<AppState> {
   let keys = Arc::new(config.keys.clone());
   let client = redis::Client::open("redis://127.0.0.1:1")?;
   let manager = redis::aio::ConnectionManager::new_lazy_with_config(
@@ -67,15 +164,20 @@ fn test_state(pool: &sqlx::PgPool) -> TestResult<AppState> {
   })
 }
 
-/// Fetch `scopes_supported` through the metadata handler.
-async fn fetch_scopes(router: &Router) -> TestResult<Vec<String>> {
+/// Fetch the metadata document through the handler.
+async fn fetch_doc(router: &Router) -> TestResult<Value> {
   let request = Request::builder()
     .uri("/.well-known/oauth-authorization-server")
     .body(Body::empty())?;
   let response = router.clone().oneshot(request).await?;
   assert_eq!(response.status(), StatusCode::OK);
   let bytes = response.into_body().collect().await?.to_bytes();
-  let doc: Value = serde_json::from_slice(&bytes)?;
+  Ok(serde_json::from_slice(&bytes)?)
+}
+
+/// Fetch `scopes_supported` through the metadata handler.
+async fn fetch_scopes(router: &Router) -> TestResult<Vec<String>> {
+  let doc = fetch_doc(router).await?;
   let scopes = doc
     .get("scopes_supported")
     .and_then(Value::as_array)
@@ -95,6 +197,10 @@ fn metadata_document_matches_snapshot() {
   let doc = AuthorizationServerMetadata::for_issuer(
     ISSUER,
     vec!["api:read".to_owned(), "orders:write".to_owned()],
+    Some(vec!["https://api.example.test".to_owned()]),
+    true,
+    true,
+    Some(JwtSigningAlg::Es256),
   );
   let rendered = serde_json::to_string_pretty(&doc).unwrap_or_default();
   insta::with_settings!({ prepend_module_to_snapshot => false }, {
@@ -104,12 +210,18 @@ fn metadata_document_matches_snapshot() {
 
 #[test]
 fn metadata_never_advertises_plain_or_jwks_uri() {
-  let doc = AuthorizationServerMetadata::for_issuer(ISSUER, Vec::new());
-  let json = serde_json::to_value(&doc).unwrap_or(Value::Null);
+  let off = AuthorizationServerMetadata::for_issuer(ISSUER, Vec::new(), None, false, false, None);
+  let json = serde_json::to_value(&off).unwrap_or(Value::Null);
 
   assert!(
     json.get("jwks_uri").is_none(),
-    "no AS-signed key is published"
+    "flag off must not advertise a JWK Set"
+  );
+  assert!(
+    json
+      .get("access_token_signing_alg_values_supported")
+      .is_none(),
+    "flag off must not advertise access-token signing algorithms"
   );
   assert_eq!(
     json.get("code_challenge_methods_supported"),
@@ -140,16 +252,42 @@ fn metadata_never_advertises_plain_or_jwks_uri() {
     Some(true)
   );
 
-  let rendered = serde_json::to_string(&doc).unwrap_or_default();
+  let rendered = serde_json::to_string(&off).unwrap_or_default();
   assert!(
     !rendered.contains("\"plain\""),
     "plain must never be advertised"
   );
+
+  for (alg, advertised) in [
+    (JwtSigningAlg::Es256, "ES256"),
+    (JwtSigningAlg::EdDsa, "EdDSA"),
+  ] {
+    let on =
+      AuthorizationServerMetadata::for_issuer(ISSUER, Vec::new(), None, false, false, Some(alg));
+    let json = serde_json::to_value(&on).unwrap_or(Value::Null);
+    assert_eq!(
+      json.get("jwks_uri").and_then(Value::as_str),
+      Some("https://auth.example.test/jwks.json"),
+      "flag on must point at the JWKS route for {advertised}"
+    );
+    assert_eq!(
+      json.get("access_token_signing_alg_values_supported"),
+      Some(&serde_json::json!([advertised])),
+      "the advertised list must be exactly the configured algorithm"
+    );
+  }
 }
 
 #[test]
 fn metadata_advertises_ps256_for_client_assertions() {
-  let doc = AuthorizationServerMetadata::for_issuer(ISSUER, Vec::new());
+  let doc = AuthorizationServerMetadata::for_issuer(
+    ISSUER,
+    Vec::new(),
+    None,
+    false,
+    false,
+    Some(JwtSigningAlg::Es256),
+  );
   let json = serde_json::to_value(&doc).unwrap_or(Value::Null);
   assert_eq!(
     json.get("token_endpoint_auth_signing_alg_values_supported"),
@@ -263,3 +401,279 @@ async fn metadata_endpoint_serves_document() -> TestResult {
   assert!(doc.get("jwks_uri").is_none());
   Ok(())
 }
+
+/// RFC 9728 §4: `protected_resources` is emitted only when
+/// `protected_resources_enabled` is set, sourced from `protected_resource_ids`.
+/// `resource_metadata` is a `WWW-Authenticate` parameter owned by the resource
+/// server (§5.1) and is never an AS metadata member, so it must never appear.
+#[tokio::test]
+async fn metadata_gates_protected_resources() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let route = "/.well-known/oauth-authorization-server";
+
+  let off = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state(&pool)?);
+  let off_doc = fetch_doc(&off).await?;
+  assert!(
+    off_doc.get("protected_resources").is_none(),
+    "flag off must omit protected_resources"
+  );
+  assert!(
+    off_doc.get("resource_metadata").is_none(),
+    "resource_metadata is an RS WWW-Authenticate parameter, not AS metadata"
+  );
+
+  let on = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state_with_config(
+      &pool,
+      Arc::new(test_config_protected(&["https://api.example.test"])?),
+    )?);
+  let on_doc = fetch_doc(&on).await?;
+  assert_eq!(
+    on_doc.get("protected_resources"),
+    Some(&serde_json::json!(["https://api.example.test"])),
+    "flag on must serve config protected_resource_ids"
+  );
+  assert!(
+    on_doc.get("resource_metadata").is_none(),
+    "resource_metadata is an RS WWW-Authenticate parameter, not AS metadata"
+  );
+  Ok(())
+}
+
+/// RFC 8707 registers the `resource` request parameter (§5.1) and the
+/// `invalid_target` error (§5.2) but no authorization-server metadata member,
+/// so `resource_indicators_supported` is this server's local extension. It is
+/// emitted as `true` only when `resource_indicators_enabled` is set; with the
+/// flag off the member is absent from the wire document.
+#[tokio::test]
+async fn metadata_gates_resource_indicators() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let route = "/.well-known/oauth-authorization-server";
+
+  let off = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state(&pool)?);
+  let off_doc = fetch_doc(&off).await?;
+  assert!(
+    off_doc.get("resource_indicators_supported").is_none(),
+    "flag off must omit resource_indicators_supported"
+  );
+
+  let on = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state_with_config(
+      &pool,
+      Arc::new(test_config_resource_indicators(true)?),
+    )?);
+  let on_doc = fetch_doc(&on).await?;
+  assert_eq!(
+    on_doc.get("resource_indicators_supported"),
+    Some(&serde_json::json!(true)),
+    "flag on must advertise resource_indicators_supported"
+  );
+  Ok(())
+}
+
+/// Advertising honesty: with `resource_indicators_enabled` off the document
+/// must not advertise a capability the server rejects. That rejection
+/// (`resource` present while the flag is off -> `invalid_target`) is covered at
+/// the authorize, PAR, token, and device layers by the `params_*`/grant suites;
+/// this test pins only the metadata side. Enabling the flag adds exactly this
+/// one member and removes nothing.
+#[tokio::test]
+async fn metadata_never_advertises_disabled_resource_indicators() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let route = "/.well-known/oauth-authorization-server";
+
+  let off = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state(&pool)?);
+  let off_doc = fetch_doc(&off).await?;
+  let off_keys = document_keys(&off_doc);
+  assert!(
+    !off_keys.contains("resource_indicators_supported"),
+    "flag off must not advertise resource_indicators_supported"
+  );
+
+  let on = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state_with_config(
+      &pool,
+      Arc::new(test_config_resource_indicators(true)?),
+    )?);
+  let on_doc = fetch_doc(&on).await?;
+  let on_keys = document_keys(&on_doc);
+
+  assert_eq!(
+    on_keys
+      .difference(&off_keys)
+      .cloned()
+      .collect::<BTreeSet<String>>(),
+    BTreeSet::from(["resource_indicators_supported".to_owned()]),
+    "turning the flag on must add exactly the extension member"
+  );
+  assert!(
+    off_keys.difference(&on_keys).next().is_none(),
+    "turning the flag on must not remove any member"
+  );
+  Ok(())
+}
+
+/// CIMD draft §6 defines the RFC 8414 `client_id_metadata_document_supported`
+/// Boolean: it signals that a `client_id` URL may name a Client ID Metadata
+/// Document. It is emitted as `true` only when `cimd_enabled` is set and is
+/// ABSENT (not `false`) otherwise, so a client never sees support the server
+/// would not honor.
+#[tokio::test]
+async fn metadata_gates_cimd() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let route = "/.well-known/oauth-authorization-server";
+
+  let off = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state_with_config(
+      &pool,
+      Arc::new(test_config_cimd(false)?),
+    )?);
+  let off_doc = fetch_doc(&off).await?;
+  assert!(
+    off_doc
+      .get("client_id_metadata_document_supported")
+      .is_none(),
+    "flag off must omit client_id_metadata_document_supported"
+  );
+
+  let on = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state_with_config(
+      &pool,
+      Arc::new(test_config_cimd(true)?),
+    )?);
+  let on_doc = fetch_doc(&on).await?;
+  assert_eq!(
+    on_doc.get("client_id_metadata_document_supported"),
+    Some(&serde_json::json!(true)),
+    "flag on must advertise client_id_metadata_document_supported"
+  );
+  Ok(())
+}
+
+/// Clients select CIMD only when the doc advertises BOTH `none` in
+/// `token_endpoint_auth_methods_supported` and the CIMD flag (CIMD draft §6
+/// together with §4.1's shared-secret prohibition: a CIMD client authenticates
+/// as a public client unless it publishes a key). The `none` member is
+/// independent of `cimd_enabled` and must never disappear.
+#[tokio::test]
+async fn metadata_advertises_none_and_cimd_flag() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let route = "/.well-known/oauth-authorization-server";
+
+  for (enabled, expect_flag) in [(true, Some(true)), (false, None)] {
+    let router = Router::new()
+      .route(route, get(metadata))
+      .with_state(test_state_with_config(
+        &pool,
+        Arc::new(test_config_cimd(enabled)?),
+      )?);
+    let doc = fetch_doc(&router).await?;
+    let none_advertised = doc
+      .get("token_endpoint_auth_methods_supported")
+      .and_then(Value::as_array)
+      .is_some_and(|methods| methods.iter().any(|method| method.as_str() == Some("none")));
+    assert!(
+      none_advertised,
+      "`none` must be advertised regardless of cimd_enabled={enabled}"
+    );
+    assert_eq!(
+      doc
+        .get("client_id_metadata_document_supported")
+        .and_then(Value::as_bool),
+      expect_flag,
+      "CIMD flag mismatch for cimd_enabled={enabled}"
+    );
+  }
+  Ok(())
+}
+
+/// RFC 8414 §2 `jwks_uri` + RFC 9068 §2.1
+/// `access_token_signing_alg_values_supported`: both are emitted only while
+/// the JWT access-token switch is on, and the algorithm list is exactly the
+/// configured algorithm the signer produces.
+#[tokio::test]
+async fn metadata_gates_jwks_uri() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let route = "/.well-known/oauth-authorization-server";
+
+  let off = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state_with_config(
+      &pool,
+      Arc::new(test_config_jwt(false, "ES256")?),
+    )?);
+  let off_doc = fetch_doc(&off).await?;
+  assert!(
+    off_doc.get("jwks_uri").is_none(),
+    "flag off must omit jwks_uri"
+  );
+  assert!(
+    off_doc
+      .get("access_token_signing_alg_values_supported")
+      .is_none(),
+    "flag off must omit access_token_signing_alg_values_supported"
+  );
+
+  let on = Router::new()
+    .route(route, get(metadata))
+    .with_state(test_state_with_config(
+      &pool,
+      Arc::new(test_config_jwt(true, "ES256")?),
+    )?);
+  let on_doc = fetch_doc(&on).await?;
+  let jwks_uri = on_doc
+    .get("jwks_uri")
+    .and_then(Value::as_str)
+    .ok_or("flag on must advertise jwks_uri")?;
+  assert_eq!(jwks_uri, format!("{ISSUER}/jwks.json"));
+  assert!(jwks_uri.ends_with("/jwks.json"));
+  assert_eq!(
+    on_doc.get("access_token_signing_alg_values_supported"),
+    Some(&serde_json::json!(["ES256"])),
+    "the list must be exactly the configured algorithm"
+  );
+  Ok(())
+}
+
+/// An EdDSA deployment advertises only `EdDSA`: the signer has no RSA branch,
+/// so no `RS256` may ever appear in this member.
+#[tokio::test]
+async fn metadata_advertises_only_the_configured_signing_alg() -> TestResult {
+  let (_db, pool) = migrated_pool().await?;
+  let route = "/.well-known/oauth-authorization-server";
+
+  for (configured, advertised) in [("ES256", "ES256"), ("EdDSA", "EdDSA")] {
+    let router = Router::new()
+      .route(route, get(metadata))
+      .with_state(test_state_with_config(
+        &pool,
+        Arc::new(test_config_jwt(true, configured)?),
+      )?);
+    let doc = fetch_doc(&router).await?;
+    assert_eq!(
+      doc.get("access_token_signing_alg_values_supported"),
+      Some(&serde_json::json!([advertised])),
+      "configured {configured} must advertise exactly [{advertised}]"
+    );
+  }
+  Ok(())
+}
+
+/// Top-level member names of a metadata document.
+fn document_keys(doc: &Value) -> BTreeSet<String> {
+  doc
+    .as_object()
+    .map(|members| members.keys().cloned().collect())
+    .unwrap_or_default()
+}

+ 3 - 0
src/protocol/mod.rs

@@ -7,14 +7,17 @@
 //! TODO(task 19): wire re-exports as each endpoint lands.
 
 pub mod authorize;
+pub mod cimd;
 pub mod client_auth;
 pub mod code;
 pub mod dcr;
 pub mod device;
 pub mod introspect;
+pub mod jwks;
 pub mod metadata;
 pub mod mtls;
 pub mod par;
+pub mod params;
 pub mod purge;
 pub mod redirect_repo;
 pub mod revoke;

+ 105 - 14
src/protocol/par.rs

@@ -10,7 +10,6 @@
 use std::{collections::HashMap, time::Duration};
 
 use axum::{
-  Form,
   extract::State,
   http::{HeaderMap, StatusCode},
 };
@@ -20,12 +19,14 @@ use sqlx::PgPool;
 use time::OffsetDateTime;
 
 use super::{
+  authorize::CimdRequest,
+  cimd,
   client_auth::{AuthContext, AuthStores, ClientAuthRequest, authenticate_client},
   redirect_repo::{RedirectDecision, match_redirect_uri},
 };
 use crate::{
   crypto::tokens::mint_token,
-  db::redirect_repo,
+  db::{client_repo, redirect_repo},
   error::AppError,
   jose::jar::PAR_REQUEST_URI_PREFIX,
   middleware::{
@@ -59,6 +60,15 @@ pub struct ParContext<'a> {
   pub now: OffsetDateTime,
   /// PAR `request_uri` lifetime (90 s per the plan).
   pub par_ttl: Duration,
+  /// Config `allow_localhost_redirect`: treat the exact host `localhost` as
+  /// an RFC 8252 §7.3 loopback redirect host when matching the pushed
+  /// `redirect_uri`.
+  pub allow_localhost_redirect: bool,
+  /// CIMD resolution settings; `None` when `config.cimd_enabled` is off.
+  pub cimd: Option<CimdRequest<'a>>,
+  /// Config `resource_indicators_enabled`: off + any `resource` present is
+  /// `invalid_target` (fail-closed, no silent drop).
+  pub resource_indicators_enabled: bool,
 }
 
 /// Successful PAR response body (RFC 9126 §2.4).
@@ -90,6 +100,7 @@ pub async fn push_authorization_request(
   ctx: &ParContext<'_>,
   auth: &ClientAuthRequest<'_>,
   params: &HashMap<String, String>,
+  resources: &[String],
 ) -> Result<ParResponse, AppError> {
   let mut auth_stores = AuthStores {
     pool: stores.pool,
@@ -100,17 +111,30 @@ pub async fn push_authorization_request(
   let auth_ctx = AuthContext {
     token_endpoint: ctx.token_endpoint,
     now: ctx.now,
+    cimd: ctx.cimd.as_ref().map(|settings| cimd::CimdSettings {
+      token_prefix: settings.token_prefix,
+      max_bytes: settings.max_bytes,
+      timeout: settings.timeout,
+      allow_localhost: ctx.allow_localhost_redirect,
+    }),
   };
   let authenticated = authenticate_client(&mut auth_stores, &auth_ctx, auth).await?;
-  validate_request_rules(
+  let aud = validate_request_rules(
     stores.pool,
     stores.keys,
+    stores.conn,
     &authenticated.client.client_id,
     params,
+    resources,
+    ctx,
   )
   .await?;
 
+  // The pinned audience travels in the record `aud`, never in the stored
+  // JSON: a single-valued map cannot hold repeats, and a joined string
+  // would collide with comma-bearing URIs.
   let mut stored = params.clone();
+  stored.remove("resource");
   for member in CREDENTIAL_PARAMS {
     stored.remove(member);
   }
@@ -119,6 +143,7 @@ pub async fn push_authorization_request(
   let record = par::ParRecord {
     client_id: authenticated.client.client_id.clone(),
     request,
+    aud,
   };
   par::put(stores.conn, stores.store, &id, &record, ctx.par_ttl).await?;
 
@@ -128,13 +153,17 @@ pub async fn push_authorization_request(
   })
 }
 
-/// Enforce the pushed-request rules (RFC 9126 §2).
+/// Enforce the pushed-request rules (RFC 9126 §2), returning the validated
+/// resource audience set (RFC 8707) for the PAR record.
 async fn validate_request_rules(
   pool: &PgPool,
   keys: &crate::keys::KeyRing,
+  conn: &mut RedisConn,
   client_id: &str,
   params: &HashMap<String, String>,
-) -> Result<(), AppError> {
+  resources: &[String],
+  ctx: &ParContext<'_>,
+) -> Result<Vec<String>, AppError> {
   match params.get("client_id") {
     Some(presented) if presented == client_id => {}
     _ => {
@@ -169,14 +198,48 @@ async fn validate_request_rules(
       "pushed request redirect_uri is required",
     ));
   };
-  let effective = redirect_repo::effective_for_client(pool, keys, client_id).await?;
-  let registered: Vec<&str> = effective.iter().map(|entry| entry.uri.as_str()).collect();
-  if match_redirect_uri(&registered, redirect_uri) == RedirectDecision::Reject {
+  let registered_uris: Vec<String> =
+    match client_repo::find_active_by_client_id(pool, keys, client_id).await? {
+      Some(_) => redirect_repo::effective_for_client(pool, keys, client_id)
+        .await?
+        .into_iter()
+        .map(|entry| entry.uri)
+        .collect(),
+      // No registered row: a CIMD `client_id` uses its document redirect set
+      // (CIMD draft §5); the catalog is never consulted.
+      None => match ctx.cimd {
+        Some(settings) if client_id.starts_with("https://") => {
+          let limits = cimd::CimdLimits {
+            max_bytes: settings.max_bytes,
+            timeout: settings.timeout,
+          };
+          cimd::resolve_with_catalog(
+            conn,
+            settings.token_prefix,
+            pool,
+            client_id,
+            ctx.allow_localhost_redirect,
+            limits,
+          )
+          .await
+          .map_err(AppError::from)?
+          .redirect_uris
+        }
+        _ => Vec::new(),
+      },
+    };
+  let registered: Vec<&str> = registered_uris.iter().map(String::as_str).collect();
+  if match_redirect_uri(&registered, redirect_uri, ctx.allow_localhost_redirect)
+    == RedirectDecision::Reject
+  {
     return Err(AppError::invalid_request(
       "pushed request redirect_uri is not registered for this client",
     ));
   }
-  Ok(())
+  // Resource indicators (RFC 8707) validate fail-closed: off + present is
+  // `invalid_target`, and every value must be an absolute `https` URI with
+  // an exact enabled-catalog match. No silent drop, no scope widening.
+  super::params::validate_resources(pool, ctx.resource_indicators_enabled, resources).await
 }
 
 /// `POST /par` (RFC 9126 §2.1).
@@ -193,8 +256,28 @@ async fn validate_request_rules(
 pub async fn par_handler(
   State(state): State<AppState>,
   headers: HeaderMap,
-  Form(form): Form<HashMap<String, String>>,
-) -> Result<(StatusCode, axum::Json<ParResponse>), AppError> {
+  body: axum::body::Bytes,
+) -> axum::response::Response {
+  use axum::response::IntoResponse as _;
+  // `Bytes` + a manual parse replaces `Form<HashMap>`: the extractor
+  // consumed the raw body, hiding repeats — the repeated `resource`
+  // indicator (RFC 8707) is captured in order by `params::repeated_values`
+  // while the single-valued map keeps last-wins absorption. The explicit
+  // content-type gate preserves the 415 `Form` rendered (`Bytes` never
+  // fails on content-type), and a malformed body is the same 400.
+  if let Err(reject) = super::params::require_form_content_type(&headers) {
+    return reject.into_response();
+  }
+  let pairs = match super::params::form_pairs(&body) {
+    Ok(pairs) => pairs,
+    Err(reject) => return reject.into_response(),
+  };
+  let form = super::params::single_valued_map(&pairs);
+  let text = match super::params::form_text(&body) {
+    Ok(text) => text,
+    Err(reject) => return reject.into_response(),
+  };
+  let resources = super::params::repeated_values(text, super::params::RESOURCE_PARAM);
   let header_value = headers
     .get(axum::http::header::AUTHORIZATION)
     .and_then(|value| value.to_str().ok());
@@ -220,15 +303,23 @@ pub async fn par_handler(
     token_endpoint: &super::client_auth::token_endpoint(&state.config.issuer),
     now: state.clock.now(),
     par_ttl: state.config.ttl.par,
+    allow_localhost_redirect: state.config.allow_localhost_redirect,
+    cimd: state.config.cimd_enabled.then(|| CimdRequest {
+      token_prefix: &state.config.redis.token_key_prefix,
+      max_bytes: state.config.cimd_max_bytes,
+      timeout: state.config.cimd_fetch_timeout,
+    }),
+    resource_indicators_enabled: state.config.resource_indicators_enabled,
   };
-  let response = match push_authorization_request(&mut stores, &ctx, &auth, &form).await {
+  let response = match push_authorization_request(&mut stores, &ctx, &auth, &form, &resources).await
+  {
     Ok(response) => response,
     Err(error) => {
       emit_pii_decrypt(&headers, &error);
-      return Err(error);
+      return error.into_response();
     }
   };
-  Ok((StatusCode::CREATED, axum::Json(response)))
+  (StatusCode::CREATED, axum::Json(response)).into_response()
 }
 
 fn emit_pii_decrypt(headers: &HeaderMap, error: &AppError) {

+ 5 - 1
src/protocol/par/tests.rs

@@ -6,6 +6,7 @@ mod rules;
 
 use std::{collections::HashMap, time::Duration};
 
+use secrecy::SecretString;
 use sqlx::PgPool;
 use time::OffsetDateTime;
 use uuid::Uuid;
@@ -22,7 +23,6 @@ use crate::{
   store::Store,
   test_guard::Lease,
 };
-use secrecy::SecretString;
 
 /// Test scaffolding failures (no `unwrap`/`expect` under the deny lints).
 #[derive(Debug, thiserror::Error)]
@@ -99,6 +99,9 @@ fn ctx_of(endpoint: &str) -> ParContext<'_> {
     token_endpoint: endpoint,
     now: OffsetDateTime::now_utc(),
     par_ttl: Duration::from_secs(90),
+    allow_localhost_redirect: false,
+    cimd: None,
+    resource_indicators_enabled: false,
   }
 }
 
@@ -134,6 +137,7 @@ async fn insert_client(
     contacts: None,
     software_id: None,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
   };
   client_repo::insert(pool, keys, false, &new).await?;

+ 5 - 4
src/protocol/par/tests/consume.rs

@@ -3,6 +3,7 @@
 
 use std::{collections::HashMap, time::Duration};
 
+use super::TestError;
 use super::{
   super::push_authorization_request, attach_redirect, bare_auth, ctx_of, expect_code, harness,
   insert_client, push_params, stores_of,
@@ -14,8 +15,6 @@ use crate::{
   store::par,
 };
 
-use super::TestError;
-
 /// Push via a public client and return the issued `request_uri`.
 async fn push_public(
   h: &mut super::Harness,
@@ -27,7 +26,7 @@ async fn push_public(
   let auth = bare_auth(client_id);
   let ctx = ctx_of(endpoint);
   let mut stores = stores_of(h);
-  let response = push_authorization_request(&mut stores, &ctx, &auth, &params)
+  let response = push_authorization_request(&mut stores, &ctx, &auth, &params, &[])
     .await
     .map_err(|error| TestError::Unexpected(format!("push failed: {}", error.code())))?;
   if response.expires_in != 90 {
@@ -166,7 +165,7 @@ async fn par_authorize_binds_client_and_rejects_replay() -> Result<(), TestError
   let merged = resolve(&mut h.conn, &h.store, &first)
     .await
     .map_err(|error| TestError::Unexpected(format!("first use failed: {}", error.code())))?;
-  if merged.get("redirect_uri").map(String::as_str) != Some(redirect_uri) {
+  if merged.params.get("redirect_uri").map(String::as_str) != Some(redirect_uri) {
     return Err(TestError::Unexpected("merged params lost".to_owned()));
   }
 
@@ -272,6 +271,7 @@ async fn par_consume_bound_transport_error_recovery() -> Result<(), TestError> {
   let record = par::ParRecord {
     client_id: client_id.to_owned(),
     request: "{\"response_type\":\"code\"}".to_owned(),
+    aud: Vec::new(),
   };
   par::put(
     &mut h.conn,
@@ -334,6 +334,7 @@ async fn par_expired_request_uri_is_rejected() -> Result<(), TestError> {
   let record = par::ParRecord {
     client_id: "par-expiry-client".to_owned(),
     request: "{\"response_type\":\"code\"}".to_owned(),
+    aud: Vec::new(),
   };
   par::put(
     &mut h.conn,

+ 133 - 10
src/protocol/par/tests/rules.rs

@@ -3,6 +3,7 @@
 
 use secrecy::SecretString;
 
+use super::TestError;
 use super::{
   super::push_authorization_request, attach_redirect, bare_auth, ctx_of, expect_code, harness,
   insert_client, push_params, stores_of,
@@ -13,8 +14,6 @@ use crate::{
   protocol::client_auth::ClientAuthRequest,
 };
 
-use super::TestError;
-
 #[tokio::test]
 async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
   let mut h = harness().await?;
@@ -36,7 +35,7 @@ async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
-  match push_authorization_request(&mut stores, &ctx, &auth, &params).await {
+  match push_authorization_request(&mut stores, &ctx, &auth, &params, &[]).await {
     Ok(_) => return Err(TestError::Unexpected("ghost push accepted".to_owned())),
     Err(error) => expect_code(&error, OAuthErrorCode::InvalidClient)?,
   }
@@ -47,7 +46,7 @@ async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
-  match push_authorization_request(&mut stores, &ctx, &auth, &params).await {
+  match push_authorization_request(&mut stores, &ctx, &auth, &params, &[]).await {
     Ok(_) => {
       return Err(TestError::Unexpected(
         "credential-free push accepted".to_owned(),
@@ -69,7 +68,7 @@ async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
-  match push_authorization_request(&mut stores, &ctx, &auth, &params).await {
+  match push_authorization_request(&mut stores, &ctx, &auth, &params, &[]).await {
     Ok(_) => {
       return Err(TestError::Unexpected(
         "wrong-secret push accepted".to_owned(),
@@ -102,7 +101,7 @@ async fn par_rejects_invalid_request_rules() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
-  match push_authorization_request(&mut stores, &ctx, &auth, &params).await {
+  match push_authorization_request(&mut stores, &ctx, &auth, &params, &[]).await {
     Ok(_) => {
       return Err(TestError::Unexpected(
         "missing response_type accepted".to_owned(),
@@ -121,7 +120,7 @@ async fn par_rejects_invalid_request_rules() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
-  match push_authorization_request(&mut stores, &ctx, &auth, &params).await {
+  match push_authorization_request(&mut stores, &ctx, &auth, &params, &[]).await {
     Ok(_) => {
       return Err(TestError::Unexpected(
         "foreign request_uri accepted".to_owned(),
@@ -137,7 +136,7 @@ async fn par_rejects_invalid_request_rules() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
-  match push_authorization_request(&mut stores, &ctx, &auth, &params).await {
+  match push_authorization_request(&mut stores, &ctx, &auth, &params, &[]).await {
     Ok(_) => {
       return Err(TestError::Unexpected(
         "unregistered redirect accepted".to_owned(),
@@ -170,7 +169,7 @@ async fn par_redirect_mismatch_rejected() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
-  match push_authorization_request(&mut stores, &ctx, &auth, &params).await {
+  match push_authorization_request(&mut stores, &ctx, &auth, &params, &[]).await {
     Ok(_) => {
       return Err(TestError::Unexpected("evil redirect accepted".to_owned()));
     }
@@ -187,7 +186,7 @@ async fn par_redirect_mismatch_rejected() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
-  match push_authorization_request(&mut stores, &ctx, &auth, &params).await {
+  match push_authorization_request(&mut stores, &ctx, &auth, &params, &[]).await {
     Ok(_) => {
       return Err(TestError::Unexpected("corrupt uri_enc accepted".to_owned()));
     }
@@ -209,3 +208,127 @@ async fn par_redirect_mismatch_rejected() -> Result<(), TestError> {
   }
   Ok(())
 }
+
+/// An unknown (or non-`https`, or flag-off) `resource` fails the push as
+/// `invalid_target` — fail-closed, never silently dropped. Two catalog
+/// resources push and pin as the sorted deduped record audience.
+#[tokio::test]
+async fn par_rejects_unknown_resource() -> Result<(), TestError> {
+  let mut h = harness().await?;
+  let client_id = "par-resource-client";
+  let redirect_uri = "https://client.example.test/cb";
+  insert_client(
+    &h.pool,
+    &h.keys,
+    client_id,
+    TokenEndpointAuthMethod::None,
+    None,
+  )
+  .await?;
+  attach_redirect(&h.pool, &h.keys, client_id, redirect_uri).await?;
+
+  let params = push_params(client_id, redirect_uri);
+  let auth = bare_auth(client_id);
+  let endpoint = h.endpoint.clone();
+  let mut ctx = ctx_of(&endpoint);
+  ctx.resource_indicators_enabled = true;
+
+  // Unknown catalog entry.
+  let mut stores = stores_of(&mut h);
+  match push_authorization_request(
+    &mut stores,
+    &ctx,
+    &auth,
+    &params,
+    &["https://ghost.example.test/".to_owned()],
+  )
+  .await
+  {
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "unknown resource accepted".to_owned(),
+      ));
+    }
+    Err(error) => expect_code(&error, OAuthErrorCode::InvalidTarget)?,
+  }
+
+  // Non-`https` value: no audience exception (not even loopback-shaped).
+  let mut stores = stores_of(&mut h);
+  match push_authorization_request(
+    &mut stores,
+    &ctx,
+    &auth,
+    &params,
+    &["http://api.example.test/".to_owned()],
+  )
+  .await
+  {
+    Ok(_) => {
+      return Err(TestError::Unexpected("http resource accepted".to_owned()));
+    }
+    Err(error) => expect_code(&error, OAuthErrorCode::InvalidTarget)?,
+  }
+
+  // Flag off + `resource` present: fail-closed, no silent drop.
+  ctx.resource_indicators_enabled = false;
+  let mut stores = stores_of(&mut h);
+  match push_authorization_request(
+    &mut stores,
+    &ctx,
+    &auth,
+    &params,
+    &["https://api.example.test/".to_owned()],
+  )
+  .await
+  {
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "flag-off resource accepted".to_owned(),
+      ));
+    }
+    Err(error) => expect_code(&error, OAuthErrorCode::InvalidTarget)?,
+  }
+
+  // Two catalog resources push and pin as the sorted deduped record audience.
+  ctx.resource_indicators_enabled = true;
+  crate::db::resource_repo::upsert(&h.pool, "https://b.example.test/", "b").await?;
+  crate::db::resource_repo::upsert(&h.pool, "https://a.example.test/", "a").await?;
+  let mut stores = stores_of(&mut h);
+  let pushed = push_authorization_request(
+    &mut stores,
+    &ctx,
+    &auth,
+    &params,
+    &[
+      "https://b.example.test/".to_owned(),
+      "https://a.example.test/".to_owned(),
+      "https://b.example.test/".to_owned(),
+    ],
+  )
+  .await
+  .map_err(|error| {
+    TestError::Unexpected(format!("catalog resources rejected: {}", error.code()))
+  })?;
+  let Some(id) = pushed
+    .request_uri
+    .strip_prefix(crate::jose::jar::PAR_REQUEST_URI_PREFIX)
+  else {
+    return Err(TestError::Unexpected("bad request_uri".to_owned()));
+  };
+  let record = crate::store::par::consume(&mut h.conn, &h.store, id)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("stored PAR record unreadable: {error:?}")))?
+    .ok_or_else(|| TestError::Unexpected("stored PAR record missing".to_owned()))?;
+  if record.aud
+    != vec![
+      "https://a.example.test/".to_owned(),
+      "https://b.example.test/".to_owned(),
+    ]
+  {
+    return Err(TestError::Unexpected(format!(
+      "PAR aud not pinned sorted deduped: {:?}",
+      record.aud
+    )));
+  }
+  Ok(())
+}

+ 360 - 0
src/protocol/params.rs

@@ -0,0 +1,360 @@
+//! Repeated `resource` parsing + RFC 8707 validation (task 7).
+//!
+//! Every entry point decodes into a single-valued map (`Query<HashMap>` on
+//! `/authorize`, `Form<HashMap>` on `/par`, `TokenForm` on `/token`,
+//! `IntrospectForm` on `/introspect`, `DeviceAuthorizationForm` on
+//! `/device_authorization`), and each extractor CONSUMES the raw query/body —
+//! so a raw parse requires switching the extractor (`RawQuery` + `Bytes` with
+//! a manual struct parse) at all five endpoints. A helper alone cannot see
+//! the raw input after `Form`/`Query` runs.
+//!
+//! This module owns the shared half of that surgery:
+//!
+//! - [`parse_pairs`] / [`single_valued_map`] — the strict urlencoded parse
+//!   (last-wins, mirroring `serde_urlencoded`'s `HashMap` absorption).
+//! - [`repeated_values`] — the ONE helper capturing repeated `resource`
+//!   values in order from a raw query/body string.
+//! - [`require_form_content_type`] / [`form_pairs`] / [`query_pairs`] — the
+//!   transport contract the `Form`/`Query` extractors used to enforce
+//!   (415 on a non-urlencoded body, 400 on malformed input), so the
+//!   extractor switch is behavior-preserving. `Bytes` never fails on
+//!   content-type, hence the explicit gate.
+//! - [`has_duplicate_field`] — mirrors serde's duplicate-field rejection for
+//!   struct forms. NOTE (do not misreport): a repeated `resource` is
+//!   silently absorbed for struct forms (`TokenForm`, `IntrospectForm`,
+//!   `DeviceAuthorizationForm` carry no `resource` member and no
+//!   `deny_unknown_fields`), NOT rejected by `serde_urlencoded` — only a
+//!   repeated KNOWN field errors.
+//! - [`validate_resources`] — fail-closed RFC 8707 validation.
+//! - [`audience_binds`] — the ONE order-insensitive, constant-time audience
+//!   comparison shared by every grant that binds a pinned resource set.
+//!
+//! Resource policy (stricter than RFC 8707 §2, which only SHOULD-NOTs a query
+//! component): every value must be an absolute `https` URI and must exactly
+//! match an enabled `resource_servers` row (`resource_repo::find_enabled`).
+//! There is no loopback exception for audiences (unlike redirect URIs under
+//! RFC 8252 §7.3), values are never delimiter-joined (a comma-bearing URI
+//! would collide — the validated set is pinned as a sorted deduped
+//! `Vec<String>`), scopes are never widened, and when the
+//! `resource_indicators_enabled` flag is off any `resource` present is
+//! `invalid_target` (fail-closed, no silent drop).
+
+use std::{
+  collections::{HashMap, HashSet},
+  fmt,
+};
+
+use axum::http::{HeaderMap, StatusCode, header};
+use sha2::{Digest as _, Sha256};
+use sqlx::PgPool;
+use subtle::ConstantTimeEq as _;
+
+use crate::{db::resource_repo, error::AppError};
+
+/// RFC 8707 indicator parameter name.
+pub const RESOURCE_PARAM: &str = "resource";
+
+/// Urlencoded parse failure.
+///
+/// Decoding itself mirrors `serde_urlencoded` and is infallible (a bare `%`
+/// stays literal, non-UTF-8 decodes lossily), so this type is retained for
+/// the [`parse_pairs`] signature rather than constructed on any current
+/// decoding path.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ParamsError;
+
+impl fmt::Display for ParamsError {
+  fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+    formatter.write_str("malformed urlencoded input")
+  }
+}
+
+impl std::error::Error for ParamsError {}
+
+/// Decode one `application/x-www-form-urlencoded` component (`+` is a space).
+///
+/// Percent-decoding mirrors `serde_urlencoded` observable behavior so
+/// malformed encodings reach typed validation and fail closed downstream
+/// (`unsupported_grant_type` / `invalid_client` / `invalid_scope`) instead
+/// of failing the parse: a `%` NOT followed by two hex digits stays a
+/// literal `%` (only `%<hex><hex>` decodes), and non-UTF-8 bytes decode
+/// lossily (`U+FFFD` replacement).
+fn decode_component(raw: &str) -> String {
+  let bytes = raw.as_bytes();
+  let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
+  let mut index = 0;
+  while index < bytes.len() {
+    match bytes[index] {
+      b'+' => {
+        out.push(b' ');
+        index += 1;
+      }
+      b'%' => {
+        match (
+          bytes.get(index + 1).copied().and_then(hex_value),
+          bytes.get(index + 2).copied().and_then(hex_value),
+        ) {
+          (Some(high), Some(low)) => {
+            out.push(high << 4 | low);
+            index += 3;
+          }
+          _ => {
+            out.push(b'%');
+            index += 1;
+          }
+        }
+      }
+      byte => {
+        out.push(byte);
+        index += 1;
+      }
+    }
+  }
+  String::from_utf8_lossy(&out).into_owned()
+}
+
+/// One percent-encoded hex digit.
+fn hex_value(byte: u8) -> Option<u8> {
+  match byte {
+    b'0'..=b'9' => Some(byte - b'0'),
+    b'a'..=b'f' => Some(byte - b'a' + 10),
+    b'A'..=b'F' => Some(byte - b'A' + 10),
+    _ => None,
+  }
+}
+
+/// Strictly parse raw urlencoded input into ordered pairs.
+///
+/// A segment without `=` carries an empty value (mirroring
+/// `serde_urlencoded`); empty segments are skipped. Decoding itself is
+/// lossy and infallible (see [`decode_component`]): a malformed `%`
+/// escape stays literal and non-UTF-8 decodes with replacement, so odd
+/// encodings reach typed validation downstream instead of failing the
+/// parse. [`ParamsError`] is retained for the signature; callers map it
+/// onto the same 400 the `Form`/`Query` extractors rendered.
+pub fn parse_pairs(raw: &str) -> Result<Vec<(String, String)>, ParamsError> {
+  if raw.is_empty() {
+    return Ok(Vec::new());
+  }
+  let mut pairs = Vec::new();
+  for segment in raw.split('&') {
+    if segment.is_empty() {
+      continue;
+    }
+    let (name, value) = match segment.split_once('=') {
+      Some((name, value)) => (name, value),
+      None => (segment, ""),
+    };
+    pairs.push((decode_component(name), decode_component(value)));
+  }
+  Ok(pairs)
+}
+
+/// Collapse ordered pairs into a single-valued map, last-wins.
+///
+/// This mirrors `serde_urlencoded`'s `HashMap` absorption exactly: a
+/// repeated parameter (including a repeated `resource`) is silently
+/// absorbed, never rejected.
+#[must_use]
+pub fn single_valued_map(pairs: &[(String, String)]) -> HashMap<String, String> {
+  let mut map = HashMap::with_capacity(pairs.len());
+  for (name, value) in pairs {
+    map.insert(name.clone(), value.clone());
+  }
+  map
+}
+
+/// Capture every `name` value in order from a raw query/body string.
+///
+/// This is the ONE helper for the query/body endpoints (JAR arrays are
+/// handled out-of-band by the merge carrier, never by this function).
+/// Infallible by design: handlers run the strict [`parse_pairs`] first for
+/// error mapping and only call this on input that already parsed, so an
+/// undecodable segment is skipped as unreachable rather than erroring.
+#[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(),
+  }
+}
+
+/// Whether any KNOWN field repeats — mirrors serde's duplicate-field
+/// rejection for struct forms (`TokenForm`, `IntrospectForm`,
+/// `DeviceAuthorizationForm`).
+///
+/// Unknown fields are ignored entirely (no `deny_unknown_fields`), so a
+/// repeated `resource` — unknown to every struct form — is silently
+/// absorbed here, exactly as `serde_urlencoded` absorbed it.
+#[must_use]
+pub fn has_duplicate_field(pairs: &[(String, String)], known: &[&str]) -> bool {
+  let mut seen: HashSet<&str> = HashSet::with_capacity(pairs.len());
+  for (name, _) in pairs {
+    if known.contains(&name.as_str()) && !seen.insert(name.as_str()) {
+      return true;
+    }
+  }
+  false
+}
+
+/// Transport rejection: a small status + body pair the handlers render with
+/// `IntoResponse`, mirroring the axum extractor's status class and body.
+pub type TransportRejection = (StatusCode, &'static str);
+
+/// Reject a non-urlencoded body with 415, mirroring axum's
+/// `InvalidFormContentType` (`Bytes` never fails on content-type, so the
+/// extractor switch needs this explicit gate to preserve the contract).
+///
+/// # Errors
+///
+/// Returns the 415 rejection when the `Content-Type` is missing or is not
+/// `application/x-www-form-urlencoded` (an optional `;charset=…` suffix is
+/// accepted, as axum did).
+pub fn require_form_content_type(headers: &HeaderMap) -> Result<(), TransportRejection> {
+  let urlencoded = headers
+    .get(header::CONTENT_TYPE)
+    .and_then(|value| value.to_str().ok())
+    .and_then(|value| value.split(';').next())
+    .is_some_and(|mime| {
+      mime
+        .trim()
+        .eq_ignore_ascii_case("application/x-www-form-urlencoded")
+    });
+  if urlencoded {
+    Ok(())
+  } else {
+    Err((
+      StatusCode::UNSUPPORTED_MEDIA_TYPE,
+      "Form requests must have `Content-Type: application/x-www-form-urlencoded`",
+    ))
+  }
+}
+
+/// Borrow a request body as text, mirroring `Form`'s 400
+/// (`FailedToDeserializeForm`) on non-UTF-8 input.
+///
+/// Handlers use this before [`parse_pairs`] + [`repeated_values`] so the
+/// raw text stays available for the repeated-value capture.
+///
+/// # Errors
+///
+/// Returns the 400 rejection when the body is not UTF-8.
+pub fn form_text(body: &[u8]) -> Result<&str, TransportRejection> {
+  std::str::from_utf8(body).map_err(|_| (StatusCode::BAD_REQUEST, "Failed to deserialize form"))
+}
+
+/// Strict urlencoded pairs from a request body, mirroring `Form`'s 400
+/// (`FailedToDeserializeForm`) on non-UTF-8 or malformed input.
+///
+/// # Errors
+///
+/// Returns the 400 rejection when the body is not strict urlencoded input.
+pub fn form_pairs(body: &[u8]) -> Result<Vec<(String, String)>, TransportRejection> {
+  let text = form_text(body)?;
+  parse_pairs(text).map_err(|_| (StatusCode::BAD_REQUEST, "Failed to deserialize form"))
+}
+
+/// Strict urlencoded pairs from a query string, mirroring `Query`'s 400
+/// (`FailedToDeserializeQueryString`) on malformed input.
+///
+/// # Errors
+///
+/// Returns the 400 rejection when the query is not strict urlencoded input.
+pub fn query_pairs(raw: Option<&str>) -> Result<Vec<(String, String)>, TransportRejection> {
+  parse_pairs(raw.unwrap_or("")).map_err(|_| {
+    (
+      StatusCode::BAD_REQUEST,
+      "Failed to deserialize query string",
+    )
+  })
+}
+
+/// Validate resource indicators fail-closed (RFC 8707 §2).
+///
+/// An empty input pins an empty set without touching the catalog. Otherwise
+/// the flag must be on (off + any `resource` present is `invalid_target`,
+/// never a silent drop), every value must be an absolute `https` URI — no
+/// loopback exception applies to audiences — and must exactly match an
+/// enabled `resource_servers` row. Returns the sorted deduped set for
+/// pinning into `PendingAuth.aud` / PAR / code / device records; values are
+/// never delimiter-joined and scopes are never widened here.
+///
+/// # Errors
+///
+/// Returns `invalid_target` for a disabled flag, a non-`https` or relative
+/// value, or an unknown/disabled catalog entry; a database outage propagates
+/// as the fail-closed `503`.
+pub async fn validate_resources(
+  pool: &PgPool,
+  enabled: bool,
+  values: &[String],
+) -> Result<Vec<String>, AppError> {
+  if values.is_empty() {
+    return Ok(Vec::new());
+  }
+  if !enabled {
+    return Err(AppError::invalid_target(
+      "resource indicators are not enabled",
+    ));
+  }
+  let mut aud = Vec::with_capacity(values.len());
+  for value in values {
+    let absolute_https = reqwest::Url::parse(value)
+      .ok()
+      .filter(|url| url.scheme() == "https" && url.has_host())
+      .is_some();
+    if !absolute_https {
+      return Err(AppError::invalid_target(
+        "resource must be an absolute https URI",
+      ));
+    }
+    let known = resource_repo::find_enabled(pool, value).await?.is_some();
+    if !known {
+      return Err(AppError::invalid_target(
+        "unknown or disabled resource server",
+      ));
+    }
+    aud.push(value.clone());
+  }
+  aud.sort();
+  aud.dedup();
+  Ok(aud)
+}
+
+/// Whether a grant's pinned audience and the presented set are identical.
+///
+/// RFC 8707 §2 requires a token request to present the same resource set the
+/// authorization request bound to the grant. Comparison is order-insensitive
+/// (both sides are sorted and deduped here; the store contract already sorts,
+/// this defends it) and fail-closed for every cardinality change. Members are
+/// hashed with SHA-256 and compared through [`subtle::ConstantTimeEq`] without
+/// a per-member early exit. Lengths and set membership are public metadata for
+/// RFC 8707 audiences, so the cardinality gate is not secret-dependent.
+pub(crate) fn audience_binds(pinned: &[String], presented: &[String]) -> bool {
+  let pinned = normalized_audience(pinned);
+  let presented = normalized_audience(presented);
+  if pinned.len() != presented.len() {
+    return false;
+  }
+  let mut equal = subtle::Choice::from(1u8);
+  for (left, right) in pinned.iter().zip(presented.iter()) {
+    let left = Sha256::digest(left.as_bytes());
+    let right = Sha256::digest(right.as_bytes());
+    equal &= left.as_slice().ct_eq(right.as_slice());
+  }
+  bool::from(equal)
+}
+
+/// Sorted, deduped view of an audience set.
+fn normalized_audience(values: &[String]) -> Vec<&str> {
+  let mut normalized: Vec<&str> = values.iter().map(String::as_str).collect();
+  normalized.sort_unstable();
+  normalized.dedup();
+  normalized
+}
+
+#[cfg(test)]
+mod tests;

+ 236 - 0
src/protocol/params/tests.rs

@@ -0,0 +1,236 @@
+//! `test(params)` suite: strict urlencoded parsing, repeated-value capture,
+//! transport-contract preservation, and fail-closed resource validation.
+
+use super::{
+  RESOURCE_PARAM, form_pairs, has_duplicate_field, parse_pairs, query_pairs, repeated_values,
+  require_form_content_type, single_valued_map, validate_resources,
+};
+use crate::{db::resource_repo, error::OAuthErrorCode};
+
+/// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
+type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
+
+/// Repeated `resource` values are captured in order.
+#[test]
+fn params_repeated_resource_is_captured_in_order() -> TestResult {
+  let raw = "client_id=web&resource=https%3A%2F%2Fapi.example.test%2Fa&scope=read&resource=https%3A%2F%2Fapi.example.test%2Fb";
+  let values = repeated_values(raw, RESOURCE_PARAM);
+  if values
+    != vec![
+      "https://api.example.test/a".to_owned(),
+      "https://api.example.test/b".to_owned(),
+    ]
+  {
+    return Err(format!("repeated resource order lost: {values:?}").into());
+  }
+  Ok(())
+}
+
+/// The single-valued map is last-wins: duplicates are silently absorbed.
+#[test]
+fn params_single_valued_map_absorbs_duplicates_last_wins() -> TestResult {
+  let pairs = parse_pairs("resource=https%3A%2F%2Fa.example&resource=https%3A%2F%2Fb.example")?;
+  let map = single_valued_map(&pairs);
+  if map.get("resource").map(String::as_str) != Some("https://b.example") {
+    return Err(format!("last-wins absorption broken: {map:?}").into());
+  }
+  Ok(())
+}
+
+/// Misencoded input decodes lossily, mirroring `serde_urlencoded`: a `%`
+/// not followed by two hex digits stays a literal `%` (`%ZZ`, trailing `%`,
+/// short `%2`), and non-UTF-8 bytes decode with replacement (`%FF`).
+/// Nothing here fails the parse; odd encodings reach typed validation
+/// downstream (`unsupported_grant_type`, `invalid_scope`, ...).
+#[test]
+fn params_malformed_input_fails_strict_parse() -> TestResult {
+  let map = single_valued_map(&parse_pairs("a=%ZZ&b=%&c=%2&d=%FF")?);
+  if map.get("a").map(String::as_str) != Some("%ZZ") {
+    return Err(format!("bare % must stay literal: {map:?}").into());
+  }
+  if map.get("b").map(String::as_str) != Some("%") {
+    return Err(format!("trailing % must stay literal: {map:?}").into());
+  }
+  if map.get("c").map(String::as_str) != Some("%2") {
+    return Err(format!("short % must stay literal: {map:?}").into());
+  }
+  if map.get("d").map(String::as_str) != Some("�") {
+    return Err(format!("non-UTF-8 must decode lossily: {map:?}").into());
+  }
+  if parse_pairs("a=ok&b=%41").is_err() {
+    return Err("well-formed input rejected".into());
+  }
+  Ok(())
+}
+
+/// `+` decodes to a space and a bare key carries an empty value.
+#[test]
+fn params_plus_and_bare_key_decode() -> TestResult {
+  let pairs = parse_pairs("scope=orders+read&flag")?;
+  let map = single_valued_map(&pairs);
+  if map.get("scope").map(String::as_str) != Some("orders read") {
+    return Err(format!("plus decoding broken: {map:?}").into());
+  }
+  if map.get("flag").map(String::as_str) != Some("") {
+    return Err(format!("bare key must carry an empty value: {map:?}").into());
+  }
+  Ok(())
+}
+
+/// Only repeated KNOWN fields count as duplicates; repeated `resource`
+/// (unknown to every struct form) is silently absorbed, never rejected.
+#[test]
+fn params_duplicate_detection_ignores_unknown_resource() -> TestResult {
+  let known = ["grant_type", "code"];
+  let pairs = parse_pairs(
+    "grant_type=code&resource=https%3A%2F%2Fa.example&resource=https%3A%2F%2Fb.example",
+  )?;
+  if has_duplicate_field(&pairs, &known) {
+    return Err("repeated unknown resource must not count as a duplicate".into());
+  }
+  let pairs = parse_pairs("grant_type=code&grant_type=refresh")?;
+  if !has_duplicate_field(&pairs, &known) {
+    return Err("repeated known field must count as a duplicate".into());
+  }
+  Ok(())
+}
+
+/// The content-type gate preserves axum's 415 contract for `Bytes` bodies.
+#[test]
+fn params_content_type_gate_preserves_415() -> TestResult {
+  use axum::http::{HeaderMap, header};
+
+  let mut headers = HeaderMap::new();
+  if require_form_content_type(&headers).is_ok() {
+    return Err("missing content-type must be 415".into());
+  }
+  headers.insert(header::CONTENT_TYPE, "application/json".parse()?);
+  if require_form_content_type(&headers).is_ok() {
+    return Err("json content-type must be 415".into());
+  }
+  headers.insert(
+    header::CONTENT_TYPE,
+    "application/x-www-form-urlencoded; charset=UTF-8".parse()?,
+  );
+  require_form_content_type(&headers).map_err(|_| "suffixed urlencoded must pass")?;
+  Ok(())
+}
+
+/// Only structurally unreadable bodies map to 400 (the `Form` extractor's
+/// class): raw non-UTF-8 bytes. Percent-misencodings (`%ZZ`, `%FF`)
+/// decode lossily and reach typed validation downstream, never the parse.
+#[test]
+fn params_malformed_body_is_400_not_silent() -> TestResult {
+  use axum::http::StatusCode;
+
+  let (status, _) = match form_pairs(b"a=\xFF") {
+    Ok(_) => return Err("non-UTF-8 body was silently accepted".into()),
+    Err(reject) => reject,
+  };
+  if status != StatusCode::BAD_REQUEST {
+    return Err(format!("non-UTF-8 body must be 400, got {status}").into());
+  }
+  if form_pairs(b"grant_type=%ZZ").is_err()
+    || form_pairs(b"grant_type=%E0%A4%A").is_err()
+    || query_pairs(Some("grant_type=%ZZ")).is_err()
+  {
+    return Err("misencoded input must decode lossily, not fail the parse".into());
+  }
+  if form_pairs(b"grant_type=code").is_err() || query_pairs(None).is_err() {
+    return Err("well-formed input must parse".into());
+  }
+  Ok(())
+}
+
+/// Flag off + `resource` present is `invalid_target` (fail-closed, no drop).
+#[tokio::test]
+async fn params_flag_off_with_resource_is_invalid_target() -> TestResult {
+  let (_db, pool) = crate::db::test_support::migrated_pool().await?;
+  match validate_resources(&pool, false, &["https://api.example.test".to_owned()]).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidTarget => Ok(()),
+    Err(error) => Err(format!("wrong code for flag-off resource: {}", error.code()).into()),
+    Ok(_) => Err("flag-off resource was silently accepted".into()),
+  }
+}
+
+/// Empty input pins an empty set without touching the catalog.
+#[tokio::test]
+async fn params_empty_input_pins_empty_without_catalog() -> TestResult {
+  let (_db, pool) = crate::db::test_support::migrated_pool().await?;
+  let aud = validate_resources(&pool, true, &[]).await?;
+  if !aud.is_empty() {
+    return Err(format!("empty input must pin empty, got {aud:?}").into());
+  }
+  Ok(())
+}
+
+/// `http:` and relative values are `invalid_target` (no loopback exception).
+#[tokio::test]
+async fn params_http_and_relative_resources_are_invalid_target() -> TestResult {
+  let (_db, pool) = crate::db::test_support::migrated_pool().await?;
+  for value in [
+    "http://api.example.test/",
+    "http://localhost:8080/",
+    "https://127.0.0.1/",
+    "//api.example.test/",
+    "/relative/path",
+    "not-a-uri",
+  ] {
+    match validate_resources(&pool, true, &[value.to_owned()]).await {
+      Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
+      Err(error) => {
+        return Err(format!("wrong code for {value}: {}", error.code()).into());
+      }
+      Ok(_) => return Err(format!("non-https resource accepted: {value}").into()),
+    }
+  }
+  Ok(())
+}
+
+/// Unknown and disabled catalog entries are `invalid_target`.
+#[tokio::test]
+async fn params_unknown_and_disabled_resources_are_invalid_target() -> TestResult {
+  let (_db, pool) = crate::db::test_support::migrated_pool().await?;
+  match validate_resources(&pool, true, &["https://ghost.example.test/".to_owned()]).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidTarget => {}
+    Err(error) => {
+      return Err(format!("wrong code for unknown resource: {}", error.code()).into());
+    }
+    Ok(_) => return Err("unknown resource was accepted".into()),
+  }
+
+  resource_repo::upsert(&pool, "https://api.example.test/", "test api").await?;
+  resource_repo::disable(&pool, "https://api.example.test/").await?;
+  match validate_resources(&pool, true, &["https://api.example.test/".to_owned()]).await {
+    Err(error) if error.code() == OAuthErrorCode::InvalidTarget => Ok(()),
+    Err(error) => Err(format!("wrong code for disabled resource: {}", error.code()).into()),
+    Ok(_) => Err("disabled resource was accepted".into()),
+  }
+}
+
+/// Two catalog resources validate and pin as a sorted deduped set.
+#[tokio::test]
+async fn params_two_catalog_resources_pin_sorted_deduped() -> 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(),
+    ],
+  )
+  .await?;
+  if aud
+    != vec![
+      "https://a.example.test/".to_owned(),
+      "https://b.example.test/".to_owned(),
+    ]
+  {
+    return Err(format!("sorted deduped set broken: {aud:?}").into());
+  }
+  Ok(())
+}

+ 85 - 5
src/protocol/purge.rs

@@ -4,10 +4,13 @@
 //! the user (canonical email or user-id string): login sessions (plus their
 //! `:meta` sidecars), the lockout counters (the account key plus every
 //! per-IP composite key), authorization codes, device records
-//! (plus `:cnf`/`:poll` sidecars and `user_code` mappings), and token grants
-//! (cascade-revoked through [`grants::revoke`] so member token keys, the
-//! member set, and the barrier stay correct, plus standalone access/refresh
-//! records and `:act` sidecars).
+//! (plus `:cnf`/`:poll` sidecars and `user_code` mappings), token grants
+//! (cascade-revoked through [`grants::revoke`] so member token keys, JWT
+//! allowlist markers, the member set, and the barrier stay correct, plus
+//! standalone access/refresh records and `:act` sidecars), and JWT allowlist
+//! markers (`jwt:<jti-hash>` hashes swept directly by their `subject` field,
+//! so session-bound and client-credentials markers die even when no grant
+//! cascade covers them).
 //!
 //! Records without a `subject` field are never touched: `par:`, `pending:`,
 //! `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the
@@ -45,6 +48,9 @@ pub struct PurgedSubject {
   pub access: u64,
   /// Standalone refresh-token records deleted.
   pub refresh: u64,
+  /// JWT allowlist marker hashes (`jwt:<jti-hash>`) whose `subject` field
+  /// named the user, deleted.
+  pub jwt_markers: u64,
 }
 
 /// Delete every Redis record whose `subject` is `email` or `user_id`.
@@ -54,7 +60,9 @@ pub struct PurgedSubject {
 /// lockout key is derived from it with the same normalization the login flow
 /// uses. Refresh-owned families cascade through [`grants::revoke`]; the
 /// grant's member `:act` sidecars are removed alongside because the revoke
-/// script deletes member keys only.
+/// script deletes member keys only. JWT allowlist markers are swept directly
+/// by their `subject` field in the same call (counted in
+/// [`PurgedSubject::jwt_markers`)).
 ///
 /// # Errors
 ///
@@ -196,6 +204,18 @@ pub async fn purge_subject(
     }
   }
 
+  // JWT allowlist markers carry the same `subject` hash field as the other
+  // records (empty for client-credentials tokens), so the subject sweep
+  // deletes them directly: grant-bound markers are additionally covered by
+  // the per-grant cascade above, but session-bound and client-credentials
+  // markers are not, and the cascade must never be the only path.
+  for key in scan_keys(conn, &store.jwt_key_prefix()).await? {
+    if is_subject(&read_fields(conn, &key).await?, email, user_id) {
+      del_key(conn, &key).await?;
+      summary.jwt_markers += 1;
+    }
+  }
+
   Ok(summary)
 }
 
@@ -556,6 +576,7 @@ mod tests {
       grants: 1,
       access: 1,
       refresh: 1,
+      jwt_markers: 0,
     };
     if summary != expected {
       return Err(TestError::Unexpected(format!(
@@ -589,6 +610,65 @@ mod tests {
     Ok(())
   }
 
+  /// `test(purge_subject_deletes_jwt_markers)`: the subject sweep deletes
+  /// `jwt:<jti-hash>` marker hashes whose `subject` field names the user and
+  /// bumps `jwt_markers`, while a marker naming another subject survives.
+  /// Grant-bound markers are additionally covered by the per-grant cascade,
+  /// but this sweep must not rely on it (client-credentials markers carry an
+  /// empty subject and session-bound markers may name no grant).
+  #[tokio::test]
+  async fn purge_subject_deletes_jwt_markers() -> TestResult {
+    let (mut conn, store) = harness().await?;
+    let email = "jwt-purge@example.test";
+    let user_id = uuid::Uuid::new_v4().to_string();
+
+    let marker_key = store.jwt_marker_key("purgejtihash");
+    hset(
+      &mut conn,
+      &marker_key,
+      &[
+        ("grant_id", "grant-jwt-purge"),
+        ("client_id", "client-jwt-purge"),
+        ("subject", email),
+      ],
+    )
+    .await?;
+    let id_marker_key = store.jwt_marker_key("purgejtihashbyid");
+    hset(
+      &mut conn,
+      &id_marker_key,
+      &[
+        ("grant_id", "grant-jwt-purge-id"),
+        ("client_id", "client-jwt-purge"),
+        ("subject", user_id.as_str()),
+      ],
+    )
+    .await?;
+    let decoy_marker_key = store.jwt_marker_key("decoyjtihash");
+    hset(
+      &mut conn,
+      &decoy_marker_key,
+      &[
+        ("grant_id", "grant-decoy"),
+        ("client_id", "client-decoy"),
+        ("subject", "someone-else@example.test"),
+      ],
+    )
+    .await?;
+
+    let summary = purge_subject(&mut conn, &store, email, &user_id).await?;
+    if summary.jwt_markers != 2 {
+      return Err(TestError::Unexpected(format!(
+        "the purge must delete both subject markers, got {}",
+        summary.jwt_markers
+      )));
+    }
+    require_gone(&mut conn, &marker_key).await?;
+    require_gone(&mut conn, &id_marker_key).await?;
+    require_present(&mut conn, &decoy_marker_key).await?;
+    Ok(())
+  }
+
   #[tokio::test]
   async fn purge_subject_with_no_matching_state_returns_zero() -> TestResult {
     let (mut conn, store) = harness().await?;

+ 30 - 15
src/protocol/redirect_repo.rs

@@ -4,13 +4,16 @@
 //! character, no normalization) plus the RFC 8252 §7.3 loopback exception:
 //! a registration whose scheme is `http` and whose host is the loopback
 //! literal `127.0.0.1` or `[::1]` matches a request with the same
-//! scheme+host+path and any port.
+//! scheme+host+path and any port. When `allow_localhost` is set (config
+//! `allow_localhost_redirect`), the exact host `localhost` joins that
+//! exception; it is not treated as loopback otherwise.
 //!
 //! Fail-closed: a registered URI carrying userinfo, a query, or a fragment
 //! is never eligible, plaintext `http` to a non-loopback host is refused,
-//! `localhost` is not treated as loopback, and matching never falls back to
-//! prefix/wildcard/substring comparison. A [`RedirectDecision::Reject`] means
-//! the authorization request MUST fail without redirecting the user agent.
+//! `localhost` is loopback only under the explicit flag, and matching never
+//! falls back to prefix/wildcard/substring comparison. A
+//! [`RedirectDecision::Reject`] means the authorization request MUST fail
+//! without redirecting the user agent.
 //!
 //! The effective (attached + enabled) set is owned by `db::redirect_repo`;
 //! this module is pure and takes that set as a slice of strings.
@@ -23,7 +26,8 @@ pub enum RedirectDecision {
   /// character-for-character identical to a registered URI.
   Exact,
   /// RFC 8252 §7.3 loopback match: the registered URI is plaintext `http`
-  /// to a loopback literal (`127.0.0.1` or `[::1]`) and the requested URI
+  /// to a loopback host (`127.0.0.1`, `[::1]`, or — only under
+  /// `allow_localhost` — the exact host `localhost`) and the requested URI
   /// has the same scheme, host, and path, with the port ignored.
   Loopback,
   /// No registered URI is an acceptable match. The request MUST be rejected
@@ -43,8 +47,17 @@ pub enum RedirectDecision {
 /// A requested or registered URI whose private-use scheme lacks reverse-DNS
 /// dot notation (draft-ietf-oauth-v2-1-16 §2.3.1, SHOULD-level) never
 /// matches: the request is rejected without redirecting the user agent.
+///
+/// `allow_localhost` maps to config `allow_localhost_redirect`: when true the
+/// exact host `localhost` is an RFC 8252 §7.3 loopback host; when false it is
+/// not loopback and plaintext `http://localhost` registrations and requests
+/// stay rejected.
 #[must_use]
-pub fn match_redirect_uri<S: AsRef<str>>(registered: &[S], requested: &str) -> RedirectDecision {
+pub fn match_redirect_uri<S: AsRef<str>>(
+  registered: &[S],
+  requested: &str,
+  allow_localhost: bool,
+) -> RedirectDecision {
   if !is_valid_redirect_scheme(requested) {
     return RedirectDecision::Reject;
   }
@@ -66,8 +79,8 @@ pub fn match_redirect_uri<S: AsRef<str>>(registered: &[S], requested: &str) -> R
       continue;
     }
 
-    // Plaintext http is only ever acceptable to a loopback literal.
-    if is_http(reg.scheme) && !is_loopback_host(reg.host) {
+    // Plaintext http is only ever acceptable to a loopback host.
+    if is_http(reg.scheme) && !is_loopback_host(reg.host, allow_localhost) {
       continue;
     }
 
@@ -79,10 +92,10 @@ pub fn match_redirect_uri<S: AsRef<str>>(registered: &[S], requested: &str) -> R
     // RFC 8252 §7.3 loopback: ignore the port, require scheme+host+path, and
     // refuse a request that smuggles userinfo, a query, or a fragment.
     if is_http(reg.scheme)
-      && is_loopback_host(reg.host)
+      && is_loopback_host(reg.host, allow_localhost)
       && let Some(req) = requested_parts.as_ref()
       && is_http(req.scheme)
-      && is_loopback_host(req.host)
+      && is_loopback_host(req.host, allow_localhost)
       && reg.host == req.host
       && reg.path == req.path
       && req.userinfo.is_none()
@@ -219,12 +232,14 @@ pub fn is_valid_redirect_scheme(uri: &str) -> bool {
   scheme.contains('.')
 }
 
-/// The only hosts eligible for the RFC 8252 §7.3 loopback exception.
+/// Hosts eligible for the RFC 8252 §7.3 loopback exception.
 ///
-/// `localhost` is deliberately absent: hostname resolution is
-/// attacker-influenced, so the plan permits loopback literal IPs only.
-fn is_loopback_host(host: &str) -> bool {
-  host == "127.0.0.1" || host == "[::1]"
+/// The literal IPs `127.0.0.1` and `[::1]` are always loopback. The exact
+/// host `localhost` is loopback only when `allow_localhost` is set: hostname
+/// resolution is attacker-influenced (RFC 8252 §8.3), so accepting it is an
+/// explicit operator opt-in, never a default.
+fn is_loopback_host(host: &str, allow_localhost: bool) -> bool {
+  host == "127.0.0.1" || host == "[::1]" || (allow_localhost && host == "localhost")
 }
 
 #[cfg(test)]

+ 114 - 23
src/protocol/redirect_repo/tests.rs

@@ -6,7 +6,7 @@ use super::{RedirectDecision, is_valid_redirect_scheme, match_redirect_uri};
 fn redirect_uri_exact_string_match_is_accepted() {
   let registered = ["https://client.example.test/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/cb"),
+    match_redirect_uri(&registered, "https://client.example.test/cb", false),
     RedirectDecision::Exact
   );
 }
@@ -15,13 +15,13 @@ fn redirect_uri_exact_string_match_is_accepted() {
 fn redirect_uri_trailing_slash_mismatch_is_rejected() {
   let registered = ["https://client.example.test/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/cb/"),
+    match_redirect_uri(&registered, "https://client.example.test/cb/", false),
     RedirectDecision::Reject
   );
 
   let registered = ["https://client.example.test/cb/"];
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/cb"),
+    match_redirect_uri(&registered, "https://client.example.test/cb", false),
     RedirectDecision::Reject
   );
 }
@@ -30,13 +30,13 @@ fn redirect_uri_trailing_slash_mismatch_is_rejected() {
 fn redirect_uri_loopback_ipv4_port_variance_is_accepted() {
   let registered = ["http://127.0.0.1:5173/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb"),
+    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb", false),
     RedirectDecision::Loopback
   );
 
   let registered = ["http://127.0.0.1/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb"),
+    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb", false),
     RedirectDecision::Loopback
   );
 }
@@ -45,7 +45,7 @@ fn redirect_uri_loopback_ipv4_port_variance_is_accepted() {
 fn redirect_uri_loopback_ipv6_port_variance_is_accepted() {
   let registered = ["http://[::1]:5173/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "http://[::1]:49152/cb"),
+    match_redirect_uri(&registered, "http://[::1]:49152/cb", false),
     RedirectDecision::Loopback
   );
 }
@@ -54,7 +54,7 @@ fn redirect_uri_loopback_ipv6_port_variance_is_accepted() {
 fn redirect_uri_loopback_same_port_is_exact() {
   let registered = ["http://127.0.0.1:5173/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "http://127.0.0.1:5173/cb"),
+    match_redirect_uri(&registered, "http://127.0.0.1:5173/cb", false),
     RedirectDecision::Exact
   );
 }
@@ -63,34 +63,125 @@ fn redirect_uri_loopback_same_port_is_exact() {
 fn redirect_uri_non_loopback_http_is_rejected() {
   let registered = ["http://evil.example/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "http://evil.example/cb"),
+    match_redirect_uri(&registered, "http://evil.example/cb", false),
     RedirectDecision::Reject
   );
 
   let registered = ["HTTP://evil.example/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "HTTP://evil.example/cb"),
+    match_redirect_uri(&registered, "HTTP://evil.example/cb", false),
     RedirectDecision::Reject
   );
 
   let registered = ["https://client.example.test/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "https://evil.example/cb"),
+    match_redirect_uri(&registered, "https://evil.example/cb", false),
     RedirectDecision::Reject
   );
 }
 
+/// With `allow_localhost` off, `localhost` is not loopback: it is rejected as
+/// a registration and cannot satisfy a loopback request. This must stay
+/// exactly as it was before the flag existed.
 #[test]
 fn redirect_uri_localhost_is_rejected() {
   let registered = ["http://localhost:8080/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "http://localhost:8080/cb"),
+    match_redirect_uri(&registered, "http://localhost:8080/cb", false),
     RedirectDecision::Reject
   );
 
   let registered = ["http://127.0.0.1/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "http://localhost:49152/cb"),
+    match_redirect_uri(&registered, "http://localhost:49152/cb", false),
+    RedirectDecision::Reject
+  );
+}
+
+/// With `allow_localhost` on, `http://localhost` is an RFC 8252 §7.3 loopback
+/// host: host+path must match exactly and the port is ignored. `127.0.0.1`
+/// and `[::1]` are unaffected by the flag.
+#[test]
+fn redirect_uri_localhost_port_variance_is_accepted() {
+  let registered = ["http://localhost:5173/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://localhost:49152/cb", true),
+    RedirectDecision::Loopback
+  );
+
+  let registered = ["http://localhost/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://localhost:49152/cb", true),
+    RedirectDecision::Loopback
+  );
+
+  // Same port is still an exact match.
+  let registered = ["http://localhost:5173/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://localhost:5173/cb", true),
+    RedirectDecision::Exact
+  );
+
+  // A path mismatch is never matched, port variance or not.
+  let registered = ["http://localhost:5173/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://localhost:49152/other", true),
+    RedirectDecision::Reject
+  );
+
+  // The literal IPs keep their pre-flag behavior when the flag is on.
+  let registered = ["http://127.0.0.1:5173/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb", true),
+    RedirectDecision::Loopback
+  );
+}
+
+/// The flag only widens the loopback exception to the exact host
+/// `localhost`; a different host, a suffix confusion, or a request smuggling
+/// userinfo/query/fragment is still rejected.
+#[test]
+fn redirect_uri_localhost_non_loopback_still_rejected() {
+  // A non-loopback host is refused even with the flag on.
+  let registered = ["http://evil.example/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://evil.example/cb", true),
+    RedirectDecision::Reject
+  );
+
+  // A suffix that merely contains `localhost` is not the exact host.
+  let registered = ["http://localhost.evil.example/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://localhost.evil.example/cb", true),
+    RedirectDecision::Reject
+  );
+
+  // A registered localhost URI does not match a different requested host.
+  let registered = ["http://localhost:5173/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb", true),
+    RedirectDecision::Reject
+  );
+
+  // Loopback port variance never excuses a query fragment on the request.
+  let registered = ["http://localhost:5173/cb"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://localhost:49152/cb?x=1", true),
+    RedirectDecision::Reject
+  );
+  assert_eq!(
+    match_redirect_uri(&registered, "http://localhost:49152/cb#frag", true),
+    RedirectDecision::Reject
+  );
+  assert_eq!(
+    match_redirect_uri(&registered, "http://user@localhost:49152/cb", true),
+    RedirectDecision::Reject
+  );
+
+  // A registered URI that smuggles userinfo/query/fragment is never eligible.
+  let registered = ["http://localhost:5173/cb?x=1"];
+  assert_eq!(
+    match_redirect_uri(&registered, "http://localhost:49152/cb", true),
     RedirectDecision::Reject
   );
 }
@@ -104,7 +195,7 @@ fn redirect_uri_registered_query_fragment_userinfo_are_rejected() {
   ] {
     let requested = registered[0];
     assert_eq!(
-      match_redirect_uri(&registered, requested),
+      match_redirect_uri(&registered, requested, false),
       RedirectDecision::Reject,
       "malformed registration must never match: {requested}"
     );
@@ -115,7 +206,7 @@ fn redirect_uri_registered_query_fragment_userinfo_are_rejected() {
 fn redirect_uri_loopback_request_with_query_is_rejected() {
   let registered = ["http://127.0.0.1:5173/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb?x=1"),
+    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb?x=1", false),
     RedirectDecision::Reject
   );
 }
@@ -124,15 +215,15 @@ fn redirect_uri_loopback_request_with_query_is_rejected() {
 fn redirect_uri_prefix_and_substring_are_not_matched() {
   let registered = ["https://client.example.test/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/cb/evil"),
+    match_redirect_uri(&registered, "https://client.example.test/cb/evil", false),
     RedirectDecision::Reject
   );
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/c"),
+    match_redirect_uri(&registered, "https://client.example.test/c", false),
     RedirectDecision::Reject
   );
   assert_eq!(
-    match_redirect_uri(&registered, "https://sub.client.example.test/cb"),
+    match_redirect_uri(&registered, "https://sub.client.example.test/cb", false),
     RedirectDecision::Reject
   );
 }
@@ -141,7 +232,7 @@ fn redirect_uri_prefix_and_substring_are_not_matched() {
 fn redirect_uri_empty_set_is_rejected() {
   let registered: [&str; 0] = [];
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/cb"),
+    match_redirect_uri(&registered, "https://client.example.test/cb", false),
     RedirectDecision::Reject
   );
 }
@@ -150,7 +241,7 @@ fn redirect_uri_empty_set_is_rejected() {
 fn redirect_uri_exact_takes_precedence_over_loopback() {
   let registered = ["http://127.0.0.1:5173/cb", "http://127.0.0.1:49152/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb"),
+    match_redirect_uri(&registered, "http://127.0.0.1:49152/cb", false),
     RedirectDecision::Exact
   );
 }
@@ -161,13 +252,13 @@ fn redirect_uri_private_scheme_without_dot_is_rejected() {
   // reverse-DNS based, so it must contain a `.`.
   let registered = ["myapp:/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "myapp:/cb"),
+    match_redirect_uri(&registered, "myapp:/cb", false),
     RedirectDecision::Reject
   );
 
   let registered = ["myapp:/"];
   assert_eq!(
-    match_redirect_uri(&registered, "myapp:/"),
+    match_redirect_uri(&registered, "myapp:/", false),
     RedirectDecision::Reject
   );
 }
@@ -176,13 +267,13 @@ fn redirect_uri_private_scheme_without_dot_is_rejected() {
 fn redirect_uri_private_scheme_with_dot_is_accepted() {
   let registered = ["com.example.app:/cb"];
   assert_eq!(
-    match_redirect_uri(&registered, "com.example.app:/cb"),
+    match_redirect_uri(&registered, "com.example.app:/cb", false),
     RedirectDecision::Exact
   );
 
   let registered = ["com.example.app:/"];
   assert_eq!(
-    match_redirect_uri(&registered, "com.example.app:/"),
+    match_redirect_uri(&registered, "com.example.app:/", false),
     RedirectDecision::Exact
   );
 }

+ 118 - 3
src/protocol/revoke.rs

@@ -36,12 +36,13 @@ use super::{
 };
 use crate::{
   error::AppError,
+  jose::jwt_access::{JwtAccessSigner, is_jwt_shape, jti_hash},
   middleware::{
     audit::AuditEvent,
     request_id::{REQUEST_ID_HEADER, RequestId},
   },
   state::{AppState, RedisConn},
-  store::Store,
+  store::{Store, StoreError, durability_barrier},
   tls::PeerCertificates,
 };
 
@@ -75,6 +76,10 @@ pub struct RevokeContext<'a> {
   pub token_endpoint: &'a str,
   /// Current time for client-secret expiry enforcement.
   pub now: OffsetDateTime,
+  /// CIMD resolution settings for a registration-free fallback; `None` when
+  /// `config.cimd_enabled` is off (RFC 7009 §2.1: a registration-free
+  /// client revokes its own token through this same path).
+  pub cimd: Option<crate::protocol::cimd::CimdSettings<'a>>,
   /// Correlating request id for the audit event, when present.
   pub request_id: Option<&'a str>,
 }
@@ -117,6 +122,11 @@ struct OwnedToken<'a> {
 /// secret, client assertion, or `client_id`) is `401` + `WWW-Authenticate`,
 /// because the endpoint is a protected resource (RFC 7009 §2.1).
 ///
+/// `jwt_signer` carries the AS access-token signer for the JWT allowlist path
+/// (built from the config by the handler only when the presented token has
+/// JWT shape and the operator switch is on); `None` leaves a JWT-shaped token
+/// as a silent no-op. It is never consulted on the opaque path.
+///
 /// # Errors
 ///
 /// Returns `invalid_request` for a missing token, `invalid_client` for failed
@@ -125,6 +135,7 @@ pub async fn revoke_tokens(
   stores: &mut RevokeStores<'_>,
   ctx: &RevokeContext<'_>,
   request: &RevokeRequest<'_>,
+  jwt_signer: Option<&JwtAccessSigner>,
 ) -> Result<RevokeOutcome, AppError> {
   if request.token.is_empty() {
     return Err(AppError::invalid_request("token is required"));
@@ -142,6 +153,7 @@ pub async fn revoke_tokens(
     let auth_ctx = AuthContext {
       token_endpoint: ctx.token_endpoint,
       now: ctx.now,
+      cimd: ctx.cimd,
     };
     authenticate_client(&mut auth_stores, &auth_ctx, request.auth).await?
   };
@@ -151,7 +163,14 @@ pub async fn revoke_tokens(
     token: request.token,
     token_type_hint: request.token_type_hint,
   };
-  let outcome = revoke_owned(stores.conn, stores.store, stores.service, &owned).await?;
+  let outcome = revoke_owned(
+    stores.conn,
+    stores.store,
+    stores.service,
+    &owned,
+    jwt_signer,
+  )
+  .await?;
   if matches!(outcome, RevokeOutcome::Revoked) {
     AuditEvent::TokenRevoke {
       request_id: ctx.request_id,
@@ -176,11 +195,21 @@ fn is_unauthenticated(auth: &ClientAuthRequest<'_>) -> bool {
 /// lookup is uniform; a foreign owner returns [`RevokeOutcome::NoOp`] without
 /// touching the store. A refresh token revokes the whole grant chain; an access
 /// token revokes only itself.
+///
+/// A JWT-shaped token (RFC 9068 §4 allowlist) misses both opaque slots by
+/// construction and falls through to [`revoke_jwt_owned`]: signature plus
+/// `iss` are verified, ownership is enforced from the verified `client_id`
+/// (and the marker's own `client_id` field), and only then are the marker and
+/// the access record/member deleted. Every miss — malformed, foreign-key,
+/// expired, wrong-issuer, foreign-owner, or already revoked — collapses to
+/// [`RevokeOutcome::NoOp`] (uniform HTTP 200 per RFC 7009 §2.2); a foreign
+/// token is never revoked.
 async fn revoke_owned(
   conn: &mut RedisConn,
   store: &Store,
   service: &TokenService,
   owned: &OwnedToken<'_>,
+  jwt_signer: Option<&JwtAccessSigner>,
 ) -> Result<RevokeOutcome, AppError> {
   match owned.token_type_hint.filter(|hint| !hint.is_empty()) {
     None | Some(HINT_ACCESS | HINT_REFRESH) => {}
@@ -206,9 +235,78 @@ async fn revoke_owned(
       .await?;
     return Ok(RevokeOutcome::Revoked);
   }
+  if is_jwt_shape(owned.token) {
+    return revoke_jwt_owned(conn, store, owned, jwt_signer).await;
+  }
   Ok(RevokeOutcome::NoOp)
 }
 
+/// Revoke a JWT access token owned by `owner_client_id` (RFC 7009 §2.1).
+///
+/// The token must verify under the AS signer (header `typ`/`alg`/`kid`,
+/// signature, `iss`, `exp`; `expected_aud` is `None` because revocation is
+/// audience-agnostic) and the verified `client_id` must equal the caller. The
+/// `jwt:<jti-hash>` marker is then read: an absent marker means the token is
+/// already revoked (`NoOp`), and a marker whose `client_id` names another
+/// client is never touched (`NoOp`). Otherwise the marker hash, the
+/// `access:jwt:<jti-hash>` record, and the grant-set member are deleted — the
+/// honest equivalent of [`TokenService::revoke_token`] for the member identity
+/// `jwt:<jti-hash>` (a `SREM` from the grant set plus `DEL` of the record
+/// key), barriered before returning.
+async fn revoke_jwt_owned(
+  conn: &mut RedisConn,
+  store: &Store,
+  owned: &OwnedToken<'_>,
+  jwt_signer: Option<&JwtAccessSigner>,
+) -> Result<RevokeOutcome, AppError> {
+  let signer = match jwt_signer {
+    Some(signer) => signer,
+    None => return Ok(RevokeOutcome::NoOp),
+  };
+  let verified = match signer.verify(owned.token, None) {
+    Ok(verified) => verified,
+    Err(_) => return Ok(RevokeOutcome::NoOp),
+  };
+  if verified.claims.client_id != owned.owner_client_id {
+    return Ok(RevokeOutcome::NoOp);
+  }
+  let hash = jti_hash(&verified.claims.jti);
+  let marker_key = store.jwt_marker_key(&hash);
+  let fields: HashMap<String, String> = redis::cmd("HGETALL")
+    .arg(&marker_key)
+    .query_async(conn)
+    .await
+    .map_err(StoreError::Redis)?;
+  if fields.is_empty() {
+    return Ok(RevokeOutcome::NoOp);
+  }
+  if fields.get("client_id").map(String::as_str) != Some(owned.owner_client_id) {
+    return Ok(RevokeOutcome::NoOp);
+  }
+  let member = format!("jwt:{hash}");
+  let record_key = store.access_key(&member);
+  let grant_id = fields.get("grant_id").cloned().unwrap_or_default();
+  let set_key = store.grant_tokens_key(&grant_id);
+  let _: () = redis::cmd("DEL")
+    .arg(&record_key)
+    .arg(&marker_key)
+    .query_async(conn)
+    .await
+    .map_err(StoreError::Redis)?;
+  if !grant_id.is_empty() {
+    let _: () = redis::cmd("SREM")
+      .arg(&set_key)
+      .arg(&member)
+      .query_async(conn)
+      .await
+      .map_err(StoreError::Redis)?;
+  }
+  durability_barrier(conn, store)
+    .await
+    .map_err(AppError::from)?;
+  Ok(RevokeOutcome::Revoked)
+}
+
 /// Render a revocation outcome: `200 OK` with an empty body for success and
 /// no-ops, `400` with `unsupported_token_type` for a rejected hint.
 fn outcome_response(outcome: RevokeOutcome) -> Response {
@@ -293,6 +391,15 @@ pub async fn revoke_handler(
   let ctx = RevokeContext {
     token_endpoint: &token_endpoint,
     now: state.clock.now(),
+    cimd: state
+      .config
+      .cimd_enabled
+      .then(|| crate::protocol::cimd::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,
+      }),
     request_id: request_id.as_ref().map(RequestId::as_str),
   };
   let request = RevokeRequest {
@@ -300,7 +407,15 @@ pub async fn revoke_handler(
     token_type_hint: form.get("token_type_hint").map(String::as_str),
     auth: &auth,
   };
-  let outcome = revoke_tokens(&mut stores, &ctx, &request).await?;
+  // The signer is built only on the JWT-shaped path: opaque revocations never
+  // touch the signing config. A missing key or a disabled switch leaves a
+  // JWT-shaped token as a silent no-op.
+  let jwt_signer = if is_jwt_shape(request.token) && state.config.jwt_access_tokens_enabled {
+    JwtAccessSigner::from_config(&state.config).ok()
+  } else {
+    None
+  };
+  let outcome = revoke_tokens(&mut stores, &ctx, &request, jwt_signer.as_ref()).await?;
   Ok(outcome_response(outcome))
 }
 

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

@@ -8,6 +8,7 @@
 
 mod auth;
 mod cascade;
+mod jwt;
 mod mtls;
 
 use std::{
@@ -25,7 +26,7 @@ use crate::{
   keys::{KeyId, KeyRing, KeyRingError},
   protocol::{
     client_auth::ClientAuthRequest,
-    token::{ClientKind, MintRequest, SenderProof, TokenService, TokenServiceError},
+    token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService, TokenServiceError},
   },
   state::{AppState, Clock, RedisConn},
   store::{Store, StoreError},
@@ -179,10 +180,14 @@ fn mint_request(client: &str, grant: &str) -> MintRequest {
   MintRequest {
     client_id: client.to_owned(),
     client_kind: ClientKind::Confidential,
+    dpop_required: false,
     subject: "user-1".to_owned(),
     scope: "orders:read".to_owned(),
     grant_id: grant.to_owned(),
     proof: SenderProof::none(),
+    aud: Vec::new(),
+    refresh_aud: Vec::new(),
+    format: TokenFormat::Opaque,
   }
 }
 

+ 4 - 2
src/protocol/revoke/tests/auth.rs

@@ -39,6 +39,7 @@ async fn revoke_unauthenticated_request_is_401() -> TestResult {
   let ctx = RevokeContext {
     token_endpoint: "https://auth.example.test/token",
     now: time::OffsetDateTime::now_utc(),
+    cimd: None,
     request_id: Some("req-revoke-test"),
   };
   let request = RevokeRequest {
@@ -47,7 +48,7 @@ async fn revoke_unauthenticated_request_is_401() -> TestResult {
     auth: &auth,
   };
 
-  match revoke_tokens(&mut stores, &ctx, &request).await {
+  match revoke_tokens(&mut stores, &ctx, &request, None).await {
     Err(error) => {
       if error.code() != OAuthErrorCode::InvalidClient {
         return Err(TestError::Unexpected(
@@ -96,6 +97,7 @@ async fn revoke_missing_token_is_invalid_request() -> TestResult {
   let ctx = RevokeContext {
     token_endpoint: "https://auth.example.test/token",
     now: time::OffsetDateTime::now_utc(),
+    cimd: None,
     request_id: None,
   };
   let request = RevokeRequest {
@@ -103,7 +105,7 @@ async fn revoke_missing_token_is_invalid_request() -> TestResult {
     token_type_hint: None,
     auth: &auth,
   };
-  match revoke_tokens(&mut stores, &ctx, &request).await {
+  match revoke_tokens(&mut stores, &ctx, &request, None).await {
     Err(error) if error.code() == OAuthErrorCode::InvalidRequest => Ok(()),
     Err(_) | Ok(_) => Err(TestError::Unexpected(
       "missing token must be invalid_request".to_owned(),

+ 7 - 0
src/protocol/revoke/tests/cascade.rs

@@ -36,6 +36,7 @@ async fn revoke_access_token_removes_only_it() -> TestResult {
       token: &minted.access_token,
       token_type_hint: Some(HINT_ACCESS),
     },
+    None,
   )
   .await?;
   if outcome != RevokeOutcome::Revoked {
@@ -89,6 +90,7 @@ async fn revoke_refresh_token_cascades_to_grant_chain() -> TestResult {
       token: &minted.refresh_token,
       token_type_hint: Some(HINT_REFRESH),
     },
+    None,
   )
   .await?;
   if outcome != RevokeOutcome::Revoked {
@@ -131,6 +133,7 @@ async fn revoke_unknown_token_is_noop_200() -> TestResult {
       token: "no-such-token",
       token_type_hint: None,
     },
+    None,
   )
   .await?;
   if outcome != RevokeOutcome::NoOp {
@@ -169,6 +172,7 @@ async fn revoke_foreign_token_is_noop_and_untouched() -> TestResult {
       token: &minted.access_token,
       token_type_hint: Some(HINT_ACCESS),
     },
+    None,
   )
   .await?;
   if outcome != RevokeOutcome::NoOp {
@@ -202,6 +206,7 @@ async fn revoke_unsupported_hint_is_rejected() -> TestResult {
       token: "some-token",
       token_type_hint: Some("password"),
     },
+    None,
   )
   .await?;
   if outcome != RevokeOutcome::UnsupportedHint {
@@ -252,6 +257,7 @@ async fn revoke_refresh_hint_on_access_token_still_revokes_it() -> TestResult {
       token: &minted.access_token,
       token_type_hint: Some(HINT_REFRESH),
     },
+    None,
   )
   .await?;
   if outcome != RevokeOutcome::Revoked {
@@ -306,6 +312,7 @@ async fn revoke_foreign_refresh_token_is_noop_and_untouched() -> TestResult {
       token: &minted.refresh_token,
       token_type_hint: Some(HINT_REFRESH),
     },
+    None,
   )
   .await?;
   if outcome != RevokeOutcome::NoOp {

+ 724 - 0
src/protocol/revoke/tests/jwt.rs

@@ -0,0 +1,724 @@
+//! `test(jwt_)` revocation suite: direct `/revoke` of JWT access tokens.
+//!
+//! The opaque probes (`find_access`/`find_refresh`) miss dotted JWTs by
+//! construction, so without the JWT branch every test below collapses to a
+//! silent `NoOp` with the marker untouched. Each test pins the allowlist
+//! behavior: ownership-scoped deletion of the literal `jwt:<jti-hash>` marker
+//! plus the `access:jwt:<jti-hash>` record/member, uniform HTTP 200, and no
+//! foreign revocation.
+
+use std::sync::Arc;
+
+use axum::{
+  Router,
+  body::Body,
+  http::{Request, StatusCode, header},
+  routing::post,
+};
+use base64::Engine as _;
+use http_body_util::BodyExt as _;
+use secrecy::SecretString;
+use serde_json::Value;
+use sqlx::PgPool;
+use tower::ServiceExt as _;
+
+use super::{TestError, TestResult, now_unix, service, test_redis, unique};
+use crate::{
+  config::{DurabilityMode, JwtSigningAlg, JwtSigningKey},
+  crypto::secret::{Argon2Params, hash_secret},
+  db::{
+    NewClient, TokenEndpointAuthMethod, client_repo,
+    test_support::migrated_pool,
+    user_repo::{self, NewUser},
+  },
+  jose::jwt_access::{JwtAccessSigner, jti_hash, marker_live, marker_live_strict},
+  keys::KeyId,
+  protocol::{
+    introspect,
+    revoke::{OwnedToken, RevokeOutcome, outcome_response, revoke_owned},
+    token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService},
+  },
+  state::{AppState, Clock, RedisConn},
+  store::Store,
+  test_guard::Lease,
+};
+
+/// A fixed 32-byte ASCII signing key (the e2e JWT fixture material).
+const JWT_TEST_KEY: &str = "bcdefghijklmnopqrstuvwxyzabcdefg";
+
+/// A foreign 32-byte ASCII signing key: valid shape, unknown `kid`.
+const JWT_FOREIGN_KEY: &str = "qcdefghijklmnopqrstuvwxyzabcdefg";
+
+/// Issuer the test signer asserts.
+const TEST_ISSUER: &str = "https://jwt-revoke.example.test";
+
+/// A test signer over fixed key material.
+fn test_signer() -> Result<JwtAccessSigner, TestError> {
+  test_signer_with(JWT_TEST_KEY, "test-kid")
+}
+
+/// A test signer over explicit key material and `kid`.
+fn test_signer_with(raw: &str, kid: &str) -> Result<JwtAccessSigner, TestError> {
+  let key = JwtSigningKey::from_raw(
+    KeyId::new(kid),
+    JwtSigningAlg::Es256,
+    &SecretString::from(raw),
+    "jwt_signing_key",
+  )
+  .map_err(|error| TestError::Unexpected(format!("signing key rejected: {error}")))?;
+  Ok(JwtAccessSigner::new(
+    TEST_ISSUER.to_owned(),
+    key,
+    Vec::new(),
+  ))
+}
+
+/// A service wired with the JWT signer.
+fn jwt_service(signer: JwtAccessSigner) -> Result<TokenService, TestError> {
+  service().map(|service| service.with_jwt_signer(signer))
+}
+
+/// A confidential-client JWT mint request with a fixed subject/scope.
+fn jwt_mint_request(client: &str, grant: &str, subject: &str) -> MintRequest {
+  MintRequest {
+    client_id: client.to_owned(),
+    client_kind: ClientKind::Confidential,
+    dpop_required: false,
+    subject: subject.to_owned(),
+    scope: "orders:read".to_owned(),
+    grant_id: grant.to_owned(),
+    proof: SenderProof::none(),
+    aud: Vec::new(),
+    refresh_aud: Vec::new(),
+    format: TokenFormat::Jwt,
+  }
+}
+
+/// Mint one JWT for `client` and return the raw token plus its `jti` hash.
+async fn mint_jwt(
+  conn: &mut RedisConn,
+  store: &Store,
+  service: &TokenService,
+  signer: &JwtAccessSigner,
+  client: &str,
+  subject: &str,
+) -> Result<(String, String), TestError> {
+  let minted = service
+    .mint(
+      conn,
+      store,
+      &jwt_mint_request(client, &unique("grant"), subject),
+      now_unix(),
+    )
+    .await?;
+  if minted.access_token.split('.').count() != 3 {
+    return Err(TestError::Unexpected(
+      "a jwt mint must return three JOSE segments".to_owned(),
+    ));
+  }
+  let verified = signer
+    .verify(&minted.access_token, None)
+    .map_err(|error| TestError::Unexpected(format!("fresh jwt rejected: {error:?}")))?;
+  Ok((minted.access_token, jti_hash(&verified.claims.jti)))
+}
+
+/// `test(jwt_direct_revoke_deletes_marker)`: the owning client revokes its JWT
+/// through the `/revoke` core path — `Revoked` plus HTTP 200, the LITERAL
+/// `jwt:<jti-hash>` marker key GONE (not merely the member set), the
+/// `access:jwt:<jti-hash>` record gone, and the token no longer live. A second
+/// revoke of the same token is still HTTP 200 (`NoOp`).
+#[tokio::test]
+async fn jwt_direct_revoke_deletes_marker() -> TestResult {
+  let (mut conn, store, _prefixes) = test_redis().await?;
+  let signer = test_signer()?;
+  let service = jwt_service(signer.clone())?;
+  let client = unique("jwt-direct-client");
+  let (token, hash) = mint_jwt(&mut conn, &store, &service, &signer, &client, "user-1").await?;
+  let marker_key = store.jwt_marker_key(&hash);
+  let member = format!("jwt:{hash}");
+
+  if !marker_live(&mut conn, &store, &hash)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("marker check failed: {error:?}")))?
+  {
+    return Err(TestError::Unexpected(
+      "the fresh mint must leave a live marker".to_owned(),
+    ));
+  }
+
+  let outcome = revoke_owned(
+    &mut conn,
+    &store,
+    &service,
+    &OwnedToken {
+      owner_client_id: &client,
+      token: &token,
+      token_type_hint: None,
+    },
+    Some(&signer),
+  )
+  .await?;
+  if outcome != RevokeOutcome::Revoked {
+    return Err(TestError::Unexpected(
+      "the owning client must revoke its own jwt".to_owned(),
+    ));
+  }
+  if outcome_response(outcome).status() != StatusCode::OK {
+    return Err(TestError::Unexpected(
+      "a revocation must render 200".to_owned(),
+    ));
+  }
+
+  let marker_exists: bool = redis::cmd("EXISTS")
+    .arg(&marker_key)
+    .query_async(&mut conn)
+    .await?;
+  if marker_exists {
+    return Err(TestError::Unexpected(format!(
+      "direct revoke must delete the literal marker key {marker_key}"
+    )));
+  }
+  let record_exists: bool = redis::cmd("EXISTS")
+    .arg(store.access_key(&member))
+    .query_async(&mut conn)
+    .await?;
+  if record_exists {
+    return Err(TestError::Unexpected(
+      "direct revoke must delete the access record".to_owned(),
+    ));
+  }
+  let live_after: bool = marker_live(&mut conn, &store, &hash)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("marker check failed: {error:?}")))?;
+  if live_after {
+    return Err(TestError::Unexpected(
+      "the marker must read dead after direct revoke".to_owned(),
+    ));
+  }
+  if marker_live_strict(&mut conn, &store, &hash)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("strict check failed: {error:?}")))?
+  {
+    return Err(TestError::Unexpected(
+      "the membership-bound check must read dead after direct revoke".to_owned(),
+    ));
+  }
+
+  let second = revoke_owned(
+    &mut conn,
+    &store,
+    &service,
+    &OwnedToken {
+      owner_client_id: &client,
+      token: &token,
+      token_type_hint: None,
+    },
+    Some(&signer),
+  )
+  .await?;
+  if second != RevokeOutcome::NoOp {
+    return Err(TestError::Unexpected(
+      "a second revoke of the same jwt must be a no-op".to_owned(),
+    ));
+  }
+  if outcome_response(second).status() != StatusCode::OK {
+    return Err(TestError::Unexpected(
+      "a no-op revoke must still render 200".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// A foreign client revoking another client's JWT is a silent `NoOp`: the
+/// marker, the record, and the grant-set member all survive untouched.
+#[tokio::test]
+async fn jwt_foreign_owner_revoke_is_noop_and_marker_intact() -> TestResult {
+  let (mut conn, store, _prefixes) = test_redis().await?;
+  let signer = test_signer()?;
+  let service = jwt_service(signer.clone())?;
+  let owner = unique("jwt-owner");
+  let attacker = unique("jwt-attacker");
+  let (token, hash) = mint_jwt(&mut conn, &store, &service, &signer, &owner, "user-1").await?;
+  let marker_key = store.jwt_marker_key(&hash);
+  let member = format!("jwt:{hash}");
+
+  let outcome = revoke_owned(
+    &mut conn,
+    &store,
+    &service,
+    &OwnedToken {
+      owner_client_id: &attacker,
+      token: &token,
+      token_type_hint: None,
+    },
+    Some(&signer),
+  )
+  .await?;
+  if outcome != RevokeOutcome::NoOp {
+    return Err(TestError::Unexpected(
+      "a foreign jwt revoke must be a no-op".to_owned(),
+    ));
+  }
+  if outcome_response(outcome).status() != StatusCode::OK {
+    return Err(TestError::Unexpected(
+      "a foreign no-op must still render 200".to_owned(),
+    ));
+  }
+  for key in [&marker_key, &store.access_key(&member)] {
+    let present: bool = redis::cmd("EXISTS").arg(key).query_async(&mut conn).await?;
+    if !present {
+      return Err(TestError::Unexpected(format!(
+        "a foreign revoke must never delete {key}"
+      )));
+    }
+  }
+  if !marker_live(&mut conn, &store, &hash)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("marker check failed: {error:?}")))?
+  {
+    return Err(TestError::Unexpected(
+      "the owner's marker must stay live after a foreign attempt".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// Malformed JWT shapes and foreign-key signatures are silent `NoOp`s: the
+/// endpoint never errors and never touches another client's marker.
+#[tokio::test]
+async fn jwt_malformed_and_foreign_signature_are_noop() -> TestResult {
+  let (mut conn, store, _prefixes) = test_redis().await?;
+  let signer = test_signer()?;
+  let service = jwt_service(signer.clone())?;
+  let client = unique("jwt-malformed-client");
+  let (token, hash) = mint_jwt(&mut conn, &store, &service, &signer, &client, "user-1").await?;
+  let marker_key = store.jwt_marker_key(&hash);
+
+  // A well-formed foreign-key JWT (unknown `kid`): verifies against nobody.
+  let foreign_signer = test_signer_with(JWT_FOREIGN_KEY, "foreign-kid")?;
+  let foreign_service = jwt_service(foreign_signer.clone())?;
+  let (foreign_token, _) = mint_jwt(
+    &mut conn,
+    &store,
+    &foreign_service,
+    &foreign_signer,
+    &unique("jwt-foreign-client"),
+    "user-1",
+  )
+  .await?;
+
+  for presented in [
+    "not-a-jwt",
+    "aaa.bbb",
+    "aaa.bbb.ccc.ddd",
+    ".bb.cc",
+    foreign_token.as_str(),
+  ] {
+    let outcome = revoke_owned(
+      &mut conn,
+      &store,
+      &service,
+      &OwnedToken {
+        owner_client_id: &client,
+        token: presented,
+        token_type_hint: None,
+      },
+      Some(&signer),
+    )
+    .await?;
+    if outcome != RevokeOutcome::NoOp {
+      return Err(TestError::Unexpected(format!(
+        "a malformed or foreign jwt must be a no-op, saw {outcome:?} for {presented}"
+      )));
+    }
+    if outcome_response(outcome).status() != StatusCode::OK {
+      return Err(TestError::Unexpected(
+        "a malformed no-op must still render 200".to_owned(),
+      ));
+    }
+  }
+  // The owner's real marker survived every foreign attempt; the owner's own
+  // well-formed token still verifies.
+  let marker_exists: bool = redis::cmd("EXISTS")
+    .arg(&marker_key)
+    .query_async(&mut conn)
+    .await?;
+  if !marker_exists {
+    return Err(TestError::Unexpected(
+      "foreign attempts must never delete the owner's marker".to_owned(),
+    ));
+  }
+  signer
+    .verify(&token, None)
+    .map_err(|error| TestError::Unexpected(format!("owner jwt rejected: {error:?}")))?;
+  Ok(())
+}
+
+/// A JWT-shaped token with no signer configured (feature off / no key) is not
+/// revocable and stays a silent `NoOp` with the marker untouched.
+#[tokio::test]
+async fn jwt_revoke_without_signer_is_noop() -> TestResult {
+  let (mut conn, store, _prefixes) = test_redis().await?;
+  let signer = test_signer()?;
+  let service = jwt_service(signer.clone())?;
+  let client = unique("jwt-nosigner-client");
+  let (token, hash) = mint_jwt(&mut conn, &store, &service, &signer, &client, "user-1").await?;
+
+  let outcome = revoke_owned(
+    &mut conn,
+    &store,
+    &service,
+    &OwnedToken {
+      owner_client_id: &client,
+      token: &token,
+      token_type_hint: None,
+    },
+    None,
+  )
+  .await?;
+  if outcome != RevokeOutcome::NoOp {
+    return Err(TestError::Unexpected(
+      "a jwt without a signer must be a no-op".to_owned(),
+    ));
+  }
+  if !marker_live(&mut conn, &store, &hash)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("marker check failed: {error:?}")))?
+  {
+    return Err(TestError::Unexpected(
+      "the marker must survive a signer-less attempt".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// Live handles for the Postgres-backed JWT tests: a migrated database, the
+/// shared Redis plus store, and the validated config.
+struct PgHarness {
+  _db: Lease<crate::db::test_support::Db>,
+  pool: PgPool,
+  conn: RedisConn,
+  store: Store,
+  config: Arc<crate::config::Config>,
+}
+
+async fn pg_harness() -> Result<PgHarness, TestError> {
+  let (db, pool) = migrated_pool()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("pg harness: {error}")))?;
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
+  let store = Store::from_parts(
+    state_prefix.clone(),
+    ratelimit_prefix.clone(),
+    DurabilityMode::WaitAof,
+  );
+  let redis_url = crate::test_support::test_redis_url();
+  let fixture = serde_json::json!({
+    "issuer": TEST_ISSUER,
+    "environment": "development",
+    "tls": {"cert_path": "/tmp/jwt-revoke-test-cert.pem", "key_path": "/tmp/jwt-revoke-test-key.pem"},
+    "database": {"url": "postgres://127.0.0.1:1/jwt-revoke"},
+    "redis": {
+      "url": redis_url,
+      "durability": "waitaof",
+      "token_key_prefix": state_prefix,
+      "ratelimit_key_prefix": ratelimit_prefix,
+    },
+    "token_pepper": "jwt-revoke-test-pepper-0123456789abcdef",
+    "totp_encryption_key": "jwt-revoke-test-totp-key-0123456789abcdef",
+    "pii_encryption_key": "jwt-revoke-test-pii-key-0123456789abcdef",
+    "jwt_access_tokens_enabled": true,
+    "jwt_signing_key": JWT_TEST_KEY,
+    "jwt_signing_kid": "test-kid",
+  });
+  let source = ::config::Config::try_from(&fixture)
+    .map_err(|error| TestError::Unexpected(format!("config fixture: {error:?}")))?;
+  let config = crate::config::Config::from_source(source)
+    .map_err(|error| TestError::Unexpected(format!("config fixture: {error:?}")))?;
+  Ok(PgHarness {
+    _db: db,
+    pool,
+    conn: RedisConn::Single(manager),
+    store,
+    config: Arc::new(config),
+  })
+}
+
+fn app_state(h: &PgHarness) -> AppState {
+  AppState {
+    pool: h.pool.clone(),
+    redis: h.conn.clone(),
+    config: Arc::clone(&h.config),
+    keys: Arc::new(h.config.keys.clone()),
+    transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
+    clock: Clock::system(),
+  }
+}
+
+async fn insert_client(
+  pool: &PgPool,
+  keys: &crate::keys::KeyRing,
+  client_id: &str,
+  secret: &str,
+) -> TestResult {
+  let params =
+    Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::Unexpected(format!("{error:?}")))?;
+  let secret_hash = Some(
+    hash_secret(secret, &params).map_err(|error| TestError::Unexpected(format!("{error:?}")))?,
+  );
+  let new = NewClient {
+    id: uuid::Uuid::new_v4(),
+    client_id: client_id.to_owned(),
+    client_secret_hash: secret_hash,
+    client_secret_expires_at: None,
+    client_name: format!("{client_id} name"),
+    grant_types: vec!["client_credentials".to_owned()],
+    response_types: Vec::new(),
+    scope: "orders:read".to_owned(),
+    token_endpoint_auth_method: TokenEndpointAuthMethod::ClientSecretBasic
+      .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,
+  };
+  client_repo::insert(pool, keys, false, &new)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("insert client: {error}")))?;
+  Ok(())
+}
+
+fn basic_header(client_id: &str, secret: &str) -> String {
+  let encoded = base64::engine::general_purpose::STANDARD.encode(format!("{client_id}:{secret}"));
+  format!("Basic {encoded}")
+}
+
+/// `test(jwt_revoked_introspects_inactive)`: the HTTP `/introspect` dual path
+/// reports a live JWT as `active:true` BEFORE the revoke and
+/// `{"active":false}` AFTER it. The AS-side liveness verdict backs both wire
+/// assertions: the token verifies and the marker plus membership checks read
+/// live before, and both read dead after (Todo 26 cascade semantics, consumed
+/// through `marker_live_strict` plus the disabled-subject re-check inside
+/// introspection).
+#[tokio::test]
+async fn jwt_revoked_introspects_inactive() -> TestResult {
+  let mut h = pg_harness().await?;
+  let signer = test_signer()?;
+  let service = TokenService::new(&h.config.ttl, &h.config.keys)
+    .map_err(TestError::from)
+    .map(|service| service.with_jwt_signer(signer.clone()))?;
+  let client_id = unique("jwt-introspect-client");
+  insert_client(&h.pool, &h.config.keys, &client_id, "secret-jri").await?;
+  let grant = unique("jwt-introspect-grant");
+  let minted = service
+    .mint(
+      &mut h.conn,
+      &h.store,
+      &jwt_mint_request(&client_id, &grant, "user-1"),
+      now_unix(),
+    )
+    .await?;
+  let verified = signer
+    .verify(&minted.access_token, None)
+    .map_err(|error| TestError::Unexpected(format!("fresh jwt rejected: {error:?}")))?;
+  let hash = jti_hash(&verified.claims.jti);
+
+  // BEFORE: the token verifies and the allowlist binds marker plus membership.
+  if !marker_live_strict(&mut h.conn, &h.store, &hash)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("strict check failed: {error:?}")))?
+  {
+    return Err(TestError::Unexpected(
+      "the fresh mint must be strictly live".to_owned(),
+    ));
+  }
+
+  // BEFORE: the dual-path HTTP endpoint reports the live JWT active, with
+  // the caller binding and no leaked claims.
+  if !http_introspect_active(&h, &client_id, "secret-jri", &minted.access_token).await? {
+    return Err(TestError::Unexpected(
+      "a live jwt must introspect active:true".to_owned(),
+    ));
+  }
+
+  let outcome = revoke_owned(
+    &mut h.conn,
+    &h.store,
+    &service,
+    &OwnedToken {
+      owner_client_id: &client_id,
+      token: &minted.access_token,
+      token_type_hint: None,
+    },
+    Some(&signer),
+  )
+  .await?;
+  if outcome != RevokeOutcome::Revoked {
+    return Err(TestError::Unexpected(
+      "the owning client must revoke its own jwt".to_owned(),
+    ));
+  }
+
+  // AFTER: the AS-side liveness verdict is inactive on both checks.
+  if marker_live(&mut h.conn, &h.store, &hash)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("marker check failed: {error:?}")))?
+  {
+    return Err(TestError::Unexpected(
+      "the marker must read dead after revoke".to_owned(),
+    ));
+  }
+  if marker_live_strict(&mut h.conn, &h.store, &hash)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("strict check failed: {error:?}")))?
+  {
+    return Err(TestError::Unexpected(
+      "the membership-bound check must read dead after revoke".to_owned(),
+    ));
+  }
+
+  // AFTER: the wire response users observe is HTTP 200 with `active: false`.
+  if http_introspect_active(&h, &client_id, "secret-jri", &minted.access_token).await? {
+    return Err(TestError::Unexpected(
+      "a revoked jwt must introspect inactive".to_owned(),
+    ));
+  }
+  Ok(())
+}
+
+/// Drive the real `/introspect` HTTP handler and report the `active` bit.
+///
+/// The handler builds its JWT signer from the harness config (flag on, key
+/// with the test `kid`), so this exercises the production dual path, not a
+/// hand-rolled verifier.
+async fn http_introspect_active(
+  h: &PgHarness,
+  client_id: &str,
+  secret: &str,
+  token: &str,
+) -> Result<bool, TestError> {
+  let app = Router::new()
+    .route("/introspect", post(introspect::handler))
+    .with_state(app_state(h));
+  let body = format!("token={token}&token_type_hint=access_token");
+  let request = Request::post("/introspect")
+    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+    .header(header::AUTHORIZATION, basic_header(client_id, secret))
+    .body(Body::from(body))
+    .map_err(|error| TestError::Unexpected(format!("request build failed: {error}")))?;
+  let response = app
+    .oneshot(request)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("router failed: {error}")))?;
+  if response.status() != StatusCode::OK {
+    return Err(TestError::Unexpected(format!(
+      "introspect must render 200, got {}",
+      response.status()
+    )));
+  }
+  let bytes = response
+    .into_body()
+    .collect()
+    .await
+    .map_err(TestError::from)?
+    .to_bytes();
+  let json: Value = serde_json::from_slice(&bytes).map_err(TestError::from)?;
+  json
+    .get("active")
+    .and_then(Value::as_bool)
+    .ok_or_else(|| TestError::Unexpected(format!("introspect lacks active, saw {json}")))
+}
+
+/// `test(jwt_disabled_subject_inactive)`: a JWT minted for a real subject
+/// reports `SubjectDisabled` from `verify_live` once the account is disabled —
+/// marker presence alone never keeps a disabled subject's token alive.
+#[tokio::test]
+async fn jwt_disabled_subject_inactive() -> TestResult {
+  let mut h = pg_harness().await?;
+  let signer = test_signer()?;
+  let service = TokenService::new(&h.config.ttl, &h.config.keys)
+    .map_err(TestError::from)
+    .map(|service| service.with_jwt_signer(signer.clone()))?;
+  let client_id = unique("jwt-disabled-client");
+  insert_client(&h.pool, &h.config.keys, &client_id, "secret-jrd").await?;
+
+  let email = format!("{}@example.test", unique("jwt-disabled-user"));
+  let params =
+    Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::Unexpected(format!("{error:?}")))?;
+  let record = user_repo::create_user(
+    &h.pool,
+    &h.config.keys,
+    false,
+    &NewUser {
+      email: email.clone(),
+      password_hash: hash_secret("correct-horse-01", &params)
+        .map_err(|error| TestError::Unexpected(format!("{error:?}")))?,
+      is_admin: false,
+      must_change_password: false,
+    },
+  )
+  .await
+  .map_err(|error| TestError::Unexpected(format!("create user: {error}")))?;
+
+  let grant = unique("jwt-disabled-grant");
+  let minted = service
+    .mint(
+      &mut h.conn,
+      &h.store,
+      &jwt_mint_request(&client_id, &grant, &email),
+      now_unix(),
+    )
+    .await?;
+
+  // BEFORE: marker plus account both live.
+  signer
+    .verify_live(
+      &mut h.conn,
+      &h.store,
+      &h.pool,
+      &h.config.keys,
+      &minted.access_token,
+      None,
+    )
+    .await
+    .map_err(|error| TestError::Unexpected(format!("fresh jwt must verify live: {error:?}")))?;
+
+  if !user_repo::disable_user(&h.pool, record.id)
+    .await
+    .map_err(|error| TestError::Unexpected(format!("disable user: {error}")))?
+  {
+    return Err(TestError::Unexpected(
+      "disable_user updated no rows".to_owned(),
+    ));
+  }
+
+  match signer
+    .verify_live(
+      &mut h.conn,
+      &h.store,
+      &h.pool,
+      &h.config.keys,
+      &minted.access_token,
+      None,
+    )
+    .await
+  {
+    Err(crate::jose::jwt_access::JwtError::SubjectDisabled) => Ok(()),
+    Err(other) => Err(TestError::Unexpected(format!(
+      "a disabled subject must report SubjectDisabled, got {other:?}"
+    ))),
+    Ok(_) => Err(TestError::Unexpected(
+      "a disabled subject's jwt must not verify live".to_owned(),
+    )),
+  }
+}

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

@@ -55,6 +55,7 @@ async fn insert_mtls_client(pool: &sqlx::PgPool, client_id: &str, thumbprint: &s
     contacts: None,
     software_id: None,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
   };
   client_repo::insert(pool, &keys, false, &new).await?;

+ 88 - 18
src/protocol/ssrf.rs

@@ -58,14 +58,31 @@ pub enum SsrfError {
   /// The response status was not successful.
   #[error("fetch returned an unsuccessful status")]
   BadStatus,
-  /// The response exceeded [`SSRF_MAX_BYTES`].
+  /// The response exceeded the configured size cap.
   #[error("fetch response exceeds the size limit")]
   TooLarge,
 }
 
-/// Whether `ip` is forbidden as a server-side fetch target: loopback,
-/// unspecified, multicast, broadcast, RFC 1918 private, CGNAT, link-local,
-/// unique-local, documentation, reserved, or a cloud-metadata address.
+/// Whether `ip` is forbidden as a server-side fetch target.
+///
+/// The blocked set is the IANA special-purpose ranges that are never a
+/// legitimate public fetch target, plus the cloud-metadata IPs:
+///
+/// - IPv4: `0.0.0.0/8` (this network), `10/8`, `100.64.0.0/10` (CGNAT),
+///   `127.0.0.0/8`, `169.254.0.0/16`, `172.16.0.0/12`, `192.0.0.0/24`
+///   (IETF protocol assignments), `192.0.2.0/24`, `192.88.99.0/24` (6to4
+///   relay anycast), `192.168.0.0/16`, `198.18.0.0/15` (benchmarking),
+///   `198.51.100.0/24`, `203.0.113.0/24`, `224.0.0.0/4` (multicast),
+///   `240.0.0.0/4` (reserved, incl. broadcast), and the Azure/Alibaba
+///   metadata addresses.
+/// - IPv6: `::1`, `::`, `fc00::/7`, `fe80::/10`, `ff00::/8`, `2001:db8::/32`,
+///   `100::/64` (discard-only), `2001:2::/48` (benchmarking), `2001:10::/28`
+///   and `2001:20::/28` (ORCHID/ORCHIDv2 identifiers), `2001::/32` (Teredo),
+///   `2002::/16` (6to4), and `64:ff9b::/96` / `64:ff9b:1::/48` (NAT64
+///   translation — can reach an embedded private IPv4 target).
+///
+/// IPv4-mapped addresses (`::ffff:a.b.c.d`) are judged by the embedded IPv4
+/// address.
 #[must_use]
 pub fn is_blocked_ip(ip: &IpAddr) -> bool {
   match ip {
@@ -86,7 +103,22 @@ pub fn is_blocked_ip(ip: &IpAddr) -> bool {
         // documentation (2001:db8::/32).
         || (v6.segments()[0] & 0xfe00) == 0xfc00
         || (v6.segments()[0] & 0xffc0) == 0xfe80
-        || v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8
+        || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0db8)
+        // Discard-only (100::/64) and benchmarking (2001:2::/48).
+        || (v6.segments()[0] == 0x0100
+          && v6.segments()[1] == 0
+          && v6.segments()[2] == 0
+          && v6.segments()[3] == 0)
+        || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0x0002 && v6.segments()[2] == 0)
+        // Non-routable identifier prefixes: ORCHID (2001:10::/28) and
+        // ORCHIDv2 (2001:20::/28).
+        || (v6.segments()[0] == 0x2001 && (v6.segments()[1] & 0xfff0) == 0x0010)
+        || (v6.segments()[0] == 0x2001 && (v6.segments()[1] & 0xfff0) == 0x0020)
+        // IPv4 transition/translation: Teredo (2001::/32), 6to4 (2002::/16),
+        // NAT64 well-known (64:ff9b::/96) and local-use (64:ff9b:1::/48).
+        || (v6.segments()[0] == 0x2001 && v6.segments()[1] == 0)
+        || v6.segments()[0] == 0x2002
+        || (v6.segments()[0] == 0x0064 && v6.segments()[1] == 0xff9b)
     }
   }
 }
@@ -101,9 +133,18 @@ fn is_blocked_v4(v4: &std::net::Ipv4Addr) -> bool {
     || v4.is_private()
     || v4.is_link_local()
     || v4.is_documentation()
+    // "This network" (0.0.0.0/8): a Linux host can reach loopback through
+    // `0.x.y.z`, and only the all-zero address is caught by `is_unspecified`.
+    || o[0] == 0
     // CGNAT shared space (RFC 6598): never a public JWKS host.
     || (o[0] == 100 && o[1] >= 64 && o[1] <= 127)
-    // Reserved benchmarking/reserved ranges.
+    // IETF protocol assignments (192.0.0.0/24) and 6to4 relay anycast
+    // (192.88.99.0/24, deprecated) — no public fetch target lives here.
+    || (o[0] == 192 && o[1] == 0 && o[2] == 0)
+    || (o[0] == 192 && o[1] == 88 && o[2] == 99)
+    // Benchmarking (RFC 2544): 198.18.0.0/15.
+    || (o[0] == 198 && (o[1] == 18 || o[1] == 19))
+    // Reserved (240.0.0.0/4, includes 255.255.255.255 broadcast).
     || o[0] >= 240
     || o == AZURE_METADATA_V4
     || o == ALIBABA_METADATA_V4
@@ -155,37 +196,66 @@ pub async fn check_fetch_host(host: &str, port: u16) -> Result<Vec<SocketAddr>,
   Ok(addrs)
 }
 
-/// Fetch `raw` through the full SSRF guard: URL policy, DNS target check,
-/// then an `https` GET with redirects disabled, a total timeout of
-/// [`SSRF_TIMEOUT`], and a body cap of [`SSRF_MAX_BYTES`].
-///
-/// The connection is pinned to the addresses validated by the DNS check
-/// (`resolve_to_addrs` for hostnames; IP literals need no DNS and connect
-/// directly to the validated literal), closing the DNS-rebinding window
-/// between validation and connect time.
+/// Fetch `raw` through the full SSRF guard with the default `jwks_uri`/
+/// `request_uri` caps: a total timeout of [`SSRF_TIMEOUT`] and a body cap of
+/// [`SSRF_MAX_BYTES`].
 ///
 /// # Errors
 ///
 /// Returns [`SsrfError`] when the URL policy, DNS check, fetch, status, or
 /// size cap fails.
 pub async fn fetch_guarded_bytes(raw: &str) -> Result<Vec<u8>, SsrfError> {
+  fetch_guarded_bytes_with(raw, SSRF_MAX_BYTES, SSRF_TIMEOUT).await
+}
+
+/// Fetch `raw` through the full SSRF guard with per-call limits.
+///
+/// Same policy as [`fetch_guarded_bytes`] — `https`-only, no redirects, a
+/// pre-flight DNS target check pinned to the validated addresses, success
+/// statuses only — but the response body cap and total timeout are the
+/// 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.
+///
+/// # Errors
+///
+/// Returns [`SsrfError`] when the URL policy, DNS check, fetch, status, or
+/// `max_bytes` cap fails.
+pub async fn fetch_guarded_bytes_with(
+  raw: &str,
+  max_bytes: usize,
+  timeout: Duration,
+) -> Result<Vec<u8>, SsrfError> {
   let url = validate_fetch_url(raw)?;
   let host = url.host_str().unwrap_or_default().to_owned();
   let port = url.port_or_known_default().unwrap_or(443);
   let addrs = check_fetch_host(&host, port).await?;
+  fetch_pinned(&url, &host, &addrs, max_bytes, timeout).await
+}
 
+/// The post-guard network step shared by every guarded fetch: an `https` GET
+/// with redirects disabled, `timeout` as the total budget, and a `max_bytes`
+/// body cap. The connection is pinned to `addrs` for a hostname
+/// (`resolve_to_addrs`); an IP literal needs no DNS and connects to the
+/// already-validated literal.
+async fn fetch_pinned(
+  url: &reqwest::Url,
+  host: &str,
+  addrs: &[SocketAddr],
+  max_bytes: usize,
+  timeout: Duration,
+) -> Result<Vec<u8>, SsrfError> {
   let builder = reqwest::Client::builder()
     .redirect(Policy::none())
-    .timeout(SSRF_TIMEOUT)
+    .timeout(timeout)
     .no_proxy();
   let builder = if host.parse::<IpAddr>().is_ok() {
     builder
   } else {
-    builder.resolve_to_addrs(&host, &addrs)
+    builder.resolve_to_addrs(host, addrs)
   };
   let client = builder.build().map_err(|_| SsrfError::FetchFailed)?;
   let mut response = client
-    .get(url)
+    .get(url.clone())
     .send()
     .await
     .map_err(|_| SsrfError::FetchFailed)?;
@@ -195,7 +265,7 @@ pub async fn fetch_guarded_bytes(raw: &str) -> Result<Vec<u8>, SsrfError> {
   let mut body = Vec::new();
   while let Some(chunk) = response.chunk().await.map_err(|_| SsrfError::FetchFailed)? {
     body.extend_from_slice(&chunk);
-    if body.len() > SSRF_MAX_BYTES {
+    if body.len() > max_bytes {
       return Err(SsrfError::TooLarge);
     }
   }

+ 193 - 1
src/protocol/ssrf/tests.rs

@@ -1,8 +1,14 @@
 //! `test(ssrf)` suite: URL policy and target blocking (offline).
 
 use std::net::IpAddr;
+use std::time::Duration;
 
-use super::{check_fetch_host, fetch_guarded_bytes, is_blocked_ip, validate_fetch_url};
+use tokio::io::{AsyncReadExt, AsyncWriteExt};
+
+use super::{
+  check_fetch_host, fetch_guarded_bytes, fetch_guarded_bytes_with, fetch_pinned, is_blocked_ip,
+  validate_fetch_url,
+};
 
 /// This suite's error type (no `unwrap`/`expect` under the deny lints).
 type TestResult = Result<(), TestError>;
@@ -194,3 +200,189 @@ async fn ssrf_check_denies_hostname_resolving_to_blocked() -> TestResult {
     )),
   }
 }
+
+async fn serve_once(
+  status: u16,
+  headers: &[(&str, &str)],
+  body: &[u8],
+  delay: Duration,
+) -> Result<reqwest::Url, TestError> {
+  let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
+    .await
+    .map_err(|err| TestError::Unexpected(err.to_string()))?;
+  let addr = listener
+    .local_addr()
+    .map_err(|err| TestError::Unexpected(err.to_string()))?;
+  let body = body.to_vec();
+  let headers: Vec<(String, String)> = headers
+    .iter()
+    .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
+    .collect();
+  tokio::spawn(async move {
+    let Ok((mut socket, _)) = listener.accept().await else {
+      return;
+    };
+    let mut scratch = [0_u8; 1024];
+    let _ = socket.read(&mut scratch).await;
+    if !delay.is_zero() {
+      tokio::time::sleep(delay).await;
+    }
+    let mut response = format!(
+      "HTTP/1.1 {status} X\r\nConnection: close\r\nContent-Length: {}\r\n",
+      body.len()
+    );
+    for (name, value) in &headers {
+      response.push_str(name);
+      response.push_str(": ");
+      response.push_str(value);
+      response.push_str("\r\n");
+    }
+    response.push_str("\r\n");
+    let _ = socket.write_all(response.as_bytes()).await;
+    let _ = socket.write_all(&body).await;
+    let _ = socket.shutdown().await;
+  });
+  reqwest::Url::parse(&format!("http://{addr}/doc.json"))
+    .map_err(|err| TestError::Unexpected(err.to_string()))
+}
+
+#[tokio::test]
+async fn ssrf_per_call_cap_and_timeout() -> TestResult {
+  match fetch_guarded_bytes_with(
+    "https://127.0.0.1:8443/doc.json",
+    5120,
+    Duration::from_secs(1),
+  )
+  .await
+  {
+    Err(super::SsrfError::BlockedTarget) => {}
+    Err(other) => return Err(TestError::Unexpected(format!("wrong error: {other}"))),
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "loopback must never be fetched".to_owned(),
+      ));
+    }
+  }
+  match fetch_guarded_bytes_with("http://127.0.0.1:9/doc.json", 5120, Duration::from_secs(1)).await
+  {
+    Err(super::SsrfError::NonHttps) => {}
+    Err(other) => return Err(TestError::Unexpected(format!("wrong error: {other}"))),
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "http must never be fetched".to_owned(),
+      ));
+    }
+  }
+  match fetch_guarded_bytes("https://192.0.2.1/doc.json").await {
+    Err(super::SsrfError::BlockedTarget) => {}
+    Err(other) => return Err(TestError::Unexpected(format!("wrong error: {other}"))),
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "documentation range must not be fetched".to_owned(),
+      ));
+    }
+  }
+
+  // The loopback ban is bypassed for these mechanics only: the https policy
+  // is asserted above, and a trusted test CA cannot be stood up offline.
+  let over = vec![b'a'; 5121];
+  let over_url = serve_once(200, &[], &over, Duration::ZERO).await?;
+  match fetch_pinned(&over_url, "127.0.0.1", &[], 5120, Duration::from_secs(5)).await {
+    Err(super::SsrfError::TooLarge) => {}
+    Err(other) => return Err(TestError::Unexpected(format!("wrong error: {other}"))),
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "over-cap body must be rejected".to_owned(),
+      ));
+    }
+  }
+
+  let exact = vec![b'b'; 5120];
+  let exact_url = serve_once(200, &[], &exact, Duration::ZERO).await?;
+  match fetch_pinned(&exact_url, "127.0.0.1", &[], 5120, Duration::from_secs(5)).await {
+    Ok(body) if body.len() == 5120 => {}
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "body at the cap must be intact".to_owned(),
+      ));
+    }
+    Err(other) => return Err(TestError::Unexpected(format!("wrong error: {other}"))),
+  }
+
+  let redirect_url = serve_once(302, &[("Location", "/elsewhere")], b"", Duration::ZERO).await?;
+  match fetch_pinned(
+    &redirect_url,
+    "127.0.0.1",
+    &[],
+    5120,
+    Duration::from_secs(5),
+  )
+  .await
+  {
+    Err(super::SsrfError::BadStatus) => {}
+    Err(other) => return Err(TestError::Unexpected(format!("wrong error: {other}"))),
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "redirect must not be followed".to_owned(),
+      ));
+    }
+  }
+
+  let slow_url = serve_once(200, &[], b"ok", Duration::from_millis(500)).await?;
+  match fetch_pinned(&slow_url, "127.0.0.1", &[], 5120, Duration::from_millis(50)).await {
+    Err(super::SsrfError::FetchFailed) => {}
+    Err(other) => return Err(TestError::Unexpected(format!("wrong error: {other}"))),
+    Ok(_) => {
+      return Err(TestError::Unexpected(
+        "slow response must time out".to_owned(),
+      ));
+    }
+  }
+
+  Ok(())
+}
+
+#[test]
+fn ssrf_rejects_special_use_ranges() -> TestResult {
+  for addr in [
+    "0.0.0.0",
+    "0.255.255.255",
+    "192.0.0.1",
+    "192.0.0.170",
+    "192.88.99.1",
+    "198.18.0.1",
+    "198.19.255.255",
+    "192.0.2.1",
+    "198.51.100.1",
+    "203.0.113.1",
+    "100.64.0.1",
+    "240.0.0.1",
+    "64:ff9b::7f00:1",
+    "64:ff9b:1::1",
+    "2002:7f00:1::",
+    "2001::1",
+    "2001:2::1",
+    "2001:10::1",
+    "2001:20::1",
+    "100::1",
+    "2001:db8::1",
+    "::ffff:0.0.0.1",
+    "::ffff:192.0.0.1",
+    "::ffff:198.18.0.1",
+  ] {
+    if !blocked(addr)? {
+      return Err(TestError::Unexpected(format!("{addr} should be blocked")));
+    }
+  }
+  for addr in [
+    "192.0.1.1",
+    "198.17.0.1",
+    "198.20.0.1",
+    "2001:4860:4860::8888",
+  ] {
+    if blocked(addr)? {
+      return Err(TestError::Unexpected(format!("{addr} should pass")));
+    }
+  }
+  Ok(())
+}

+ 238 - 24
src/protocol/token/dispatch.rs

@@ -13,7 +13,7 @@
 mod grants;
 
 use axum::Json;
-use axum::extract::{Extension, State, rejection::FormRejection};
+use axum::extract::{Extension, State};
 use axum::http::{HeaderMap, HeaderValue, header};
 use axum::response::{IntoResponse, Response};
 use secrecy::SecretString;
@@ -25,7 +25,10 @@ use super::{ClientKind, SenderProof, TokenService};
 use crate::{
   db::{TokenEndpointAuthMethod, client_repo, scope_repo},
   error::AppError,
-  jose::dpop::{DpopRequest, verify_dpop},
+  jose::{
+    dpop::{DpopRequest, verify_dpop},
+    jwt_access::JwtAccessSigner,
+  },
   protocol::{
     client_auth::{self, AuthContext, AuthStores, ClientAuthRequest},
     mtls,
@@ -77,6 +80,58 @@ pub struct TokenForm {
   pub client_assertion: Option<String>,
 }
 
+impl TokenForm {
+  /// Fields the extractor recognizes: a repeat of any of these is the
+  /// duplicate-field rejection (mapped to `invalid_request`, per RFC 6749
+  /// §5.2 "includes a parameter more than once"). Unknown fields (including
+  /// a repeated `resource`) are silently absorbed, as `serde_urlencoded`
+  /// absorbed them.
+  const KNOWN_FIELDS: [&str; 17] = [
+    "grant_type",
+    "code",
+    "redirect_uri",
+    "code_verifier",
+    "refresh_token",
+    "client_id",
+    "client_secret",
+    "scope",
+    "device_code",
+    "subject_token",
+    "subject_token_type",
+    "actor_token",
+    "actor_token_type",
+    "requested_token_type",
+    "token_type_hint",
+    "client_assertion_type",
+    "client_assertion",
+  ];
+
+  /// Build the form from a strict single-valued map (the `Bytes` extractor
+  /// replacement for `Form<TokenForm>`). Every member is optional: each
+  /// grant validates its own required members.
+  fn from_map(map: &std::collections::HashMap<String, String>) -> Self {
+    Self {
+      grant_type: map.get("grant_type").cloned(),
+      code: map.get("code").cloned(),
+      redirect_uri: map.get("redirect_uri").cloned(),
+      code_verifier: map.get("code_verifier").cloned(),
+      refresh_token: map.get("refresh_token").cloned(),
+      client_id: map.get("client_id").cloned(),
+      client_secret: map.get("client_secret").cloned(),
+      scope: map.get("scope").cloned(),
+      device_code: map.get("device_code").cloned(),
+      subject_token: map.get("subject_token").cloned(),
+      subject_token_type: map.get("subject_token_type").cloned(),
+      actor_token: map.get("actor_token").cloned(),
+      actor_token_type: map.get("actor_token_type").cloned(),
+      requested_token_type: map.get("requested_token_type").cloned(),
+      token_type_hint: map.get("token_type_hint").cloned(),
+      client_assertion_type: map.get("client_assertion_type").cloned(),
+      client_assertion: map.get("client_assertion").cloned(),
+    }
+  }
+}
+
 /// RFC 6749 §5.1 token response plus the RFC 8693 §2.2.1
 /// `issued_token_type` member. Optional members are omitted, never null.
 #[derive(Debug, Serialize)]
@@ -141,6 +196,12 @@ struct Dispatch<'a> {
   form: &'a TokenForm,
   /// Verified peer chain injected by the TLS accept loop, when present.
   peer_certs: Option<&'a PeerCertificates>,
+  /// Validated resource audiences (RFC 8707) from this token request, sorted
+  /// and deduped; empty when the request carried no `resource`.
+  resources: &'a [String],
+  /// CIMD resolution settings for a registration-free fallback; `None` when
+  /// `config.cimd_enabled` is off.
+  cimd: Option<crate::protocol::cimd::CimdSettings<'a>>,
   /// Current time as seconds since the Unix epoch.
   now_unix: i64,
 }
@@ -166,10 +227,52 @@ impl Dispatch<'_> {
     let ctx = AuthContext {
       token_endpoint: &endpoint,
       now: self.state.clock.now(),
+      cimd: self.cimd,
     };
     client_auth::authenticate_client(&mut stores, &ctx, &request).await
   }
 
+  /// Enforce this client's access-token format on a minting path.
+  ///
+  /// `opaque` returns without constructing the JWT signer or reading any key
+  /// material; a `jwt` client whose global switch is off (or any unrecognized
+  /// stored value) fails closed with `temporarily_unavailable` (503).
+  fn require_token_format(&self, client: &client_repo::Client) -> Result<TokenFormat, AppError> {
+    resolve_token_format(
+      &client.access_token_format,
+      self.state.config.jwt_access_tokens_enabled,
+    )
+  }
+
+  /// Apply the format gate to a grant that authenticates inside its own
+  /// module (`authorization_code`, `device_code`), and report the resolved
+  /// format for the mint call.
+  ///
+  /// Before delegation the dispatcher only knows a candidate presenter id. An
+  /// unknown or inactive client, or a structurally inconsistent Basic/body
+  /// pair, is left to the grant's existing `invalid_client` path so error
+  /// precedence does not change (`Ok(None)`: the grant never mints). A
+  /// client the dispatcher can name is gated first, so a `jwt` client with
+  /// the global switch off never reaches minting (and never burns a code or
+  /// a device poll). A registration-free CIMD id resolves to `Ok(None)` here
+  /// and to `Opaque` at the call site: the synthetic principal is always
+  /// `opaque` by construction (a document can never opt into JWT), so no
+  /// format gate is skipped.
+  async fn gate_candidate_client_format(
+    &self,
+    client_id: Option<String>,
+  ) -> Result<Option<TokenFormat>, AppError> {
+    let Some(client_id) = client_id else {
+      return Ok(None);
+    };
+    let Some(client) =
+      client_repo::find_active_by_client_id(&self.state.pool, &self.state.keys, &client_id).await?
+    else {
+      return Ok(None);
+    };
+    self.require_token_format(&client).map(Some)
+  }
+
   /// Verify this request's fresh sender proofs (DPoP `jkt`, mTLS `x5t#S256`).
   async fn sender_proof(&mut self, client: &client_repo::Client) -> Result<SenderProof, AppError> {
     let htu = client_auth::token_endpoint(&self.state.config.issuer);
@@ -224,6 +327,35 @@ fn dpop_proof(headers: &HeaderMap) -> Option<&str> {
   headers.get("dpop").and_then(|value| value.to_str().ok())
 }
 
+/// Re-exported from [`super::types`], where the decision type lives so both
+/// this dispatcher and [`super::service`] share it without a module cycle.
+pub use super::types::TokenFormat;
+
+/// Resolve a client's stored `access_token_format` to a [`TokenFormat`].
+///
+/// `opaque` is the default and never consults the JWT stack. RFC 9068 §2 makes
+/// JWT access tokens an opt-in profile the authorization server decides per
+/// client; §4 ties issuance to signing-key material, so a `jwt` client is
+/// accepted only while the operator's global `jwt_access_tokens_enabled`
+/// switch is on. When that switch is off — or the stored value is unknown
+/// (corruption or a future value) — the request fails closed with
+/// `temporarily_unavailable` (HTTP 503): the client asked for a token shape
+/// this deployment cannot mint, so there is no silent opaque fallback and no
+/// partial token. It is deliberately not `server_error` (HTTP 500), which
+/// would retract the fail-closed dependency contract, and not a 4xx, which
+/// would blame the client for a deployment state.
+fn resolve_token_format(
+  stored: &str,
+  jwt_access_tokens_enabled: bool,
+) -> Result<TokenFormat, AppError> {
+  match stored {
+    "opaque" => Ok(TokenFormat::Opaque),
+    "jwt" if jwt_access_tokens_enabled => Ok(TokenFormat::Jwt),
+    "jwt" => Err(AppError::temporarily_unavailable()),
+    _ => Err(AppError::temporarily_unavailable()),
+  }
+}
+
 /// Public vs confidential: the sender-constraint matrix selector.
 fn client_kind(client: &client_repo::Client) -> Result<ClientKind, AppError> {
   match client.auth_method() {
@@ -282,6 +414,15 @@ fn token_service(state: &AppState) -> Result<TokenService, AppError> {
   TokenService::new(&state.config.ttl, &state.keys).map_err(AppError::from)
 }
 
+/// Build the JWT signer for a `Jwt` mint from the validated config: the
+/// active `jwt_signing_key` signs, previous keys verify only. Fail-closed
+/// `temporarily_unavailable` when no key is configured (the format gate
+/// guarantees this is unreachable in practice): never a 500, and the typed
+/// [`crate::jose::jwt_access::JwtError`] carries no key material.
+fn jwt_signer(state: &AppState) -> Result<JwtAccessSigner, AppError> {
+  JwtAccessSigner::from_config(&state.config).map_err(|_| AppError::temporarily_unavailable())
+}
+
 /// `POST /token`: the five-grant dispatcher.
 ///
 /// # Errors
@@ -292,11 +433,61 @@ pub async fn handler(
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
-  form: Result<axum::Form<TokenForm>, FormRejection>,
+  body: axum::body::Bytes,
 ) -> Response {
-  let form = match form {
-    Ok(axum::Form(form)) => form,
-    Err(rejection) => return form_rejection_response(rejection),
+  // `Bytes` + a manual parse replaces `Result<Form<TokenForm>, FormRejection>`:
+  // the extractor consumed the raw body, hiding repeats. The explicit
+  // content-type gate preserves the 415 `Form` rendered (`Bytes` never fails
+  // on content-type); any other transport failure keeps the documented
+  // `invalid_request` mapping ("includes a parameter more than once"). The
+  // repeated `resource` indicator (RFC 8707) is captured in order by
+  // `params::repeated_values` and validated fail-closed here; the
+  // authorization-code branch binds the validated set to the redeemed code,
+  // and the remaining grants wire it in later todos.
+  if let Err(reject) = crate::protocol::params::require_form_content_type(&headers) {
+    return reject.into_response();
+  }
+  let malformed = || {
+    AppError::invalid_request(
+      "the request body is malformed or includes a parameter more than once",
+    )
+  };
+  let text = match std::str::from_utf8(&body) {
+    Ok(text) => text,
+    Err(_) => return TokenError::from(malformed()).into_response(),
+  };
+  let pairs = match crate::protocol::params::parse_pairs(text) {
+    Ok(pairs) => pairs,
+    Err(_) => return TokenError::from(malformed()).into_response(),
+  };
+  if crate::protocol::params::has_duplicate_field(&pairs, &TokenForm::KNOWN_FIELDS) {
+    return TokenError::from(malformed()).into_response();
+  }
+  let map = crate::protocol::params::single_valued_map(&pairs);
+  let form = TokenForm::from_map(&map);
+  let resources =
+    crate::protocol::params::repeated_values(text, crate::protocol::params::RESOURCE_PARAM);
+  // RFC 8707 resource indicators do not apply to RFC 8693 token exchange;
+  // reject the parameter BEFORE the catalog lookup so a bogus value cannot
+  // surface `invalid_target` from a grant that must answer `invalid_request`.
+  if form.grant_type.as_deref()
+    == Some(crate::protocol::token::grants::token_exchange::GRANT_TYPE_TOKEN_EXCHANGE)
+    && !resources.is_empty()
+  {
+    return TokenError::from(AppError::invalid_request(
+      "resource is not supported by token-exchange",
+    ))
+    .into_response();
+  }
+  let aud = match crate::protocol::params::validate_resources(
+    &state.pool,
+    state.config.resource_indicators_enabled,
+    &resources,
+  )
+  .await
+  {
+    Ok(aud) => aud,
+    Err(error) => return TokenError::from(error).into_response(),
   };
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
@@ -308,6 +499,16 @@ pub async fn handler(
     headers: &headers,
     form: &form,
     peer_certs: peer_certs.as_deref(),
+    resources: &aud,
+    cimd: state
+      .config
+      .cimd_enabled
+      .then(|| crate::protocol::cimd::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,
+      }),
     now_unix,
   };
   match grants::dispatch_grant(&mut dispatch).await {
@@ -316,24 +517,6 @@ pub async fn handler(
   }
 }
 
-/// Map a form-extractor rejection onto the documented error model.
-///
-/// A deserialization failure is the transport shape of RFC 6749 §5.2
-/// "includes a parameter more than once", so it renders the documented
-/// `invalid_request` JSON error; non-form content types and body-read
-/// failures keep the extractor's own status and body.
-fn form_rejection_response(rejection: FormRejection) -> Response {
-  match rejection {
-    FormRejection::FailedToDeserializeForm(_) | FormRejection::FailedToDeserializeFormBody(_) => {
-      AppError::invalid_request(
-        "the request body is malformed or includes a parameter more than once",
-      )
-      .into_response()
-    }
-    other => other.into_response(),
-  }
-}
-
 /// Shape a success response: JSON plus the RFC 6749 §5.1 no-store headers.
 fn token_response(body: TokenResponseBody) -> Response {
   let mut response = Json(body).into_response();
@@ -345,3 +528,34 @@ fn token_response(body: TokenResponseBody) -> Response {
     .insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
   response
 }
+
+#[cfg(test)]
+mod tests {
+  use axum::http::StatusCode;
+
+  use super::{TokenFormat, resolve_token_format};
+
+  #[test]
+  fn unknown_format_fails_closed_503() {
+    let unknown = resolve_token_format("bogus", true)
+      .err()
+      .map(|error| error.status());
+    assert_eq!(unknown, Some(StatusCode::SERVICE_UNAVAILABLE));
+    let jwt_off = resolve_token_format("jwt", false)
+      .err()
+      .map(|error| error.status());
+    assert_eq!(jwt_off, Some(StatusCode::SERVICE_UNAVAILABLE));
+    assert!(matches!(
+      resolve_token_format("opaque", false),
+      Ok(TokenFormat::Opaque)
+    ));
+    assert!(matches!(
+      resolve_token_format("opaque", true),
+      Ok(TokenFormat::Opaque)
+    ));
+    assert!(matches!(
+      resolve_token_format("jwt", true),
+      Ok(TokenFormat::Jwt)
+    ));
+  }
+}

+ 75 - 4
src/protocol/token/dispatch/grants.rs

@@ -6,8 +6,8 @@ use std::time::Duration;
 use secrecy::SecretString;
 
 use super::{
-  Dispatch, TokenError, TokenResponseBody, basic_header, client_kind, dpop_proof, enabled_scopes,
-  require_dpop_binding, require_grant, token_service,
+  Dispatch, TokenError, TokenFormat, TokenResponseBody, basic_header, client_kind, dpop_proof,
+  enabled_scopes, jwt_signer, require_dpop_binding, require_grant, token_service,
 };
 use crate::{
   db::{user_repo, user_scope_repo},
@@ -53,10 +53,38 @@ pub(super) async fn dispatch_grant(d: &mut Dispatch<'_>) -> Result<TokenResponse
   }
 }
 
+/// The candidate presenter id for `authorization_code`, mirroring the grant's
+/// own resolution so the format gate and the grant agree: Basic wins, and a
+/// disagreeing body id is left to the grant's `invalid_client` path.
+fn authorization_code_candidate_id(form_id: Option<&str>, basic: Option<&str>) -> Option<String> {
+  match basic {
+    Some(header) => {
+      let creds = client_auth::parse_basic_header(header)?;
+      match form_id {
+        Some(body_id) if body_id != creds.client_id => None,
+        _ => Some(creds.client_id),
+      }
+    }
+    None => form_id.map(str::to_owned),
+  }
+}
+
 /// `grant_type=authorization_code`: the grant authenticates the client itself.
 async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
   let basic = basic_header(d.headers);
   let proof = dpop_proof(d.headers);
+  let format = d
+    .gate_candidate_client_format(authorization_code_candidate_id(
+      d.form.client_id.as_deref(),
+      basic,
+    ))
+    .await?
+    .unwrap_or(TokenFormat::Opaque);
+  let jwt = if format == TokenFormat::Jwt {
+    Some(jwt_signer(d.state)?)
+  } else {
+    None
+  };
   let body_secret = d.form.client_secret.clone().map(SecretString::from);
   let mut grant = CodeGrantState {
     pool: &d.state.pool,
@@ -65,6 +93,9 @@ async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
     keys: &d.state.keys,
     issuer: &d.state.config.issuer,
     ttl: &d.state.config.ttl,
+    token_format: format,
+    jwt_signer: jwt,
+    cimd: d.cimd,
     now_unix: d.now_unix,
   };
   let inputs = CodeGrantInputs {
@@ -78,6 +109,7 @@ async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
     assertion: d.form.client_assertion.as_deref(),
     dpop_proof: proof,
     peer_certs: d.peer_certs,
+    aud: d.resources,
   };
   let tokens = authorization_code::redeem_code(&mut grant, &inputs).await?;
   Ok(TokenResponseBody {
@@ -95,11 +127,15 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
   let authenticated = d.authenticate().await?;
   let client = authenticated.client;
   require_grant(&client, refresh_token::GRANT_TYPE_REFRESH_TOKEN)?;
+  let format = d.require_token_format(&client)?;
   let kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
   require_dpop_binding(&client, &proof)?;
   let enabled = enabled_scopes(&d.state.pool).await?;
-  let service = token_service(d.state)?;
+  let mut service = token_service(d.state)?;
+  if format == TokenFormat::Jwt {
+    service = service.with_jwt_signer(jwt_signer(d.state)?);
+  }
   let users = refresh_token::RefreshUserStores {
     pool: &d.state.pool,
     keys: &d.state.keys,
@@ -117,12 +153,15 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
     &RefreshInput {
       client_id: &client.client_id,
       client_kind: kind,
+      dpop_required: client.dpop_bound_access_tokens,
       refresh_token: d.form.refresh_token.as_deref().unwrap_or(""),
       requested_scope: d.form.scope.as_deref(),
+      requested_resources: d.resources,
       proof: &proof,
       enabled: &enabled,
       client_registered: &registered,
       user_entitled: &no_user_term,
+      token_format: format,
       now_unix: d.now_unix,
     },
   )
@@ -142,12 +181,16 @@ async fn client_credentials(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
   let authenticated = d.authenticate().await?;
   let client = authenticated.client;
   require_grant(&client, client_credentials::GRANT_TYPE_CLIENT_CREDENTIALS)?;
+  let format = d.require_token_format(&client)?;
   let kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
   require_dpop_binding(&client, &proof)?;
   let registered = ScopeSet::parse(&client.scope).map_err(|_| AppError::server_error())?;
   let enabled = enabled_scopes(&d.state.pool).await?;
-  let service = token_service(d.state)?;
+  let mut service = token_service(d.state)?;
+  if format == TokenFormat::Jwt {
+    service = service.with_jwt_signer(jwt_signer(d.state)?);
+  }
   let issued = client_credentials::issue_client_credentials(
     &mut *d.conn,
     d.store,
@@ -161,6 +204,8 @@ async fn client_credentials(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
       dpop_bound_jkt: None,
       mtls_bound_x5t_s256: client.tls_client_auth_cert_thumbprint.as_deref(),
       proof: &proof,
+      aud: d.resources,
+      token_format: format,
       now_unix: d.now_unix,
     },
   )
@@ -188,6 +233,16 @@ async fn device(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
       basic.and_then(|header| client_auth::parse_basic_header(header).map(|creds| creds.client_id))
     })
     .unwrap_or_default();
+  let candidate = (!client_id.is_empty()).then_some(client_id.clone());
+  let format = d
+    .gate_candidate_client_format(candidate)
+    .await?
+    .unwrap_or(TokenFormat::Opaque);
+  let jwt = if format == TokenFormat::Jwt {
+    Some(jwt_signer(d.state)?)
+  } else {
+    None
+  };
   let mut poll = PollState {
     pool: &d.state.pool,
     conn: &mut *d.conn,
@@ -197,6 +252,9 @@ async fn device(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
     device_ttl: d.state.config.ttl.device,
     interval: Duration::from_secs(DEVICE_INTERVAL_SECS),
     ttl: &d.state.config.ttl,
+    token_format: format,
+    jwt_signer: jwt,
+    cimd: d.cimd,
     now_unix: d.now_unix,
   };
   let inputs = DevicePollInputs {
@@ -208,6 +266,7 @@ async fn device(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
     assertion: d.form.client_assertion.as_deref(),
     dpop_proof: proof,
     peer_certs: d.peer_certs,
+    aud: d.resources,
   };
   let tokens = device_code::poll_device(&mut poll, &inputs).await?;
   Ok(TokenResponseBody {
@@ -221,10 +280,21 @@ async fn device(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
 }
 
 /// `grant_type=urn:ietf:params:oauth:grant-type:token-exchange`: policy gate.
+///
+/// Token exchange mints an access token, so it is subject to the same
+/// per-client `access_token_format` gate as every other minting path: a `jwt`
+/// caller while the global switch is off fails closed with
+/// `temporarily_unavailable` (503) rather than exchanging. A `jwt` caller
+/// with the switch on is rejected the same way (Todo 26): exchange never
+/// mints an `act` JWT, so the format gate is a rejection, not a mint branch.
 async fn exchange(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
   let authenticated = d.authenticate().await?;
   let client = authenticated.client;
   require_grant(&client, token_exchange::GRANT_TYPE_TOKEN_EXCHANGE)?;
+  match d.require_token_format(&client)? {
+    TokenFormat::Opaque => {}
+    TokenFormat::Jwt => return Err(AppError::temporarily_unavailable().into()),
+  }
   let kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
   require_dpop_binding(&client, &proof)?;
@@ -240,6 +310,7 @@ async fn exchange(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
     &ExchangeInput {
       caller_client_id: &client.client_id,
       caller_kind: kind,
+      caller_dpop_required: client.dpop_bound_access_tokens,
       caller_registered: &registered,
       subject_token,
       subject_token_type: d.form.subject_token_type.as_deref().unwrap_or(""),

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini