Kaynağa Gözat

Added optionals

Efren Yevale Varela 2 hafta önce
ebeveyn
işleme
9478660260
100 değiştirilmiş dosya ile 11784 ekleme ve 415 silme
  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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -269,6 +269,17 @@
       },
       },
       {
       {
         "ordinal": 24,
         "ordinal": 24,
+        "name": "access_token_format",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "access_token_format"
+          }
+        }
+      },
+      {
+        "ordinal": 25,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -279,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -290,7 +301,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -333,8 +344,9 @@
       false,
       false,
       false,
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -269,6 +269,17 @@
       },
       },
       {
       {
         "ordinal": 24,
         "ordinal": 24,
+        "name": "access_token_format",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "access_token_format"
+          }
+        }
+      },
+      {
+        "ordinal": 25,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -279,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -290,7 +301,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -329,7 +340,8 @@
         "Text",
         "Text",
         "Bytea",
         "Bytea",
         "Text",
         "Text",
-        "Bool"
+        "Bool",
+        "Text"
       ]
       ]
     },
     },
     "nullable": [
     "nullable": [
@@ -359,8 +371,9 @@
       false,
       false,
       false,
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -269,6 +269,17 @@
       },
       },
       {
       {
         "ordinal": 24,
         "ordinal": 24,
+        "name": "access_token_format",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "access_token_format"
+          }
+        }
+      },
+      {
+        "ordinal": 25,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -279,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -290,7 +301,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -333,8 +344,9 @@
       false,
       false,
       false,
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -269,6 +269,17 @@
       },
       },
       {
       {
         "ordinal": 24,
         "ordinal": 24,
+        "name": "access_token_format",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "access_token_format"
+          }
+        }
+      },
+      {
+        "ordinal": 25,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -279,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -290,7 +301,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -355,8 +366,9 @@
       false,
       false,
       false,
       false,
       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",
   "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": {
   "describe": {
     "columns": [
     "columns": [
       {
       {
@@ -269,6 +269,17 @@
       },
       },
       {
       {
         "ordinal": 24,
         "ordinal": 24,
+        "name": "access_token_format",
+        "type_info": "Text",
+        "origin": {
+          "Table": {
+            "table": "oauth_clients",
+            "name": "access_token_format"
+          }
+        }
+      },
+      {
+        "ordinal": 25,
         "name": "active",
         "name": "active",
         "type_info": "Bool",
         "type_info": "Bool",
         "origin": {
         "origin": {
@@ -279,7 +290,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 25,
+        "ordinal": 26,
         "name": "created_at",
         "name": "created_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -290,7 +301,7 @@
         }
         }
       },
       },
       {
       {
-        "ordinal": 26,
+        "ordinal": 27,
         "name": "updated_at",
         "name": "updated_at",
         "type_info": "Timestamptz",
         "type_info": "Timestamptz",
         "origin": {
         "origin": {
@@ -333,8 +344,9 @@
       false,
       false,
       false,
       false,
       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. |
 | 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. |
 | 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. |
 | 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`. |
 | 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). |
 | 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. |
 | 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. |
 | 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). |
 | 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). |
 | 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. |
 | 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. |
 | 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
 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",
  "base64ct",
  "clap",
  "clap",
  "config",
  "config",
+ "ed25519-dalek",
  "hmac 0.13.0",
  "hmac 0.13.0",
  "http-body-util",
  "http-body-util",
  "hyper",
  "hyper",
@@ -942,6 +943,7 @@ dependencies = [
  "insta",
  "insta",
  "jsonwebtoken",
  "jsonwebtoken",
  "oauth2",
  "oauth2",
+ "p256",
  "password-hash",
  "password-hash",
  "proptest",
  "proptest",
  "qrcode",
  "qrcode",
@@ -3174,9 +3176,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "rustls-webpki"
 name = "rustls-webpki"
-version = "0.103.15"
+version = "0.103.16"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
 dependencies = [
 dependencies = [
  "ring",
  "ring",
  "rustls-pki-types",
  "rustls-pki-types",
@@ -4594,9 +4596,9 @@ dependencies = [
 
 
 [[package]]
 [[package]]
 name = "webpki-roots"
 name = "webpki-roots"
-version = "1.0.9"
+version = "1.0.10"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+checksum = "1c1c8e20c8a38fc7d65abfe74eb7280e4aa667e8eec8bc84996d2874810748d9"
 dependencies = [
 dependencies = [
  "rustls-pki-types",
  "rustls-pki-types",
 ]
 ]

+ 2 - 0
Cargo.toml

@@ -24,11 +24,13 @@ base64 = "0.23.1"
 base64ct = "1.8.3"
 base64ct = "1.8.3"
 clap = { version = "4.6.7", features = ["derive"] }
 clap = { version = "4.6.7", features = ["derive"] }
 config = "0.15.27"
 config = "0.15.27"
+ed25519-dalek = { version = "2.2.0", default-features = false }
 hmac = "0.13.0"
 hmac = "0.13.0"
 hyper = "1.11.1"
 hyper = "1.11.1"
 hyper-util = { version = "0.1.21", features = ["server-auto", "tokio", "service"] }
 hyper-util = { version = "0.1.21", features = ["server-auto", "tokio", "service"] }
 jsonwebtoken = { version = "11.1.0", features = ["rust_crypto"] }
 jsonwebtoken = { version = "11.1.0", features = ["rust_crypto"] }
 password-hash = "0.6.1"
 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
 # TOTP enrollment QR: SVG-only (no `image` codecs), embedded inline in the
 # server-rendered setup page under a `script-src 'none'` policy.
 # server-rendered setup page under a `script-src 'none'` policy.
 qrcode = { version = "0.14.1", default-features = false, features = ["svg"] }
 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
 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
 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
 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,
 It implements the authorization-code, refresh-token, client-credentials,
 device-code, and token-exchange grants, with DPoP, mTLS, PAR, JAR, dynamic
 device-code, and token-exchange grants, with DPoP, mTLS, PAR, JAR, dynamic
 client registration, introspection, and revocation. Tokens are opaque and
 client registration, introspection, and revocation. Tokens are opaque and
-hashed at rest. 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)
 - Protocol and deltas: [docs/OAUTH.md](docs/OAUTH.md)
 - Threat model and token design: [docs/SECURITY.md](docs/SECURITY.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)
 - Vulnerability reporting policy: [SECURITY.md](SECURITY.md)
 - Module layout and request flow: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
 - Module layout and request flow: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)
 - Every configuration key: [docs/CONFIGURATION.md](docs/CONFIGURATION.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)
 - Redis keys and durability: [docs/REDIS.md](docs/REDIS.md)
 - Container and cluster: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
 - Container and cluster: [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md)
 - Admin CLI and runbooks: [docs/OPERATIONS.md](docs/OPERATIONS.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)
 - Contributor guide: [docs/CONTRIBUTING.md](docs/CONTRIBUTING.md)
 - Changelog: [docs/CHANGELOG.md](docs/CHANGELOG.md)
 - Changelog: [docs/CHANGELOG.md](docs/CHANGELOG.md)
 - Admin bootstrap procedure: [RUNBOOK.md](RUNBOOK.md)
 - Admin bootstrap procedure: [RUNBOOK.md](RUNBOOK.md)
@@ -140,8 +145,14 @@ curl -sk https://localhost:8080/token \
   -d code_verifier="$VERIFIER"
   -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
 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
 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
   `client attach-redirect-uri|detach-redirect-uri` accept
   `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
   `--kind authorize|post_logout` (default `authorize`); `post_logout` URIs are
   the return targets for `POST /logout` (`GET /logout` is read-only).
   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`.
 - Users and entitlements: `user create|enroll-totp|disable|unlock|grant-scope|revoke-scope|scopes`.
 - Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
 - Admin: `admin bootstrap` (see [RUNBOOK.md](RUNBOOK.md)).
 - Declarative apply: `config sync --file` and `config check`.
 - 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.
 > Task 48 owns `SECURITY.md`; this file is the task-47 operator runbook.
 > The threat model and the replace-immediately rationale live there.
 > 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
 ## Prerequisites
 
 
 - Migrations applied exactly once via the migration Job (never per replica):
 - 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.
 # the padding-timing oracle does not apply. Re-evaluate when rsa ships a fix.
 ignore = [
 ignore = [
   { id = "RUSTSEC-2023-0071", reason = "unpatched upstream; RSA verify-only, never decryption" },
   { 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]
 [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/` | 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/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/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/store/` | Redis ephemeral state and the durability barrier. See [REDIS.md](REDIS.md). |
 | `src/protocol/` | OAuth endpoints and grants, plus the subject-scoped Redis purges (`src/protocol/purge.rs`: `purge_subject` for `user disable`, `purge_sibling_sessions` on credential change). |
 | `src/protocol/` | OAuth endpoints and grants, plus the subject-scoped Redis purges (`src/protocol/purge.rs`: `purge_subject` for `user disable`, `purge_sibling_sessions` on credential change). |
+| `src/protocol/params.rs` | Shared raw-parameter parsing (`parse_pairs`, `repeated_values`, the form-content-type gate) and fail-closed RFC 8707 audience validation (`validate_resources`, `audience_binds`). |
 | `src/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `src/ui/` | Server-rendered login, consent, and device pages (Askama templates) plus the embedded same-origin stylesheet served at `/assets/app.css`. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/middleware/` | Request id, tracing and redaction, rate limiting, security headers, and audit. |
 | `src/resilience.rs` | Load-shed, timeout, and concurrency-limit guard between CORS and rate limiting. |
 | `src/resilience.rs` | Load-shed, timeout, and concurrency-limit guard between CORS and rate limiting. |
@@ -50,7 +52,9 @@ is fixed in `build_router_with_options` in `src/routes.rs`:
    `http_request` tracing span. Tracing and redaction live here.
    `http_request` tracing span. Tracing and redaction live here.
 2. Security headers: adds `Referrer-Policy`, `X-Content-Type-Options`, a CSP
 2. Security headers: adds `Referrer-Policy`, `X-Content-Type-Options`, a CSP
    with `frame-ancestors 'none'`, HSTS on the TLS listener, and
    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
    Sits just inside the request-id layer so short-circuiting layers below
    (CORS preflights, resilience 503s, rate-limit 429/503s, body-limit 413s)
    (CORS preflights, resilience 503s, rate-limit 429/503s, body-limit 413s)
    still carry the headers.
    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`
 the correct HTTP status. A dependency outage on a security path maps to `503`
 with `Retry-After`; it never fails open.
 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 two listeners
 
 
 - The public listener binds `bind_addr` (default `0.0.0.0:8080`) and speaks TLS
 - 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:
 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
   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
   do re-read Postgres on every use: a non-empty subject resolving to a disabled
   user yields inactive (introspection) or `invalid_grant` (refresh) without
   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
   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
   is down it returns `503` rather than advertising an empty or unknown scope
   list.
   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
 - Migrations are applied out of band. A replica whose schema version is behind
   the binary reports not ready and is pulled from service.
   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.
 set. Mounted files are read and trimmed of trailing newlines.
 
 
 String-list keys (`cors_allowed_origins`, `rate_limit.trusted_proxies`,
 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"]'` and
 `D_OAUTH_RATE_LIMIT__TRUSTED_PROXIES='10.0.0.5,10.0.1.0/24'` load; a blank
 `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
 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). |
 | `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. |
 | `allow_token_exchange` | `false` | Enables the token-exchange grant. |
 | `token_exchange_allowlist` | empty | Rules of `{caller, subject, scopes[]}` for token exchange. |
 | `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
 ## 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
 `token_exchange_allowlist` entries are `{caller, subject, scopes[]}`; caller and
 subject may not be empty.
 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 timeout ladder
 
 
 The four database and request timeouts must satisfy:
 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
 - `catalog`: only redirect URIs present in the runtime catalog may be
   registered. This is the default.
   registered. This is the default.
 - `catalog_loopback`: catalog URIs plus dynamic loopback URIs (RFC 8252).
 - `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
 - `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
   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
 ## 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. |
 | 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. |
 | 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. |
 | 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. |
 | Redis store | `src/store/` | Ephemeral state: codes, tokens, grants, sessions, PAR, device, DCR, rate limits. |
 | Protocol handlers | `src/protocol/` | OAuth endpoints and grants. |
 | Protocol handlers | `src/protocol/` | OAuth endpoints and grants. |
 | Server-rendered UI | `src/ui/` | Login, consent, and device pages. |
 | 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
 lifecycle, and the operational rules for applying migrations. It is the
 reference for anyone changing a table or a query.
 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
 bookkeeping table. They create schema only and seed zero rows, so a fresh
 install starts empty by design.
 install starts empty by design.
 
 
@@ -50,6 +50,7 @@ One row per registered client (RFC 7591 / RFC 7592).
 | `active` | `bool` | Soft disable, default true. |
 | `active` | `bool` | Soft disable, default true. |
 | `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
 | `registration_access_token_hash` | `text` | Keyed hash of the RFC 7592 management token. |
 | `dpop_bound_access_tokens` | `bool` | Whether this client's tokens require DPoP proof (`migrations/1791244800_client_dpop_bound.sql`). |
 | `dpop_bound_access_tokens` | `bool` | Whether this client's tokens require DPoP proof (`migrations/1791244800_client_dpop_bound.sql`). |
+| `access_token_format` | `text` | `opaque` (default) or `jwt`, checked by `ck_oauth_clients_access_token_format`; `jwt` is honored only while JWT access tokens are enabled (`migrations/1791504000_resource_servers.sql`). Dynamic registration always inserts `opaque`; only `client set-access-token-format` changes it. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 | `created_at`, `updated_at` | `timestamptz` | Audit timestamps. |
 
 
 Four partial unique indexes enforce client-identity uniqueness. Two are on the
 Four partial unique indexes enforce client-identity uniqueness. Two are on the
@@ -166,6 +167,23 @@ The join between a client and a catalog redirect URI.
 
 
 Composite primary key `(client_id, redirect_uri_id)`.
 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 citext extension
 
 
 The `users` migration runs `CREATE EXTENSION IF NOT EXISTS citext`. This makes
 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 |
 | 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` |
 | 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
 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
 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
 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:
 tables exist and are empty:
 
 
 ```sql
 ```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/1791331200_redirect_uri_kind.sql` for `redirect_uris.kind`.
 - `migrations/1791417600_pii_encryption.sql` for the `_enc`/`_index` PII
 - `migrations/1791417600_pii_encryption.sql` for the `_enc`/`_index` PII
   columns, their partial unique indexes, and the dropped `NOT NULL`s.
   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/db/` for the repositories.
 - `src/crypto/field.rs` for the field codec and blind index.
 - `src/crypto/field.rs` for the field codec and blind index.
 - `src/cli/pii/` for `pii backfill|verify|rotate`.
 - `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.
 - Resource requests and limits.
 - A `startupProbe` and `livenessProbe` on `/health/live`, and a
 - A `startupProbe` and `livenessProbe` on `/health/live`, and a
   `readinessProbe` on `/health/ready`, all an HTTP GET against the plaintext
   `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
 - `terminationGracePeriodSeconds` greater than the drain deadline. The server
   uses a one second readiness propagation grace and a bounded drain below the
   uses a one second readiness propagation grace and a bounded drain below the
   grace budget.
   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
 - **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
   `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
   DDL, no superuser. The migration Job and the Deployment can then use different
   credentials.
   credentials.
 - **Encrypt the transport and pin the CA.** Production requires
 - **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 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 |
 | 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 |
 | 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 |
 | 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 |
 | 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 |
 | `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
 only catalog-registered URIs; `catalog_loopback` additionally upserts RFC 8252
 loopback URIs; `open` additionally upserts any absolute `https` URI with a
 loopback URIs; `open` additionally upserts any absolute `https` URI with a
 non-empty host and no userinfo, fragment, or whitespace. Loopback URIs are
 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
 ## 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`,
 Parameters: `code`, `code_verifier`, `client_id`, optional `redirect_uri`,
 `client_secret` or `client_assertion_type` with `client_assertion`, and a DPoP
 `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
 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
 (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.
 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
 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
 refresh token keeps the presented scope re-checked against the current
 `enabled` catalog and the client's current registration and the user's 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
 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.
 Reference: RFC 8628 §3.1 through §3.5.
 
 
 Authorization: `POST /device_authorization` returns a device code, a user code,
 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.
 `GET /device` and `POST /device` and approves or denies.
 
 
 Polling: `grant_type=urn:ietf:params:oauth:grant-type:device_code` with
 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
 scope, the subject's entitlements, the enabled catalog, and the allowlist, and
 it can only narrow. Chained exchange is rejected.
 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
 ## PKCE
 
 
 Reference: RFC 7636, RFC 9700 §2.1.1.
 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
 - 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
   `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.
   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
 - 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.
   §2.3.1): `com.example.app:/cb` is accepted, `myapp:/cb` is rejected.
   `https` and the loopback `http` exception are unaffected.
   `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
 `302` with `error`, `error_description`, the echoed `state`, and the RFC 9207
 `iss`. This covers
 `iss`. This covers
 a missing or unsupported `response_type` (`invalid_request` when absent,
 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
 ## 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_client` | 401 with `WWW-Authenticate` for Basic or Bearer, else 400 | Bad client authentication; ambiguous mTLS certificate. |
 | `invalid_grant` | 400 | Unknown, consumed, or foreign code; rotation reuse; PKCE mismatch; proof against the wrong grant. |
 | `invalid_grant` | 400 | Unknown, consumed, or foreign code; rotation reuse; PKCE mismatch; proof against the wrong grant. |
 | `invalid_scope` | 400 | Unknown, disabled, over-registered, or non-entitled scope; refresh widening. |
 | `invalid_scope` | 400 | Unknown, disabled, over-registered, or non-entitled scope; refresh widening. |
+| `invalid_target` | 400, or 302 on the `/authorize` error redirect once `redirect_uri` is validated | Resource indicator rejected: flag off, non-`https` or relative value, unknown or disabled catalog row, or a refresh audience that exceeds the grant. |
 | `unsupported_grant_type` | 400 | A `grant_type` outside the five. |
 | `unsupported_grant_type` | 400 | A `grant_type` outside the five. |
 | `unsupported_response_type` | 302 error redirect | A `response_type` other than `code`. |
 | `unsupported_response_type` | 302 error redirect | A `response_type` other than `code`. |
 | `unauthorized_client` | 400 | A client that may not use the requested grant. |
 | `unauthorized_client` | 400 | A client that may not use the requested grant. |
@@ -285,7 +497,7 @@ on every use.
 
 
 | Client class | Binding |
 | 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. |
 | 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
 `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
 30 s clock-skew leeway. The single-use `jti` marker lives for the assertion's
 remaining validity, clamped to `[1 s, 360 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
 ## Metadata
 
 
 `GET /.well-known/oauth-authorization-server` returns the RFC 8414 document.
 `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
 `authorization_response_iss_parameter_supported` is true, so the authorization
 response carries `iss` (RFC 9207). The document advertises
 response carries `iss` (RFC 9207). The document advertises
 `token_endpoint_auth_signing_alg_values_supported = [ES256, EdDSA, RS256,
 `token_endpoint_auth_signing_alg_values_supported = [ES256, EdDSA, RS256,
 PS256]`; `dpop_signing_alg_values_supported` stays `[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
 ## JAR and PAR
 
 
 Reference: RFC 9101 §4.1 and §5.2, RFC 9126 §2 and §3.
 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/protocol/metadata.rs` for the RFC 8414 document.
 - `src/crypto/pkce.rs` for PKCE.
 - `src/crypto/pkce.rs` for PKCE.
 - `src/protocol/redirect_repo.rs` for redirect matching.
 - `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/jose/` for DPoP, JAR, and `private_key_jwt`.
 - `src/error.rs` for the error-to-HTTP mapping.
 - `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 `migrate` subcommand owns the schema and is the only writer of it.
 - The `serve` subcommand serves OAuth and never migrates and never provisions.
 - 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
 - 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.
   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 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 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 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`
 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
 unambiguous or nothing happens. Registering a second enabled `post_logout` URI
 is a configuration error, not a silent last-writer-wins.
 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
 ### Declarative config
 
 
 ```bash
 ```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
 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.
 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
 ## config sync file format
 
 
 The file is JSON. Every section is optional and defaults to empty.
 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/callback", "enabled": true },
     { "uri": "https://app.example.com/signed-out", "kind": "post_logout", "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": [
   "user_scopes": [
     { "email": "operator@example.com", "scope": "orders:read" }
     { "email": "operator@example.com", "scope": "orders:read" }
   ],
   ],
   "client_redirect_uris": [
   "client_redirect_uris": [
     { "client_id": "example-client", "uri": "https://app.example.com/callback" },
     { "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_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.
 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
 Redirect URIs must be absolute and are matched exactly at `/authorize` (or, for
 `kind: post_logout`, at `POST /logout`). `kind` defaults to `authorize`.
 `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:
 Apply and re-apply:
 
 
@@ -280,8 +526,10 @@ CLI mutations go to the `audit` target with `event = "cli.mutation"`, an
 `user.grant_scope`,
 `user.grant_scope`,
 `user.revoke_scope`, `scope.create`, `scope.enable`, `scope.disable`,
 `user.revoke_scope`, `scope.create`, `scope.enable`, `scope.disable`,
 `scope.delete`, `redirect_uri.create`, `redirect_uri.enable`,
 `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
 `pii.backfill`, `pii.rotate`, `totp.rewrap`, `admin.count.changed`, and the
 `admin.bootstrap.*` family. `pii verify` and `user scopes` are read-only and
 `admin.bootstrap.*` family. `pii verify` and `user scopes` are read-only and
 emit no mutation event.
 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
 replacing the mounted material and performing a rolling restart; there is no
 hot reload, by design.
 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
 ## PII and TOTP key rotation
 
 
 Both the PII field-encryption key and the TOTP encryption key are versioned in
 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.rs` for the command tree and flags.
 - `src/cli/ops.rs`, `src/cli/user.rs`, `src/cli/scope.rs`,
 - `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
 - `src/cli/pii/` for `pii backfill|verify|rotate` and `src/cli/totp.rs` for
   `totp rewrap`.
   `totp rewrap`.
 - `src/crypto/field.rs` for the PII field codec and blind index.
 - `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:ip:<sha256>:<sha256>` | Composite failed-login counter for one source IP and one account (both halves hashed; locks that IP only). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:unknown:<sha256>` | Unknown-label bucket for one source IP (hashed IP only; no per-label keys, so label enumeration cannot spray the keyspace). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:loginfail:unknown:<sha256>` | Unknown-label bucket for one source IP (hashed IP only; no per-label keys, so label enumeration cannot spray the keyspace). | `ui::login::LOCKOUT_WINDOW` (15 min) |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. The fingerprint is an HMAC under the token pepper (never the code itself), so a dump does not reveal the code. | 180 s (`USED_CODE_TTL`; fixed, 6× the 30 s step) |
 | `oauth:{state}:totp_replay:<fingerprint>` | TOTP used-code replay marker. The fingerprint is an HMAC under the token pepper (never the code itself), so a dump does not reveal the code. | 180 s (`USED_CODE_TTL`; fixed, 6× the 30 s step) |
+| `oauth:{state}:jwt:<jti-hash>` | JWT access-token kill marker (allowlist: a signed token is valid only while this hash is live). Carries `grant_id`, `client_id`, and the raw `subject` (email/user-id; empty for client-credentials), matching the `purge_subject` `subject` decoder. | `ttl.access` (300s) |
+| `oauth:{state}:cimd:<sha256>` | Cached client-id-metadata document (keyed by a hash of the URL, never the raw URL). | Bounded cache lifetime |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 | `oauth:{rl}:ratelimit:<bucket-hash>` | Fixed-window rate-limit counter. | `rate_limit.window` (60s) |
 
 
 `<hash>` is always a keyed hash. Tokens are opaque, so the server can look a
 `<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
 record up by re-hashing the presented value but cannot recover the raw value
 from storage.
 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
 ## Hash tags and single-slot atomicity
 
 
 Every token-state key carries the `{state}` hash tag, so one Lua operation that
 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
 - `grant:<id>` families via the grant cascade (member token keys, the
   `:tokens` set, the record, and member `:act` sidecars), plus standalone
   `:tokens` set, the record, and member `:act` sidecars), plus standalone
   `access:<hash>` (with `:act`) and `refresh:<hash>` records.
   `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:`,
 Records without a `subject` field are never touched: `par:`, `pending:`,
 `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the
 `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the

+ 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 |
 | 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. |
 | 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. |
 | 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. |
 | 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. |
 | 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. |
 | 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`). |
 | 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. |
 | 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. |
 | 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. |
 | 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
 Deploy provenance is a documented verify-before-rollout path
 (`docs/DEPLOYMENT.md`, `RUNBOOK.md`): the signed `ghcr.io` image verified with
 (`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
 `cosign verify` and a recorded digest, with local builds kept only as a signed
@@ -95,11 +130,20 @@ fallback.
 
 
 ## Browser and intermediary caching
 ## 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
   `Cache-Control: no-store` (+ `Pragma: no-cache` for legacy clients). An
   authorization page, consent page, token body, request handle, or error is
   authorization page, consent page, token body, request handle, or error is
   therefore never reused by a browser, the back/forward cache, or a shared
   therefore never reused by a browser, the back/forward cache, or a shared
   intermediary.
   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
 - This covers the `GET /login` and `GET /consent` pages (which carry a
   session-bound CSRF token and the bound `request_id`), the `/authorize`
   session-bound CSRF token and the bound `request_id`), the `/authorize`
   redirects and error responses, the token endpoint (success and error), PAR,
   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.
 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
 ## Sender constraints
 
 
 Reference: RFC 9449 (DPoP), RFC 8705 (mTLS).
 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 |
 | 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. |
 | 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.
 - `cnf` propagates to every newly minted token.
 - A stored `cnf` alone never mints: the proof is re-verified per use.
 - 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,
 - For token exchange, the proof must match the subject token's confirmation,
@@ -186,6 +298,77 @@ Reference: RFC 9126, RFC 9101.
   blocked address.
   blocked address.
 - PAR credentials are read from the header and body only, never the query.
 - 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
 ## Key ring and rotation
 
 
 - One versioned `KeyRing` holds three independent chains, newest first: the
 - One versioned `KeyRing` holds three independent chains, newest first: the
@@ -200,8 +383,10 @@ Reference: RFC 9126, RFC 9101.
   (`totp rewrap`). See
   (`totp rewrap`). See
   [OPERATIONS.md](OPERATIONS.md#pii-and-totp-key-rotation).
   [OPERATIONS.md](OPERATIONS.md#pii-and-totp-key-rotation).
 - `Debug` prints key ids only, never key material.
 - `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
 ## 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
   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
   contain reverse-DNS dot notation (draft-16 §2.3.1): `com.example.app:/` is
   accepted, `myapp:/` is rejected; `https` and loopback are unaffected.
   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.
 - PKCE is `S256` only. A verifier cannot be omitted to downgrade.
 - Scope is intersected: `requested ∩ client-registered ∩ user-entitled ∩
 - Scope is intersected: `requested ∩ client-registered ∩ user-entitled ∩
   enabled`. Refresh re-checks the current `enabled` catalog, the client's
   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;
     return;
   };
   };
   let registered: Vec<&str> = lines.take(MAX_REGISTERED).collect();
   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.
   // An empty catalog can never match.
   if registered.is_empty() {
   if registered.is_empty() {
     debug_assert_eq!(decision, RedirectDecision::Reject);
     debug_assert_eq!(decision, RedirectDecision::Reject);
+    debug_assert_eq!(localhost_decision, RedirectDecision::Reject);
   }
   }
   // `Exact` fires only for a character-for-character registered member.
   // `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 ops;
 mod pii;
 mod pii;
 mod redirect;
 mod redirect;
+mod resource;
 mod scope;
 mod scope;
 mod secret;
 mod secret;
 mod sync;
 mod sync;
@@ -90,6 +91,12 @@ pub enum Command {
     #[command(subcommand)]
     #[command(subcommand)]
     command: RedirectUriCommand,
     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.
   /// Manage per-client redirect-URI attachments.
   Client {
   Client {
     /// Client subcommand.
     /// Client subcommand.
@@ -318,6 +325,45 @@ pub enum ClientCommand {
     #[arg(long)]
     #[arg(long)]
     client_id: String,
     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.
 /// `config` subcommands.
@@ -446,6 +492,11 @@ pub async fn run(cli: Cli) -> Result<i32, CliError> {
       redirect::run_uri(&context, command).await?;
       redirect::run_uri(&context, command).await?;
       Ok(0)
       Ok(0)
     }
     }
+    Command::ResourceServer { command } => {
+      let context = context::Context::from_env().await?;
+      resource::run(&context, command).await?;
+      Ok(0)
+    }
     Command::Client { command } => {
     Command::Client { command } => {
       let context = context::Context::from_env().await?;
       let context = context::Context::from_env().await?;
       redirect::run_client(&context, command).await?;
       redirect::run_client(&context, command).await?;

+ 28 - 1
src/cli/redirect.rs

@@ -10,7 +10,7 @@ use super::{
 };
 };
 use crate::{
 use crate::{
   crypto::field::blind_index_candidates,
   crypto::field::blind_index_candidates,
-  db::{NewRedirectUri, RedirectKind, UpsertOutcome, redirect_repo},
+  db::{NewRedirectUri, RedirectKind, UpsertOutcome, client_repo, redirect_repo},
 };
 };
 
 
 /// Run a `redirect-uri` subcommand.
 /// Run a `redirect-uri` subcommand.
@@ -56,9 +56,36 @@ pub async fn run_client(context: &Context, command: ClientCommand) -> Result<(),
       kind,
       kind,
     } => detach(context, &client_id, &uri, &kind).await,
     } => detach(context, &client_id, &uri, &kind).await,
     ClientCommand::RedirectUris { client_id } => list_for_client(context, &client_id).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> {
 async fn list(context: &Context) -> Result<(), CliError> {
   let uris = redirect_repo::list(context.pool(), &context.config().keys, true).await?;
   let uris = redirect_repo::list(context.pool(), &context.config().keys, true).await?;
   if uris.is_empty() {
   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::{
 use super::{
   context::{CliError, Context, audit},
   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.
 /// Top-level declarative file.
 #[derive(Debug, Default, Deserialize)]
 #[derive(Debug, Default, Deserialize)]
@@ -23,12 +23,18 @@ pub struct SyncFile {
   /// Redirect-URI-catalog entries.
   /// Redirect-URI-catalog entries.
   #[serde(default)]
   #[serde(default)]
   pub redirect_uris: Vec<RedirectEntry>,
   pub redirect_uris: Vec<RedirectEntry>,
+  /// Resource-server (RFC 8707 resource indicator) catalog entries.
+  #[serde(default)]
+  pub resource_servers: Vec<ResourceServerEntry>,
   /// Per-user scope entitlements.
   /// Per-user scope entitlements.
   #[serde(default)]
   #[serde(default)]
   pub user_scopes: Vec<EntitlementEntry>,
   pub user_scopes: Vec<EntitlementEntry>,
   /// Per-client redirect-URI attachments.
   /// Per-client redirect-URI attachments.
   #[serde(default)]
   #[serde(default)]
   pub client_redirect_uris: Vec<AttachmentEntry>,
   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.
 /// A declarative scope.
@@ -69,6 +75,28 @@ pub struct EntitlementEntry {
   pub scope: String,
   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.
 /// A declarative client attachment.
 #[derive(Debug, Deserialize)]
 #[derive(Debug, Deserialize)]
 pub struct AttachmentEntry {
 pub struct AttachmentEntry {
@@ -93,10 +121,16 @@ pub struct SyncReport {
   pub redirects_created: u64,
   pub redirects_created: u64,
   /// Redirect URIs updated.
   /// Redirect URIs updated.
   pub redirects_updated: u64,
   pub redirects_updated: u64,
+  /// Resource servers inserted.
+  pub resource_servers_created: u64,
+  /// Resource servers updated.
+  pub resource_servers_updated: u64,
   /// Entitlements inserted.
   /// Entitlements inserted.
   pub entitlements_added: u64,
   pub entitlements_added: u64,
   /// Attachments inserted.
   /// Attachments inserted.
   pub attachments_added: u64,
   pub attachments_added: u64,
+  /// Client access-token formats changed.
+  pub client_formats_set: u64,
 }
 }
 
 
 impl SyncReport {
 impl SyncReport {
@@ -107,8 +141,11 @@ impl SyncReport {
       && self.scopes_updated == 0
       && self.scopes_updated == 0
       && self.redirects_created == 0
       && self.redirects_created == 0
       && self.redirects_updated == 0
       && self.redirects_updated == 0
+      && self.resource_servers_created == 0
+      && self.resource_servers_updated == 0
       && self.entitlements_added == 0
       && self.entitlements_added == 0
       && self.attachments_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 {
   fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
     write!(
     write!(
       f,
       f,
-      "config sync: scopes +{} ~{}; redirects +{} ~{}; entitlements +{}; attachments +{}",
+      "config sync: scopes +{} ~{}; redirects +{} ~{}; resource_servers +{} ~{}; \
+       entitlements +{}; attachments +{}; client_formats {}",
       self.scopes_created,
       self.scopes_created,
       self.scopes_updated,
       self.scopes_updated,
       self.redirects_created,
       self.redirects_created,
       self.redirects_updated,
       self.redirects_updated,
+      self.resource_servers_created,
+      self.resource_servers_updated,
       self.entitlements_added,
       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 {
   for entry in &file.user_scopes {
     let user_id = super::user::find_user_id(context, &entry.email).await?;
     let user_id = super::user::find_user_id(context, &entry.email).await?;
     report.entitlements_added += sqlx::query(
     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?);
       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());
   audit("config.sync", &report.to_string());
   Ok(report)
   Ok(report)
 }
 }

+ 183 - 4
src/cli/tests.rs

@@ -15,16 +15,16 @@ use sqlx::Row as _;
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 
 
 use super::{
 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},
   test_support::{self, TestError},
   user,
   user,
 };
 };
 use crate::{
 use crate::{
   config::Config,
   config::Config,
   crypto::totp,
   crypto::totp,
-  db::{client_repo, redirect_repo, user_repo},
+  db::{client_repo, redirect_repo, resource_repo, user_repo},
   keys::{KeyId, KeyRing},
   keys::{KeyId, KeyRing},
   state::{AppState, RedisConn},
   state::{AppState, RedisConn},
   ui::login::{
   ui::login::{
@@ -491,6 +491,8 @@ async fn cli_config_sync_is_idempotent() -> Result<(), TestError> {
     }],
     }],
     user_scopes: Vec::new(),
     user_scopes: Vec::new(),
     client_redirect_uris: 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?;
   let first = super::sync::apply(&context, &file).await?;
@@ -541,6 +543,7 @@ async fn insert_redirect_client(
       contacts: None,
       contacts: None,
       software_id: None,
       software_id: None,
       dpop_bound_access_tokens: false,
       dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
       registration_access_token_hash: None,
       registration_access_token_hash: None,
     },
     },
   )
   )
@@ -566,6 +569,8 @@ async fn config_sync_redirects() -> Result<(), TestError> {
     }],
     }],
     user_scopes: Vec::new(),
     user_scopes: Vec::new(),
     client_redirect_uris: 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?;
   let first = super::sync::apply(&context, &file).await?;
@@ -1837,3 +1842,177 @@ fn cli_password_file_trims_trailing_newline() -> Result<(), TestError> {
   );
   );
   Ok(())
   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;
 mod build;
 pub mod db;
 pub mod db;
+mod jwt;
 pub mod policy;
 pub mod policy;
 mod raw;
 mod raw;
 pub mod redis;
 pub mod redis;
@@ -36,10 +37,10 @@ pub mod scope;
 mod tests;
 mod tests;
 pub mod types;
 pub mod types;
 
 
-use raw::RawConfig;
-
 pub use db::DbConfig;
 pub use db::DbConfig;
+pub use jwt::{JwtPublicJwk, JwtSigningAlg, JwtSigningKey};
 pub use policy::{FailMode, FailurePolicy, RetryableClass};
 pub use policy::{FailMode, FailurePolicy, RetryableClass};
+use raw::RawConfig;
 pub use redis::{DurabilityMode, RedisConfig, RedisTopology};
 pub use redis::{DurabilityMode, RedisConfig, RedisTopology};
 pub use scope::{ClientClass, GrantKind, ScopeDefinition, TokenExchangeRule};
 pub use scope::{ClientClass, GrantKind, ScopeDefinition, TokenExchangeRule};
 pub use types::{
 pub use types::{
@@ -107,6 +108,29 @@ pub struct Config {
   pub allow_token_exchange: bool,
   pub allow_token_exchange: bool,
   /// Explicit `(caller, subject, scopes)` allowlist for token exchange.
   /// Explicit `(caller, subject, scopes)` allowlist for token exchange.
   pub token_exchange_allowlist: Vec<TokenExchangeRule>,
   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 {
 impl fmt::Debug for Config {
@@ -136,6 +160,29 @@ impl fmt::Debug for Config {
         "token_exchange_allowlist_len",
         "token_exchange_allowlist_len",
         &self.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()
       .finish_non_exhaustive()
   }
   }
 }
 }
@@ -358,6 +405,29 @@ pub enum ConfigError {
     /// The configuration key that carried the weak material.
     /// The configuration key that carried the weak material.
     key: &'static str,
     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.
   /// Argon2 parameters were outside the safe range.
   #[error("invalid argon2 parameters")]
   #[error("invalid argon2 parameters")]
   InvalidArgon2Params,
   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
 //! Kept in a submodule so the root `config.rs` stays focused on the public
 //! types and loader surface.
 //! types and loader surface.
 
 
-use std::{net::SocketAddr, path::Path, str::FromStr};
+use std::{net::SocketAddr, path::Path, str::FromStr, time::Duration};
 
 
 use secrecy::{ExposeSecret, SecretString};
 use secrecy::{ExposeSecret, SecretString};
 
 
 use super::{
 use super::{
   Config, ConfigError, TlsConfig,
   Config, ConfigError, TlsConfig,
   db::DbConfig,
   db::DbConfig,
+  jwt::{JWT_KEY_BYTES, JwtSigningKey},
   policy::FailurePolicy,
   policy::FailurePolicy,
   raw::{RawConfig, RawTls},
   raw::{RawConfig, RawTls},
   read_secret_file,
   read_secret_file,
@@ -36,6 +37,9 @@ impl Config {
       })?;
       })?;
     let tls = TlsConfig::from_raw(&raw.tls)?;
     let tls = TlsConfig::from_raw(&raw.tls)?;
     let keys = build_key_ring(&raw)?;
     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 database = DbConfig::from_raw(raw.database, environment)?;
     let redis = RedisConfig::from_raw(raw.redis)?;
     let redis = RedisConfig::from_raw(raw.redis)?;
     let failure_policy = FailurePolicy::from_raw(raw.failure_policy)?;
     let failure_policy = FailurePolicy::from_raw(raw.failure_policy)?;
@@ -81,6 +85,17 @@ impl Config {
       scope_registry,
       scope_registry,
       allow_token_exchange: raw.allow_token_exchange,
       allow_token_exchange: raw.allow_token_exchange,
       token_exchange_allowlist,
       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)
   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
 /// 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
 /// 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
 /// 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::{
 use super::{
   db::RawDbConfig,
   db::RawDbConfig,
+  jwt::JwtSigningAlg,
   policy::RawFailurePolicy,
   policy::RawFailurePolicy,
   redis::RawRedisConfig,
   redis::RawRedisConfig,
   scope::{RawScopeDefinition, RawTokenExchangeRule},
   scope::{RawScopeDefinition, RawTokenExchangeRule},
   types::{DcrRedirectPolicy, Environment, RawArgon2, RawRateLimit, RawTtlConfig},
   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.
 /// Raw `tls` block.
 #[derive(Debug, Clone, Default, Deserialize)]
 #[derive(Debug, Clone, Default, Deserialize)]
 #[serde(default)]
 #[serde(default)]
@@ -67,4 +76,29 @@ pub(crate) struct RawConfig {
   pub scope_registry: Vec<RawScopeDefinition>,
   pub scope_registry: Vec<RawScopeDefinition>,
   pub allow_token_exchange: bool,
   pub allow_token_exchange: bool,
   pub token_exchange_allowlist: Vec<RawTokenExchangeRule>,
   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
 /// The trusted-proxy allowlist defaults to empty: headers are never honored
 /// unless the operator configures the proxy peers explicitly.
 /// 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]
 #[test]
 fn config_trusted_proxies_default_empty() -> Result<(), Box<dyn Error>> {
 fn config_trusted_proxies_default_empty() -> Result<(), Box<dyn Error>> {
   let cfg = try_load(&base())?;
   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)]
 #[cfg(test)]
 mod crypto_tests {
 mod crypto_tests {
-  use super::*;
   use std::collections::HashSet;
   use std::collections::HashSet;
 
 
+  use super::*;
+
   fn is_base64url(s: &str) -> bool {
   fn is_base64url(s: &str) -> bool {
     s.bytes()
     s.bytes()
       .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
       .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')

+ 39 - 9
src/db/client_repo.rs

@@ -23,10 +23,9 @@
 mod tests;
 mod tests;
 mod types;
 mod types;
 
 
-pub use types::{Client, ClientMetadata, ClientRepoError, NewClient, TokenEndpointAuthMethod};
-
 use sqlx::PgPool;
 use sqlx::PgPool;
 use time::OffsetDateTime;
 use time::OffsetDateTime;
+pub use types::{Client, ClientMetadata, ClientRepoError, NewClient, TokenEndpointAuthMethod};
 use uuid::Uuid;
 use uuid::Uuid;
 
 
 use crate::crypto::field::{
 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, tls_client_auth_cert_thumbprint_enc, \
        tls_client_auth_cert_thumbprint_index, request_uris, request_uris_enc, contacts, \
        tls_client_auth_cert_thumbprint_index, request_uris, request_uris_enc, contacts, \
        contacts_enc, software_id, software_id_enc, registration_access_token_hash, \
        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, \
      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, \
      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, \
        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, \
        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, \
        tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
        request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
        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.id,
     &new.client_id,
     &new.client_id,
     new.client_secret_hash.as_deref(),
     new.client_secret_hash.as_deref(),
@@ -163,6 +162,7 @@ pub async fn insert(
     sealed.software_enc,
     sealed.software_enc,
     new.registration_access_token_hash.as_deref(),
     new.registration_access_token_hash.as_deref(),
     new.dpop_bound_access_tokens,
     new.dpop_bound_access_tokens,
+    &new.access_token_format,
   )
   )
   .fetch_one(pool)
   .fetch_one(pool)
   .await?;
   .await?;
@@ -192,6 +192,7 @@ pub async fn insert(
     contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
     contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
     software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
     software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
     dpop_bound_access_tokens: row.dpop_bound_access_tokens,
     dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+    access_token_format: row.access_token_format,
     active: row.active,
     active: row.active,
     created_at: row.created_at,
     created_at: row.created_at,
     updated_at: row.updated_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, \
        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, \
        tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
        request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
        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",
      FROM oauth_clients WHERE client_id = $1 AND active = true",
     client_id,
     client_id,
   )
   )
@@ -249,6 +250,7 @@ pub async fn find_active_by_client_id(
         contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
         contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
         software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
         software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
         dpop_bound_access_tokens: row.dpop_bound_access_tokens,
         dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+        access_token_format: row.access_token_format,
         active: row.active,
         active: row.active,
         created_at: row.created_at,
         created_at: row.created_at,
         updated_at: row.updated_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, \
        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, \
        tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
        request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
        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",
      FROM oauth_clients WHERE id = $1",
     id,
     id,
   )
   )
@@ -308,6 +310,7 @@ pub async fn find_by_id(
         contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
         contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
         software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
         software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
         dpop_bound_access_tokens: row.dpop_bound_access_tokens,
         dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+        access_token_format: row.access_token_format,
         active: row.active,
         active: row.active,
         created_at: row.created_at,
         created_at: row.created_at,
         updated_at: row.updated_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, \
        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, \
        tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
        request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
        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",
      FROM oauth_clients WHERE ($1 OR active = true) ORDER BY created_at, client_id",
     include_inactive,
     include_inactive,
   )
   )
@@ -368,6 +371,7 @@ pub async fn list(
         contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
         contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
         software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
         software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
         dpop_bound_access_tokens: row.dpop_bound_access_tokens,
         dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+        access_token_format: row.access_token_format,
         active: row.active,
         active: row.active,
         created_at: row.created_at,
         created_at: row.created_at,
         updated_at: row.updated_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, \
        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, \
        tls_client_auth_cert_thumbprint, tls_client_auth_cert_thumbprint_enc, request_uris, \
        request_uris_enc, contacts, contacts_enc, software_id, software_id_enc, \
        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,
     current.id,
     name_plain,
     name_plain,
     sealed.name_enc,
     sealed.name_enc,
@@ -537,6 +541,7 @@ pub async fn update_metadata(
         contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
         contacts: decrypt_optional_json(keys, row.contacts_enc, row.contacts)?,
         software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
         software_id: decrypt_optional_text(keys, row.software_id_enc, row.software_id)?,
         dpop_bound_access_tokens: row.dpop_bound_access_tokens,
         dpop_bound_access_tokens: row.dpop_bound_access_tokens,
+        access_token_format: row.access_token_format,
         active: row.active,
         active: row.active,
         created_at: row.created_at,
         created_at: row.created_at,
         updated_at: row.updated_at,
         updated_at: row.updated_at,
@@ -587,6 +592,31 @@ pub async fn rotate_secret_hash(
   Ok(result.rows_affected() > 0)
   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.
 /// Verify a presented client secret against the stored Argon2id hash.
 ///
 ///
 /// Returns `None` when no active client has that `client_id` or the client has
 /// 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,
     contacts: None,
     software_id: None,
     software_id: None,
     dpop_bound_access_tokens: false,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
     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>,
   pub software_id: Option<String>,
   /// Whether the client registered DPoP-bound access tokens (RFC 9449 §5).
   /// Whether the client registered DPoP-bound access tokens (RFC 9449 §5).
   pub dpop_bound_access_tokens: bool,
   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).
   /// Whether the client is active (soft-delete flag).
   pub active: bool,
   pub active: bool,
   /// Row creation timestamp.
   /// Row creation timestamp.
@@ -104,6 +107,7 @@ impl fmt::Debug for Client {
       .field("contacts", &"[REDACTED]")
       .field("contacts", &"[REDACTED]")
       .field("software_id", &"[REDACTED]")
       .field("software_id", &"[REDACTED]")
       .field("dpop_bound_access_tokens", &self.dpop_bound_access_tokens)
       .field("dpop_bound_access_tokens", &self.dpop_bound_access_tokens)
+      .field("access_token_format", &self.access_token_format)
       .field("active", &self.active)
       .field("active", &self.active)
       .field("created_at", &self.created_at)
       .field("created_at", &self.created_at)
       .field("updated_at", &self.updated_at)
       .field("updated_at", &self.updated_at)
@@ -223,6 +227,9 @@ pub struct NewClient {
   pub software_id: Option<String>,
   pub software_id: Option<String>,
   /// Whether the client registers DPoP-bound access tokens (RFC 9449 §5).
   /// Whether the client registers DPoP-bound access tokens (RFC 9449 §5).
   pub dpop_bound_access_tokens: bool,
   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.
   /// Keyed hash of the RFC 7592 registration access token, if issued.
   pub registration_access_token_hash: Option<String>,
   pub registration_access_token_hash: Option<String>,
 }
 }

+ 1 - 0
src/db/mod.rs

@@ -6,6 +6,7 @@
 
 
 pub mod client_repo;
 pub mod client_repo;
 pub mod redirect_repo;
 pub mod redirect_repo;
+pub mod resource_repo;
 pub mod scope_repo;
 pub mod scope_repo;
 pub mod user_repo;
 pub mod user_repo;
 pub mod user_scope_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,
       contacts: None,
       software_id: None,
       software_id: None,
       dpop_bound_access_tokens: false,
       dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
       registration_access_token_hash: None,
       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 std::{net::IpAddr, time::Duration};
 
 
+use super::normalize_email;
 use crate::{
 use crate::{
   state::RedisConn,
   state::RedisConn,
   store::{self, Store, StoreError, sessions},
   store::{self, Store, StoreError, sessions},
 };
 };
 
 
-use super::normalize_email;
-
 /// Login lockout policy: after `threshold` failures from one IP for one
 /// Login lockout policy: after `threshold` failures from one IP for one
 /// account within `lock_duration`, that IP is locked out for the account;
 /// account within `lock_duration`, that IP is locked out for the account;
 /// after `account_threshold` failures from any IPs in the window, 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
 //! must never fail a live token. Every test gets a fresh migrated database
 //! from [`crate::db::test_support`].
 //! from [`crate::db::test_support`].
 
 
+use secrecy::SecretString;
+use sqlx::PgPool;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
 use super::{NewUser, SubjectAccountStatus, create_user, disable_user, subject_account_status};
 use super::{NewUser, SubjectAccountStatus, create_user, disable_user, subject_account_status};
 use crate::crypto::secret::{Argon2Params, hash_secret};
 use crate::crypto::secret::{Argon2Params, hash_secret};
 use crate::db::test_support::{TestError, migrated_pool};
 use crate::db::test_support::{TestError, migrated_pool};
 use crate::keys::{KeyId, KeyRing};
 use crate::keys::{KeyId, KeyRing};
-use secrecy::SecretString;
-use sqlx::PgPool;
 
 
 /// Test PII key ring (the blind-index candidates must derive from it).
 /// Test PII key ring (the blind-index candidates must derive from it).
 fn test_keys() -> Result<KeyRing, TestError> {
 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)]
 #[cfg(test)]
 mod tests {
 mod tests {
-  use super::*;
   use axum::body::Bytes;
   use axum::body::Bytes;
   use axum::http::{HeaderMap, header};
   use axum::http::{HeaderMap, header};
   use http_body_util::BodyExt as _;
   use http_body_util::BodyExt as _;
   use serde_json::Value;
   use serde_json::Value;
 
 
+  use super::*;
+
   /// Drive an `AppError` through `IntoResponse` and collect its status,
   /// Drive an `AppError` through `IntoResponse` and collect its status,
   /// headers, and parsed JSON body.
   /// headers, and parsed JSON body.
   async fn render(err: AppError) -> (StatusCode, HeaderMap, Value) {
   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 std::{collections::HashMap, time::Duration};
 
 
-use crate::{db::client_repo::Client, error::AppError, state::RedisConn, store::Store};
-
 use merge::merge_claims;
 use merge::merge_claims;
 use request_uri::resolve_uri;
 use request_uri::resolve_uri;
 use verify::verify_request_object;
 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.
 /// 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:";
 pub const PAR_REQUEST_URI_PREFIX: &str = "urn:ietf:params:oauth:request_uri:";
 /// Maximum fetched request-object body (SSRF size cap).
 /// 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
 /// neither, a public client falls back to the raw query while a
 /// confidential client is rejected (see [`require_par_or_jar`]).
 /// 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
 /// # Errors
 ///
 ///
 /// Returns `invalid_request_object` for malformed, mis-bound, or
 /// Returns `invalid_request_object` for malformed, mis-bound, or
@@ -71,7 +77,7 @@ pub async fn resolve(
   conn: &mut RedisConn,
   conn: &mut RedisConn,
   store: &Store,
   store: &Store,
   input: &JarRequest<'_>,
   input: &JarRequest<'_>,
-) -> Result<HashMap<String, String>, AppError> {
+) -> Result<MergedClaims, AppError> {
   match (input.request, input.request_uri) {
   match (input.request, input.request_uri) {
     (Some(_), Some(_)) => Err(AppError::invalid_request_object(
     (Some(_), Some(_)) => Err(AppError::invalid_request_object(
       "request and request_uri must not both be present",
       "request and request_uri must not both be present",
@@ -85,7 +91,12 @@ pub async fn resolve(
     }
     }
     (None, None) => {
     (None, None) => {
       require_par_or_jar(input.client)?;
       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
 //! statuses only) and then enforces the request-object body cap of
 //! [`MAX_REQUEST_OBJECT_BYTES`] with a UTF-8 requirement.
 //! [`MAX_REQUEST_OBJECT_BYTES`] with a UTF-8 requirement.
 
 
+use super::MAX_REQUEST_OBJECT_BYTES;
 use crate::{
 use crate::{
   error::AppError,
   error::AppError,
   protocol::ssrf::{self, SsrfError},
   protocol::ssrf::{self, SsrfError},
 };
 };
 
 
-use super::MAX_REQUEST_OBJECT_BYTES;
-
 /// Fetch a pre-registered request object behind the SSRF guard.
 /// Fetch a pre-registered request object behind the SSRF guard.
 ///
 ///
 /// # Errors
 /// # 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).
 /// 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
 /// # Errors
 ///
 ///
 /// Returns `invalid_request_object` on any conflict or forbidden member.
 /// Returns `invalid_request_object` on any conflict or forbidden member.
@@ -24,7 +38,7 @@ pub(crate) fn merge_claims(
   query: &HashMap<String, String>,
   query: &HashMap<String, String>,
   claims: &serde_json::Map<String, Value>,
   claims: &serde_json::Map<String, Value>,
   client_id: &str,
   client_id: &str,
-) -> Result<HashMap<String, String>, AppError> {
+) -> Result<MergedClaims, AppError> {
   if let Some(Value::String(claimed)) = claims.get("client_id") {
   if let Some(Value::String(claimed)) = claims.get("client_id") {
     if claimed != client_id {
     if claimed != client_id {
       return Err(AppError::invalid_request_object(
       return Err(AppError::invalid_request_object(
@@ -37,6 +51,9 @@ pub(crate) fn merge_claims(
     ));
     ));
   }
   }
   let mut merged = query.clone();
   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 {
   for (name, value) in claims {
     if name == "request" || name == "request_uri" {
     if name == "request" || name == "request_uri" {
       return Err(AppError::invalid_request_object(
       return Err(AppError::invalid_request_object(
@@ -46,6 +63,10 @@ pub(crate) fn merge_claims(
     if JWT_METADATA_CLAIMS.contains(&name.as_str()) {
     if JWT_METADATA_CLAIMS.contains(&name.as_str()) {
       continue;
       continue;
     }
     }
+    if name == "resource" {
+      resource = jar_resources(value)?;
+      continue;
+    }
     let rendered = claim_to_string(value)?;
     let rendered = claim_to_string(value)?;
     match merged.get(name) {
     match merged.get(name) {
       Some(seen) if seen != &rendered => {
       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.
 /// 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 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::{
 use crate::{
   db::client_repo::Client,
   db::client_repo::Client,
   error::AppError,
   error::AppError,
@@ -16,9 +19,6 @@ use crate::{
   store::{Store, par},
   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.
 /// Resolve a `request_uri`: PAR URN or pre-registered URI.
 ///
 ///
 /// # Errors
 /// # Errors
@@ -32,7 +32,7 @@ pub(crate) async fn resolve_uri(
   issuer: &str,
   issuer: &str,
   query: &HashMap<String, String>,
   query: &HashMap<String, String>,
   uri: &str,
   uri: &str,
-) -> Result<HashMap<String, String>, AppError> {
+) -> Result<MergedClaims, AppError> {
   if let Some(id) = uri.strip_prefix(PAR_REQUEST_URI_PREFIX) {
   if let Some(id) = uri.strip_prefix(PAR_REQUEST_URI_PREFIX) {
     return resolve_par(conn, store, client, query, id).await;
     return resolve_par(conn, store, client, query, id).await;
   }
   }
@@ -72,7 +72,7 @@ async fn resolve_par(
   client: &Client,
   client: &Client,
   query: &HashMap<String, String>,
   query: &HashMap<String, String>,
   id: &str,
   id: &str,
-) -> Result<HashMap<String, String>, AppError> {
+) -> Result<MergedClaims, AppError> {
   if id.is_empty() {
   if id.is_empty() {
     return Err(AppError::invalid_request("request_uri is malformed"));
     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",
       "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)]
 #[cfg(test)]
@@ -122,6 +127,7 @@ mod tests {
       contacts: None,
       contacts: None,
       software_id: None,
       software_id: None,
       dpop_bound_access_tokens: false,
       dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
       active: true,
       active: true,
       created_at: time::OffsetDateTime::UNIX_EPOCH,
       created_at: time::OffsetDateTime::UNIX_EPOCH,
       updated_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)]
 #[cfg(test)]
 mod tests {
 mod tests {
+  use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+
   use super::*;
   use super::*;
   use crate::error::OAuthErrorCode;
   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).
   /// Test body result (no `unwrap`/`expect` under the deny lints).
   type TestResult = Result<(), TestError>;
   type TestResult = Result<(), TestError>;
@@ -145,6 +146,7 @@ mod tests {
       contacts: None,
       contacts: None,
       software_id: None,
       software_id: None,
       dpop_bound_access_tokens: false,
       dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
       active: true,
       active: true,
       created_at: time::OffsetDateTime::UNIX_EPOCH,
       created_at: time::OffsetDateTime::UNIX_EPOCH,
       updated_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
 //! 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`.
 //! TODO(task 20): wire re-exports for `dpop`/`jar`/`jwk`.
 
 
 pub mod dpop;
 pub mod dpop;
 pub mod jar;
 pub mod jar;
 pub mod jwk;
 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::body::Body;
 use axum::http::{HeaderValue, Request, Response, StatusCode, header};
 use axum::http::{HeaderValue, Request, Response, StatusCode, header};
 use axum::response::IntoResponse;
 use axum::response::IntoResponse;
+use identity::{client_bucket_raw, client_ip, counter_key, extract_client_id};
 use redis::Script;
 use redis::Script;
 use serde::Serialize;
 use serde::Serialize;
 use tower::{Layer, Service};
 use tower::{Layer, Service};
 
 
 use crate::config::RateLimitConfig;
 use crate::config::RateLimitConfig;
 use crate::state::AppState;
 use crate::state::AppState;
-use identity::{client_bucket_raw, client_ip, counter_key, extract_client_id};
 
 
 #[cfg(test)]
 #[cfg(test)]
 mod test_support;
 mod test_support;

+ 2 - 1
src/middleware/redaction.rs

@@ -139,10 +139,11 @@ fn redact_pair(pair: &str) -> String {
 
 
 #[cfg(test)]
 #[cfg(test)]
 mod redaction_tests {
 mod redaction_tests {
-  use super::*;
   use axum::http::{HeaderName, HeaderValue};
   use axum::http::{HeaderName, HeaderValue};
   use serde_json::json;
   use serde_json::json;
 
 
+  use super::*;
+
   #[test]
   #[test]
   fn redaction_header_map_masks_authorization_cookie_and_dpop() {
   fn redaction_header_map_masks_authorization_cookie_and_dpop() {
     let mut headers = HeaderMap::new();
     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
 //! when the router serves the TLS public listener (`hsts: true`); the
 //! plaintext health listener never claims HSTS.
 //! 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
 //! [`cors_layer`] builds the explicit browser-facing CORS policy from
 //! `config.cors_allowed_origins`: an empty allowlist yields `None` (no CORS
 //! `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
 /// so caching it is intentional and safe. The explicit directive replaces
 /// browser heuristic freshness with a tunable, reviewable policy.
 /// browser heuristic freshness with a tunable, reviewable policy.
 pub const METADATA_CACHE_VALUE: &str = "public, max-age=3600";
 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).
 /// 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")
   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]
 #[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.
 /// Tower layer decorating responses with the security-header set.
@@ -130,20 +146,25 @@ where
 
 
   fn call(&mut self, request: Request<B>) -> Self::Future {
   fn call(&mut self, request: Request<B>) -> Self::Future {
     let page = is_page_path(request.uri().path());
     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 hsts = self.hsts;
     let mut inner = self.inner.clone();
     let mut inner = self.inner.clone();
     Box::pin(async move {
     Box::pin(async move {
       let mut response = inner.call(request).await?;
       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)
       Ok(response)
     })
     })
   }
   }
 }
 }
 
 
 /// Insert the header set on an outbound response (`page` selects the CSP;
 /// 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 };
   let csp = if page { PAGE_CSP_VALUE } else { API_CSP_VALUE };
   for (name, value) in [
   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);
     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));
     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 {
   if hsts {
     headers.insert(
     headers.insert(
       header::STRICT_TRANSPORT_SECURITY,
       header::STRICT_TRANSPORT_SECURITY,
@@ -224,8 +236,8 @@ mod tests {
   use axum::http::header;
   use axum::http::header;
 
 
   use super::{
   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]
   #[test]
@@ -239,17 +251,27 @@ mod tests {
   }
   }
 
 
   #[test]
   #[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"] {
     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]
   #[test]
   fn non_cacheable_response_forbids_storage() {
   fn non_cacheable_response_forbids_storage() {
     let mut headers = axum::http::HeaderMap::new();
     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| {
     let value = |name: header::HeaderName| {
       headers
       headers
         .get(name)
         .get(name)
@@ -265,7 +287,7 @@ mod tests {
   #[test]
   #[test]
   fn cacheable_metadata_sets_positive_cache_control() {
   fn cacheable_metadata_sets_positive_cache_control() {
     let mut headers = axum::http::HeaderMap::new();
     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
     let cache_control = headers
       .get(header::CACHE_CONTROL)
       .get(header::CACHE_CONTROL)
       .and_then(|value| value.to_str().ok())
       .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
 //! failure redirects to the validated `redirect_uri` with `error` (+ `error_description`, + `state`, + RFC 9207
 //! `iss`).
 //! `iss`).
 
 
-use std::collections::HashMap;
+use std::{collections::HashMap, time::Duration};
 
 
 use axum::{
 use axum::{
   body::Body,
   body::Body,
@@ -31,11 +31,27 @@ use crate::{
   db::{client_repo, redirect_repo, scope_repo},
   db::{client_repo, redirect_repo, scope_repo},
   error::{AppError, OAuthErrorCode},
   error::{AppError, OAuthErrorCode},
   jose::jar::{self, JarRequest},
   jose::jar::{self, JarRequest},
-  protocol::{redirect_repo as redirect_match, scope},
+  protocol::{cimd, redirect_repo as redirect_match, scope},
   state::RedisConn,
   state::RedisConn,
   store::Store,
   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.
 /// Inputs to [`validate`]: live stores plus the raw authorize query.
 pub struct AuthorizeInput<'a> {
 pub struct AuthorizeInput<'a> {
   /// Postgres pool for the client, redirect, and scope catalog reads.
   /// Postgres pool for the client, redirect, and scope catalog reads.
@@ -48,8 +64,19 @@ pub struct AuthorizeInput<'a> {
   pub store: &'a Store,
   pub store: &'a Store,
   /// Server issuer; the `aud` of JAR request objects.
   /// Server issuer; the `aud` of JAR request objects.
   pub issuer: &'a str,
   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>,
   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.
 /// An authorize request that passed every validation step.
@@ -70,6 +97,9 @@ pub struct ValidatedRequest {
   pub code_challenge: String,
   pub code_challenge: String,
   /// The `state` value to echo in the authorization response, if supplied.
   /// The `state` value to echo in the authorization response, if supplied.
   pub state: Option<String>,
   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.
   /// 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
   /// Resolved during validation so a later logout can return the browser to
   /// the client without accepting any redirect target from a request.
   /// 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.
 /// Validate an authorize request following the mandated order.
 ///
 ///
 /// Returns the normalized request when every check passes. Returns
 /// Returns the normalized request when every check passes. Returns
@@ -148,28 +209,61 @@ impl AuthorizeFailure {
 /// # Errors
 /// # Errors
 ///
 ///
 /// Returns [`AuthorizeFailure`] as described by the error-delivery matrix.
 /// 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"))
   let client_id = present(input.query.get("client_id"))
     .ok_or_else(|| page(AppError::invalid_request("client_id is required")))?;
     .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
     .await
     .map_err(AuthorizeFailure::Page)?
     .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 {
   let jar_input = JarRequest {
-    client: &client,
+    client: jar_client,
     issuer: input.issuer,
     issuer: input.issuer,
     query: input.query,
     query: input.query,
     request: present(input.query.get("request")).map(String::as_str),
     request: present(input.query.get("request")).map(String::as_str),
     request_uri: present(input.query.get("request_uri")).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
     .await
     .map_err(AuthorizeFailure::Page)?;
     .map_err(AuthorizeFailure::Page)?;
+  // Query-presented indicators union with JAR/PAR-pinned ones (both are
+  // catalog-validated below, so the union cannot smuggle anything past the
+  // catalog). A `resource` in both transports is already rejected by the
+  // merge as `invalid_request_object`.
+  let mut resources = input.resources.clone();
+  resources.extend(merged_claims.resource);
+  let merged = &merged_claims.params;
 
 
+  let expected_client_id = resolution.client_id();
   if merged
   if merged
     .get("client_id")
     .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(
     return Err(page(AppError::invalid_request(
       "client_id binding mismatch",
       "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"))
   let redirect_uri = present(merged.get("redirect_uri"))
     .ok_or_else(|| page(AppError::invalid_request("redirect_uri is required")))?;
     .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::Exact | redirect_match::RedirectDecision::Loopback => {}
     redirect_match::RedirectDecision::Reject => {
     redirect_match::RedirectDecision::Reject => {
       return Err(page(AppError::invalid_request(
       return Err(page(AppError::invalid_request(
@@ -200,6 +309,15 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
     iss: input.issuer.to_owned(),
     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")) {
   match present(merged.get("response_type")) {
     Some(response_type) if response_type == "code" => {}
     Some(response_type) if response_type == "code" => {}
     Some(_) => {
     Some(_) => {
@@ -218,9 +336,9 @@ pub async fn validate(input: AuthorizeInput<'_>) -> Result<ValidatedRequest, Aut
     .await
     .await
     .map_err(redirect_err)?;
     .map_err(redirect_err)?;
   let enabled_names: Vec<String> = enabled.iter().map(|record| record.name.clone()).collect();
   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 {
   let scope_input = scope::ScopeInput {
     requested: present(merged.get("scope")).map(String::as_str),
     requested: present(merged.get("scope")).map(String::as_str),
     client_registered: &client_registered,
     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()))?;
   verify_method(S256_METHOD).map_err(|_| redirect_err(AppError::server_error()))?;
   check_challenge_shape(challenge).map_err(redirect_err)?;
   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 {
   Ok(ValidatedRequest {
-    client_id: client.client_id,
+    client_id: resolution.client_id().to_owned(),
     redirect_uri: redirect,
     redirect_uri: redirect,
     scope: effective_scope,
     scope: effective_scope,
     code_challenge: challenge.clone(),
     code_challenge: challenge.clone(),
     state: merged.get("state").cloned(),
     state: merged.get("state").cloned(),
+    aud,
     post_logout_uri,
     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.
 /// Treat `None` and `""` alike: an absent parameter.
 fn present(value: Option<&String>) -> Option<&String> {
 fn present(value: Option<&String>) -> Option<&String> {
   value.filter(|text| !text.is_empty())
   value.filter(|text| !text.is_empty())

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

@@ -3,19 +3,24 @@
 use std::{collections::HashMap, time::Duration};
 use std::{collections::HashMap, time::Duration};
 
 
 use axum::http::{StatusCode, header};
 use axum::http::{StatusCode, header};
+use secrecy::SecretString;
 use uuid::Uuid;
 use uuid::Uuid;
 
 
 use super::{AuthorizeFailure, AuthorizeInput, validate};
 use super::{AuthorizeFailure, AuthorizeInput, validate};
 use crate::{
 use crate::{
   config::DurabilityMode,
   config::DurabilityMode,
   crypto::pkce::challenge_from_verifier,
   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},
   error::{AppError, OAuthErrorCode},
   keys::{KeyId, KeyRing},
   keys::{KeyId, KeyRing},
+  protocol::cimd,
   state::RedisConn,
   state::RedisConn,
   store::Store,
   store::Store,
+  test_support,
 };
 };
-use secrecy::SecretString;
 
 
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
 /// Boxed test error (no `unwrap`/`expect` under the crate deny lints).
 type TestResult<T = ()> = Result<T, Box<dyn std::error::Error + Send + Sync>>;
 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,
     contacts: None,
     software_id: None,
     software_id: None,
     dpop_bound_access_tokens: false,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
     registration_access_token_hash: None,
   }
   }
 }
 }
@@ -161,7 +167,11 @@ async fn authorize_validate_public_client_plain_query_proceeds() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   .map_err(|failure| format!("valid request rejected: {}", failure.code()))?;
   .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,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -225,7 +239,11 @@ async fn authorize_validate_unregistered_redirect_shows_page() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -258,7 +276,11 @@ async fn authorize_validate_token_response_type_redirects() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -317,7 +339,11 @@ async fn authorize_validate_unknown_scope_redirects() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -376,7 +402,11 @@ async fn authorize_validate_confidential_plain_query_rejected() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -409,7 +439,11 @@ async fn authorize_redirect_exact_match() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   .map_err(|failure| format!("exact redirect rejected: {}", failure.code()))?;
   .map_err(|failure| format!("exact redirect rejected: {}", failure.code()))?;
@@ -427,7 +461,11 @@ async fn authorize_redirect_exact_match() -> TestResult {
       conn: &mut conn,
       conn: &mut conn,
       store: &store,
       store: &store,
       issuer: ISSUER,
       issuer: ISSUER,
+      allow_localhost_redirect: false,
+      cimd: None,
       query: &params,
       query: &params,
+      resources: Vec::new(),
+      resource_indicators_enabled: false,
     })
     })
     .await
     .await
     {
     {
@@ -453,7 +491,11 @@ async fn authorize_redirect_exact_match() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -491,7 +533,11 @@ async fn authorize_loopback_allows_port() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   .map_err(|failure| format!("loopback port rejected: {}", failure.code()))?;
   .map_err(|failure| format!("loopback port rejected: {}", failure.code()))?;
@@ -507,7 +553,11 @@ async fn authorize_loopback_allows_port() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -537,7 +587,11 @@ async fn authorize_validate_missing_challenge_redirects() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -624,7 +678,11 @@ async fn authorize_post_logout_outage_redirects() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -664,7 +722,11 @@ async fn authorize_error_iss() -> TestResult {
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     store: &store,
     issuer: ISSUER,
     issuer: ISSUER,
+    allow_localhost_redirect: false,
+    cimd: None,
     query: &params,
     query: &params,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
   })
   })
   .await
   .await
   {
   {
@@ -734,7 +796,11 @@ async fn authorize_validate_missing_response_type_redirects() -> TestResult {
       conn: &mut conn,
       conn: &mut conn,
       store: &store,
       store: &store,
       issuer: ISSUER,
       issuer: ISSUER,
+      allow_localhost_redirect: false,
+      cimd: None,
       query: &params,
       query: &params,
+      resources: Vec::new(),
+      resource_indicators_enabled: false,
     })
     })
     .await
     .await
     {
     {
@@ -778,3 +844,285 @@ async fn authorize_validate_missing_response_type_redirects() -> TestResult {
   }
   }
   Ok(())
   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},
   db::{Client, TokenEndpointAuthMethod, client_repo},
   error::AppError,
   error::AppError,
   keys::KeyRing,
   keys::KeyRing,
-  protocol::mtls,
+  protocol::{cimd, mtls},
   state::RedisConn,
   state::RedisConn,
   store::Store,
   store::Store,
   tls::PeerCertificates,
   tls::PeerCertificates,
@@ -143,6 +143,10 @@ pub struct AuthContext<'a> {
   pub token_endpoint: &'a str,
   pub token_endpoint: &'a str,
   /// Current time for secret-expiry enforcement.
   /// Current time for secret-expiry enforcement.
   pub now: OffsetDateTime,
   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.
 /// Authenticate a token-request client.
@@ -186,7 +190,7 @@ pub async fn authenticate_client(
     {
     {
       return Err(invalid_client_for(true));
       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)?;
     require_method(&client, TokenEndpointAuthMethod::ClientSecretBasic, true)?;
     authenticate_secret(
     authenticate_secret(
       stores.pool,
       stores.pool,
@@ -200,7 +204,7 @@ pub async fn authenticate_client(
     let Some(client_id) = req.client_id else {
     let Some(client_id) = req.client_id else {
       return Err(invalid_client_for(false));
       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)?;
     require_method(&client, TokenEndpointAuthMethod::ClientSecretPost, false)?;
     authenticate_secret(stores.pool, &client, secret.expose_secret(), ctx.now, false).await
     authenticate_secret(stores.pool, &client, secret.expose_secret(), ctx.now, false).await
   } else if let Some(assertion_str) = req.assertion {
   } else if let Some(assertion_str) = req.assertion {
@@ -213,11 +217,11 @@ pub async fn authenticate_client(
       (None, Some(iss)) => iss,
       (None, Some(iss)) => iss,
       (None, None) => return Err(invalid_client_for(false)),
       (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)?;
     require_method(&client, TokenEndpointAuthMethod::PrivateKeyJwt, false)?;
     assertion::authenticate(stores, &client, assertion_str, ctx).await
     assertion::authenticate(stores, &client, assertion_str, ctx).await
   } else if let Some(client_id) = req.client_id {
   } 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() {
     match client.auth_method() {
       Ok(TokenEndpointAuthMethod::None) => Ok(AuthenticatedClient {
       Ok(TokenEndpointAuthMethod::None) => Ok(AuthenticatedClient {
         client,
         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(
 async fn lookup_client(
-  pool: &PgPool,
-  keys: &KeyRing,
+  stores: &mut AuthStores<'_>,
+  cimd: Option<cimd::CimdSettings<'_>>,
   client_id: &str,
   client_id: &str,
   used_basic: bool,
   used_basic: bool,
 ) -> Result<Client, AppError> {
 ) -> 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(Some(client)) => Ok(client),
     Ok(None) => Err(invalid_client_for(used_basic)),
     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 {
   AuthContext {
     token_endpoint: endpoint,
     token_endpoint: endpoint,
     now: OffsetDateTime::now_utc(),
     now: OffsetDateTime::now_utc(),
+    cimd: None,
   }
   }
 }
 }
 
 
@@ -171,6 +172,7 @@ async fn insert_client(
     contacts: None,
     contacts: None,
     software_id: None,
     software_id: None,
     dpop_bound_access_tokens: false,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
     registration_access_token_hash: None,
   };
   };
   client_repo::insert(pool, &keys, false, &new).await?;
   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.
 /// Whether the code is issued to a public or a confidential client.
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 pub enum CodeClientKind {
 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,
   Public,
   /// Authenticated client: the DPoP `jkt` is recorded when proven.
   /// Authenticated client: the DPoP `jkt` is recorded when proven.
   Confidential,
   Confidential,
@@ -53,6 +54,10 @@ pub struct CodeIssueRequest<'a> {
   pub client_id: &'a str,
   pub client_id: &'a str,
   /// Public or confidential: selects the DPoP matrix.
   /// Public or confidential: selects the DPoP matrix.
   pub client_kind: CodeClientKind,
   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.
   /// Resource-owner subject the code is authorized for.
   pub user_id: &'a str,
   pub user_id: &'a str,
   /// Redirect URI the code is bound to.
   /// Redirect URI the code is bound to.
@@ -67,6 +72,9 @@ pub struct CodeIssueRequest<'a> {
   pub dpop_jkt: Option<&'a str>,
   pub dpop_jkt: Option<&'a str>,
   /// `state` value to echo in the success redirect, if supplied.
   /// `state` value to echo in the success redirect, if supplied.
   pub state: Option<&'a str>,
   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.
   /// Server issuer echoed as the RFC 9207 `iss` parameter.
   pub issuer: &'a str,
   pub issuer: &'a str,
 }
 }
@@ -243,7 +251,7 @@ impl CodeIssuer {
     if !request.approved_scope.is_subset_of(request.user_entitled) {
     if !request.approved_scope.is_subset_of(request.user_entitled) {
       return Err(CodeIssueError::UnentitledScope);
       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 raw = crypto_tokens::mint_token()?;
     let active = self.keys.first().ok_or(CodeIssueError::NoPepper)?;
     let active = self.keys.first().ok_or(CodeIssueError::NoPepper)?;
     let code_hash = crypto_tokens::token_hash(&active.generation, active.secret.as_slice(), &raw)
     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(),
       subject: request.user_id.to_owned(),
       grant_id: grant_id.clone(),
       grant_id: grant_id.clone(),
       cnf,
       cnf,
+      aud: request.aud.to_vec(),
     };
     };
     codes::issue(conn, store, &code_hash, &record, self.ttl).await?;
     codes::issue(conn, store, &code_hash, &record, self.ttl).await?;
     let location = success_redirect(request.redirect_uri, &raw, request.state, request.issuer);
     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());
   let bound = jkt.filter(|value| !value.is_empty());
   match kind {
   match kind {
-    CodeClientKind::Public => bound
+    CodeClientKind::Public if dpop_required => bound
       .map(str::to_owned)
       .map(str::to_owned)
       .ok_or(CodeIssueError::MissingDpopBinding),
       .ok_or(CodeIssueError::MissingDpopBinding),
+    CodeClientKind::Public => Ok(bound.unwrap_or_default().to_owned()),
     CodeClientKind::Confidential => 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 {
   CodeIssueRequest {
     client_id: "client-abc",
     client_id: "client-abc",
     client_kind: kind,
     client_kind: kind,
+    dpop_required: kind == CodeClientKind::Public,
     user_id: "user-1",
     user_id: "user-1",
     redirect_uri: REDIRECT,
     redirect_uri: REDIRECT,
     approved_scope: approved,
     approved_scope: approved,
@@ -95,6 +96,7 @@ fn request<'a>(
     code_challenge: CHALLENGE,
     code_challenge: CHALLENGE,
     dpop_jkt: jkt,
     dpop_jkt: jkt,
     state,
     state,
+    aud: &[],
     issuer: ISSUER,
     issuer: ISSUER,
   }
   }
 }
 }
@@ -209,6 +211,52 @@ async fn code_issue_redirect_carries_code_state_iss() -> TestResult {
   Ok(())
   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]
 #[tokio::test]
 async fn code_issue_stores_only_hash_with_bounded_ttl() -> TestResult {
 async fn code_issue_stores_only_hash_with_bounded_ttl() -> TestResult {
   let (state_prefix, ratelimit_prefix) = crate::test_support::unique_prefixes();
   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.
 /// 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)]
 #[derive(Debug, Clone, Deserialize)]
 pub struct RegisterRequest {
 pub struct RegisterRequest {
   /// Requested redirect URIs (required, non-empty).
   /// Requested redirect URIs (required, non-empty).
@@ -168,6 +170,23 @@ struct ValidatedRegistration {
   redirect_uris: Vec<String>,
   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).
 /// Axum entry point for `POST /register` (mounted by the router in task 40).
 ///
 ///
 /// # Errors
 /// # Errors
@@ -180,8 +199,9 @@ struct ValidatedRegistration {
 pub async fn register_handler(
 pub async fn register_handler(
   State(state): State<AppState>,
   State(state): State<AppState>,
   headers: HeaderMap,
   headers: HeaderMap,
-  Json(request): Json<RegisterRequest>,
+  Json(body): Json<serde_json::Value>,
 ) -> Result<(StatusCode, Json<RegisterResponse>), AppError> {
 ) -> Result<(StatusCode, Json<RegisterResponse>), AppError> {
+  let request = parse_register_request(body)?;
   let token = bearer_token(&headers);
   let token = bearer_token(&headers);
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
@@ -250,6 +270,7 @@ pub async fn register_client(
     contacts: validated.contacts.clone(),
     contacts: validated.contacts.clone(),
     software_id: validated.software_id.clone(),
     software_id: validated.software_id.clone(),
     dpop_bound_access_tokens: validated.dpop_bound,
     dpop_bound_access_tokens: validated.dpop_bound,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: Some(registration_hash),
     registration_access_token_hash: Some(registration_hash),
   };
   };
   let row = client_repo::insert(
   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
 /// anything else follows `dcr_redirect_policy` (`open` and dynamic loopback
 /// URIs are upserted into the catalog, then attached). An upsert that lands
 /// URIs are upserted into the catalog, then attached). An upsert that lands
 /// on an admin-disabled row is rejected rather than resurrected.
 /// 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(
 async fn resolve_redirects(
   pool: &PgPool,
   pool: &PgPool,
   config: &Config,
   config: &Config,
@@ -510,14 +540,14 @@ async fn resolve_redirects(
         ));
         ));
       }
       }
       DcrRedirectPolicy::CatalogLoopback => {
       DcrRedirectPolicy::CatalogLoopback => {
-        if !is_loopback_uri(uri) {
+        if !is_loopback_uri(uri, config.allow_localhost_redirect) {
           return Err(AppError::invalid_redirect_uri(
           return Err(AppError::invalid_redirect_uri(
             "redirect URI is not in the catalog or a loopback URI",
             "redirect URI is not in the catalog or a loopback URI",
           ));
           ));
         }
         }
       }
       }
       DcrRedirectPolicy::Open => {
       DcrRedirectPolicy::Open => {
-        if !is_open_redirect_uri(uri) {
+        if !is_open_redirect_uri(uri, config.allow_localhost_redirect) {
           return Err(AppError::invalid_redirect_uri(
           return Err(AppError::invalid_redirect_uri(
             "redirect URI must be an absolute https URI or a loopback 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
 /// 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('#') {
   let (before_fragment, fragment) = match uri.split_once('#') {
     Some((before, fragment)) => (before, Some(fragment)),
     Some((before, fragment)) => (before, Some(fragment)),
     None => (uri, None),
     None => (uri, None),
@@ -589,9 +620,10 @@ fn is_loopback_uri(uri: &str) -> bool {
       None => false,
       None => false,
     }
     }
   } else {
   } else {
+    let is_loopback = |host: &str| host == "127.0.0.1" || (allow_localhost && host == "localhost");
     match authority.split_once(':') {
     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
 /// RFC 8252 loopback URI, or an absolute `https` URI with a non-empty host
 /// and no userinfo, fragment, or whitespace. Accepting an arbitrary absolute
 /// and no userinfo, fragment, or whitespace. Accepting an arbitrary absolute
 /// URI here would turn the opt-in `open` policy into an open-redirect
 /// 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;
     return true;
   }
   }
   if uri.contains('#') || uri.chars().any(char::is_whitespace) {
   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,
 //! `test(dcr_manage)` delete suite: cross-client denial, DELETE, revocation,
 //! and Redis-loss survival.
 //! and Redis-loss survival.
 
 
-use uuid::Uuid;
-
 use testcontainers::{
 use testcontainers::{
   GenericImage, ImageExt as _,
   GenericImage, ImageExt as _,
   core::{IntoContainerPort, WaitFor},
   core::{IntoContainerPort, WaitFor},
   runners::AsyncRunner,
   runners::AsyncRunner,
 };
 };
+use uuid::Uuid;
 
 
 use super::delete::delete_client;
 use super::delete::delete_client;
 use super::read_client;
 use super::read_client;
@@ -17,7 +16,7 @@ use super::support::{
 };
 };
 use crate::db::client_repo::{self, NewClient};
 use crate::db::client_repo::{self, NewClient};
 use crate::error::OAuthErrorCode;
 use crate::error::OAuthErrorCode;
-use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenService};
+use crate::protocol::token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService};
 
 
 #[tokio::test]
 #[tokio::test]
 async fn dcr_manage_cross_client_forbidden() -> TestResult {
 async fn dcr_manage_cross_client_forbidden() -> TestResult {
@@ -164,10 +163,14 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
         &MintRequest {
         &MintRequest {
           client_id: registered.client_id.clone(),
           client_id: registered.client_id.clone(),
           client_kind: ClientKind::Confidential,
           client_kind: ClientKind::Confidential,
+          dpop_required: false,
           subject: String::new(),
           subject: String::new(),
           scope: String::new(),
           scope: String::new(),
           grant_id: Uuid::new_v4().to_string(),
           grant_id: Uuid::new_v4().to_string(),
           proof: SenderProof::none(),
           proof: SenderProof::none(),
+          aud: Vec::new(),
+          refresh_aud: Vec::new(),
+          format: TokenFormat::Opaque,
         },
         },
         now,
         now,
       )
       )
@@ -183,10 +186,14 @@ async fn dcr_manage_delete_revokes_tokens() -> TestResult {
       &MintRequest {
       &MintRequest {
         client_id: other.client_id.clone(),
         client_id: other.client_id.clone(),
         client_kind: ClientKind::Confidential,
         client_kind: ClientKind::Confidential,
+        dpop_required: false,
         subject: String::new(),
         subject: String::new(),
         scope: String::new(),
         scope: String::new(),
         grant_id: Uuid::new_v4().to_string(),
         grant_id: Uuid::new_v4().to_string(),
         proof: SenderProof::none(),
         proof: SenderProof::none(),
+        aud: Vec::new(),
+        refresh_aud: Vec::new(),
+        format: TokenFormat::Opaque,
       },
       },
       now,
       now,
     )
     )
@@ -363,6 +370,7 @@ async fn insert_thumbprint_client(
     contacts: None,
     contacts: None,
     software_id: None,
     software_id: None,
     dpop_bound_access_tokens: false,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: Some(registration_token_hash.to_owned()),
     registration_access_token_hash: Some(registration_token_hash.to_owned()),
   };
   };
   client_repo::insert(
   client_repo::insert(

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

@@ -1,6 +1,15 @@
 //! `test(dcr_register)` container suite: `POST /register` against real
 //! `test(dcr_register)` container suite: `POST /register` against real
 //! Postgres and Redis (IAT enforcement, metadata validation, the
 //! Postgres and Redis (IAT enforcement, metadata validation, the
 //! confidential sender-constraint rule, and hash-only persistence).
 //! 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;
 use std::time::Duration;
 
 
@@ -65,6 +74,18 @@ struct Harness {
 /// The Postgres container is owned by the shared `db` lease; Redis comes from
 /// The Postgres container is owned by the shared `db` lease; Redis comes from
 /// the shared compose service, isolated per test by [`unique_prefixes`].
 /// the shared compose service, isolated per test by [`unique_prefixes`].
 async fn harness(open: bool, policy: &str) -> Result<Harness, TestError> {
 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()
   let (_db, pool) = crate::db::test_support::migrated_pool()
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("{error}")))?;
     .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",
     "redis://127.0.0.1:1",
     open,
     open,
     policy,
     policy,
+    allow_localhost,
+    cimd,
   )?;
   )?;
   let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
   let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
   let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
   let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
@@ -97,6 +120,8 @@ fn test_config(
   redis_url: &str,
   redis_url: &str,
   open: bool,
   open: bool,
   policy: &str,
   policy: &str,
+  allow_localhost: bool,
+  cimd: bool,
 ) -> Result<Config, TestError> {
 ) -> Result<Config, TestError> {
   let fixture = serde_json::json!({
   let fixture = serde_json::json!({
     "issuer": "https://auth.example.test",
     "issuer": "https://auth.example.test",
@@ -110,6 +135,8 @@ fn test_config(
     "argon2": {"memory_kib": 8192, "iterations": 1, "parallelism": 1},
     "argon2": {"memory_kib": 8192, "iterations": 1, "parallelism": 1},
     "allow_open_registration": open,
     "allow_open_registration": open,
     "dcr_redirect_policy": policy,
     "dcr_redirect_policy": policy,
+    "allow_localhost_redirect": allow_localhost,
+    "cimd_enabled": cimd,
   });
   });
   let source = ::config::Config::try_from(&fixture)?;
   let source = ::config::Config::try_from(&fixture)?;
   Ok(Config::from_source(source)?)
   Ok(Config::from_source(source)?)
@@ -177,6 +204,15 @@ async fn issue_iat(harness: &mut Harness, uses: u64) -> Result<String, TestError
   Ok(token)
   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.
 /// A registration request against `redirect_uri` with DPoP binding.
 fn dpop_request(redirect_uri: &str, scope: Option<&str>) -> RegisterRequest {
 fn dpop_request(redirect_uri: &str, scope: Option<&str>) -> RegisterRequest {
   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]
 #[tokio::test]
 async fn dcr_register_bad_redirect_rejected() -> TestResult {
 async fn dcr_register_bad_redirect_rejected() -> TestResult {
   let mut harness = harness(false, "catalog").await?;
   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),
     Err(error) => expect_error(error, OAuthErrorCode::InvalidRedirectUri, 400),
   }
   }
 }
 }
-
 #[tokio::test]
 #[tokio::test]
 async fn dcr_register_bad_auth_method_rejected() -> TestResult {
 async fn dcr_register_bad_auth_method_rejected() -> TestResult {
   let mut harness = harness(false, "catalog").await?;
   let mut harness = harness(false, "catalog").await?;
@@ -836,40 +1051,65 @@ async fn dcr_register_jwks_xor_rejected() -> TestResult {
 
 
 #[test]
 #[test]
 fn dcr_register_loopback_shapes() {
 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]
 #[test]
 fn dcr_register_open_redirect_shapes() {
 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]
 #[test]
@@ -877,3 +1117,55 @@ fn dcr_register_bearer_exposes_nothing_secret() {
   let exposed = format!("{:?}", AppError::invalid_client_bearer());
   let exposed = format!("{:?}", AppError::invalid_client_bearer());
   assert!(!exposed.to_ascii_lowercase().contains("pepper"));
   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 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 rand::{TryRng as _, rngs::SysRng};
 use secrecy::{ExposeSecret as _, SecretString};
 use secrecy::{ExposeSecret as _, SecretString};
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
@@ -72,6 +72,34 @@ pub struct DeviceAuthorizationForm {
   pub client_assertion: Option<String>,
   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).
 /// Successful `POST /device_authorization` body (RFC 8628 §3.2).
 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
 #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
 pub struct DeviceAuthorizationResponse {
 pub struct DeviceAuthorizationResponse {
@@ -106,6 +134,9 @@ pub struct DeviceHttpInputs<'a> {
   pub assertion: Option<&'a str>,
   pub assertion: Option<&'a str>,
   /// Raw `DPoP` header (proof JWT), when present.
   /// Raw `DPoP` header (proof JWT), when present.
   pub dpop_proof: Option<&'a str>,
   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.
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
   pub peer_certs: Option<&'a PeerCertificates>,
 }
 }
@@ -130,6 +161,9 @@ pub struct DeviceContext<'a> {
   pub device_ttl: Duration,
   pub device_ttl: Duration,
   /// Current time as seconds since the Unix epoch (DPoP freshness).
   /// Current time as seconds since the Unix epoch (DPoP freshness).
   pub now_unix: i64,
   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.
 /// Sender-constraint binding captured at authorization time.
 ///
 ///
@@ -231,6 +265,15 @@ pub async fn authorize_device(
   if inputs.client_id.trim().is_empty() {
   if inputs.client_id.trim().is_empty() {
     return Err(AppError::invalid_request("client_id is required"));
     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 used_basic = inputs.basic_header.is_some();
   let unknown_client = || {
   let unknown_client = || {
     if used_basic {
     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"));
     return Err(AppError::invalid_dpop_proof("DPoP proof is required"));
   }
   }
 
 
@@ -321,6 +368,7 @@ pub async fn authorize_device(
     status: store::device::DeviceStatus::Pending,
     status: store::device::DeviceStatus::Pending,
     subject: String::new(),
     subject: String::new(),
     grant_id: String::new(),
     grant_id: String::new(),
+    aud,
   };
   };
   store::device::put(
   store::device::put(
     stores.conn,
     stores.conn,
@@ -379,6 +427,9 @@ async fn authenticate_confidential(
     token_endpoint: endpoint.as_str(),
     token_endpoint: endpoint.as_str(),
     now: time::OffsetDateTime::from_unix_timestamp(ctx.now_unix)
     now: time::OffsetDateTime::from_unix_timestamp(ctx.now_unix)
       .unwrap_or(time::OffsetDateTime::UNIX_EPOCH),
       .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?;
   client_auth::authenticate_client(&mut auth, &auth_ctx, &request).await?;
   Ok(())
   Ok(())
@@ -456,14 +507,48 @@ async fn put_binding(
 
 
 /// Axum handler for `POST /device_authorization` (wired by task 40).
 /// Axum handler for `POST /device_authorization` (wired by task 40).
 ///
 ///
-/// Thin glue: 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(
 pub async fn handler(
   State(state): State<AppState>,
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
   headers: HeaderMap,
-  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 dpop_proof = headers.get("dpop").and_then(|value| value.to_str().ok());
   let basic_header = headers
   let basic_header = headers
     .get(axum::http::header::AUTHORIZATION)
     .get(axum::http::header::AUTHORIZATION)
@@ -476,6 +561,7 @@ pub async fn handler(
     assertion_type: form.client_assertion_type.as_deref(),
     assertion_type: form.client_assertion_type.as_deref(),
     assertion: form.client_assertion.as_deref(),
     assertion: form.client_assertion.as_deref(),
     dpop_proof,
     dpop_proof,
+    resource: &resources,
     peer_certs: peer_certs.as_deref(),
     peer_certs: peer_certs.as_deref(),
   };
   };
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
@@ -490,7 +576,10 @@ pub async fn handler(
     issuer: &state.config.issuer,
     issuer: &state.config.issuer,
     device_ttl: state.config.ttl.device,
     device_ttl: state.config.ttl.device,
     now_unix: state.clock.now().unix_timestamp(),
     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,
   method: TokenEndpointAuthMethod,
   secret: Option<&str>,
   secret: Option<&str>,
   scope: &str,
   scope: &str,
+  dpop_bound: bool,
 ) -> TestResult {
 ) -> TestResult {
   let params =
   let params =
     Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::KeyGen(format!("{error:?}")))?;
     Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::KeyGen(format!("{error:?}")))?;
@@ -151,7 +152,8 @@ async fn insert_client(
     request_uris: None,
     request_uris: None,
     contacts: None,
     contacts: None,
     software_id: 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,
     registration_access_token_hash: None,
   };
   };
   client_repo::insert(pool, keys, false, &new).await?;
   client_repo::insert(pool, keys, false, &new).await?;
@@ -230,6 +232,7 @@ async fn issue(
     issuer: &h.config.issuer,
     issuer: &h.config.issuer,
     device_ttl: h.config.ttl.device,
     device_ttl: h.config.ttl.device,
     now_unix: now_unix(),
     now_unix: now_unix(),
+    resource_indicators_enabled: false,
   };
   };
   authorize_device(&mut stores, &ctx, inputs).await
   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,
     TokenEndpointAuthMethod::None,
     None,
     None,
     "orders:read",
     "orders:read",
+    false,
   )
   )
   .await?;
   .await?;
 
 
@@ -259,6 +263,7 @@ async fn test_device_authorize_public_client_issues_code_and_interval() -> TestR
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
     peer_certs: None,
   };
   };
   let ttl = h.config.ttl.device;
   let ttl = h.config.ttl.device;
@@ -346,6 +351,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
     TokenEndpointAuthMethod::ClientSecretPost,
     TokenEndpointAuthMethod::ClientSecretPost,
     Some("post-secret"),
     Some("post-secret"),
     "orders:read",
     "orders:read",
+    false,
   )
   )
   .await?;
   .await?;
 
 
@@ -358,6 +364,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     dpop_proof: None,
     dpop_proof: None,
+    resource: &[],
     peer_certs: None,
     peer_certs: None,
   };
   };
   let response = issue(&mut h, &inputs)
   let response = issue(&mut h, &inputs)
@@ -382,6 +389,7 @@ async fn test_device_authorize_confidential_client_secret_post() -> TestResult {
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     dpop_proof: None,
     dpop_proof: None,
+    resource: &[],
     peer_certs: None,
     peer_certs: None,
   };
   };
   match issue(&mut h, &bad).await {
   match issue(&mut h, &bad).await {
@@ -405,6 +413,7 @@ async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResu
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     None,
     None,
     "orders:read",
     "orders:read",
+    true,
   )
   )
   .await?;
   .await?;
 
 
@@ -416,6 +425,7 @@ async fn test_device_authorize_public_client_without_dpop_rejected() -> TestResu
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     dpop_proof: None,
     dpop_proof: None,
+    resource: &[],
     peer_certs: None,
     peer_certs: None,
   };
   };
   match issue(&mut h, &inputs).await {
   match issue(&mut h, &inputs).await {
@@ -441,6 +451,7 @@ async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     None,
     None,
     "orders:read",
     "orders:read",
+    false,
   )
   )
   .await?;
   .await?;
 
 
@@ -455,6 +466,7 @@ async fn test_device_authorize_over_registered_scope_rejected() -> TestResult {
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
     peer_certs: None,
   };
   };
   match issue(&mut h, &inputs).await {
   match issue(&mut h, &inputs).await {
@@ -480,6 +492,7 @@ async fn test_device_authorize_approval_transitions_state() -> TestResult {
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     None,
     None,
     "orders:read",
     "orders:read",
+    false,
   )
   )
   .await?;
   .await?;
 
 
@@ -494,6 +507,7 @@ async fn test_device_authorize_approval_transitions_state() -> TestResult {
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
     peer_certs: None,
   };
   };
   let response = issue(&mut h, &inputs)
   let response = issue(&mut h, &inputs)
@@ -561,6 +575,7 @@ async fn test_device_authorize_unknown_and_expired_user_code_rejected() -> TestR
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     None,
     None,
     "",
     "",
+    false,
   )
   )
   .await?;
   .await?;
   let key = fresh_key()?;
   let key = fresh_key()?;
@@ -574,6 +589,7 @@ async fn test_device_authorize_unknown_and_expired_user_code_rejected() -> TestR
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
     peer_certs: None,
   };
   };
   let response = issue(&mut h, &inputs)
   let response = issue(&mut h, &inputs)
@@ -639,6 +655,7 @@ async fn test_device_authorize_verification_uri_complete_carries_user_code() ->
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     None,
     None,
     "orders:read",
     "orders:read",
+    false,
   )
   )
   .await?;
   .await?;
 
 
@@ -653,6 +670,7 @@ async fn test_device_authorize_verification_uri_complete_carries_user_code() ->
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
     peer_certs: None,
   };
   };
   let response = issue(&mut h, &inputs)
   let response = issue(&mut h, &inputs)
@@ -728,6 +746,7 @@ async fn test_device_authorize_user_code_misses_are_generic_and_normalized() ->
     TokenEndpointAuthMethod::None,
     TokenEndpointAuthMethod::None,
     None,
     None,
     "orders:read",
     "orders:read",
+    false,
   )
   )
   .await?;
   .await?;
   let key = fresh_key()?;
   let key = fresh_key()?;
@@ -741,6 +760,7 @@ async fn test_device_authorize_user_code_misses_are_generic_and_normalized() ->
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     dpop_proof: Some(proof.as_str()),
     dpop_proof: Some(proof.as_str()),
+    resource: &[],
     peer_certs: None,
     peer_certs: None,
   };
   };
   let response = issue(&mut h, &inputs)
   let response = issue(&mut h, &inputs)

+ 262 - 10
src/protocol/introspect.rs

@@ -21,10 +21,12 @@ mod tests;
 
 
 use std::time::Duration;
 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 secrecy::SecretString;
 use serde::{Deserialize, Serialize};
 use serde::{Deserialize, Serialize};
+use sha2::{Digest as _, Sha256};
 use sqlx::PgPool;
 use sqlx::PgPool;
+use subtle::ConstantTimeEq as _;
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 
 
 use super::{
 use super::{
@@ -34,6 +36,7 @@ use super::{
 use crate::{
 use crate::{
   db::{Client, TokenEndpointAuthMethod, user_repo},
   db::{Client, TokenEndpointAuthMethod, user_repo},
   error::{AppError, OAuthErrorCode},
   error::{AppError, OAuthErrorCode},
+  jose::jwt_access::{JwtAccessSigner, is_jwt_shape, jti_hash, marker_live_strict, subject_active},
   state::{AppState, RedisConn},
   state::{AppState, RedisConn},
   store::{Store, StoreError},
   store::{Store, StoreError},
   tls::PeerCertificates,
   tls::PeerCertificates,
@@ -70,6 +73,35 @@ pub struct IntrospectForm {
   pub client_assertion: Option<String>,
   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.
 /// RFC 7662 §2.2 introspection response.
 ///
 ///
 /// Inactive responses serialize to exactly `{"active": false}`: every other
 /// Inactive responses serialize to exactly `{"active": false}`: every other
@@ -103,6 +135,14 @@ pub struct IntrospectionResponse {
   /// RFC 8693 actor claim, for token-exchange tokens.
   /// RFC 8693 actor claim, for token-exchange tokens.
   #[serde(skip_serializing_if = "Option::is_none")]
   #[serde(skip_serializing_if = "Option::is_none")]
   pub act: Option<serde_json::Value>,
   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 {
 impl IntrospectionResponse {
@@ -119,6 +159,7 @@ impl IntrospectionResponse {
       sub: None,
       sub: None,
       cnf: None,
       cnf: None,
       act: None,
       act: None,
+      aud: None,
     }
     }
   }
   }
 }
 }
@@ -142,6 +183,15 @@ pub struct IntrospectHttpInputs<'a> {
   pub assertion: Option<&'a str>,
   pub assertion: Option<&'a str>,
   /// Verified peer chain injected by the TLS accept loop, when present.
   /// Verified peer chain injected by the TLS accept loop, when present.
   pub peer_certs: Option<&'a PeerCertificates>,
   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`].
 /// Datastore handles needed by [`introspect`].
@@ -162,8 +212,18 @@ pub struct IntrospectContext<'a> {
   pub issuer: &'a str,
   pub issuer: &'a str,
   /// Current time as seconds since the Unix epoch.
   /// Current time as seconds since the Unix epoch.
   pub now_unix: i64,
   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.
   /// Token service: pepper-retried lookup plus configured lifetimes.
   pub service: &'a TokenService,
   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.
 /// Which store record a raw token resolved to.
@@ -187,12 +247,20 @@ struct Located {
   subject: String,
   subject: String,
   /// Stored `cnf` (empty when unbound).
   /// Stored `cnf` (empty when unbound).
   cnf: String,
   cnf: String,
+  /// Resource audiences bound to the record (RFC 8707).
+  aud: Vec<String>,
   /// Access or refresh record.
   /// Access or refresh record.
   kind: TokenKind,
   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
 /// # Errors
 ///
 ///
@@ -200,6 +268,8 @@ struct Located {
 /// uses the public `none` method, `invalid_request` for an empty `token`, and
 /// uses the public `none` method, `invalid_request` for an empty `token`, and
 /// a masked `5xx` when a datastore fails. Unknown, expired, revoked, foreign,
 /// a masked `5xx` when a datastore fails. Unknown, expired, revoked, foreign,
 /// and inactive tokens are never errors: they return the inactive response.
 /// 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(
 pub async fn introspect(
   stores: &mut IntrospectStores<'_>,
   stores: &mut IntrospectStores<'_>,
   ctx: &IntrospectContext<'_>,
   ctx: &IntrospectContext<'_>,
@@ -211,6 +281,9 @@ pub async fn introspect(
   }
   }
   let client = authenticate(stores, ctx, inputs).await?;
   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 prefer_refresh = inputs.token_type_hint == Some(REFRESH_TOKEN_HINT);
   let located = locate(ctx.service, stores.conn, stores.store, raw, prefer_refresh)
   let located = locate(ctx.service, stores.conn, stores.store, raw, prefer_refresh)
     .await
     .await
@@ -241,9 +314,115 @@ pub async fn introspect(
   {
   {
     return Ok(IntrospectionResponse::inactive());
     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
   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.
 /// Authenticate the introspection caller, rejecting the public `none` method.
 ///
 ///
 /// RFC 7662 §2.3 requires HTTP 401 for invalid client credentials, so every
 /// RFC 7662 §2.3 requires HTTP 401 for invalid client credentials, so every
@@ -272,6 +451,7 @@ async fn authenticate(
   let auth_ctx = AuthContext {
   let auth_ctx = AuthContext {
     token_endpoint: &endpoint,
     token_endpoint: &endpoint,
     now,
     now,
+    cimd: ctx.cimd,
   };
   };
   let authenticated = client_auth::authenticate_client(&mut auth_stores, &auth_ctx, &request)
   let authenticated = client_auth::authenticate_client(&mut auth_stores, &auth_ctx, &request)
     .await
     .await
@@ -317,6 +497,7 @@ async fn locate(
           scope: found.record.scope,
           scope: found.record.scope,
           subject: found.record.subject,
           subject: found.record.subject,
           cnf: found.record.cnf,
           cnf: found.record.cnf,
+          aud: found.record.aud,
           kind: TokenKind::Access,
           kind: TokenKind::Access,
         }),
         }),
       TokenKind::Refresh => service
       TokenKind::Refresh => service
@@ -328,6 +509,7 @@ async fn locate(
           scope: found.record.scope,
           scope: found.record.scope,
           subject: found.record.subject,
           subject: found.record.subject,
           cnf: found.record.cnf,
           cnf: found.record.cnf,
+          aud: found.record.aud,
           kind: TokenKind::Refresh,
           kind: TokenKind::Refresh,
         }),
         }),
     };
     };
@@ -374,6 +556,7 @@ async fn active_response(
     sub: (!located.subject.is_empty()).then_some(located.subject),
     sub: (!located.subject.is_empty()).then_some(located.subject),
     cnf: cnf.is_bound().then_some(cnf),
     cnf: cnf.is_bound().then_some(cnf),
     act,
     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).
 /// 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(
 pub async fn handler(
   State(state): State<AppState>,
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
   headers: HeaderMap,
-  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
   let basic_header = headers
     .get(axum::http::header::AUTHORIZATION)
     .get(axum::http::header::AUTHORIZATION)
     .and_then(|value| value.to_str().ok());
     .and_then(|value| value.to_str().ok());
-  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 store = Store::from_config(&state.config.redis);
   let mut conn = state.redis.clone();
   let mut conn = state.redis.clone();
   let mut stores = IntrospectStores {
   let mut stores = IntrospectStores {
@@ -484,10 +714,28 @@ pub async fn handler(
     conn: &mut conn,
     conn: &mut conn,
     store: &store,
     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 {
   let ctx = IntrospectContext {
     issuer: &state.config.issuer,
     issuer: &state.config.issuer,
     now_unix: state.clock.now().unix_timestamp(),
     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,
     service: &service,
+    jwt_signer: jwt_signer.as_ref(),
   };
   };
   let inputs = IntrospectHttpInputs {
   let inputs = IntrospectHttpInputs {
     token: form.token.as_str(),
     token: form.token.as_str(),
@@ -498,7 +746,11 @@ pub async fn handler(
     assertion_type: form.client_assertion_type.as_deref(),
     assertion_type: form.client_assertion_type.as_deref(),
     assertion: form.client_assertion.as_deref(),
     assertion: form.client_assertion.as_deref(),
     peer_certs: peer_certs.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,
   config::Config,
   crypto::secret::{Argon2Params, hash_secret},
   crypto::secret::{Argon2Params, hash_secret},
   db::{
   db::{
-    NewClient, TokenEndpointAuthMethod, client_repo,
+    NewClient, TokenEndpointAuthMethod, client_repo, resource_repo,
     test_support::migrated_pool,
     test_support::migrated_pool,
     user_repo::{self, NewUser, UserRecord},
     user_repo::{self, NewUser, UserRecord},
   },
   },
+  jose::jwt_access::{JwtAccessSigner, JwtClaims},
   protocol::{
   protocol::{
     client_auth::ClientAuthRequest,
     client_auth::ClientAuthRequest,
     revoke::{RevokeContext, RevokeOutcome, RevokeRequest, RevokeStores, revoke_tokens},
     revoke::{RevokeContext, RevokeOutcome, RevokeRequest, RevokeStores, revoke_tokens},
-    token::{ClientKind, MintRequest, SenderProof, TokenService},
+    token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService},
   },
   },
   state::{AppState, Clock, RedisConn},
   state::{AppState, Clock, RedisConn},
   store::Store,
   store::Store,
@@ -94,6 +95,9 @@ async fn harness() -> TestResult<Harness> {
     "token_pepper": "introspect-test-pepper-0123456789abcdef",
     "token_pepper": "introspect-test-pepper-0123456789abcdef",
     "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
     "totp_encryption_key": "introspect-test-totp-key-0123456789abcdef",
     "pii_encryption_key": "introspect-test-pii-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 source = ::config::Config::try_from(&fixture)?;
   let config = Config::from_source(source)?;
   let config = Config::from_source(source)?;
@@ -135,6 +139,7 @@ async fn insert_client(
     contacts: None,
     contacts: None,
     software_id: None,
     software_id: None,
     dpop_bound_access_tokens: false,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
     registration_access_token_hash: None,
   };
   };
   client_repo::insert(pool, keys, false, &new).await?;
   client_repo::insert(pool, keys, false, &new).await?;
@@ -160,10 +165,14 @@ async fn mint_bound(
         &MintRequest {
         &MintRequest {
           client_id: client_id.to_owned(),
           client_id: client_id.to_owned(),
           client_kind: ClientKind::Confidential,
           client_kind: ClientKind::Confidential,
+          dpop_required: false,
           subject: "user-1".to_owned(),
           subject: "user-1".to_owned(),
           scope: "orders:read".to_owned(),
           scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           grant_id: unique("grant"),
           proof,
           proof,
+          aud: Vec::new(),
+          refresh_aud: Vec::new(),
+          format: TokenFormat::Opaque,
         },
         },
         now_unix(),
         now_unix(),
       )
       )
@@ -194,8 +203,40 @@ async fn run_core(
   hint: Option<&str>,
   hint: Option<&str>,
   client_id: &str,
   client_id: &str,
   secret: &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> {
 ) -> TestResult<IntrospectionResponse> {
   let service = token_service(h)?;
   let service = token_service(h)?;
+  let signer = with_signer
+    .then(|| JwtAccessSigner::from_config(&h.config))
+    .transpose()?;
   let mut stores = IntrospectStores {
   let mut stores = IntrospectStores {
     pool: &h.pool,
     pool: &h.pool,
     keys: &h.config.keys,
     keys: &h.config.keys,
@@ -205,7 +246,9 @@ async fn run_core(
   let ctx = IntrospectContext {
   let ctx = IntrospectContext {
     issuer: &h.config.issuer,
     issuer: &h.config.issuer,
     now_unix: now_unix(),
     now_unix: now_unix(),
+    cimd: None,
     service: &service,
     service: &service,
+    jwt_signer: signer.as_ref(),
   };
   };
   let inputs = IntrospectHttpInputs {
   let inputs = IntrospectHttpInputs {
     token,
     token,
@@ -216,6 +259,7 @@ async fn run_core(
     assertion_type: None,
     assertion_type: None,
     assertion: None,
     assertion: None,
     peer_certs: None,
     peer_certs: None,
+    resource,
   };
   };
   Ok(introspect(&mut stores, &ctx, &inputs).await?)
   Ok(introspect(&mut stores, &ctx, &inputs).await?)
 }
 }
@@ -793,6 +837,7 @@ async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
     let ctx = RevokeContext {
     let ctx = RevokeContext {
       token_endpoint: "https://auth.example.test/token",
       token_endpoint: "https://auth.example.test/token",
       now: OffsetDateTime::now_utc(),
       now: OffsetDateTime::now_utc(),
+      cimd: None,
       request_id: Some("req-introspect-revoke"),
       request_id: Some("req-introspect-revoke"),
     };
     };
     let request = RevokeRequest {
     let request = RevokeRequest {
@@ -800,7 +845,7 @@ async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
       token_type_hint: None,
       token_type_hint: None,
       auth: &auth,
       auth: &auth,
     };
     };
-    revoke_tokens(&mut stores, &ctx, &request).await?
+    revoke_tokens(&mut stores, &ctx, &request, None).await?
   };
   };
   assert_eq!(
   assert_eq!(
     outcome,
     outcome,
@@ -823,6 +868,48 @@ async fn test_introspect_after_revoke_endpoint_is_inactive() -> TestResult {
   Ok(())
   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.
 /// Mint one token pair owned by `client_id` with an explicit subject.
 async fn mint_subject(
 async fn mint_subject(
   h: &mut Harness,
   h: &mut Harness,
@@ -838,10 +925,14 @@ async fn mint_subject(
         &MintRequest {
         &MintRequest {
           client_id: client_id.to_owned(),
           client_id: client_id.to_owned(),
           client_kind: ClientKind::Confidential,
           client_kind: ClientKind::Confidential,
+          dpop_required: false,
           subject: subject.to_owned(),
           subject: subject.to_owned(),
           scope: "orders:read".to_owned(),
           scope: "orders:read".to_owned(),
           grant_id: unique("grant"),
           grant_id: unique("grant"),
           proof: SenderProof::none(),
           proof: SenderProof::none(),
+          aud: Vec::new(),
+          refresh_aud: Vec::new(),
+          format: TokenFormat::Opaque,
         },
         },
         now_unix(),
         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"));
   assert_eq!(response.sub.as_deref(), Some("synthetic-subject-9"));
   Ok(())
   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
 //! no restart. When Postgres is unavailable the endpoint fails closed with `503
 //! temporarily_unavailable` rather than advertising an unknown scope list.
 //! 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.
 //! never offered.
 
 
 use axum::{Json, extract::State};
 use axum::{Json, extract::State};
 use serde::Serialize;
 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).
 /// The five grant types this server supports (RFC 6749, RFC 8628, RFC 8693).
 pub const GRANT_TYPES_SUPPORTED: [&str; 5] = [
 pub const GRANT_TYPES_SUPPORTED: [&str; 5] = [
@@ -103,6 +110,45 @@ pub struct AuthorizationServerMetadata {
   pub authorization_response_iss_parameter_supported: bool,
   pub authorization_response_iss_parameter_supported: bool,
   /// Enabled scope catalog (single source of truth: the `scopes` table).
   /// Enabled scope catalog (single source of truth: the `scopes` table).
   pub scopes_supported: Vec<String>,
   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 {
 impl AuthorizationServerMetadata {
@@ -111,7 +157,17 @@ impl AuthorizationServerMetadata {
   /// `issuer` is validated elsewhere to be absolute `https` with no trailing
   /// `issuer` is validated elsewhere to be absolute `https` with no trailing
   /// slash, so endpoint paths append directly.
   /// slash, so endpoint paths append directly.
   #[must_use]
   #[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 {
     Self {
       issuer: issuer.to_owned(),
       issuer: issuer.to_owned(),
       authorization_endpoint: endpoint(issuer, "/authorize"),
       authorization_endpoint: endpoint(issuer, "/authorize"),
@@ -132,6 +188,11 @@ impl AuthorizationServerMetadata {
       tls_client_certificate_bound_access_tokens: true,
       tls_client_certificate_bound_access_tokens: true,
       authorization_response_iss_parameter_supported: true,
       authorization_response_iss_parameter_supported: true,
       scopes_supported,
       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 rows = scope_repo::list_enabled(&state.pool).await?;
   let mut scopes: Vec<String> = rows.into_iter().map(|row| row.name).collect();
   let mut scopes: Vec<String> = rows.into_iter().map(|row| row.name).collect();
   scopes.sort();
   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(
   Ok(Json(AuthorizationServerMetadata::for_issuer(
     &state.config.issuer,
     &state.config.issuer,
     scopes,
     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": [
   "scopes_supported": [
     "api:read",
     "api:read",
     "orders:write"
     "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.
 //! `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::{
 use axum::{
   Router,
   Router,
@@ -14,7 +14,7 @@ use tower::ServiceExt as _;
 
 
 use super::{AuthorizationServerMetadata, metadata};
 use super::{AuthorizationServerMetadata, metadata};
 use crate::{
 use crate::{
-  config::{Config, ConfigError},
+  config::{Config, ConfigError, JwtSigningAlg},
   db::{scope_repo, test_support::migrated_pool},
   db::{scope_repo, test_support::migrated_pool},
   state::{AppState, Clock, RedisConn},
   state::{AppState, Clock, RedisConn},
 };
 };
@@ -44,10 +44,107 @@ fn test_config() -> Result<Config, ConfigError> {
   Config::from_source(source)
   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
 /// 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> {
 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 keys = Arc::new(config.keys.clone());
   let client = redis::Client::open("redis://127.0.0.1:1")?;
   let client = redis::Client::open("redis://127.0.0.1:1")?;
   let manager = redis::aio::ConnectionManager::new_lazy_with_config(
   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()
   let request = Request::builder()
     .uri("/.well-known/oauth-authorization-server")
     .uri("/.well-known/oauth-authorization-server")
     .body(Body::empty())?;
     .body(Body::empty())?;
   let response = router.clone().oneshot(request).await?;
   let response = router.clone().oneshot(request).await?;
   assert_eq!(response.status(), StatusCode::OK);
   assert_eq!(response.status(), StatusCode::OK);
   let bytes = response.into_body().collect().await?.to_bytes();
   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
   let scopes = doc
     .get("scopes_supported")
     .get("scopes_supported")
     .and_then(Value::as_array)
     .and_then(Value::as_array)
@@ -95,6 +197,10 @@ fn metadata_document_matches_snapshot() {
   let doc = AuthorizationServerMetadata::for_issuer(
   let doc = AuthorizationServerMetadata::for_issuer(
     ISSUER,
     ISSUER,
     vec!["api:read".to_owned(), "orders:write".to_owned()],
     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();
   let rendered = serde_json::to_string_pretty(&doc).unwrap_or_default();
   insta::with_settings!({ prepend_module_to_snapshot => false }, {
   insta::with_settings!({ prepend_module_to_snapshot => false }, {
@@ -104,12 +210,18 @@ fn metadata_document_matches_snapshot() {
 
 
 #[test]
 #[test]
 fn metadata_never_advertises_plain_or_jwks_uri() {
 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!(
   assert!(
     json.get("jwks_uri").is_none(),
     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!(
   assert_eq!(
     json.get("code_challenge_methods_supported"),
     json.get("code_challenge_methods_supported"),
@@ -140,16 +252,42 @@ fn metadata_never_advertises_plain_or_jwks_uri() {
     Some(true)
     Some(true)
   );
   );
 
 
-  let rendered = serde_json::to_string(&doc).unwrap_or_default();
+  let rendered = serde_json::to_string(&off).unwrap_or_default();
   assert!(
   assert!(
     !rendered.contains("\"plain\""),
     !rendered.contains("\"plain\""),
     "plain must never be advertised"
     "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]
 #[test]
 fn metadata_advertises_ps256_for_client_assertions() {
 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);
   let json = serde_json::to_value(&doc).unwrap_or(Value::Null);
   assert_eq!(
   assert_eq!(
     json.get("token_endpoint_auth_signing_alg_values_supported"),
     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());
   assert!(doc.get("jwks_uri").is_none());
   Ok(())
   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.
 //! TODO(task 19): wire re-exports as each endpoint lands.
 
 
 pub mod authorize;
 pub mod authorize;
+pub mod cimd;
 pub mod client_auth;
 pub mod client_auth;
 pub mod code;
 pub mod code;
 pub mod dcr;
 pub mod dcr;
 pub mod device;
 pub mod device;
 pub mod introspect;
 pub mod introspect;
+pub mod jwks;
 pub mod metadata;
 pub mod metadata;
 pub mod mtls;
 pub mod mtls;
 pub mod par;
 pub mod par;
+pub mod params;
 pub mod purge;
 pub mod purge;
 pub mod redirect_repo;
 pub mod redirect_repo;
 pub mod revoke;
 pub mod revoke;

+ 105 - 14
src/protocol/par.rs

@@ -10,7 +10,6 @@
 use std::{collections::HashMap, time::Duration};
 use std::{collections::HashMap, time::Duration};
 
 
 use axum::{
 use axum::{
-  Form,
   extract::State,
   extract::State,
   http::{HeaderMap, StatusCode},
   http::{HeaderMap, StatusCode},
 };
 };
@@ -20,12 +19,14 @@ use sqlx::PgPool;
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 
 
 use super::{
 use super::{
+  authorize::CimdRequest,
+  cimd,
   client_auth::{AuthContext, AuthStores, ClientAuthRequest, authenticate_client},
   client_auth::{AuthContext, AuthStores, ClientAuthRequest, authenticate_client},
   redirect_repo::{RedirectDecision, match_redirect_uri},
   redirect_repo::{RedirectDecision, match_redirect_uri},
 };
 };
 use crate::{
 use crate::{
   crypto::tokens::mint_token,
   crypto::tokens::mint_token,
-  db::redirect_repo,
+  db::{client_repo, redirect_repo},
   error::AppError,
   error::AppError,
   jose::jar::PAR_REQUEST_URI_PREFIX,
   jose::jar::PAR_REQUEST_URI_PREFIX,
   middleware::{
   middleware::{
@@ -59,6 +60,15 @@ pub struct ParContext<'a> {
   pub now: OffsetDateTime,
   pub now: OffsetDateTime,
   /// PAR `request_uri` lifetime (90 s per the plan).
   /// PAR `request_uri` lifetime (90 s per the plan).
   pub par_ttl: Duration,
   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).
 /// Successful PAR response body (RFC 9126 §2.4).
@@ -90,6 +100,7 @@ pub async fn push_authorization_request(
   ctx: &ParContext<'_>,
   ctx: &ParContext<'_>,
   auth: &ClientAuthRequest<'_>,
   auth: &ClientAuthRequest<'_>,
   params: &HashMap<String, String>,
   params: &HashMap<String, String>,
+  resources: &[String],
 ) -> Result<ParResponse, AppError> {
 ) -> Result<ParResponse, AppError> {
   let mut auth_stores = AuthStores {
   let mut auth_stores = AuthStores {
     pool: stores.pool,
     pool: stores.pool,
@@ -100,17 +111,30 @@ pub async fn push_authorization_request(
   let auth_ctx = AuthContext {
   let auth_ctx = AuthContext {
     token_endpoint: ctx.token_endpoint,
     token_endpoint: ctx.token_endpoint,
     now: ctx.now,
     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?;
   let authenticated = authenticate_client(&mut auth_stores, &auth_ctx, auth).await?;
-  validate_request_rules(
+  let aud = validate_request_rules(
     stores.pool,
     stores.pool,
     stores.keys,
     stores.keys,
+    stores.conn,
     &authenticated.client.client_id,
     &authenticated.client.client_id,
     params,
     params,
+    resources,
+    ctx,
   )
   )
   .await?;
   .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();
   let mut stored = params.clone();
+  stored.remove("resource");
   for member in CREDENTIAL_PARAMS {
   for member in CREDENTIAL_PARAMS {
     stored.remove(member);
     stored.remove(member);
   }
   }
@@ -119,6 +143,7 @@ pub async fn push_authorization_request(
   let record = par::ParRecord {
   let record = par::ParRecord {
     client_id: authenticated.client.client_id.clone(),
     client_id: authenticated.client.client_id.clone(),
     request,
     request,
+    aud,
   };
   };
   par::put(stores.conn, stores.store, &id, &record, ctx.par_ttl).await?;
   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(
 async fn validate_request_rules(
   pool: &PgPool,
   pool: &PgPool,
   keys: &crate::keys::KeyRing,
   keys: &crate::keys::KeyRing,
+  conn: &mut RedisConn,
   client_id: &str,
   client_id: &str,
   params: &HashMap<String, String>,
   params: &HashMap<String, String>,
-) -> Result<(), AppError> {
+  resources: &[String],
+  ctx: &ParContext<'_>,
+) -> Result<Vec<String>, AppError> {
   match params.get("client_id") {
   match params.get("client_id") {
     Some(presented) if presented == client_id => {}
     Some(presented) if presented == client_id => {}
     _ => {
     _ => {
@@ -169,14 +198,48 @@ async fn validate_request_rules(
       "pushed request redirect_uri is required",
       "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(
     return Err(AppError::invalid_request(
       "pushed request redirect_uri is not registered for this client",
       "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).
 /// `POST /par` (RFC 9126 §2.1).
@@ -193,8 +256,28 @@ async fn validate_request_rules(
 pub async fn par_handler(
 pub async fn par_handler(
   State(state): State<AppState>,
   State(state): State<AppState>,
   headers: HeaderMap,
   headers: HeaderMap,
-  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
   let header_value = headers
     .get(axum::http::header::AUTHORIZATION)
     .get(axum::http::header::AUTHORIZATION)
     .and_then(|value| value.to_str().ok());
     .and_then(|value| value.to_str().ok());
@@ -220,15 +303,23 @@ pub async fn par_handler(
     token_endpoint: &super::client_auth::token_endpoint(&state.config.issuer),
     token_endpoint: &super::client_auth::token_endpoint(&state.config.issuer),
     now: state.clock.now(),
     now: state.clock.now(),
     par_ttl: state.config.ttl.par,
     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,
     Ok(response) => response,
     Err(error) => {
     Err(error) => {
       emit_pii_decrypt(&headers, &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) {
 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 std::{collections::HashMap, time::Duration};
 
 
+use secrecy::SecretString;
 use sqlx::PgPool;
 use sqlx::PgPool;
 use time::OffsetDateTime;
 use time::OffsetDateTime;
 use uuid::Uuid;
 use uuid::Uuid;
@@ -22,7 +23,6 @@ use crate::{
   store::Store,
   store::Store,
   test_guard::Lease,
   test_guard::Lease,
 };
 };
-use secrecy::SecretString;
 
 
 /// Test scaffolding failures (no `unwrap`/`expect` under the deny lints).
 /// Test scaffolding failures (no `unwrap`/`expect` under the deny lints).
 #[derive(Debug, thiserror::Error)]
 #[derive(Debug, thiserror::Error)]
@@ -99,6 +99,9 @@ fn ctx_of(endpoint: &str) -> ParContext<'_> {
     token_endpoint: endpoint,
     token_endpoint: endpoint,
     now: OffsetDateTime::now_utc(),
     now: OffsetDateTime::now_utc(),
     par_ttl: Duration::from_secs(90),
     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,
     contacts: None,
     software_id: None,
     software_id: None,
     dpop_bound_access_tokens: false,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
     registration_access_token_hash: None,
   };
   };
   client_repo::insert(pool, keys, false, &new).await?;
   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 std::{collections::HashMap, time::Duration};
 
 
+use super::TestError;
 use super::{
 use super::{
   super::push_authorization_request, attach_redirect, bare_auth, ctx_of, expect_code, harness,
   super::push_authorization_request, attach_redirect, bare_auth, ctx_of, expect_code, harness,
   insert_client, push_params, stores_of,
   insert_client, push_params, stores_of,
@@ -14,8 +15,6 @@ use crate::{
   store::par,
   store::par,
 };
 };
 
 
-use super::TestError;
-
 /// Push via a public client and return the issued `request_uri`.
 /// Push via a public client and return the issued `request_uri`.
 async fn push_public(
 async fn push_public(
   h: &mut super::Harness,
   h: &mut super::Harness,
@@ -27,7 +26,7 @@ async fn push_public(
   let auth = bare_auth(client_id);
   let auth = bare_auth(client_id);
   let ctx = ctx_of(endpoint);
   let ctx = ctx_of(endpoint);
   let mut stores = stores_of(h);
   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
     .await
     .map_err(|error| TestError::Unexpected(format!("push failed: {}", error.code())))?;
     .map_err(|error| TestError::Unexpected(format!("push failed: {}", error.code())))?;
   if response.expires_in != 90 {
   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)
   let merged = resolve(&mut h.conn, &h.store, &first)
     .await
     .await
     .map_err(|error| TestError::Unexpected(format!("first use failed: {}", error.code())))?;
     .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()));
     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 {
   let record = par::ParRecord {
     client_id: client_id.to_owned(),
     client_id: client_id.to_owned(),
     request: "{\"response_type\":\"code\"}".to_owned(),
     request: "{\"response_type\":\"code\"}".to_owned(),
+    aud: Vec::new(),
   };
   };
   par::put(
   par::put(
     &mut h.conn,
     &mut h.conn,
@@ -334,6 +334,7 @@ async fn par_expired_request_uri_is_rejected() -> Result<(), TestError> {
   let record = par::ParRecord {
   let record = par::ParRecord {
     client_id: "par-expiry-client".to_owned(),
     client_id: "par-expiry-client".to_owned(),
     request: "{\"response_type\":\"code\"}".to_owned(),
     request: "{\"response_type\":\"code\"}".to_owned(),
+    aud: Vec::new(),
   };
   };
   par::put(
   par::put(
     &mut h.conn,
     &mut h.conn,

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

@@ -3,6 +3,7 @@
 
 
 use secrecy::SecretString;
 use secrecy::SecretString;
 
 
+use super::TestError;
 use super::{
 use super::{
   super::push_authorization_request, attach_redirect, bare_auth, ctx_of, expect_code, harness,
   super::push_authorization_request, attach_redirect, bare_auth, ctx_of, expect_code, harness,
   insert_client, push_params, stores_of,
   insert_client, push_params, stores_of,
@@ -13,8 +14,6 @@ use crate::{
   protocol::client_auth::ClientAuthRequest,
   protocol::client_auth::ClientAuthRequest,
 };
 };
 
 
-use super::TestError;
-
 #[tokio::test]
 #[tokio::test]
 async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
 async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
   let mut h = harness().await?;
   let mut h = harness().await?;
@@ -36,7 +35,7 @@ async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
   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())),
     Ok(_) => return Err(TestError::Unexpected("ghost push accepted".to_owned())),
     Err(error) => expect_code(&error, OAuthErrorCode::InvalidClient)?,
     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 endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
   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(_) => {
     Ok(_) => {
       return Err(TestError::Unexpected(
       return Err(TestError::Unexpected(
         "credential-free push accepted".to_owned(),
         "credential-free push accepted".to_owned(),
@@ -69,7 +68,7 @@ async fn par_rejects_unauthenticated_client() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
   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(_) => {
     Ok(_) => {
       return Err(TestError::Unexpected(
       return Err(TestError::Unexpected(
         "wrong-secret push accepted".to_owned(),
         "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 endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
   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(_) => {
     Ok(_) => {
       return Err(TestError::Unexpected(
       return Err(TestError::Unexpected(
         "missing response_type accepted".to_owned(),
         "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 endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
   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(_) => {
     Ok(_) => {
       return Err(TestError::Unexpected(
       return Err(TestError::Unexpected(
         "foreign request_uri accepted".to_owned(),
         "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 endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
   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(_) => {
     Ok(_) => {
       return Err(TestError::Unexpected(
       return Err(TestError::Unexpected(
         "unregistered redirect accepted".to_owned(),
         "unregistered redirect accepted".to_owned(),
@@ -170,7 +169,7 @@ async fn par_redirect_mismatch_rejected() -> Result<(), TestError> {
   let endpoint = h.endpoint.clone();
   let endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
   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(_) => {
     Ok(_) => {
       return Err(TestError::Unexpected("evil redirect accepted".to_owned()));
       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 endpoint = h.endpoint.clone();
   let ctx = ctx_of(&endpoint);
   let ctx = ctx_of(&endpoint);
   let mut stores = stores_of(&mut h);
   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(_) => {
     Ok(_) => {
       return Err(TestError::Unexpected("corrupt uri_enc accepted".to_owned()));
       return Err(TestError::Unexpected("corrupt uri_enc accepted".to_owned()));
     }
     }
@@ -209,3 +208,127 @@ async fn par_redirect_mismatch_rejected() -> Result<(), TestError> {
   }
   }
   Ok(())
   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
 //! the user (canonical email or user-id string): login sessions (plus their
 //! `:meta` sidecars), the lockout counters (the account key plus every
 //! `:meta` sidecars), the lockout counters (the account key plus every
 //! per-IP composite key), authorization codes, device records
 //! 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:`,
 //! Records without a `subject` field are never touched: `par:`, `pending:`,
 //! `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the
 //! `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the
@@ -45,6 +48,9 @@ pub struct PurgedSubject {
   pub access: u64,
   pub access: u64,
   /// Standalone refresh-token records deleted.
   /// Standalone refresh-token records deleted.
   pub refresh: u64,
   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`.
 /// 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
 /// lockout key is derived from it with the same normalization the login flow
 /// uses. Refresh-owned families cascade through [`grants::revoke`]; the
 /// uses. Refresh-owned families cascade through [`grants::revoke`]; the
 /// grant's member `:act` sidecars are removed alongside because the revoke
 /// 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
 /// # 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)
   Ok(summary)
 }
 }
 
 
@@ -556,6 +576,7 @@ mod tests {
       grants: 1,
       grants: 1,
       access: 1,
       access: 1,
       refresh: 1,
       refresh: 1,
+      jwt_markers: 0,
     };
     };
     if summary != expected {
     if summary != expected {
       return Err(TestError::Unexpected(format!(
       return Err(TestError::Unexpected(format!(
@@ -589,6 +610,65 @@ mod tests {
     Ok(())
     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]
   #[tokio::test]
   async fn purge_subject_with_no_matching_state_returns_zero() -> TestResult {
   async fn purge_subject_with_no_matching_state_returns_zero() -> TestResult {
     let (mut conn, store) = harness().await?;
     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:
 //! character, no normalization) plus the RFC 8252 §7.3 loopback exception:
 //! a registration whose scheme is `http` and whose host is the loopback
 //! 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
 //! 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
 //! Fail-closed: a registered URI carrying userinfo, a query, or a fragment
 //! is never eligible, plaintext `http` to a non-loopback host is refused,
 //! 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`;
 //! The effective (attached + enabled) set is owned by `db::redirect_repo`;
 //! this module is pure and takes that set as a slice of strings.
 //! 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.
   /// character-for-character identical to a registered URI.
   Exact,
   Exact,
   /// RFC 8252 §7.3 loopback match: the registered URI is plaintext `http`
   /// 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.
   /// has the same scheme, host, and path, with the port ignored.
   Loopback,
   Loopback,
   /// No registered URI is an acceptable match. The request MUST be rejected
   /// 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
 /// 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
 /// dot notation (draft-ietf-oauth-v2-1-16 §2.3.1, SHOULD-level) never
 /// matches: the request is rejected without redirecting the user agent.
 /// 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]
 #[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) {
   if !is_valid_redirect_scheme(requested) {
     return RedirectDecision::Reject;
     return RedirectDecision::Reject;
   }
   }
@@ -66,8 +79,8 @@ pub fn match_redirect_uri<S: AsRef<str>>(registered: &[S], requested: &str) -> R
       continue;
       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;
       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
     // RFC 8252 §7.3 loopback: ignore the port, require scheme+host+path, and
     // refuse a request that smuggles userinfo, a query, or a fragment.
     // refuse a request that smuggles userinfo, a query, or a fragment.
     if is_http(reg.scheme)
     if is_http(reg.scheme)
-      && is_loopback_host(reg.host)
+      && is_loopback_host(reg.host, allow_localhost)
       && let Some(req) = requested_parts.as_ref()
       && let Some(req) = requested_parts.as_ref()
       && is_http(req.scheme)
       && is_http(req.scheme)
-      && is_loopback_host(req.host)
+      && is_loopback_host(req.host, allow_localhost)
       && reg.host == req.host
       && reg.host == req.host
       && reg.path == req.path
       && reg.path == req.path
       && req.userinfo.is_none()
       && req.userinfo.is_none()
@@ -219,12 +232,14 @@ pub fn is_valid_redirect_scheme(uri: &str) -> bool {
   scheme.contains('.')
   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)]
 #[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() {
 fn redirect_uri_exact_string_match_is_accepted() {
   let registered = ["https://client.example.test/cb"];
   let registered = ["https://client.example.test/cb"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/cb"),
+    match_redirect_uri(&registered, "https://client.example.test/cb", false),
     RedirectDecision::Exact
     RedirectDecision::Exact
   );
   );
 }
 }
@@ -15,13 +15,13 @@ fn redirect_uri_exact_string_match_is_accepted() {
 fn redirect_uri_trailing_slash_mismatch_is_rejected() {
 fn redirect_uri_trailing_slash_mismatch_is_rejected() {
   let registered = ["https://client.example.test/cb"];
   let registered = ["https://client.example.test/cb"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/cb/"),
+    match_redirect_uri(&registered, "https://client.example.test/cb/", false),
     RedirectDecision::Reject
     RedirectDecision::Reject
   );
   );
 
 
   let registered = ["https://client.example.test/cb/"];
   let registered = ["https://client.example.test/cb/"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/cb"),
+    match_redirect_uri(&registered, "https://client.example.test/cb", false),
     RedirectDecision::Reject
     RedirectDecision::Reject
   );
   );
 }
 }
@@ -30,13 +30,13 @@ fn redirect_uri_trailing_slash_mismatch_is_rejected() {
 fn redirect_uri_loopback_ipv4_port_variance_is_accepted() {
 fn redirect_uri_loopback_ipv4_port_variance_is_accepted() {
   let registered = ["http://127.0.0.1:5173/cb"];
   let registered = ["http://127.0.0.1:5173/cb"];
   assert_eq!(
   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
     RedirectDecision::Loopback
   );
   );
 
 
   let registered = ["http://127.0.0.1/cb"];
   let registered = ["http://127.0.0.1/cb"];
   assert_eq!(
   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
     RedirectDecision::Loopback
   );
   );
 }
 }
@@ -45,7 +45,7 @@ fn redirect_uri_loopback_ipv4_port_variance_is_accepted() {
 fn redirect_uri_loopback_ipv6_port_variance_is_accepted() {
 fn redirect_uri_loopback_ipv6_port_variance_is_accepted() {
   let registered = ["http://[::1]:5173/cb"];
   let registered = ["http://[::1]:5173/cb"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "http://[::1]:49152/cb"),
+    match_redirect_uri(&registered, "http://[::1]:49152/cb", false),
     RedirectDecision::Loopback
     RedirectDecision::Loopback
   );
   );
 }
 }
@@ -54,7 +54,7 @@ fn redirect_uri_loopback_ipv6_port_variance_is_accepted() {
 fn redirect_uri_loopback_same_port_is_exact() {
 fn redirect_uri_loopback_same_port_is_exact() {
   let registered = ["http://127.0.0.1:5173/cb"];
   let registered = ["http://127.0.0.1:5173/cb"];
   assert_eq!(
   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
     RedirectDecision::Exact
   );
   );
 }
 }
@@ -63,34 +63,125 @@ fn redirect_uri_loopback_same_port_is_exact() {
 fn redirect_uri_non_loopback_http_is_rejected() {
 fn redirect_uri_non_loopback_http_is_rejected() {
   let registered = ["http://evil.example/cb"];
   let registered = ["http://evil.example/cb"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "http://evil.example/cb"),
+    match_redirect_uri(&registered, "http://evil.example/cb", false),
     RedirectDecision::Reject
     RedirectDecision::Reject
   );
   );
 
 
   let registered = ["HTTP://evil.example/cb"];
   let registered = ["HTTP://evil.example/cb"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "HTTP://evil.example/cb"),
+    match_redirect_uri(&registered, "HTTP://evil.example/cb", false),
     RedirectDecision::Reject
     RedirectDecision::Reject
   );
   );
 
 
   let registered = ["https://client.example.test/cb"];
   let registered = ["https://client.example.test/cb"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "https://evil.example/cb"),
+    match_redirect_uri(&registered, "https://evil.example/cb", false),
     RedirectDecision::Reject
     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]
 #[test]
 fn redirect_uri_localhost_is_rejected() {
 fn redirect_uri_localhost_is_rejected() {
   let registered = ["http://localhost:8080/cb"];
   let registered = ["http://localhost:8080/cb"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "http://localhost:8080/cb"),
+    match_redirect_uri(&registered, "http://localhost:8080/cb", false),
     RedirectDecision::Reject
     RedirectDecision::Reject
   );
   );
 
 
   let registered = ["http://127.0.0.1/cb"];
   let registered = ["http://127.0.0.1/cb"];
   assert_eq!(
   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
     RedirectDecision::Reject
   );
   );
 }
 }
@@ -104,7 +195,7 @@ fn redirect_uri_registered_query_fragment_userinfo_are_rejected() {
   ] {
   ] {
     let requested = registered[0];
     let requested = registered[0];
     assert_eq!(
     assert_eq!(
-      match_redirect_uri(&registered, requested),
+      match_redirect_uri(&registered, requested, false),
       RedirectDecision::Reject,
       RedirectDecision::Reject,
       "malformed registration must never match: {requested}"
       "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() {
 fn redirect_uri_loopback_request_with_query_is_rejected() {
   let registered = ["http://127.0.0.1:5173/cb"];
   let registered = ["http://127.0.0.1:5173/cb"];
   assert_eq!(
   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
     RedirectDecision::Reject
   );
   );
 }
 }
@@ -124,15 +215,15 @@ fn redirect_uri_loopback_request_with_query_is_rejected() {
 fn redirect_uri_prefix_and_substring_are_not_matched() {
 fn redirect_uri_prefix_and_substring_are_not_matched() {
   let registered = ["https://client.example.test/cb"];
   let registered = ["https://client.example.test/cb"];
   assert_eq!(
   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
     RedirectDecision::Reject
   );
   );
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/c"),
+    match_redirect_uri(&registered, "https://client.example.test/c", false),
     RedirectDecision::Reject
     RedirectDecision::Reject
   );
   );
   assert_eq!(
   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
     RedirectDecision::Reject
   );
   );
 }
 }
@@ -141,7 +232,7 @@ fn redirect_uri_prefix_and_substring_are_not_matched() {
 fn redirect_uri_empty_set_is_rejected() {
 fn redirect_uri_empty_set_is_rejected() {
   let registered: [&str; 0] = [];
   let registered: [&str; 0] = [];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "https://client.example.test/cb"),
+    match_redirect_uri(&registered, "https://client.example.test/cb", false),
     RedirectDecision::Reject
     RedirectDecision::Reject
   );
   );
 }
 }
@@ -150,7 +241,7 @@ fn redirect_uri_empty_set_is_rejected() {
 fn redirect_uri_exact_takes_precedence_over_loopback() {
 fn redirect_uri_exact_takes_precedence_over_loopback() {
   let registered = ["http://127.0.0.1:5173/cb", "http://127.0.0.1:49152/cb"];
   let registered = ["http://127.0.0.1:5173/cb", "http://127.0.0.1:49152/cb"];
   assert_eq!(
   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
     RedirectDecision::Exact
   );
   );
 }
 }
@@ -161,13 +252,13 @@ fn redirect_uri_private_scheme_without_dot_is_rejected() {
   // reverse-DNS based, so it must contain a `.`.
   // reverse-DNS based, so it must contain a `.`.
   let registered = ["myapp:/cb"];
   let registered = ["myapp:/cb"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "myapp:/cb"),
+    match_redirect_uri(&registered, "myapp:/cb", false),
     RedirectDecision::Reject
     RedirectDecision::Reject
   );
   );
 
 
   let registered = ["myapp:/"];
   let registered = ["myapp:/"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "myapp:/"),
+    match_redirect_uri(&registered, "myapp:/", false),
     RedirectDecision::Reject
     RedirectDecision::Reject
   );
   );
 }
 }
@@ -176,13 +267,13 @@ fn redirect_uri_private_scheme_without_dot_is_rejected() {
 fn redirect_uri_private_scheme_with_dot_is_accepted() {
 fn redirect_uri_private_scheme_with_dot_is_accepted() {
   let registered = ["com.example.app:/cb"];
   let registered = ["com.example.app:/cb"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "com.example.app:/cb"),
+    match_redirect_uri(&registered, "com.example.app:/cb", false),
     RedirectDecision::Exact
     RedirectDecision::Exact
   );
   );
 
 
   let registered = ["com.example.app:/"];
   let registered = ["com.example.app:/"];
   assert_eq!(
   assert_eq!(
-    match_redirect_uri(&registered, "com.example.app:/"),
+    match_redirect_uri(&registered, "com.example.app:/", false),
     RedirectDecision::Exact
     RedirectDecision::Exact
   );
   );
 }
 }

+ 118 - 3
src/protocol/revoke.rs

@@ -36,12 +36,13 @@ use super::{
 };
 };
 use crate::{
 use crate::{
   error::AppError,
   error::AppError,
+  jose::jwt_access::{JwtAccessSigner, is_jwt_shape, jti_hash},
   middleware::{
   middleware::{
     audit::AuditEvent,
     audit::AuditEvent,
     request_id::{REQUEST_ID_HEADER, RequestId},
     request_id::{REQUEST_ID_HEADER, RequestId},
   },
   },
   state::{AppState, RedisConn},
   state::{AppState, RedisConn},
-  store::Store,
+  store::{Store, StoreError, durability_barrier},
   tls::PeerCertificates,
   tls::PeerCertificates,
 };
 };
 
 
@@ -75,6 +76,10 @@ pub struct RevokeContext<'a> {
   pub token_endpoint: &'a str,
   pub token_endpoint: &'a str,
   /// Current time for client-secret expiry enforcement.
   /// Current time for client-secret expiry enforcement.
   pub now: OffsetDateTime,
   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.
   /// Correlating request id for the audit event, when present.
   pub request_id: Option<&'a str>,
   pub request_id: Option<&'a str>,
 }
 }
@@ -117,6 +122,11 @@ struct OwnedToken<'a> {
 /// secret, client assertion, or `client_id`) is `401` + `WWW-Authenticate`,
 /// secret, client assertion, or `client_id`) is `401` + `WWW-Authenticate`,
 /// because the endpoint is a protected resource (RFC 7009 §2.1).
 /// 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
 /// # Errors
 ///
 ///
 /// Returns `invalid_request` for a missing token, `invalid_client` for failed
 /// Returns `invalid_request` for a missing token, `invalid_client` for failed
@@ -125,6 +135,7 @@ pub async fn revoke_tokens(
   stores: &mut RevokeStores<'_>,
   stores: &mut RevokeStores<'_>,
   ctx: &RevokeContext<'_>,
   ctx: &RevokeContext<'_>,
   request: &RevokeRequest<'_>,
   request: &RevokeRequest<'_>,
+  jwt_signer: Option<&JwtAccessSigner>,
 ) -> Result<RevokeOutcome, AppError> {
 ) -> Result<RevokeOutcome, AppError> {
   if request.token.is_empty() {
   if request.token.is_empty() {
     return Err(AppError::invalid_request("token is required"));
     return Err(AppError::invalid_request("token is required"));
@@ -142,6 +153,7 @@ pub async fn revoke_tokens(
     let auth_ctx = AuthContext {
     let auth_ctx = AuthContext {
       token_endpoint: ctx.token_endpoint,
       token_endpoint: ctx.token_endpoint,
       now: ctx.now,
       now: ctx.now,
+      cimd: ctx.cimd,
     };
     };
     authenticate_client(&mut auth_stores, &auth_ctx, request.auth).await?
     authenticate_client(&mut auth_stores, &auth_ctx, request.auth).await?
   };
   };
@@ -151,7 +163,14 @@ pub async fn revoke_tokens(
     token: request.token,
     token: request.token,
     token_type_hint: request.token_type_hint,
     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) {
   if matches!(outcome, RevokeOutcome::Revoked) {
     AuditEvent::TokenRevoke {
     AuditEvent::TokenRevoke {
       request_id: ctx.request_id,
       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
 /// lookup is uniform; a foreign owner returns [`RevokeOutcome::NoOp`] without
 /// touching the store. A refresh token revokes the whole grant chain; an access
 /// touching the store. A refresh token revokes the whole grant chain; an access
 /// token revokes only itself.
 /// 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(
 async fn revoke_owned(
   conn: &mut RedisConn,
   conn: &mut RedisConn,
   store: &Store,
   store: &Store,
   service: &TokenService,
   service: &TokenService,
   owned: &OwnedToken<'_>,
   owned: &OwnedToken<'_>,
+  jwt_signer: Option<&JwtAccessSigner>,
 ) -> Result<RevokeOutcome, AppError> {
 ) -> Result<RevokeOutcome, AppError> {
   match owned.token_type_hint.filter(|hint| !hint.is_empty()) {
   match owned.token_type_hint.filter(|hint| !hint.is_empty()) {
     None | Some(HINT_ACCESS | HINT_REFRESH) => {}
     None | Some(HINT_ACCESS | HINT_REFRESH) => {}
@@ -206,9 +235,78 @@ async fn revoke_owned(
       .await?;
       .await?;
     return Ok(RevokeOutcome::Revoked);
     return Ok(RevokeOutcome::Revoked);
   }
   }
+  if is_jwt_shape(owned.token) {
+    return revoke_jwt_owned(conn, store, owned, jwt_signer).await;
+  }
   Ok(RevokeOutcome::NoOp)
   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
 /// Render a revocation outcome: `200 OK` with an empty body for success and
 /// no-ops, `400` with `unsupported_token_type` for a rejected hint.
 /// no-ops, `400` with `unsupported_token_type` for a rejected hint.
 fn outcome_response(outcome: RevokeOutcome) -> Response {
 fn outcome_response(outcome: RevokeOutcome) -> Response {
@@ -293,6 +391,15 @@ pub async fn revoke_handler(
   let ctx = RevokeContext {
   let ctx = RevokeContext {
     token_endpoint: &token_endpoint,
     token_endpoint: &token_endpoint,
     now: state.clock.now(),
     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),
     request_id: request_id.as_ref().map(RequestId::as_str),
   };
   };
   let request = RevokeRequest {
   let request = RevokeRequest {
@@ -300,7 +407,15 @@ pub async fn revoke_handler(
     token_type_hint: form.get("token_type_hint").map(String::as_str),
     token_type_hint: form.get("token_type_hint").map(String::as_str),
     auth: &auth,
     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))
   Ok(outcome_response(outcome))
 }
 }
 
 

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

@@ -8,6 +8,7 @@
 
 
 mod auth;
 mod auth;
 mod cascade;
 mod cascade;
+mod jwt;
 mod mtls;
 mod mtls;
 
 
 use std::{
 use std::{
@@ -25,7 +26,7 @@ use crate::{
   keys::{KeyId, KeyRing, KeyRingError},
   keys::{KeyId, KeyRing, KeyRingError},
   protocol::{
   protocol::{
     client_auth::ClientAuthRequest,
     client_auth::ClientAuthRequest,
-    token::{ClientKind, MintRequest, SenderProof, TokenService, TokenServiceError},
+    token::{ClientKind, MintRequest, SenderProof, TokenFormat, TokenService, TokenServiceError},
   },
   },
   state::{AppState, Clock, RedisConn},
   state::{AppState, Clock, RedisConn},
   store::{Store, StoreError},
   store::{Store, StoreError},
@@ -179,10 +180,14 @@ fn mint_request(client: &str, grant: &str) -> MintRequest {
   MintRequest {
   MintRequest {
     client_id: client.to_owned(),
     client_id: client.to_owned(),
     client_kind: ClientKind::Confidential,
     client_kind: ClientKind::Confidential,
+    dpop_required: false,
     subject: "user-1".to_owned(),
     subject: "user-1".to_owned(),
     scope: "orders:read".to_owned(),
     scope: "orders:read".to_owned(),
     grant_id: grant.to_owned(),
     grant_id: grant.to_owned(),
     proof: SenderProof::none(),
     proof: SenderProof::none(),
+    aud: Vec::new(),
+    refresh_aud: Vec::new(),
+    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 {
   let ctx = RevokeContext {
     token_endpoint: "https://auth.example.test/token",
     token_endpoint: "https://auth.example.test/token",
     now: time::OffsetDateTime::now_utc(),
     now: time::OffsetDateTime::now_utc(),
+    cimd: None,
     request_id: Some("req-revoke-test"),
     request_id: Some("req-revoke-test"),
   };
   };
   let request = RevokeRequest {
   let request = RevokeRequest {
@@ -47,7 +48,7 @@ async fn revoke_unauthenticated_request_is_401() -> TestResult {
     auth: &auth,
     auth: &auth,
   };
   };
 
 
-  match revoke_tokens(&mut stores, &ctx, &request).await {
+  match revoke_tokens(&mut stores, &ctx, &request, None).await {
     Err(error) => {
     Err(error) => {
       if error.code() != OAuthErrorCode::InvalidClient {
       if error.code() != OAuthErrorCode::InvalidClient {
         return Err(TestError::Unexpected(
         return Err(TestError::Unexpected(
@@ -96,6 +97,7 @@ async fn revoke_missing_token_is_invalid_request() -> TestResult {
   let ctx = RevokeContext {
   let ctx = RevokeContext {
     token_endpoint: "https://auth.example.test/token",
     token_endpoint: "https://auth.example.test/token",
     now: time::OffsetDateTime::now_utc(),
     now: time::OffsetDateTime::now_utc(),
+    cimd: None,
     request_id: None,
     request_id: None,
   };
   };
   let request = RevokeRequest {
   let request = RevokeRequest {
@@ -103,7 +105,7 @@ async fn revoke_missing_token_is_invalid_request() -> TestResult {
     token_type_hint: None,
     token_type_hint: None,
     auth: &auth,
     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(error) if error.code() == OAuthErrorCode::InvalidRequest => Ok(()),
     Err(_) | Ok(_) => Err(TestError::Unexpected(
     Err(_) | Ok(_) => Err(TestError::Unexpected(
       "missing token must be invalid_request".to_owned(),
       "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: &minted.access_token,
       token_type_hint: Some(HINT_ACCESS),
       token_type_hint: Some(HINT_ACCESS),
     },
     },
+    None,
   )
   )
   .await?;
   .await?;
   if outcome != RevokeOutcome::Revoked {
   if outcome != RevokeOutcome::Revoked {
@@ -89,6 +90,7 @@ async fn revoke_refresh_token_cascades_to_grant_chain() -> TestResult {
       token: &minted.refresh_token,
       token: &minted.refresh_token,
       token_type_hint: Some(HINT_REFRESH),
       token_type_hint: Some(HINT_REFRESH),
     },
     },
+    None,
   )
   )
   .await?;
   .await?;
   if outcome != RevokeOutcome::Revoked {
   if outcome != RevokeOutcome::Revoked {
@@ -131,6 +133,7 @@ async fn revoke_unknown_token_is_noop_200() -> TestResult {
       token: "no-such-token",
       token: "no-such-token",
       token_type_hint: None,
       token_type_hint: None,
     },
     },
+    None,
   )
   )
   .await?;
   .await?;
   if outcome != RevokeOutcome::NoOp {
   if outcome != RevokeOutcome::NoOp {
@@ -169,6 +172,7 @@ async fn revoke_foreign_token_is_noop_and_untouched() -> TestResult {
       token: &minted.access_token,
       token: &minted.access_token,
       token_type_hint: Some(HINT_ACCESS),
       token_type_hint: Some(HINT_ACCESS),
     },
     },
+    None,
   )
   )
   .await?;
   .await?;
   if outcome != RevokeOutcome::NoOp {
   if outcome != RevokeOutcome::NoOp {
@@ -202,6 +206,7 @@ async fn revoke_unsupported_hint_is_rejected() -> TestResult {
       token: "some-token",
       token: "some-token",
       token_type_hint: Some("password"),
       token_type_hint: Some("password"),
     },
     },
+    None,
   )
   )
   .await?;
   .await?;
   if outcome != RevokeOutcome::UnsupportedHint {
   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: &minted.access_token,
       token_type_hint: Some(HINT_REFRESH),
       token_type_hint: Some(HINT_REFRESH),
     },
     },
+    None,
   )
   )
   .await?;
   .await?;
   if outcome != RevokeOutcome::Revoked {
   if outcome != RevokeOutcome::Revoked {
@@ -306,6 +312,7 @@ async fn revoke_foreign_refresh_token_is_noop_and_untouched() -> TestResult {
       token: &minted.refresh_token,
       token: &minted.refresh_token,
       token_type_hint: Some(HINT_REFRESH),
       token_type_hint: Some(HINT_REFRESH),
     },
     },
+    None,
   )
   )
   .await?;
   .await?;
   if outcome != RevokeOutcome::NoOp {
   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,
     contacts: None,
     software_id: None,
     software_id: None,
     dpop_bound_access_tokens: false,
     dpop_bound_access_tokens: false,
+    access_token_format: "opaque".to_owned(),
     registration_access_token_hash: None,
     registration_access_token_hash: None,
   };
   };
   client_repo::insert(pool, &keys, false, &new).await?;
   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.
   /// The response status was not successful.
   #[error("fetch returned an unsuccessful status")]
   #[error("fetch returned an unsuccessful status")]
   BadStatus,
   BadStatus,
-  /// The response exceeded [`SSRF_MAX_BYTES`].
+  /// The response exceeded the configured size cap.
   #[error("fetch response exceeds the size limit")]
   #[error("fetch response exceeds the size limit")]
   TooLarge,
   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]
 #[must_use]
 pub fn is_blocked_ip(ip: &IpAddr) -> bool {
 pub fn is_blocked_ip(ip: &IpAddr) -> bool {
   match ip {
   match ip {
@@ -86,7 +103,22 @@ pub fn is_blocked_ip(ip: &IpAddr) -> bool {
         // documentation (2001:db8::/32).
         // documentation (2001:db8::/32).
         || (v6.segments()[0] & 0xfe00) == 0xfc00
         || (v6.segments()[0] & 0xfe00) == 0xfc00
         || (v6.segments()[0] & 0xffc0) == 0xfe80
         || (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_private()
     || v4.is_link_local()
     || v4.is_link_local()
     || v4.is_documentation()
     || 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.
     // CGNAT shared space (RFC 6598): never a public JWKS host.
     || (o[0] == 100 && o[1] >= 64 && o[1] <= 127)
     || (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[0] >= 240
     || o == AZURE_METADATA_V4
     || o == AZURE_METADATA_V4
     || o == ALIBABA_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)
   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
 /// # Errors
 ///
 ///
 /// Returns [`SsrfError`] when the URL policy, DNS check, fetch, status, or
 /// Returns [`SsrfError`] when the URL policy, DNS check, fetch, status, or
 /// size cap fails.
 /// size cap fails.
 pub async fn fetch_guarded_bytes(raw: &str) -> Result<Vec<u8>, SsrfError> {
 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 url = validate_fetch_url(raw)?;
   let host = url.host_str().unwrap_or_default().to_owned();
   let host = url.host_str().unwrap_or_default().to_owned();
   let port = url.port_or_known_default().unwrap_or(443);
   let port = url.port_or_known_default().unwrap_or(443);
   let addrs = check_fetch_host(&host, port).await?;
   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()
   let builder = reqwest::Client::builder()
     .redirect(Policy::none())
     .redirect(Policy::none())
-    .timeout(SSRF_TIMEOUT)
+    .timeout(timeout)
     .no_proxy();
     .no_proxy();
   let builder = if host.parse::<IpAddr>().is_ok() {
   let builder = if host.parse::<IpAddr>().is_ok() {
     builder
     builder
   } else {
   } else {
-    builder.resolve_to_addrs(&host, &addrs)
+    builder.resolve_to_addrs(host, addrs)
   };
   };
   let client = builder.build().map_err(|_| SsrfError::FetchFailed)?;
   let client = builder.build().map_err(|_| SsrfError::FetchFailed)?;
   let mut response = client
   let mut response = client
-    .get(url)
+    .get(url.clone())
     .send()
     .send()
     .await
     .await
     .map_err(|_| SsrfError::FetchFailed)?;
     .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();
   let mut body = Vec::new();
   while let Some(chunk) = response.chunk().await.map_err(|_| SsrfError::FetchFailed)? {
   while let Some(chunk) = response.chunk().await.map_err(|_| SsrfError::FetchFailed)? {
     body.extend_from_slice(&chunk);
     body.extend_from_slice(&chunk);
-    if body.len() > SSRF_MAX_BYTES {
+    if body.len() > max_bytes {
       return Err(SsrfError::TooLarge);
       return Err(SsrfError::TooLarge);
     }
     }
   }
   }

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

@@ -1,8 +1,14 @@
 //! `test(ssrf)` suite: URL policy and target blocking (offline).
 //! `test(ssrf)` suite: URL policy and target blocking (offline).
 
 
 use std::net::IpAddr;
 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).
 /// This suite's error type (no `unwrap`/`expect` under the deny lints).
 type TestResult = Result<(), TestError>;
 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;
 mod grants;
 
 
 use axum::Json;
 use axum::Json;
-use axum::extract::{Extension, State, rejection::FormRejection};
+use axum::extract::{Extension, State};
 use axum::http::{HeaderMap, HeaderValue, header};
 use axum::http::{HeaderMap, HeaderValue, header};
 use axum::response::{IntoResponse, Response};
 use axum::response::{IntoResponse, Response};
 use secrecy::SecretString;
 use secrecy::SecretString;
@@ -25,7 +25,10 @@ use super::{ClientKind, SenderProof, TokenService};
 use crate::{
 use crate::{
   db::{TokenEndpointAuthMethod, client_repo, scope_repo},
   db::{TokenEndpointAuthMethod, client_repo, scope_repo},
   error::AppError,
   error::AppError,
-  jose::dpop::{DpopRequest, verify_dpop},
+  jose::{
+    dpop::{DpopRequest, verify_dpop},
+    jwt_access::JwtAccessSigner,
+  },
   protocol::{
   protocol::{
     client_auth::{self, AuthContext, AuthStores, ClientAuthRequest},
     client_auth::{self, AuthContext, AuthStores, ClientAuthRequest},
     mtls,
     mtls,
@@ -77,6 +80,58 @@ pub struct TokenForm {
   pub client_assertion: Option<String>,
   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
 /// RFC 6749 §5.1 token response plus the RFC 8693 §2.2.1
 /// `issued_token_type` member. Optional members are omitted, never null.
 /// `issued_token_type` member. Optional members are omitted, never null.
 #[derive(Debug, Serialize)]
 #[derive(Debug, Serialize)]
@@ -141,6 +196,12 @@ struct Dispatch<'a> {
   form: &'a TokenForm,
   form: &'a TokenForm,
   /// Verified peer chain injected by the TLS accept loop, when present.
   /// Verified peer chain injected by the TLS accept loop, when present.
   peer_certs: Option<&'a PeerCertificates>,
   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.
   /// Current time as seconds since the Unix epoch.
   now_unix: i64,
   now_unix: i64,
 }
 }
@@ -166,10 +227,52 @@ impl Dispatch<'_> {
     let ctx = AuthContext {
     let ctx = AuthContext {
       token_endpoint: &endpoint,
       token_endpoint: &endpoint,
       now: self.state.clock.now(),
       now: self.state.clock.now(),
+      cimd: self.cimd,
     };
     };
     client_auth::authenticate_client(&mut stores, &ctx, &request).await
     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`).
   /// 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> {
   async fn sender_proof(&mut self, client: &client_repo::Client) -> Result<SenderProof, AppError> {
     let htu = client_auth::token_endpoint(&self.state.config.issuer);
     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())
   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.
 /// Public vs confidential: the sender-constraint matrix selector.
 fn client_kind(client: &client_repo::Client) -> Result<ClientKind, AppError> {
 fn client_kind(client: &client_repo::Client) -> Result<ClientKind, AppError> {
   match client.auth_method() {
   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)
   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.
 /// `POST /token`: the five-grant dispatcher.
 ///
 ///
 /// # Errors
 /// # Errors
@@ -292,11 +433,61 @@ pub async fn handler(
   State(state): State<AppState>,
   State(state): State<AppState>,
   peer_certs: Option<Extension<PeerCertificates>>,
   peer_certs: Option<Extension<PeerCertificates>>,
   headers: HeaderMap,
   headers: HeaderMap,
-  form: Result<axum::Form<TokenForm>, FormRejection>,
+  body: axum::body::Bytes,
 ) -> Response {
 ) -> 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 mut conn = state.redis.clone();
   let store = Store::from_config(&state.config.redis);
   let store = Store::from_config(&state.config.redis);
@@ -308,6 +499,16 @@ pub async fn handler(
     headers: &headers,
     headers: &headers,
     form: &form,
     form: &form,
     peer_certs: peer_certs.as_deref(),
     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,
     now_unix,
   };
   };
   match grants::dispatch_grant(&mut dispatch).await {
   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.
 /// Shape a success response: JSON plus the RFC 6749 §5.1 no-store headers.
 fn token_response(body: TokenResponseBody) -> Response {
 fn token_response(body: TokenResponseBody) -> Response {
   let mut response = Json(body).into_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"));
     .insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
   response
   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 secrecy::SecretString;
 
 
 use super::{
 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::{
 use crate::{
   db::{user_repo, user_scope_repo},
   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.
 /// `grant_type=authorization_code`: the grant authenticates the client itself.
 async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
 async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
   let basic = basic_header(d.headers);
   let basic = basic_header(d.headers);
   let proof = dpop_proof(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 body_secret = d.form.client_secret.clone().map(SecretString::from);
   let mut grant = CodeGrantState {
   let mut grant = CodeGrantState {
     pool: &d.state.pool,
     pool: &d.state.pool,
@@ -65,6 +93,9 @@ async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
     keys: &d.state.keys,
     keys: &d.state.keys,
     issuer: &d.state.config.issuer,
     issuer: &d.state.config.issuer,
     ttl: &d.state.config.ttl,
     ttl: &d.state.config.ttl,
+    token_format: format,
+    jwt_signer: jwt,
+    cimd: d.cimd,
     now_unix: d.now_unix,
     now_unix: d.now_unix,
   };
   };
   let inputs = CodeGrantInputs {
   let inputs = CodeGrantInputs {
@@ -78,6 +109,7 @@ async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
     assertion: d.form.client_assertion.as_deref(),
     assertion: d.form.client_assertion.as_deref(),
     dpop_proof: proof,
     dpop_proof: proof,
     peer_certs: d.peer_certs,
     peer_certs: d.peer_certs,
+    aud: d.resources,
   };
   };
   let tokens = authorization_code::redeem_code(&mut grant, &inputs).await?;
   let tokens = authorization_code::redeem_code(&mut grant, &inputs).await?;
   Ok(TokenResponseBody {
   Ok(TokenResponseBody {
@@ -95,11 +127,15 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
   let authenticated = d.authenticate().await?;
   let authenticated = d.authenticate().await?;
   let client = authenticated.client;
   let client = authenticated.client;
   require_grant(&client, refresh_token::GRANT_TYPE_REFRESH_TOKEN)?;
   require_grant(&client, refresh_token::GRANT_TYPE_REFRESH_TOKEN)?;
+  let format = d.require_token_format(&client)?;
   let kind = client_kind(&client)?;
   let kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
   let proof = d.sender_proof(&client).await?;
   require_dpop_binding(&client, &proof)?;
   require_dpop_binding(&client, &proof)?;
   let enabled = enabled_scopes(&d.state.pool).await?;
   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 {
   let users = refresh_token::RefreshUserStores {
     pool: &d.state.pool,
     pool: &d.state.pool,
     keys: &d.state.keys,
     keys: &d.state.keys,
@@ -117,12 +153,15 @@ async fn refresh(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
     &RefreshInput {
     &RefreshInput {
       client_id: &client.client_id,
       client_id: &client.client_id,
       client_kind: kind,
       client_kind: kind,
+      dpop_required: client.dpop_bound_access_tokens,
       refresh_token: d.form.refresh_token.as_deref().unwrap_or(""),
       refresh_token: d.form.refresh_token.as_deref().unwrap_or(""),
       requested_scope: d.form.scope.as_deref(),
       requested_scope: d.form.scope.as_deref(),
+      requested_resources: d.resources,
       proof: &proof,
       proof: &proof,
       enabled: &enabled,
       enabled: &enabled,
       client_registered: &registered,
       client_registered: &registered,
       user_entitled: &no_user_term,
       user_entitled: &no_user_term,
+      token_format: format,
       now_unix: d.now_unix,
       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 authenticated = d.authenticate().await?;
   let client = authenticated.client;
   let client = authenticated.client;
   require_grant(&client, client_credentials::GRANT_TYPE_CLIENT_CREDENTIALS)?;
   require_grant(&client, client_credentials::GRANT_TYPE_CLIENT_CREDENTIALS)?;
+  let format = d.require_token_format(&client)?;
   let kind = client_kind(&client)?;
   let kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
   let proof = d.sender_proof(&client).await?;
   require_dpop_binding(&client, &proof)?;
   require_dpop_binding(&client, &proof)?;
   let registered = ScopeSet::parse(&client.scope).map_err(|_| AppError::server_error())?;
   let registered = ScopeSet::parse(&client.scope).map_err(|_| AppError::server_error())?;
   let enabled = enabled_scopes(&d.state.pool).await?;
   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(
   let issued = client_credentials::issue_client_credentials(
     &mut *d.conn,
     &mut *d.conn,
     d.store,
     d.store,
@@ -161,6 +204,8 @@ async fn client_credentials(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
       dpop_bound_jkt: None,
       dpop_bound_jkt: None,
       mtls_bound_x5t_s256: client.tls_client_auth_cert_thumbprint.as_deref(),
       mtls_bound_x5t_s256: client.tls_client_auth_cert_thumbprint.as_deref(),
       proof: &proof,
       proof: &proof,
+      aud: d.resources,
+      token_format: format,
       now_unix: d.now_unix,
       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))
       basic.and_then(|header| client_auth::parse_basic_header(header).map(|creds| creds.client_id))
     })
     })
     .unwrap_or_default();
     .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 {
   let mut poll = PollState {
     pool: &d.state.pool,
     pool: &d.state.pool,
     conn: &mut *d.conn,
     conn: &mut *d.conn,
@@ -197,6 +252,9 @@ async fn device(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
     device_ttl: d.state.config.ttl.device,
     device_ttl: d.state.config.ttl.device,
     interval: Duration::from_secs(DEVICE_INTERVAL_SECS),
     interval: Duration::from_secs(DEVICE_INTERVAL_SECS),
     ttl: &d.state.config.ttl,
     ttl: &d.state.config.ttl,
+    token_format: format,
+    jwt_signer: jwt,
+    cimd: d.cimd,
     now_unix: d.now_unix,
     now_unix: d.now_unix,
   };
   };
   let inputs = DevicePollInputs {
   let inputs = DevicePollInputs {
@@ -208,6 +266,7 @@ async fn device(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
     assertion: d.form.client_assertion.as_deref(),
     assertion: d.form.client_assertion.as_deref(),
     dpop_proof: proof,
     dpop_proof: proof,
     peer_certs: d.peer_certs,
     peer_certs: d.peer_certs,
+    aud: d.resources,
   };
   };
   let tokens = device_code::poll_device(&mut poll, &inputs).await?;
   let tokens = device_code::poll_device(&mut poll, &inputs).await?;
   Ok(TokenResponseBody {
   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.
 /// `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> {
 async fn exchange(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
   let authenticated = d.authenticate().await?;
   let authenticated = d.authenticate().await?;
   let client = authenticated.client;
   let client = authenticated.client;
   require_grant(&client, token_exchange::GRANT_TYPE_TOKEN_EXCHANGE)?;
   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 kind = client_kind(&client)?;
   let proof = d.sender_proof(&client).await?;
   let proof = d.sender_proof(&client).await?;
   require_dpop_binding(&client, &proof)?;
   require_dpop_binding(&client, &proof)?;
@@ -240,6 +310,7 @@ async fn exchange(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError>
     &ExchangeInput {
     &ExchangeInput {
       caller_client_id: &client.client_id,
       caller_client_id: &client.client_id,
       caller_kind: kind,
       caller_kind: kind,
+      caller_dpop_required: client.dpop_bound_access_tokens,
       caller_registered: &registered,
       caller_registered: &registered,
       subject_token,
       subject_token,
       subject_token_type: d.form.subject_token_type.as_deref().unwrap_or(""),
       subject_token_type: d.form.subject_token_type.as_deref().unwrap_or(""),

Bu fark içinde çok fazla dosya değişikliği olduğu için bazı dosyalar gösterilmiyor