Efren Yevale Varela 1 semana atrás
pai
commit
0aad5b36ee
62 arquivos alterados com 7158 adições e 408 exclusões
  1. 14 8
      .github/workflows/ci.yml
  2. 13 4
      AGENTS.md
  3. 13 2
      README.md
  4. 40 5
      docs/CONTRIBUTING.md
  5. 243 0
      docs/TEST-COVERAGE.md
  6. 29 0
      fuzz/Cargo.toml
  7. 57 0
      fuzz/fuzz_targets/cimd_document.rs
  8. 101 0
      fuzz/fuzz_targets/jwt_access_verify.rs
  9. 24 0
      fuzz/fuzz_targets/mtls_cert.rs
  10. 53 0
      fuzz/fuzz_targets/params_form.rs
  11. 44 0
      fuzz/fuzz_targets/pkce.rs
  12. 49 0
      fuzz/fuzz_targets/ssrf_url.rs
  13. 76 0
      fuzz/fuzz_targets/totp_input.rs
  14. 391 0
      scripts/check_coverage.py
  15. 19 0
      scripts/coverage_floors.json
  16. 222 0
      scripts/test_check_coverage.py
  17. 2 1
      src/config/jwt.rs
  18. 1 0
      src/middleware/ratelimit/test_support.rs
  19. 32 6
      src/protocol/authorize.rs
  20. 1 0
      src/protocol/authorize/tests.rs
  21. 88 41
      src/protocol/cimd.rs
  22. 74 7
      src/protocol/cimd/tests.rs
  23. 107 2
      src/protocol/cimd/tests_principal.rs
  24. 4 4
      src/protocol/client_auth.rs
  25. 2 1
      src/protocol/introspect.rs
  26. 1 0
      src/protocol/introspect/tests.rs
  27. 1 0
      src/protocol/metadata/tests.rs
  28. 1 1
      src/protocol/mtls.rs
  29. 4 1
      src/protocol/par.rs
  30. 2 1
      src/protocol/revoke.rs
  31. 1 0
      src/protocol/revoke/tests.rs
  32. 1 0
      src/protocol/revoke/tests/jwt.rs
  33. 1 0
      src/protocol/revoke/tests/mtls.rs
  34. 2 1
      src/protocol/token/dispatch.rs
  35. 2 2
      src/protocol/token/dispatch/grants.rs
  36. 2 2
      src/protocol/token/grants/authorization_code.rs
  37. 1 1
      src/protocol/token/grants/device_code.rs
  38. 1 1
      src/protocol/token/grants/device_code/proofs.rs
  39. 2 0
      src/routes.rs
  40. 5 0
      src/state.rs
  41. 3 1
      src/ui/consent.rs
  42. 1 0
      src/ui/consent/tests.rs
  43. 1 0
      src/ui/login/tests.rs
  44. 239 0
      tests/cli_user_disable.rs
  45. 566 0
      tests/e2e/browser.rs
  46. 186 0
      tests/e2e/device.rs
  47. 242 0
      tests/e2e/edge.rs
  48. 81 0
      tests/e2e/exchange.rs
  49. 217 5
      tests/e2e/harness.rs
  50. 126 0
      tests/e2e/peer.rs
  51. 89 0
      tests/e2e/pkjwt.rs
  52. 441 0
      tests/e2e_admin.rs
  53. 662 0
      tests/e2e_cimd.rs
  54. 428 0
      tests/e2e_dcr.rs
  55. 293 0
      tests/e2e_device.rs
  56. 181 0
      tests/e2e_edge_matrix.rs
  57. 143 311
      tests/e2e_flow.rs
  58. 193 0
      tests/e2e_logout.rs
  59. 171 0
      tests/e2e_mtls_grant.rs
  60. 589 0
      tests/e2e_stale_catalog.rs
  61. 300 0
      tests/e2e_token_exchange.rs
  62. 280 0
      tests/proptest_inputs.rs

+ 14 - 8
.github/workflows/ci.yml

@@ -274,7 +274,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        target: [token_form, dcr_json, jar_claims, dpop_proof, jar_verify, redirect_uri, scope_parse]
+        target: [token_form, dcr_json, jar_claims, dpop_proof, jar_verify, redirect_uri, scope_parse, cimd_document, jwt_access_verify, mtls_cert, params_form, pkce, ssrf_url, totp_input]
     defaults:
       run:
         working-directory: fuzz
@@ -362,16 +362,16 @@ jobs:
 
   coverage:
     name: coverage
-    # Report-only coverage signal (T4-4). Non-blocking by design: the upload
-    # is the deliverable; there is no `--fail-under-*` threshold yet.
+    # All-targets coverage signal (lib + integration + e2e). Blocking on the
+    # recorded floors in `scripts/coverage_floors.json` (see docs/TEST-COVERAGE.md
+    # baseline): the `Check coverage floors` step fails the job when global,
+    # security, or any per-module floor is missed.
     # Services mirror `nextest` (shared Postgres/Redis plus redis-server),
     # because several lib unit tests are testcontainers-backed.
-    # Follow-up: add a threshold and extend beyond `--lib` once
-    # the first reports land.
     runs-on: ubuntu-24.04
     env:
       RUSTC_WRAPPER: sccache
-    continue-on-error: true
+    timeout-minutes: 90
     steps:
       - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
       - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable
@@ -392,12 +392,18 @@ jobs:
         uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2
         with:
           tool: cargo-llvm-cov
+      - name: Install cargo-nextest
+        uses: taiki-e/install-action@ab6895351a88b908e96a4697860d4ffea5e57dbe # v2
+        with:
+          tool: nextest
       - name: Install redis-server (in-process Sentinel/Cluster fixtures)
         run: sudo apt-get update && sudo apt-get install -y redis-server
       - name: Start shared test services (Postgres + Redis)
         run: docker compose -f compose.test.yaml up -d
-      - name: Coverage report (lib unit tests only; integration tests stay in `nextest`)
-        run: cargo llvm-cov --lib --lcov --output-path lcov.info
+      - name: Coverage report (all targets; test files excluded by filename regex)
+        run: cargo llvm-cov nextest --all-targets --lcov --output-path lcov.info --ignore-filename-regex '(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$'
+      - name: Check coverage floors
+        run: python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
       - name: Stop shared test services
         if: always()
         run: docker compose -f compose.test.yaml down -v

+ 13 - 4
AGENTS.md

@@ -116,10 +116,19 @@ cargo deny check
 python3 scripts/check_doc_links.py
 ```
 
-`fuzz/` is a standalone workspace, so no gate above compiles it, and a fuzz
-target that builds a library type by hand goes stale the moment that struct
-gains a field. When a change adds or reshapes a type or function a target
-constructs or calls, build the targets too:
+Coverage is a separate blocking gate. It runs every target under
+`cargo llvm-cov` against the shared services and checks the global (89.0),
+security (91.0), and per-module floors in `scripts/coverage_floors.json`:
+
+```bash
+cargo llvm-cov nextest --all-targets --lcov --output-path lcov.info --ignore-filename-regex '(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$'
+python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
+```
+
+The `fuzz/` standalone workspace has 14 targets, and no gate above compiles
+it, so a fuzz target that builds a library type by hand goes stale the moment
+that struct gains a field. When a change adds or reshapes a type or function a
+target constructs or calls, build the targets too:
 
 ```bash
 cd fuzz && cargo fuzz build --target x86_64-unknown-linux-gnu

+ 13 - 2
README.md

@@ -218,8 +218,19 @@ cargo deny check
 python3 scripts/check_doc_links.py
 ```
 
-CI also builds the fuzz targets, which none of the gates above compile. Run it
-when a change adds or reshapes a type or function a target constructs:
+Coverage is a separate blocking gate. It runs every target under
+`cargo llvm-cov` against the shared services and checks the global (89.0),
+security (91.0), and per-module floors in `scripts/coverage_floors.json`:
+
+```bash
+cargo llvm-cov nextest --all-targets --lcov --output-path lcov.info --ignore-filename-regex '(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$'
+python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
+```
+
+CI also builds the 14 fuzz targets, which none of the gates above compile: the
+original seven plus `jwt_access_verify`, `cimd_document`, `mtls_cert`,
+`params_form`, `ssrf_url`, `totp_input`, and `pkce`. Run it when a change adds
+or reshapes a type or function a target constructs:
 
 ```bash
 cd fuzz && cargo fuzz build --target x86_64-unknown-linux-gnu

+ 40 - 5
docs/CONTRIBUTING.md

@@ -63,6 +63,33 @@ cargo deny check
 python3 scripts/check_doc_links.py
 ```
 
+The measured coverage baseline, the per-module floors, the flow-by-level
+matrix, and the exact reproduction commands are in
+[TEST-COVERAGE.md](TEST-COVERAGE.md).
+
+### Coverage gate
+
+The coverage job runs every target (lib, integration, and end-to-end) under
+`cargo llvm-cov nextest` and checks the recorded floors. There are two
+aggregate floors: global 89.0 and the security aggregate at 91.0 (the ten core
+modules: crypto, jose, protocol, db, store, state, middleware, keys, error,
+routes). Each module also carries its own floor in
+`scripts/coverage_floors.json`: crypto 94.0, jose 91.0, protocol 92.0, db 90.0,
+store 87.0, state 75.0, middleware 94.0, ui 87.0, config 92.0, keys 87.0,
+error 88.0, routes 91.0, and tls 95.0. `ui`, `config`, and `tls` are outside
+the security aggregate.
+
+```bash
+docker compose -f compose.test.yaml up -d
+cargo llvm-cov nextest --all-targets --lcov --output-path lcov.info --ignore-filename-regex '(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$'
+python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
+docker compose -f compose.test.yaml down -v
+```
+
+`scripts/check_coverage.py` reads the lcov report, normalizes the source paths,
+and exits non-zero when the global, security, or any per-module floor is
+missed. It fails closed when a listed module is missing from the report.
+
 Start the shared test services before `cargo nextest run --all-targets`:
 `docker compose -f compose.test.yaml up -d`, then tear them down with
 `docker compose -f compose.test.yaml down -v`. `cargo nextest run` is the
@@ -136,11 +163,19 @@ Fuzz targets live in `fuzz/fuzz_targets/` (a standalone workspace under
 | `jar_verify` | `protocol::client_auth::assertion::{unverified_iss, validate_assertion}` |
 | `redirect_uri` | `protocol::redirect_repo::match_redirect_uri` |
 | `scope_parse` | `protocol::scope::ScopeSet::{parse, render}` |
-
-Every target is small and hermetic (no network, Postgres, or Redis):
-signature verification that needs keys and replay claims that need the store
-stay under unit/proptest cover. Targets contain no `unwrap`/`expect`/`panic`/
-`unsafe`, so arbitrary input can only surface a genuine parser panic.
+| `cimd_document` | `protocol::cimd::{CimdDocument, validate_document}` |
+| `jwt_access_verify` | `jose::jwt_access::JwtAccessSigner::verify` |
+| `mtls_cert` | `protocol::mtls::{subject_dn, ensure_within_validity, thumbprint_b64url}` |
+| `params_form` | `protocol::params::{parse_pairs, single_valued_map, values_of, has_duplicate_field}` |
+| `pkce` | `crypto::pkce::{challenge_from_verifier, verify_s256}` |
+| `ssrf_url` | `protocol::ssrf::{validate_fetch_url, is_blocked_ip}` |
+| `totp_input` | `crypto::totp::{build_totp, verify_window, normalize_recovery_code}` |
+
+Every target is small and hermetic (no network, Postgres, or Redis): keyed
+signature verification with crafted tokens is fuzzed by `jwt_access_verify`,
+while replay and claims-state checks still need the store and stay under
+unit/proptest cover. Targets contain no `unwrap`/`expect`/`panic`/`unsafe`, so
+arbitrary input can only surface a genuine parser panic.
 
 ```bash
 cd fuzz

+ 243 - 0
docs/TEST-COVERAGE.md

@@ -0,0 +1,243 @@
+# Test Coverage
+
+## Baseline
+
+Authoritative all-targets baseline (orchestrator run, 1050/1050 tests,
+elapsed 767.6 s = 12.79 min):
+
+- Global line coverage: 90.5% (22026/24335), floor 89.0.
+- Security aggregate (the ten core modules: crypto, jose, protocol, db,
+  store, state, middleware, keys, error, routes): 92.2% (17230/18695),
+  floor 91.0.
+
+Per-module baselines and floors (floor = truncate(baseline) - 1.0,
+enforced by `scripts/coverage_floors.json`):
+
+| Module     | Baseline | Hit / Found | Floor |
+|------------|----------|-------------|-------|
+| crypto     | 95.6%    | 1066/1115   | 94.0  |
+| jose       | 92.7%    | 1107/1194   | 91.0  |
+| protocol   | 93.3%    | 8312/8911   | 92.0  |
+| db         | 91.7%    | 1602/1747   | 90.0  |
+| store      | 88.6%    | 2091/2360   | 87.0  |
+| state      | 76.0%    | 355/467     | 75.0  |
+| middleware | 95.7%    | 986/1030    | 94.0  |
+| ui         | 88.7%    | 1362/1535   | 87.0  |
+| config     | 93.5%    | 907/970     | 92.0  |
+| keys       | 88.5%    | 184/208     | 87.0  |
+| error      | 89.7%    | 425/474     | 88.0  |
+| routes     | 92.7%    | 1102/1189   | 91.0  |
+| tls        | 96.1%    | 49/51       | 95.0  |
+
+The `ui`, `config`, and `tls` modules carry per-module floors only and are
+excluded from the security aggregate.
+
+Floors file: `scripts/coverage_floors.json`.
+
+Reproduce:
+
+```bash
+docker compose -f compose.test.yaml up -d
+cargo llvm-cov nextest --all-targets --lcov --output-path lcov.info --ignore-filename-regex '(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$'
+python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
+docker compose -f compose.test.yaml down -v
+```
+
+Note: the authoritative run used `--test-threads=1 --retries=3
+--no-fail-fast` because WAITAOF barriers flaked under high local parallelism
+(host-specific; 1050/1050 passed).
+
+## Flow matrix
+
+Every row is one OAuth flow. The level columns read `covered` or `gap`; a
+covered cell names the suite or target that delivers it. The evidence column
+names the canonical capture under `.omo/evidence/test-coverage-audit/` (local
+run evidence, not shipped in the repository). Each `NN-*.txt` name in that
+column is shorthand for `.omo/evidence/test-coverage-audit/NN-*.txt`.
+
+Levels:
+
+- `unit`: white-box module tests, either inline in `src/` or in a pure
+  `tests/*.rs` suite that never starts the router.
+- `proptest`: a `proptest!` property suite over the flow's core parser or
+  invariant.
+- `e2e`: the real router or TLS handshake over the shared Postgres and Redis.
+- `fuzz`: a hermetic target under `fuzz/fuzz_targets/`.
+
+| Flow | unit | proptest | e2e | fuzz | Evidence |
+| --- | --- | --- | --- | --- | --- |
+| Authorization code | covered (`authorization_code.rs`, `code/tests.rs`) | gap | covered (`e2e_flow`, `e2e_conformance`, `e2e_grants`, `e2e_stale_catalog`) | gap | `03-baseline.txt`, `18-login-negatives.txt`, `17-stale-catalog.txt` |
+| Refresh | covered (`refresh_token/tests/*`, 34 tests) | covered (`proptest_rotation`) | covered (`e2e_grants`, `e2e_stale_catalog`, `e2e_admin`) | gap | `03-baseline.txt`, `17-stale-catalog.txt`, `11-admin.txt` |
+| Client credentials | covered (`client_credentials/tests.rs`, 14 tests) | gap | covered (`e2e_grants`, `e2e_mtls_grant`) | gap | `03-baseline.txt`, `14-mtls.txt` |
+| Device | covered (`device_code/*`, `device/tests.rs`) | gap | covered (`e2e_device`, `e2e_edge_matrix`, `e2e_stale_catalog`) | gap | `09-device.txt`, `16-edge-matrix.txt`, `17-stale-catalog.txt` |
+| Exchange | covered (`token_exchange/tests/*`, 7 tests) | gap | covered (`e2e_token_exchange`, `e2e_edge_matrix`, `e2e_grants`) | gap | `10-token-exchange.txt`, `16-edge-matrix.txt` |
+| DPoP | covered (`jose/dpop/tests.rs`, 21 tests) | gap | covered (`e2e_grants`, `e2e_flow`) | covered (`dpop_proof`) | `03-baseline.txt`, `20-fuzz-build.txt` |
+| mTLS | covered (`tests/mtls.rs` vectors and selection) | gap | covered (`e2e_mtls_grant`, `tests/mtls.rs` handshake) | covered (`mtls_cert`) | `14-mtls.txt`, `20-fuzz-build.txt` |
+| PAR | covered (`par/tests/*`, 29 tests) | covered (`proptest_inputs` wire-form properties) | covered (`e2e_conformance`, `e2e_grants`, `e2e_cimd`) | covered (`params_form`) | `03-baseline.txt`, `15-cimd.txt`, `19-proptest.txt`, `21-fuzz-smoke.txt` |
+| JAR | covered (`jose/jar/*` inline tests, 7 tests) | gap | covered (`tests/jar.rs`, `e2e_cimd`) | covered (`jar_claims`, `jar_verify`) | `03-baseline.txt`, `15-cimd.txt`, `20-fuzz-build.txt` |
+| DCR | covered (`dcr/tests.rs`, 50 tests) | gap | covered (`e2e_dcr`, `e2e_edge_matrix`) | covered (`dcr_json`) | `08-dcr.txt`, `16-edge-matrix.txt`, `20-fuzz-build.txt` |
+| Introspection | covered (`introspect/tests.rs`, 23 tests) | gap | covered (`e2e_conformance`, `e2e_abuse`, `e2e_device`, `e2e_admin`, `e2e_token_exchange`, `e2e_mtls_grant`) | gap | `03-baseline.txt`, `11-admin.txt` |
+| Revocation | covered (`revoke/tests/*`, 20 tests) | gap | covered (`e2e_abuse`, `e2e_conformance`) | gap | `03-baseline.txt` |
+| Logout | covered (`src/ui/login/tests/auth.rs`) | gap | covered (`e2e_logout`) | gap | `13-logout.txt` |
+| CIMD | covered (`cimd/tests*.rs`, `ssrf/tests.rs`) | gap | covered (`e2e_cimd`) | covered (`cimd_document`, `ssrf_url`) | `15-cimd.txt`, `20-fuzz-build.txt`, `21-fuzz-smoke.txt` |
+| Resource indicators | covered (`params/tests.rs`, token audience tests) | covered (`proptest_inputs` repeated `resource` fields) | covered (`e2e_grants`, `e2e_stale_catalog`) | covered (`params_form`) | `03-baseline.txt`, `17-stale-catalog.txt`, `19-proptest.txt`, `21-fuzz-smoke.txt` |
+| Scope catalog | covered (`tests/scope_engine.rs`, 5 pure tests) | covered (`proptest_scope`, `scope_engine` round trip) | covered (`e2e_stale_catalog`, `e2e_abuse`) | covered (`scope_parse`) | `03-baseline.txt`, `17-stale-catalog.txt`, `21-fuzz-smoke.txt` |
+| PKCE | covered (`crypto/pkce.rs`, 9 tests) | covered (`proptest_pkce`) | covered (`e2e_conformance`, `e2e_grants`, `e2e_flow`) | covered (`pkce`) | `03-baseline.txt`, `18-login-negatives.txt`, `21-fuzz-smoke.txt` |
+
+Reading the gaps: `proptest` gaps are flows whose invariant is carried by
+example-based unit and e2e tests instead of a generated strategy (device,
+exchange, DCR, introspection, revocation, logout, CIMD, JAR, DPoP, mTLS,
+client credentials, authorization code). The `fuzz` gaps are grants with no
+hermetic parser to fuzz; the five form endpoints share the `params_form`
+target, and opaque token storage strings share `token_form`, but neither
+target exercises grant-specific state. A gap is a statement about this
+matrix, not an unbounded hole.
+
+## Closed gaps (waves 3-5)
+
+Each row names the suite or target that closed the gap and the capture that
+proves it.
+
+| Closed gap | Delivering suite or target | Evidence |
+| --- | --- | --- |
+| DCR registration lifecycle: register, use, read, update, delete | `tests/e2e_dcr.rs` | `08-dcr.txt` |
+| Device grant approve, poll, deny, user-code normalization, guess limit | `tests/e2e_device.rs` | `09-device.txt` |
+| Token exchange allowlisted mint, scope widening, multi-hop | `tests/e2e_token_exchange.rs` | `10-token-exchange.txt` |
+| Disabled-user cascade: introspection, refresh, code, device, session | `tests/e2e_admin.rs` | `11-admin.txt` |
+| CLI `user disable` purge-then-flag ordering | `tests/cli_user_disable.rs` | `12-cli-disable.txt` |
+| Logout POST, GET read-only, CSRF mismatch | `tests/e2e_logout.rs` | `13-logout.txt` |
+| mTLS `tls_client_auth` token mint with `cnf` binding | `tests/e2e_mtls_grant.rs` | `14-mtls.txt` |
+| CIMD authorize, login, consent, token, cache, invalid documents, dead row | `tests/e2e_cimd.rs` | `15-cimd.txt` |
+| DCR, device, and exchange HTTP edge matrix | `tests/e2e_edge_matrix.rs` | `16-edge-matrix.txt` |
+| Stale resource and scope catalog on refresh, device, auth code, authorize | `tests/e2e_stale_catalog.rs` | `17-stale-catalog.txt` |
+| Login TOTP and CSRF negatives | `tests/e2e_flow.rs` | `18-login-negatives.txt` |
+| Input properties: params wire order, TOTP window, at+jwt no panic | `tests/proptest_inputs.rs` | `19-proptest.txt` |
+| Fuzz targets: `jwt_access_verify`, `cimd_document`, `mtls_cert`, `params_form`, `ssrf_url`, `totp_input`, `pkce` | `fuzz/fuzz_targets/` | `20-fuzz-build.txt`, `21-fuzz-smoke.txt` |
+| Local seeds and CI fuzz matrix | `fuzz/corpus/`, `.github/workflows/ci.yml` | `22-ci-matrix.txt` |
+
+## Reproduce a run
+
+Coverage, with the shared services up:
+
+```bash
+docker compose -f compose.test.yaml up -d
+cargo llvm-cov nextest --all-targets --lcov --output-path lcov.info --ignore-filename-regex '(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$'
+python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
+docker compose -f compose.test.yaml down -v
+```
+
+The floor checker's own fixtures:
+
+```bash
+python3 scripts/test_check_coverage.py
+```
+
+One suite at a time:
+
+```bash
+cargo nextest run -E 'binary(e2e_dcr)'
+cargo nextest run -E 'binary(e2e_device)'
+cargo nextest run -E 'binary(e2e_token_exchange)'
+cargo nextest run -E 'binary(e2e_admin)'
+cargo nextest run -E 'binary(cli_user_disable)'
+cargo nextest run -E 'binary(e2e_logout)'
+cargo nextest run -E 'binary(e2e_mtls_grant)'
+cargo nextest run -E 'binary(e2e_cimd)'
+cargo nextest run -E 'binary(e2e_edge_matrix)'
+cargo nextest run -E 'binary(e2e_stale_catalog)'
+cargo nextest run -E 'binary(e2e_flow)'
+cargo nextest run -E 'binary(proptest_inputs)'
+```
+
+The property suites that predate the audit:
+
+```bash
+cargo nextest run -E 'binary(proptest_pkce)'
+cargo nextest run -E 'binary(proptest_redirect)'
+cargo nextest run -E 'binary(proptest_scope)'
+cargo nextest run -E 'binary(proptest_rotation)'
+```
+
+Fuzz targets and seeds (standalone workspace, nightly pin):
+
+```bash
+cd fuzz
+cargo fuzz build --target x86_64-unknown-linux-gnu
+cargo fuzz run <target> -- -max_total_time=60 -rss_limit_mb=2048
+cargo fuzz run token_form corpus/token_form/valid-zero-digest corpus/token_form/malformed -- -runs=1
+cargo fuzz run dcr_json corpus/dcr_json/minimal corpus/dcr_json/malformed -- -runs=1
+cargo fuzz run jar_claims corpus/jar_claims/envelope corpus/jar_claims/malformed -- -runs=1
+```
+
+The documentation link check:
+
+```bash
+python3 scripts/check_doc_links.py
+```
+
+## Final gate run
+
+The closing run on the final tree passed every authoritative gate with exit
+code 0. The full nextest suite passed 1050/1050 tests in 59.1 s; the coverage
+run passed the same 1050/1050 tests in 761.2 s, and the full coverage command
+took 784 s. These are the final numbers; the baseline above stays as the
+historical first measurement.
+
+Final coverage (`scripts/check_coverage.py` exit 0):
+
+- Global: 90.5% (22022/24335), floor 89.0.
+- Security aggregate: 92.1% (17226/18695), floor 91.0.
+
+| Module     | Final    | Hit / Found | Floor |
+|------------|----------|-------------|-------|
+| crypto     | 95.6%    | 1066/1115   | 94.0  |
+| jose       | 92.6%    | 1106/1194   | 91.0  |
+| protocol   | 93.3%    | 8313/8911   | 92.0  |
+| db         | 91.7%    | 1602/1747   | 90.0  |
+| store      | 88.6%    | 2090/2360   | 87.0  |
+| state      | 76.0%    | 355/467     | 75.0  |
+| middleware | 95.7%    | 986/1030    | 94.0  |
+| ui         | 88.7%    | 1362/1535   | 87.0  |
+| config     | 93.5%    | 907/970     | 92.0  |
+| keys       | 88.5%    | 184/208     | 87.0  |
+| error      | 89.0%    | 422/474     | 88.0  |
+| routes     | 92.7%    | 1102/1189   | 91.0  |
+| tls        | 96.1%    | 49/51       | 95.0  |
+
+The gates, all exit 0: `cargo fmt --check`,
+`cargo clippy --all-targets -- -D warnings`,
+`cargo nextest run --all-targets --retries=3 --no-fail-fast`,
+`cargo test --doc`, the `cargo llvm-cov nextest` command from
+[Reproduce a run](#reproduce-a-run), `scripts/check_coverage.py`,
+`cd fuzz && cargo fuzz build --target x86_64-unknown-linux-gnu`,
+`cd fuzz && cargo fmt --check`,
+`cd fuzz && cargo clippy --all-targets -- -D warnings`,
+`python3 scripts/check_doc_links.py`, `cargo audit`, and
+`cargo deny check`. `cargo audit` reports one allowed unmaintained warning
+(`basic-toml`, RUSTSEC-2026-0374); `cargo deny check` reports advisories, bans,
+licenses, and sources all ok.
+
+The run used the shared Postgres and Redis from `compose.test.yaml` with
+`--test-threads=1 --retries=3 --no-fail-fast`. Local WAITAOF barriers flake
+under high parallelism on this host; CI uses its own settings.
+
+An interim `cargo clippy` on the fuzz workspace failed with
+`clippy::collapsible-if` in `fuzz/fuzz_targets/ssrf_url.rs`. The nested
+`if let` blocks were collapsed into an edition-2024 let-chain with the
+assertions unchanged, and the final fuzz clippy, fmt, build, and `ssrf_url`
+smoke runs all pass.
+
+## Coverage caveats
+
+Known untested network path: `ssrf::fetch_guarded_bytes_with` is covered only
+by its unit tests and by the URL-policy fuzz target (`ssrf_url`). No test opens
+a real outbound connection through it: CIMD tests inject a `CimdFetcher`, so
+the socket connect, the redirect refusal on a live response, and the byte and
+time caps over a real body stay unexercised. The unit tests pin the address
+policy and the failure mapping; `validate_fetch_url` and `is_blocked_ip` are
+the fuzzed surface. Treat a change to the connection loop as unverified by the
+suite and review it by hand.
+
+Inline `#[cfg(test)]` modules inside production files cannot be excluded by
+filename, so `--all-targets` coverage counts their lines and modestly inflates
+the score. The per-module table shows that effect rather than hiding it.

+ 29 - 0
fuzz/Cargo.toml

@@ -16,6 +16,7 @@ cargo-fuzz = true
 base64 = "0.23"
 jsonwebtoken = { version = "11", default-features = false, features = ["rust_crypto"] }
 libfuzzer-sys = "0.4"
+secrecy = "0.10.3"
 serde_json = "1"
 time = "0.3"
 uuid = "1"
@@ -52,3 +53,31 @@ path = "fuzz_targets/redirect_uri.rs"
 [[bin]]
 name = "scope_parse"
 path = "fuzz_targets/scope_parse.rs"
+
+[[bin]]
+name = "jwt_access_verify"
+path = "fuzz_targets/jwt_access_verify.rs"
+
+[[bin]]
+name = "cimd_document"
+path = "fuzz_targets/cimd_document.rs"
+
+[[bin]]
+name = "mtls_cert"
+path = "fuzz_targets/mtls_cert.rs"
+
+[[bin]]
+name = "params_form"
+path = "fuzz_targets/params_form.rs"
+
+[[bin]]
+name = "ssrf_url"
+path = "fuzz_targets/ssrf_url.rs"
+
+[[bin]]
+name = "totp_input"
+path = "fuzz_targets/totp_input.rs"
+
+[[bin]]
+name = "pkce"
+path = "fuzz_targets/pkce.rs"

+ 57 - 0
fuzz/fuzz_targets/cimd_document.rs

@@ -0,0 +1,57 @@
+//! Fuzz client-ID metadata document (CIMD) validation.
+//!
+//! [`validate_document`] is pure: it enforces the `client_id` echo, the
+//! registration-free auth methods, inline-only JWKS, the wildcard-free
+//! redirect set, the enabled-scope subset, and the grant allowlist without a
+//! fetch, Postgres, or Redis. The input is a first selector byte
+//! (`allow_localhost`), one `client_id` line, then a JSON document. An accepted
+//! document must echo its identifier, contain no wildcard redirect, and grant
+//! only the two registration-free grants.
+
+#![no_main]
+
+use std::collections::HashSet;
+
+use d_oauth::protocol::cimd::{CimdDocument, CimdValidation, validate_document};
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+  let Some((flag, rest)) = data.split_first() else {
+    return;
+  };
+  let Ok(text) = core::str::from_utf8(rest) else {
+    return;
+  };
+  let Some((client_id, json)) = text.split_once('\n') else {
+    return;
+  };
+  let Ok(document) = serde_json::from_str::<CimdDocument>(json) else {
+    return;
+  };
+  let scopes: HashSet<String> = ["read", "write"]
+    .iter()
+    .map(|name| (*name).to_owned())
+    .collect();
+  let validation = CimdValidation {
+    enabled_scopes: &scopes,
+    allow_localhost: *flag % 2 == 1,
+  };
+  let Ok(validated) = validate_document(client_id, &document, &validation) else {
+    return;
+  };
+  // Accepted documents echo the URL they were validated under.
+  assert_eq!(validated.client_id, client_id);
+  // No wildcard ever survives the redirect allowlist.
+  assert!(
+    validated.redirect_uris.iter().all(|uri| !uri.contains('*')),
+    "accepted document kept a wildcard redirect"
+  );
+  // Only the registration-free grants survive.
+  assert!(
+    validated
+      .grant_types
+      .iter()
+      .all(|grant| grant == "authorization_code" || grant == "refresh_token"),
+    "accepted document kept a non-CIMD grant"
+  );
+});

+ 101 - 0
fuzz/fuzz_targets/jwt_access_verify.rs

@@ -0,0 +1,101 @@
+//! Fuzz RFC 9068 `at+jwt` access-token verification.
+//!
+//! [`JwtAccessSigner::verify`] checks the JOSE envelope (`typ`, `alg`, `kid`),
+//! the signature, `iss`/`exp`, and `aud` containment without touching Redis or
+//! Postgres. A first selector byte of `0` replays one fixed, valid token so the
+//! accepted branch is exercised deterministically; any other selector treats
+//! the remaining bytes as a hostile compact JWT. Random bytes alone are not
+//! claimed to reach the accepted branch (that needs a valid signature).
+//!
+//! Any accepted token must carry the audience the signer was minted for.
+
+#![no_main]
+
+use std::sync::OnceLock;
+
+use d_oauth::{
+  config::{JwtSigningAlg, JwtSigningKey},
+  jose::jwt_access::{JwtAccessSigner, JwtClaims},
+  keys::KeyId,
+};
+use libfuzzer_sys::fuzz_target;
+use secrecy::SecretString;
+
+/// Fixed issuer the signer asserts (never the wall clock or config).
+const ISSUER: &str = "https://issuer.fuzz.test";
+/// The one audience the fixed token carries.
+const AUD: &str = "https://rs.fuzz.test";
+
+/// The fixed signer and its one valid token, built once per process.
+struct Fixture {
+  signer: JwtAccessSigner,
+  token: String,
+  aud: Vec<String>,
+}
+
+/// Build the fixed ES256 signer and token. `None` when key material or signing
+/// fails, which makes the target a no-op instead of panicking on setup.
+fn build_fixture() -> Option<Fixture> {
+  let secret = SecretString::from("C".repeat(32));
+  let key = JwtSigningKey::from_raw(
+    KeyId::new("v2"),
+    JwtSigningAlg::Es256,
+    &secret,
+    "fuzz_jwt_signing_key",
+  )
+  .ok()?;
+  let signer = JwtAccessSigner::new(ISSUER.to_owned(), key, Vec::new());
+  let now = time::OffsetDateTime::now_utc().unix_timestamp();
+  let aud = vec![AUD.to_owned()];
+  let claims = JwtClaims {
+    iss: ISSUER.to_owned(),
+    sub: "subject@example.test".to_owned(),
+    aud: aud.clone(),
+    scope: "read".to_owned(),
+    exp: now + 86_400,
+    iat: now,
+    jti: "fuzz-jti".to_owned(),
+    client_id: "fuzz-client".to_owned(),
+    cnf: None,
+    act: None,
+  };
+  let token = signer.sign(&claims).ok()?;
+  Some(Fixture { signer, token, aud })
+}
+
+/// The process-wide fixture, built on first use and reused thereafter.
+fn fixture() -> Option<&'static Fixture> {
+  static FIXTURE: OnceLock<Option<Fixture>> = OnceLock::new();
+  FIXTURE.get_or_init(build_fixture).as_ref()
+}
+
+fuzz_target!(|data: &[u8]| {
+  let Some(fixture) = fixture() else {
+    return;
+  };
+  let Some((selector, rest)) = data.split_first() else {
+    return;
+  };
+  let token: &str = if *selector == 0 {
+    fixture.token.as_str()
+  } else {
+    match core::str::from_utf8(rest) {
+      Ok(text) => text,
+      Err(_) => return,
+    }
+  };
+  // No expected audience: any accepted token must still carry the minted one.
+  if let Ok(verified) = fixture.signer.verify(token, None) {
+    assert!(
+      verified.claims.aud.iter().any(|aud| aud == AUD),
+      "accepted token lost its expected audience"
+    );
+  }
+  // With an expected audience, acceptance implies containment.
+  if let Ok(verified) = fixture.signer.verify(token, Some(fixture.aud.as_slice())) {
+    assert!(
+      verified.claims.aud.iter().any(|aud| aud == AUD),
+      "accepted token lost its expected audience"
+    );
+  }
+});

+ 24 - 0
fuzz/fuzz_targets/mtls_cert.rs

@@ -0,0 +1,24 @@
+//! Fuzz mTLS leaf-certificate parsing (`tls_client_auth`, RFC 8705).
+//!
+//! Arbitrary DER is hostile input: [`subject_dn`] and [`ensure_within_validity`]
+//! must fail closed on malformed bytes without panicking, and
+//! [`thumbprint_b64url`] hashes any bytes unconditionally. Validity is read
+//! against the wall clock, so this target never asserts a fixed verdict — only
+//! no-panic behavior and the fixed 43-character thumbprint length.
+
+#![no_main]
+
+use d_oauth::protocol::mtls::{ensure_within_validity, subject_dn, thumbprint_b64url};
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+  let _ = subject_dn(data);
+  let _ = ensure_within_validity(data);
+  let thumbprint = thumbprint_b64url(data);
+  // base64url (no pad) of a SHA-256 digest is always 43 characters.
+  assert_eq!(
+    thumbprint.len(),
+    43,
+    "thumbprint length is not the fixed SHA-256 rendering"
+  );
+});

+ 53 - 0
fuzz/fuzz_targets/params_form.rs

@@ -0,0 +1,53 @@
+//! Fuzz the wire-form helpers (`protocol::params`).
+//!
+//! [`parse_pairs`] is lossy but infallible on malformed percent escapes and
+//! non-UTF-8, so it must never panic on any input. Once accepted, the ordered
+//! pairs feed [`values_of`] (wire order) and [`single_valued_map`] (last-wins);
+//! [`has_duplicate_field`] reports a repeat only for a known field.
+
+#![no_main]
+
+use d_oauth::protocol::params::{has_duplicate_field, parse_pairs, single_valued_map, values_of};
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+  let Ok(raw) = core::str::from_utf8(data) else {
+    return;
+  };
+  let Ok(pairs) = parse_pairs(raw) else {
+    return;
+  };
+
+  // One pair per non-empty segment, in wire order.
+  let segments = raw.split('&').filter(|segment| !segment.is_empty()).count();
+  assert_eq!(pairs.len(), segments, "parse_pairs dropped or added a pair");
+
+  // Distinct names in first-occurrence order.
+  let mut names: Vec<&str> = Vec::new();
+  for (name, _) in &pairs {
+    if !names.contains(&name.as_str()) {
+      names.push(name);
+    }
+  }
+
+  let map = single_valued_map(&pairs);
+  assert_eq!(map.len(), names.len(), "map lost a distinct name");
+  for name in &names {
+    let values = values_of(&pairs, name);
+    assert!(!values.is_empty(), "known name produced no value");
+    // Last-wins: the map carries the final occurrence.
+    assert_eq!(
+      map.get(*name),
+      values.last(),
+      "map did not keep the last value"
+    );
+  }
+
+  // A repeat is reported exactly when a known name occurs twice or more.
+  let expected = names.iter().any(|name| values_of(&pairs, name).len() > 1);
+  assert_eq!(
+    has_duplicate_field(&pairs, &names),
+    expected,
+    "duplicate detection disagreed with the wire pairs"
+  );
+});

+ 44 - 0
fuzz/fuzz_targets/pkce.rs

@@ -0,0 +1,44 @@
+//! Fuzz PKCE S256 derivation and verification (`crypto::pkce`).
+//!
+//! The input is a verifier line; anything after a newline is an arbitrary
+//! presented challenge. [`challenge_from_verifier`] accepts only the RFC 7636
+//! §4.1 verifier shape; when it derives a challenge, the same verifier must
+//! verify against it, and a one-character challenge tamper must fail.
+
+#![no_main]
+
+use d_oauth::crypto::pkce::{challenge_from_verifier, verify_s256};
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+  let Ok(text) = core::str::from_utf8(data) else {
+    return;
+  };
+  let (verifier, presented) = match text.split_once('\n') {
+    Some((verifier, presented)) => (verifier, Some(presented)),
+    None => (text, None),
+  };
+
+  if let Ok(challenge) = challenge_from_verifier(verifier) {
+    assert!(
+      verify_s256(verifier, &challenge).is_ok(),
+      "a derived S256 challenge did not verify"
+    );
+
+    // Flip one base64url character; the digest can no longer match.
+    if let Some(first) = challenge.as_bytes().first().copied() {
+      let replacement = if first == b'A' { "B" } else { "A" };
+      let mut tampered = challenge.clone();
+      tampered.replace_range(0..1, replacement);
+      assert!(
+        verify_s256(verifier, &tampered).is_err(),
+        "a one-character tampered challenge verified"
+      );
+    }
+  }
+
+  // Exercise the verifier against an arbitrary presented challenge.
+  if let Some(presented) = presented {
+    let _ = verify_s256(verifier, presented);
+  }
+});

+ 49 - 0
fuzz/fuzz_targets/ssrf_url.rs

@@ -0,0 +1,49 @@
+//! Fuzz the SSRF URL gate and address classifier (`protocol::ssrf`).
+//!
+//! [`validate_fetch_url`] is pure: an accepted URL is always `https`, has a
+//! non-empty host, and carries no credentials. The classifier half maps the
+//! first 16 input bytes to an [`IpAddr`] and calls [`is_blocked_ip`]; an
+//! IPv4-mapped address must be judged exactly like its embedded IPv4 address.
+//! [`fetch_guarded_bytes_with`] is deliberately not called: it is async, opens
+//! a socket, and is non-hermetic.
+
+#![no_main]
+
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
+
+use d_oauth::protocol::ssrf::{is_blocked_ip, validate_fetch_url};
+use libfuzzer_sys::fuzz_target;
+
+fuzz_target!(|data: &[u8]| {
+  if let Ok(raw) = core::str::from_utf8(data)
+    && let Ok(url) = validate_fetch_url(raw)
+  {
+    assert_eq!(url.scheme(), "https", "accepted URL is not https");
+    assert!(
+      url.host_str().is_some_and(|host| !host.is_empty()),
+      "accepted URL has no host"
+    );
+    assert!(url.username().is_empty(), "accepted URL kept a username");
+    assert!(url.password().is_none(), "accepted URL kept a password");
+  }
+
+  if data.len() < 16 {
+    return;
+  }
+  let mut octets = [0u8; 16];
+  octets.copy_from_slice(&data[..16]);
+
+  let v6 = IpAddr::V6(Ipv6Addr::from(octets));
+  let _ = is_blocked_ip(&v6);
+
+  let v4 = Ipv4Addr::new(octets[0], octets[1], octets[2], octets[3]);
+  let blocked_v4 = is_blocked_ip(&IpAddr::V4(v4));
+  // `::ffff:a.b.c.d` reaches the same host as `a.b.c.d`, so the guard must
+  // return the same verdict for both.
+  let mapped = IpAddr::V6(v4.to_ipv6_mapped());
+  assert_eq!(
+    is_blocked_ip(&mapped),
+    blocked_v4,
+    "IPv4-mapped address was judged differently from its embedded IPv4"
+  );
+});

+ 76 - 0
fuzz/fuzz_targets/totp_input.rs

@@ -0,0 +1,76 @@
+//! Fuzz TOTP verifier construction and the ±1-step window (`crypto::totp`).
+//!
+//! The input is a secret line, a newline, then the presented code. A malformed
+//! stored secret is always [`TotpError::InvalidSecret`], never a wrong-code
+//! `None`. A matched step must sit within one step of the counter for the fixed
+//! `now`, and it must be the step that generates the presented code. A
+//! 6-digit collision across neighbouring steps is therefore not a failure.
+//! [`normalize_recovery_code`] is idempotent, strips separators, and leaves no
+//! ASCII lowercase (Unicode characters without an uppercase mapping can stay
+//! lowercase).
+
+#![no_main]
+
+use d_oauth::crypto::totp::{build_totp, normalize_recovery_code, verify_window};
+use libfuzzer_sys::fuzz_target;
+
+/// Fixed instant: 2023-11-14T22:13:20Z, never the wall clock.
+const NOW: u64 = 1_700_000_000;
+/// RFC 6238 default step in seconds.
+const STEP: u64 = 30;
+
+fuzz_target!(|data: &[u8]| {
+  let Ok(text) = core::str::from_utf8(data) else {
+    return;
+  };
+  let (secret, code) = match text.split_once('\n') {
+    Some((secret, code)) => (secret, code),
+    None => (text, ""),
+  };
+
+  // Normalization is idempotent, separator-free, and uppercase.
+  let normalized = normalize_recovery_code(code);
+  assert_eq!(
+    normalized,
+    normalize_recovery_code(&normalized),
+    "recovery normalization is not idempotent"
+  );
+  assert!(
+    normalized.chars().all(|c| !c.is_whitespace() && c != '-'),
+    "normalized recovery code kept a separator"
+  );
+  assert!(
+    normalized.chars().all(|c| !c.is_ascii_lowercase()),
+    "normalized recovery code kept ASCII lowercase"
+  );
+
+  match build_totp(secret) {
+    Err(_) => {
+      // A malformed stored secret is an error once the presented code reaches
+      // secret parsing; a non-conforming code is rejected as a wrong code
+      // (`Ok(None)`) before the secret is inspected, so probe with a shaped
+      // code to exercise the error path deterministically.
+      assert!(
+        verify_window(secret, "000000", NOW).is_err(),
+        "malformed secret slipped through verify_window"
+      );
+      // The fuzzed code is still processed without panicking.
+      let _ = verify_window(secret, code, NOW);
+    }
+    Ok(totp) => {
+      let counter = NOW / STEP;
+      if let Ok(Some(step)) = verify_window(secret, code, NOW) {
+        assert!(
+          step.abs_diff(counter) <= 1,
+          "matched step is outside the ±1 window"
+        );
+        let generated = totp.generate(step.saturating_mul(STEP)).to_string();
+        assert_eq!(
+          generated,
+          code.trim(),
+          "matched step did not generate the presented code"
+        );
+      }
+    }
+  }
+});

+ 391 - 0
scripts/check_coverage.py

@@ -0,0 +1,391 @@
+#!/usr/bin/env python3
+"""Line-coverage floor checker for d-OAuth-Server.
+
+Reads an lcov tracefile produced by `cargo llvm-cov ... --lcov`, normalizes
+every `SF:` path to a workspace-relative path, applies the single canonical
+exclusion regex shared with CI, and enforces the floors in
+`scripts/coverage_floors.json` (optionally overridden on the command line).
+
+Usage:
+  python3 scripts/check_coverage.py lcov.info
+  python3 scripts/check_coverage.py lcov.info --floors scripts/coverage_floors.json
+  python3 scripts/check_coverage.py lcov.info --global-floor 81.0 \\
+      --security-floor 88.0 --module-floor crypto=90.0
+
+The `security` aggregate is exactly the ten core modules: crypto, jose,
+protocol, db, store, state, middleware, keys, error, routes. `ui`, `config`,
+and `tls` carry per-module floors only and are excluded from the aggregate.
+
+Exit code 0 only when the global line percentage, the security aggregate, and
+every listed module meet their floors. A listed module that is missing from the
+tracefile, or that contains zero executable lines, fails closed.
+
+Known limitation: inline `#[cfg(test)]` modules inside *production* files cannot
+be excluded by filename, so an `--all-targets` run records their lines and
+therefore modestly inflates production coverage. The per-module table makes the
+effect visible; it does not hide it.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+DEFAULT_FLOORS = REPO_ROOT / "scripts" / "coverage_floors.json"
+
+# The ONE canonical exclusion regex. CI passes this same pattern verbatim to
+# `cargo llvm-cov --ignore-filename-regex`; keep the two byte-identical.
+EXCLUDE_PATTERN = r"(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$"
+EXCLUDE_RE = re.compile(EXCLUDE_PATTERN)
+
+# Module -> workspace-relative path (directory prefix or exact file). Order is
+# the reporting order from the plan and is stable for tests and docs.
+MODULE_SPECS = {
+  "crypto": "src/crypto/",
+  "jose": "src/jose/",
+  "protocol": "src/protocol/",
+  "db": "src/db/",
+  "store": "src/store/",
+  "state": "src/state/",
+  "middleware": "src/middleware/",
+  "ui": "src/ui/",
+  "config": "src/config/",
+  "keys": "src/keys.rs",
+  "error": "src/error.rs",
+  "routes": "src/routes.rs",
+  "tls": "src/tls.rs",
+}
+MODULE_ORDER = (
+  "crypto",
+  "jose",
+  "protocol",
+  "db",
+  "store",
+  "state",
+  "middleware",
+  "ui",
+  "config",
+  "keys",
+  "error",
+  "routes",
+  "tls",
+)
+# The ten-module aggregate. ui/config/tls are deliberately absent: they are
+# operator-facing/peripheral and carry per-module floors only.
+CORE_MODULES = (
+  "crypto",
+  "jose",
+  "protocol",
+  "db",
+  "store",
+  "state",
+  "middleware",
+  "keys",
+  "error",
+  "routes",
+)
+
+
+def normalize_sf_path(raw: str, lcov_path: Path) -> str:
+  """Return a workspace-relative path for one lcov `SF:` value.
+
+  Handles absolute paths, a leading `./`, paths relative to the workspace
+  root, and paths relative to the lcov file's own directory (the common
+  "coverage file sits next to the sources" case).
+  """
+  raw = raw.strip()
+  if not raw:
+    return raw
+  raw_path = Path(raw)
+  if raw_path.is_absolute():
+    candidates = [raw_path]
+  else:
+    candidates = [
+      REPO_ROOT / raw_path,
+      lcov_path.parent / raw_path,
+      Path.cwd() / raw_path,
+    ]
+  repo_root = REPO_ROOT.resolve()
+  for candidate in candidates:
+    try:
+      resolved = candidate.resolve()
+    except OSError:
+      continue
+    try:
+      return resolved.relative_to(repo_root).as_posix()
+    except ValueError:
+      continue
+  # Lexical fallback for a path that cannot be placed under the workspace.
+  lexical = raw_path.as_posix()
+  while lexical.startswith("./"):
+    lexical = lexical[2:]
+  return lexical
+
+
+def parse_lcov(text: str) -> list[dict]:
+  """Parse lcov `SF:`/`DA:`/`end_of_record` records into source records."""
+  records: list[dict] = []
+  current: dict | None = None
+  for raw_line in text.splitlines():
+    line = raw_line.strip()
+    if not line:
+      continue
+    if line.startswith("SF:"):
+      if current is not None:
+        records.append(current)
+      current = {"sf": line[3:], "lines": {}}
+    elif line.startswith("DA:"):
+      if current is None:
+        raise ValueError(f"DA record without a preceding SF record: {line}")
+      body = line[3:].split(",")
+      if len(body) < 2:
+        raise ValueError(f"malformed DA record: {line}")
+      try:
+        line_no = int(body[0])
+        hits = int(body[1])
+      except ValueError as exc:
+        raise ValueError(f"malformed DA record: {line}") from exc
+      previous = current["lines"].get(line_no)
+      current["lines"][line_no] = hits if previous is None else max(previous, hits)
+    elif line == "end_of_record":
+      if current is not None:
+        records.append(current)
+        current = None
+  if current is not None:
+    records.append(current)
+  return records
+
+
+def aggregate(records: list[dict], lcov_path: Path) -> dict[str, dict[int, int]]:
+  """Merge records into `path -> {line: max hits}`, dropping excluded files."""
+  totals: dict[str, dict[int, int]] = {}
+  for record in records:
+    path = normalize_sf_path(record["sf"], lcov_path)
+    if not path or EXCLUDE_RE.search(path):
+      continue
+    lines = totals.setdefault(path, {})
+    for line_no, hits in record["lines"].items():
+      previous = lines.get(line_no)
+      lines[line_no] = hits if previous is None else max(previous, hits)
+  return totals
+
+
+def line_counts(lines: dict[int, int]) -> tuple[int, int]:
+  """Return (executable lines, covered lines) for one file."""
+  return len(lines), sum(1 for hits in lines.values() if hits > 0)
+
+
+def module_of(path: str) -> str | None:
+  """Return the listed module a workspace-relative path belongs to, if any."""
+  for name in MODULE_ORDER:
+    spec = MODULE_SPECS[name]
+    if spec.endswith("/"):
+      if path.startswith(spec):
+        return name
+    elif path == spec:
+      return name
+  return None
+
+
+def module_counts(totals: dict[str, dict[int, int]]) -> dict[str, dict[str, int]]:
+  """Aggregate (`files`, `found`, `hit`) per listed module."""
+  counts = {
+    name: {"files": 0, "found": 0, "hit": 0}
+    for name in MODULE_ORDER
+  }
+  for path, lines in totals.items():
+    name = module_of(path)
+    if name is None:
+      continue
+    found, hit = line_counts(lines)
+    counts[name]["files"] += 1
+    counts[name]["found"] += found
+    counts[name]["hit"] += hit
+  return counts
+
+
+def percent(hit: int, found: int) -> float:
+  if found == 0:
+    return 0.0
+  return 100.0 * hit / found
+
+
+def _number(value: object, label: str) -> float:
+  if isinstance(value, bool) or not isinstance(value, (int, float)):
+    raise ValueError(f"{label} must be a number")
+  return float(value)
+
+
+def load_floors(path: Path, explicit: bool) -> dict:
+  """Load `{"global", "security", "modules"}`; a missing default means zero."""
+  floors = {"global": 0.0, "security": 0.0, "modules": {}}
+  if not path.exists():
+    if explicit:
+      raise ValueError(f"--floors file not found: {path}")
+    return floors
+  try:
+    data = json.loads(path.read_text(encoding="utf-8"))
+  except (OSError, json.JSONDecodeError) as exc:
+    raise ValueError(f"cannot read floors file {path}: {exc}") from exc
+  if not isinstance(data, dict):
+    raise ValueError(f"floors file {path} must contain a JSON object")
+  if "global" in data:
+    floors["global"] = _number(data["global"], "global floor")
+  if "security" in data:
+    floors["security"] = _number(data["security"], "security floor")
+  modules = data.get("modules", {})
+  if not isinstance(modules, dict):
+    raise ValueError(f"floors file {path}: 'modules' must be an object")
+  for name, value in modules.items():
+    if name not in MODULE_SPECS:
+      raise ValueError(f"floors file {path}: unknown module {name!r}")
+    floors["modules"][name] = _number(value, f"module floor {name!r}")
+  return floors
+
+
+def parse_module_overrides(values: list[str]) -> dict[str, float]:
+  overrides: dict[str, float] = {}
+  for item in values:
+    name, sep, raw = item.partition("=")
+    if not sep or not name:
+      raise ValueError(f"--module-floor expects NAME=F, got {item!r}")
+    if name not in MODULE_SPECS:
+      raise ValueError(f"--module-floor: unknown module {name!r}")
+    try:
+      overrides[name] = float(raw)
+    except ValueError as exc:
+      raise ValueError(f"--module-floor {name}: not a number: {raw!r}") from exc
+  return overrides
+
+
+def build_parser() -> argparse.ArgumentParser:
+  parser = argparse.ArgumentParser(
+    description="Enforce line-coverage floors over an lcov tracefile."
+  )
+  parser.add_argument("lcov", help="path to an lcov .info tracefile")
+  parser.add_argument(
+    "--floors",
+    default=None,
+    help=f"floors JSON (default: {DEFAULT_FLOORS})",
+  )
+  parser.add_argument(
+    "--global-floor",
+    type=float,
+    default=None,
+    help="override the global line-coverage floor (percent)",
+  )
+  parser.add_argument(
+    "--security-floor",
+    type=float,
+    default=None,
+    help="override the security aggregate floor (percent)",
+  )
+  parser.add_argument(
+    "--module-floor",
+    action="append",
+    default=[],
+    metavar="NAME=F",
+    help="override one module floor (repeatable)",
+  )
+  return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+  args = build_parser().parse_args(argv)
+  lcov_path = Path(args.lcov)
+  floors_path = Path(args.floors) if args.floors else DEFAULT_FLOORS
+
+  try:
+    floors = load_floors(floors_path, explicit=args.floors is not None)
+    if args.global_floor is not None:
+      floors["global"] = float(args.global_floor)
+    if args.security_floor is not None:
+      floors["security"] = float(args.security_floor)
+    floors["modules"].update(parse_module_overrides(args.module_floor))
+  except ValueError as exc:
+    print(f"coverage check: {exc}")
+    return 1
+
+  if not lcov_path.is_file():
+    print(f"coverage check: lcov file not found: {lcov_path}")
+    return 1
+  try:
+    records = parse_lcov(lcov_path.read_text(encoding="utf-8"))
+  except (OSError, ValueError) as exc:
+    print(f"coverage check: cannot parse {lcov_path}: {exc}")
+    return 1
+
+  totals = aggregate(records, lcov_path)
+  counts = module_counts(totals)
+  failures: list[str] = []
+
+  global_found = sum(len(lines) for lines in totals.values())
+  global_hit = sum(line_counts(lines)[1] for lines in totals.values())
+  global_pct = percent(global_hit, global_found)
+  global_floor = floors["global"]
+  if global_pct < global_floor:
+    failures.append("global below floor")
+    global_status = "FAIL: global below floor"
+  else:
+    global_status = "OK"
+
+  security_found = sum(counts[name]["found"] for name in CORE_MODULES)
+  security_hit = sum(counts[name]["hit"] for name in CORE_MODULES)
+  security_pct = percent(security_hit, security_found)
+  security_floor = floors["security"]
+  if security_found == 0:
+    failures.append("security aggregate has zero executable lines (fail closed)")
+    security_status = "FAIL: zero executable lines (fail closed)"
+  elif security_pct < security_floor:
+    failures.append("security below floor")
+    security_status = "FAIL: security below floor"
+  else:
+    security_status = "OK"
+
+  print(f"coverage check: {lcov_path}")
+  print(
+    f"global: {global_pct:.1f}% ({global_hit}/{global_found} lines), "
+    f"floor {global_floor:.1f}% -> {global_status}"
+  )
+  print(
+    f"security: {security_pct:.1f}% ({security_hit}/{security_found} lines), "
+    f"floor {security_floor:.1f}% -> {security_status}"
+  )
+  print("per-module line coverage:")
+  for name in MODULE_ORDER:
+    stats = counts[name]
+    pct = percent(stats["hit"], stats["found"])
+    floor = floors["modules"].get(name, 0.0)
+    if stats["files"] == 0:
+      status = "FAIL: missing from lcov (fail closed)"
+      failures.append(f"module '{name}' missing from lcov (fail closed)")
+    elif stats["found"] == 0:
+      status = "FAIL: zero executable lines (fail closed)"
+      failures.append(f"module '{name}' has zero executable lines (fail closed)")
+    elif pct < floor:
+      status = f"FAIL: below floor ({floor:.1f}%)"
+      failures.append(f"module '{name}' below floor: {pct:.1f}% < {floor:.1f}%")
+    else:
+      status = "OK"
+    scope = "" if name in CORE_MODULES else "  (per-module only)"
+    print(
+      f"  {name:<10} {pct:>6.1f}% ({stats['hit']}/{stats['found']} lines) "
+      f"floor {floor:>5.1f}%  {status}{scope}"
+    )
+
+  if failures:
+    print("failed checks:")
+    for failure in failures:
+      print(f"  - {failure}")
+    print(f"RESULT: FAIL ({len(failures)} check(s))")
+    return 1
+  print("RESULT: PASS")
+  return 0
+
+
+if __name__ == "__main__":
+  sys.exit(main(sys.argv[1:]))

+ 19 - 0
scripts/coverage_floors.json

@@ -0,0 +1,19 @@
+{
+  "global": 89.0,
+  "security": 91.0,
+  "modules": {
+    "crypto": 94.0,
+    "jose": 91.0,
+    "protocol": 92.0,
+    "db": 90.0,
+    "store": 87.0,
+    "state": 75.0,
+    "middleware": 94.0,
+    "ui": 87.0,
+    "config": 92.0,
+    "keys": 87.0,
+    "error": 88.0,
+    "routes": 91.0,
+    "tls": 95.0
+  }
+}

+ 222 - 0
scripts/test_check_coverage.py

@@ -0,0 +1,222 @@
+#!/usr/bin/env python3
+"""Fixture tests for scripts/check_coverage.py (plan todo 1).
+
+Every case builds an lcov tracefile in a fresh temporary directory, runs the
+real checker over it, and asserts the exit code and the message the contract
+requires. No network, no framework: `python3 scripts/test_check_coverage.py`.
+"""
+
+from __future__ import annotations
+
+import contextlib
+import importlib.util
+import io
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+REPO = Path(__file__).resolve().parent.parent
+CHECKER_PATH = REPO / "scripts" / "check_coverage.py"
+
+# Independent copy of the canonical regex: if the checker's constant drifts
+# from the one CI uses, this literal stops matching and the test fails.
+CANONICAL_EXCLUDE = r"(^|/)tests/|(^|/)tests\.rs$|(^|/)tests_[^/]*\.rs$|test_support\.rs$|test_guard\.rs$"
+
+
+def _load_checker():
+  spec = importlib.util.spec_from_file_location("check_coverage", CHECKER_PATH)
+  if spec is None or spec.loader is None:
+    raise RuntimeError(f"cannot load {CHECKER_PATH}")
+  module = importlib.util.module_from_spec(spec)
+  spec.loader.exec_module(module)
+  return module
+
+
+CM = _load_checker()
+
+
+def _module_path(name: str) -> str:
+  spec = CM.MODULE_SPECS[name]
+  return spec if not spec.endswith("/") else spec + "mod.rs"
+
+
+def _module_records(names, hits: int, lines: int = 4) -> list:
+  return [
+    (_module_path(name), [(line_no, hits) for line_no in range(1, lines + 1)])
+    for name in names
+  ]
+
+
+def _lcov_text(records: list) -> str:
+  out: list[str] = []
+  for sf, das in records:
+    out.append(f"SF:{sf}")
+    for line_no, hits in das:
+      out.append(f"DA:{line_no},{hits}")
+    out.append("end_of_record")
+  return "\n".join(out) + "\n"
+
+
+def _write_lcov(directory: Path, records: list) -> Path:
+  lcov_path = directory / "lcov.info"
+  lcov_path.write_text(_lcov_text(records), encoding="utf-8")
+  return lcov_path
+
+
+def _run(records: list, *args: str) -> tuple[int, str]:
+  directory = Path(tempfile.mkdtemp(prefix="check-coverage-"))
+  try:
+    lcov_path = _write_lcov(directory, records)
+    stdout = io.StringIO()
+    with contextlib.redirect_stdout(stdout):
+      code = CM.main([str(lcov_path), *args])
+    return code, stdout.getvalue()
+  finally:
+    shutil.rmtree(directory, ignore_errors=True)
+
+
+def test_full_coverage_passes():
+  records = _module_records(CM.MODULE_ORDER, hits=1)
+  code, out = _run(
+    records,
+    "--global-floor",
+    "100.0",
+    "--security-floor",
+    "100.0",
+  )
+  assert code == 0, out
+  assert "RESULT: PASS" in out, out
+
+
+def test_zero_coverage_fails_below_global_floor():
+  records = _module_records(CM.MODULE_ORDER, hits=0)
+  code, out = _run(records, "--global-floor", "1.0", "--security-floor", "0.0")
+  assert code == 1, out
+  assert "global below floor" in out, out
+
+
+def test_missing_security_group_fails_closed():
+  records = _module_records(("ui", "config", "tls"), hits=1)
+  code, out = _run(records, "--global-floor", "0.0", "--security-floor", "0.0")
+  assert code == 1, out
+  assert "security" in out, out
+  assert "fail closed" in out, out
+
+
+def test_tests_e2e_records_are_excluded():
+  records = _module_records(CM.MODULE_ORDER, hits=1)
+  records.append(("tests/e2e/flow.rs", [(1, 0), (2, 0)]))
+  code, out = _run(
+    records,
+    "--global-floor",
+    "100.0",
+    "--security-floor",
+    "100.0",
+  )
+  assert code == 0, out
+  assert "RESULT: PASS" in out, out
+
+
+def test_module_test_files_are_excluded():
+  records = _module_records(CM.MODULE_ORDER, hits=1)
+  records.append(("src/protocol/tests_extra.rs", [(1, 0), (2, 0)]))
+  records.append(("src/protocol/tests.rs", [(1, 0)]))
+  records.append(("src/test_support.rs", [(1, 0)]))
+  records.append(("src/test_guard.rs", [(1, 0)]))
+  code, out = _run(
+    records,
+    "--global-floor",
+    "100.0",
+    "--security-floor",
+    "100.0",
+  )
+  assert code == 0, out
+  assert "RESULT: PASS" in out, out
+
+
+def test_one_module_below_its_floor_fails():
+  records: list = []
+  for name in CM.MODULE_ORDER:
+    hits = 0 if name == "crypto" else 1
+    records.extend(_module_records((name,), hits=hits))
+  code, out = _run(
+    records,
+    "--module-floor",
+    "crypto=100.0",
+    "--global-floor",
+    "0.0",
+    "--security-floor",
+    "0.0",
+  )
+  assert code == 1, out
+  assert "module 'crypto' below floor" in out, out
+
+
+def test_canonical_exclude_regex_matches_ci_contract():
+  assert CM.EXCLUDE_PATTERN == CANONICAL_EXCLUDE
+  assert CM.EXCLUDE_RE.pattern == CANONICAL_EXCLUDE
+  for path in (
+    "tests/e2e/flow.rs",
+    "tests/integration.rs",
+    "src/protocol/tests.rs",
+    "src/protocol/tests_extra.rs",
+    "src/test_support.rs",
+    "src/test_guard.rs",
+  ):
+    assert CM.EXCLUDE_RE.search(path), path
+  for path in ("src/protocol/cimd.rs", "src/routes.rs", "src/crypto/hash.rs"):
+    assert not CM.EXCLUDE_RE.search(path), path
+
+
+def test_aggregation_merges_duplicates_and_totals_modules():
+  records = [
+    ("src/crypto/a.rs", [(1, 1), (2, 0)]),
+    ("src/crypto/a.rs", [(1, 3), (3, 1)]),
+    ("src/crypto/b.rs", [(1, 0)]),
+    ("src/jose/j.rs", [(1, 1)]),
+    ("src/other.rs", [(1, 0)]),
+    ("tests/e2e/x.rs", [(1, 0)]),
+  ]
+  directory = Path(tempfile.mkdtemp(prefix="check-coverage-agg-"))
+  try:
+    lcov_path = _write_lcov(directory, records)
+    totals = CM.aggregate(
+      CM.parse_lcov(lcov_path.read_text(encoding="utf-8")), lcov_path
+    )
+  finally:
+    shutil.rmtree(directory, ignore_errors=True)
+
+  assert totals["src/crypto/a.rs"] == {1: 3, 2: 0, 3: 1}
+  assert totals["src/crypto/b.rs"] == {1: 0}
+  assert "tests/e2e/x.rs" not in totals
+  counts = CM.module_counts(totals)
+  assert counts["crypto"] == {"files": 2, "found": 4, "hit": 2}
+  assert counts["jose"] == {"files": 1, "found": 1, "hit": 1}
+  global_found = sum(len(lines) for lines in totals.values())
+  global_hit = sum(CM.line_counts(lines)[1] for lines in totals.values())
+  assert (global_hit, global_found) == (3, 6)
+  assert CM.percent(global_hit, global_found) == 50.0
+
+
+def main() -> int:
+  tests = [
+    value
+    for name, value in sorted(globals().items())
+    if name.startswith("test_") and callable(value)
+  ]
+  failed = 0
+  for test in tests:
+    try:
+      test()
+    except AssertionError as exc:
+      failed += 1
+      print(f"FAIL {test.__name__}: {exc}")
+    else:
+      print(f"PASS {test.__name__}")
+  print(f"{len(tests) - failed}/{len(tests)} passed")
+  return 1 if failed else 0
+
+
+if __name__ == "__main__":
+  sys.exit(main())

+ 2 - 1
src/config/jwt.rs

@@ -94,7 +94,8 @@ impl JwtSigningKey {
   /// # Errors
   /// Returns [`ConfigError::InvalidJwtSigningKey`] when the material cannot
   /// sign under `alg`.
-  pub(crate) fn from_raw(
+  #[doc(hidden)]
+  pub fn from_raw(
     kid: KeyId,
     alg: JwtSigningAlg,
     secret: &SecretString,

+ 1 - 0
src/middleware/ratelimit/test_support.rs

@@ -84,6 +84,7 @@ fn build_state(
     keys,
     transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
+    cimd_fetcher: crate::protocol::cimd::ssrf_fetcher(),
   }
 }
 

+ 32 - 6
src/protocol/authorize.rs

@@ -41,7 +41,7 @@ use crate::{
 /// 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)]
+#[derive(Clone)]
 pub struct CimdRequest<'a> {
   /// Token keyspace prefix carrying the `{state}` hash tag, for the
   /// `cimd:<sha256(url)>` document cache.
@@ -50,6 +50,19 @@ pub struct CimdRequest<'a> {
   pub max_bytes: usize,
   /// Total fetch budget (`config.cimd_fetch_timeout`).
   pub timeout: Duration,
+  /// Document fetcher: the SSRF-guarded production fetch in handlers, an
+  /// injected stub in tests. Cloned (never re-fetched) at each use.
+  pub fetcher: cimd::CimdFetcher,
+}
+
+impl std::fmt::Debug for CimdRequest<'_> {
+  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+    f.debug_struct("CimdRequest")
+      .field("token_prefix", &self.token_prefix)
+      .field("max_bytes", &self.max_bytes)
+      .field("timeout", &self.timeout)
+      .finish_non_exhaustive()
+  }
 }
 
 /// Inputs to [`validate`]: live stores plus the raw authorize query.
@@ -221,10 +234,22 @@ pub async fn validate(mut input: AuthorizeInput<'_>) -> Result<ValidatedRequest,
     .map_err(AuthorizeFailure::Page)?
   {
     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())),
-    },
+    None => {
+      // Fail-closed: a registered row that is not active is dead. An
+      // inactive `https://` row must never fall through to the CIMD
+      // fallback below (zero fetches); the caller keeps unknown-client.
+      if client_repo::find_by_client_id_any_state(input.pool, input.keys, client_id)
+        .await
+        .map_err(AuthorizeFailure::Page)?
+        .is_some()
+      {
+        return Err(page(AppError::invalid_client_non_basic()));
+      }
+      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
@@ -420,7 +445,7 @@ async fn resolve_cimd(
   input: &mut AuthorizeInput<'_>,
   client_id: &str,
 ) -> Result<Option<cimd::ValidatedCimd>, AuthorizeFailure> {
-  let Some(settings) = input.cimd else {
+  let Some(settings) = input.cimd.as_ref() else {
     return Ok(None);
   };
   // CIMD draft §3: only an `https://` URL names a metadata document.
@@ -438,6 +463,7 @@ async fn resolve_cimd(
     client_id,
     input.allow_localhost_redirect,
     limits,
+    &settings.fetcher,
   )
   .await
   .map_err(|error| page(AppError::from(error)))?;

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

@@ -981,6 +981,7 @@ fn cimd_settings(prefix: &str) -> super::CimdRequest<'_> {
     token_prefix: prefix,
     max_bytes: 5_120,
     timeout: Duration::from_secs(2),
+    fetcher: cimd::ssrf_fetcher(),
   }
 }
 

+ 88 - 41
src/protocol/cimd.rs

@@ -42,7 +42,9 @@ mod tests;
 #[cfg(test)]
 mod tests_principal;
 
-use std::{collections::HashSet, sync::LazyLock, time::Duration};
+use std::{collections::HashSet, future::Future, pin::Pin, sync::Arc, time::Duration};
+
+use std::sync::LazyLock;
 
 use serde::{Deserialize, Serialize};
 use sqlx::PgPool;
@@ -215,6 +217,32 @@ pub struct CimdLimits {
   pub timeout: Duration,
 }
 
+/// An injectable CIMD document fetch: `fetch_document` in production, a
+/// counting or failing stub in tests. The higher-ranked bound lets one value
+/// serve any borrow of the URL.
+pub type CimdFetchFuture<'a> =
+  Pin<Box<dyn Future<Output = Result<CimdDocument, CimdError>> + Send + 'a>>;
+/// Shared fetcher handle threaded through [`AppState`](crate::state::AppState)
+/// and the CIMD carrier structs down to [`resolve`].
+pub type CimdFetcher =
+  Arc<dyn for<'a> Fn(&'a str, CimdLimits) -> CimdFetchFuture<'a> + Send + Sync>;
+
+/// Box one fetch so the higher-ranked [`CimdFetcher`] closure compiles: the
+/// named helper pins the elided borrow lifetime instead of asking inference
+/// to thread it through the closure.
+fn boxed<'a>(
+  f: impl Future<Output = Result<CimdDocument, CimdError>> + Send + 'a,
+) -> CimdFetchFuture<'a> {
+  Box::pin(f)
+}
+
+/// The production fetcher: exactly one guarded [`fetch_document`] per fetch,
+/// with the same limits and error mapping (CIMD draft §4.1/§5, §8.6/§8.7).
+#[must_use]
+pub fn ssrf_fetcher() -> CimdFetcher {
+  Arc::new(|url, limits| boxed(fetch_document(url, limits)))
+}
+
 /// Validation inputs resolved by the caller (keeps this module pure).
 pub struct CimdValidation<'a> {
   /// Every enabled scope name (`scope_repo::list_enabled`).
@@ -384,7 +412,7 @@ pub fn invalid_cache_key(token_prefix: &str, client_id_url: &str) -> String {
 /// 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)]
+#[derive(Clone)]
 pub struct CimdSettings<'a> {
   /// Token keyspace prefix carrying the `{state}` hash tag, for the
   /// `cimd:<sha256(url)>` document cache.
@@ -396,6 +424,20 @@ pub struct CimdSettings<'a> {
   /// Config `allow_localhost_redirect`: whether the host `localhost` joins
   /// the RFC 8252 §7.3 loopback exception.
   pub allow_localhost: bool,
+  /// Document fetcher: [`ssrf_fetcher`] in production, an injected stub in
+  /// tests. Cloned (never re-fetched) at every construction site.
+  pub fetcher: CimdFetcher,
+}
+
+impl std::fmt::Debug for CimdSettings<'_> {
+  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+    f.debug_struct("CimdSettings")
+      .field("token_prefix", &self.token_prefix)
+      .field("max_bytes", &self.max_bytes)
+      .field("timeout", &self.timeout)
+      .field("allow_localhost", &self.allow_localhost)
+      .finish_non_exhaustive()
+  }
 }
 
 /// The single client-principal resolution for every client-auth consumer
@@ -460,6 +502,7 @@ pub async fn resolve_client(
     client_id,
     settings.allow_localhost,
     limits,
+    &settings.fetcher,
   )
   .await?;
   Ok(Some(validated.to_client()))
@@ -593,33 +636,23 @@ pub async fn resolve(
   client_id_url: &str,
   validation: &CimdValidation<'_>,
   limits: CimdLimits,
+  fetcher: &CimdFetcher,
 ) -> Result<ValidatedCimd, CimdError> {
-  let key = cache_key(token_prefix, client_id_url);
-  let neg_key = negative_cache_key(token_prefix, client_id_url);
-  let invalid_key = invalid_cache_key(token_prefix, client_id_url);
+  let keys = CacheKeys::of(token_prefix, client_id_url);
   let lock = lock_key(token_prefix, client_id_url);
-  if is_negatively_cached(conn, &neg_key).await? {
+  if is_negatively_cached(conn, &keys.neg_key).await? {
     return Err(CimdError::Fetch);
   }
-  if let Some(document) = get_cached(conn, &key).await? {
+  if let Some(document) = get_cached(conn, &keys.key).await? {
     return validate_document(client_id_url, &document, validation);
   }
-  if let Some(error) = is_invalid_cached(conn, &invalid_key).await? {
+  if let Some(error) = is_invalid_cached(conn, &keys.invalid_key).await? {
     return Err(error);
   }
 
   let ttl_ms = lock_ttl_ms(limits);
   if let Some(token) = acquire_lock(conn, &lock, ttl_ms).await? {
-    let result = resolve_under_lock(
-      conn,
-      &key,
-      &neg_key,
-      &invalid_key,
-      client_id_url,
-      validation,
-      limits,
-    )
-    .await;
+    let result = resolve_under_lock(conn, &keys, client_id_url, validation, limits, fetcher).await;
     // Best effort: a missed release is bounded by the lock TTL.
     let _ = release_lock(conn, &lock, &token).await;
     return result;
@@ -630,13 +663,13 @@ pub async fn resolve(
     .and_then(|instant| instant.checked_add(LOCK_WAIT_MARGIN))
     .unwrap_or_else(tokio::time::Instant::now);
   loop {
-    if is_negatively_cached(conn, &neg_key).await? {
+    if is_negatively_cached(conn, &keys.neg_key).await? {
       return Err(CimdError::Fetch);
     }
-    if let Some(document) = get_cached(conn, &key).await? {
+    if let Some(document) = get_cached(conn, &keys.key).await? {
       return validate_document(client_id_url, &document, validation);
     }
-    if let Some(error) = is_invalid_cached(conn, &invalid_key).await? {
+    if let Some(error) = is_invalid_cached(conn, &keys.invalid_key).await? {
       return Err(error);
     }
     // A holder that failed validation releases without a cache entry; stop
@@ -658,16 +691,7 @@ pub async fn resolve(
   }
 
   if let Some(token) = acquire_lock(conn, &lock, ttl_ms).await? {
-    let result = resolve_under_lock(
-      conn,
-      &key,
-      &neg_key,
-      &invalid_key,
-      client_id_url,
-      validation,
-      limits,
-    )
-    .await;
+    let result = resolve_under_lock(conn, &keys, client_id_url, validation, limits, fetcher).await;
     let _ = release_lock(conn, &lock, &token).await;
     return result;
   }
@@ -678,26 +702,25 @@ pub async fn resolve(
 /// published), then fetch, validate, and cache.
 async fn resolve_under_lock(
   conn: &mut RedisConn,
-  key: &str,
-  neg_key: &str,
-  invalid_key: &str,
+  keys: &CacheKeys,
   client_id_url: &str,
   validation: &CimdValidation<'_>,
   limits: CimdLimits,
+  fetcher: &CimdFetcher,
 ) -> Result<ValidatedCimd, CimdError> {
-  if is_negatively_cached(conn, neg_key).await? {
+  if is_negatively_cached(conn, &keys.neg_key).await? {
     return Err(CimdError::Fetch);
   }
-  if let Some(document) = get_cached(conn, key).await? {
+  if let Some(document) = get_cached(conn, &keys.key).await? {
     return validate_document(client_id_url, &document, validation);
   }
-  if let Some(error) = is_invalid_cached(conn, invalid_key).await? {
+  if let Some(error) = is_invalid_cached(conn, &keys.invalid_key).await? {
     return Err(error);
   }
-  let document = match fetch_document(client_id_url, limits).await {
+  let document = match fetcher(client_id_url, limits).await {
     Ok(document) => document,
     Err(error @ (CimdError::Fetch | CimdError::InvalidJson)) => {
-      put_negative(conn, neg_key).await?;
+      put_negative(conn, &keys.neg_key).await?;
       return Err(error);
     }
     Err(error) => return Err(error),
@@ -705,7 +728,7 @@ async fn resolve_under_lock(
   let validated = match validate_document(client_id_url, &document, validation) {
     Ok(validated) => validated,
     Err(error) => {
-      if put_invalid(conn, invalid_key, InvalidClass::of(&error))
+      if put_invalid(conn, &keys.invalid_key, InvalidClass::of(&error))
         .await
         .is_err()
       {
@@ -714,12 +737,34 @@ async fn resolve_under_lock(
       return Err(error);
     }
   };
-  if put_cached(conn, key, &document).await.is_err() {
+  if put_cached(conn, &keys.key, &document).await.is_err() {
     tracing::warn!("cimd cache population failed after validation");
   }
   Ok(validated)
 }
 
+/// The three `{state}`-prefixed cache keys for one CIMD URL, derived once
+/// per resolution and shared by the lock-holder and waiter paths.
+struct CacheKeys {
+  /// Positive document cache (`cimd:<sha256(url)>`).
+  key: String,
+  /// Origin-failure negative cache (`cimd:neg:<sha256(url)>`).
+  neg_key: String,
+  /// Fresh-fetch validation-failure marker (`cimd:invalid:<sha256(url)>`).
+  invalid_key: String,
+}
+
+impl CacheKeys {
+  /// Derive all three keys from the token prefix and the document URL.
+  fn of(token_prefix: &str, client_id_url: &str) -> Self {
+    Self {
+      key: cache_key(token_prefix, client_id_url),
+      neg_key: negative_cache_key(token_prefix, client_id_url),
+      invalid_key: invalid_cache_key(token_prefix, client_id_url),
+    }
+  }
+}
+
 /// The single-flight lock key: `{state}`-prefixed, hash of the URL, never the
 /// raw URL.
 fn lock_key(token_prefix: &str, client_id_url: &str) -> String {
@@ -786,6 +831,7 @@ pub async fn resolve_with_catalog(
   client_id_url: &str,
   allow_localhost: bool,
   limits: CimdLimits,
+  fetcher: &CimdFetcher,
 ) -> Result<ValidatedCimd, CimdError> {
   let enabled_scopes: HashSet<String> = scope_repo::list_enabled(pool)
     .await
@@ -802,6 +848,7 @@ pub async fn resolve_with_catalog(
       allow_localhost,
     },
     limits,
+    fetcher,
   )
   .await
 }

+ 74 - 7
src/protocol/cimd/tests.rs

@@ -1,14 +1,21 @@
 //! `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 std::{
+  collections::HashSet,
+  sync::{
+    Arc,
+    atomic::{AtomicUsize, Ordering},
+  },
+  time::Duration,
+};
 
 use serde_json::{Value, json};
 
 use super::{
-  CimdAuthMethod, CimdDocument, CimdError, CimdLimits, CimdValidation, ValidatedCimd, acquire_lock,
-  cache_key, fetch_document, get_cached, invalid_cache_key, lock_key, negative_cache_key,
-  put_cached, release_lock, resolve, validate_document,
+  CimdAuthMethod, CimdDocument, CimdError, CimdFetcher, CimdLimits, CimdValidation, ValidatedCimd,
+  acquire_lock, boxed, cache_key, fetch_document, get_cached, invalid_cache_key, lock_key,
+  negative_cache_key, put_cached, release_lock, resolve, ssrf_fetcher, validate_document,
 };
 use crate::state::RedisConn;
 
@@ -330,6 +337,7 @@ async fn cimd_cache_round_trip() -> TestResult {
   }
 
   let enabled = scopes();
+  let fetcher = ssrf_fetcher();
   let resolved = resolve(
     &mut conn,
     &prefix,
@@ -342,6 +350,7 @@ async fn cimd_cache_round_trip() -> TestResult {
       max_bytes: 5_120,
       timeout: Duration::from_secs(1),
     },
+    &fetcher,
   )
   .await
   .map_err(|error| TestError::Unexpected(format!("resolve: {error}")))?;
@@ -382,6 +391,7 @@ async fn cimd_negative_cache_blocks_fetch() -> TestResult {
     .await
     .map_err(|error| TestError::Unexpected(format!("negative seed: {error}")))?;
   let enabled = scopes();
+  let fetcher = ssrf_fetcher();
   match resolve(
     &mut conn,
     &prefix,
@@ -394,6 +404,7 @@ async fn cimd_negative_cache_blocks_fetch() -> TestResult {
       max_bytes: 5_120,
       timeout: Duration::from_secs(1),
     },
+    &fetcher,
   )
   .await
   {
@@ -433,6 +444,7 @@ async fn cimd_fetch_failure_negatively_cached() -> TestResult {
   let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
   let url = "https://client.invalid/neg-write.json";
   let enabled = scopes();
+  let fetcher = ssrf_fetcher();
   match resolve(
     &mut conn,
     &prefix,
@@ -445,6 +457,7 @@ async fn cimd_fetch_failure_negatively_cached() -> TestResult {
       max_bytes: 5_120,
       timeout: Duration::from_secs(2),
     },
+    &fetcher,
   )
   .await
   {
@@ -495,6 +508,7 @@ async fn cimd_validation_failure_not_negatively_cached() -> TestResult {
     .await
     .map_err(|error| TestError::Unexpected(format!("cache write: {error}")))?;
   let enabled = scopes();
+  let fetcher = ssrf_fetcher();
   match resolve(
     &mut conn,
     &prefix,
@@ -507,6 +521,7 @@ async fn cimd_validation_failure_not_negatively_cached() -> TestResult {
       max_bytes: 5_120,
       timeout: Duration::from_secs(1),
     },
+    &fetcher,
   )
   .await
   {
@@ -536,6 +551,55 @@ async fn cimd_validation_failure_not_negatively_cached() -> TestResult {
   Ok(())
 }
 
+/// The injected fetcher serves the single fetch: its failure is the resolve
+/// failure, and the negative cache blocks a second call (the count stays 1).
+#[tokio::test]
+async fn cimd_injected_fetcher_failure_is_negatively_cached() -> TestResult {
+  let manager = crate::test_support::redis_manager()
+    .await
+    .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
+  let mut conn = RedisConn::Single(manager);
+  let (prefix, _ratelimit) = crate::test_support::unique_prefixes();
+  let url = "https://client.invalid/injected-fetch.json";
+  let calls = Arc::new(AtomicUsize::new(0));
+  let counting = Arc::clone(&calls);
+  let fetcher: CimdFetcher = Arc::new(move |_url: &str, _limits: CimdLimits| {
+    counting.fetch_add(1, Ordering::SeqCst);
+    boxed(async { Err::<CimdDocument, _>(CimdError::Fetch) })
+  });
+  let enabled = scopes();
+  let validation = CimdValidation {
+    enabled_scopes: &enabled,
+    allow_localhost: false,
+  };
+  let limits = CimdLimits {
+    max_bytes: 5_120,
+    timeout: Duration::from_secs(1),
+  };
+  for _ in 0..2 {
+    match resolve(&mut conn, &prefix, url, &validation, limits, &fetcher).await {
+      Err(CimdError::Fetch) => {}
+      Err(other) => {
+        return Err(TestError::Unexpected(format!(
+          "injected failure must be Fetch, got {other}"
+        )));
+      }
+      Ok(_) => {
+        return Err(TestError::Unexpected(
+          "injected failure resolved".to_owned(),
+        ));
+      }
+    }
+  }
+  if calls.load(Ordering::SeqCst) != 1 {
+    return Err(TestError::Unexpected(format!(
+      "negative cache must block the second fetch, saw {} calls",
+      calls.load(Ordering::SeqCst)
+    )));
+  }
+  Ok(())
+}
+
 /// The single-flight lock is exclusive, ignores a release with the wrong
 /// token, and can be reacquired after the owner releases it.
 #[tokio::test]
@@ -620,8 +684,9 @@ async fn cimd_single_flight_waiters_share_one_fetch() -> TestResult {
     max_bytes: 5_120,
     timeout: Duration::from_secs(1),
   };
-  let a = resolve(&mut waiter_a, &prefix, url, &validation, limits);
-  let b = resolve(&mut waiter_b, &prefix, url, &validation, limits);
+  let fetcher = ssrf_fetcher();
+  let a = resolve(&mut waiter_a, &prefix, url, &validation, limits, &fetcher);
+  let b = resolve(&mut waiter_b, &prefix, url, &validation, limits, &fetcher);
   let publish = async {
     tokio::time::sleep(Duration::from_millis(100)).await;
     put_cached(&mut holder, &key, &document)
@@ -679,7 +744,8 @@ async fn cimd_single_flight_waiter_observes_negative() -> TestResult {
     max_bytes: 5_120,
     timeout: Duration::from_secs(1),
   };
-  let waiting = resolve(&mut waiter, &prefix, url, &validation, limits);
+  let fetcher = ssrf_fetcher();
+  let waiting = resolve(&mut waiter, &prefix, url, &validation, limits, &fetcher);
   let publish = async {
     tokio::time::sleep(Duration::from_millis(100)).await;
     redis::cmd("SET")
@@ -747,6 +813,7 @@ async fn cimd_invalid_cache_blocks_fetch() -> TestResult {
         max_bytes: 5_120,
         timeout: Duration::from_secs(1),
       },
+      &ssrf_fetcher(),
     )
     .await
     {

+ 107 - 2
src/protocol/cimd/tests_principal.rs

@@ -9,7 +9,10 @@
 //! keeps the exact pre-CIMD unknown-client behavior.
 
 use std::collections::HashMap;
-use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::{
+  Arc,
+  atomic::{AtomicU64, Ordering},
+};
 use std::time::Duration;
 
 use base64::Engine as _;
@@ -20,7 +23,10 @@ use secrecy::SecretString;
 use serde::{Deserialize, Serialize};
 use time::OffsetDateTime;
 
-use super::{CimdDocument, CimdSettings, cache_key, resolve_client};
+use super::{
+  CimdDocument, CimdError, CimdFetcher, CimdLimits, CimdSettings, boxed, cache_key, resolve_client,
+  ssrf_fetcher,
+};
 use crate::config::{DurabilityMode, TtlConfig};
 use crate::crypto::secret::{Argon2Params, hash_secret};
 use crate::db::{NewClient, TokenEndpointAuthMethod, client_repo, scope_repo};
@@ -165,6 +171,7 @@ fn auth_ctx_for<'a>(endpoint: &'a str, token_prefix: &'a str) -> AuthContext<'a>
       max_bytes: 5_120,
       timeout: Duration::from_secs(2),
       allow_localhost: false,
+      fetcher: ssrf_fetcher(),
     }),
   }
 }
@@ -373,6 +380,7 @@ async fn redeem(
       max_bytes: 5_120,
       timeout: Duration::from_secs(2),
       allow_localhost: false,
+      fetcher: ssrf_fetcher(),
     }),
     now_unix: now,
   };
@@ -662,6 +670,7 @@ async fn cimd_client_jar_or_par() -> TestResult {
     token_prefix: &token_prefix,
     max_bytes: 5_120,
     timeout: Duration::from_secs(2),
+    fetcher: ssrf_fetcher(),
   };
   let pushed = {
     let mut stores = ParStores {
@@ -706,6 +715,7 @@ async fn cimd_client_jar_or_par() -> TestResult {
       token_prefix: &h.token_prefix,
       max_bytes: 5_120,
       timeout: Duration::from_secs(2),
+      fetcher: ssrf_fetcher(),
     }),
     query: &par_query,
     resources: Vec::new(),
@@ -752,6 +762,7 @@ async fn cimd_client_jar_or_par() -> TestResult {
       token_prefix: &h.token_prefix,
       max_bytes: 5_120,
       timeout: Duration::from_secs(2),
+      fetcher: ssrf_fetcher(),
     }),
     query: &jar_query,
     resources: Vec::new(),
@@ -833,6 +844,7 @@ async fn cimd_client_can_revoke() -> TestResult {
       max_bytes: 5_120,
       timeout: Duration::from_secs(2),
       allow_localhost: false,
+      fetcher: ssrf_fetcher(),
     }),
     request_id: None,
   };
@@ -957,6 +969,7 @@ async fn cimd_none_cannot_introspect() -> TestResult {
       max_bytes: 5_120,
       timeout: Duration::from_secs(2),
       allow_localhost: false,
+      fetcher: ssrf_fetcher(),
     }),
     service: &service,
     jwt_signer: None,
@@ -1134,6 +1147,7 @@ async fn cimd_fetch_failure_maps_fail_closed() -> TestResult {
     max_bytes: 5_120,
     timeout: Duration::from_secs(2),
     allow_localhost: false,
+    fetcher: ssrf_fetcher(),
   };
   // No cache seed: `.invalid` can never fetch, so this is a pure Fetch
   // failure (CIMD draft §5, fail-closed per Todo 19's mapping).
@@ -1216,6 +1230,7 @@ async fn cimd_inactive_url_row_never_fetches() -> TestResult {
     max_bytes: 5_120,
     timeout: Duration::from_secs(2),
     allow_localhost: false,
+    fetcher: ssrf_fetcher(),
   };
   match resolve_client(&h.pool, &h.ring, &mut h.conn, Some(settings), &url).await {
     Ok(None) => Ok(()),
@@ -1227,3 +1242,93 @@ async fn cimd_inactive_url_row_never_fetches() -> TestResult {
     ))),
   }
 }
+
+/// An inactive registered `https://` row is dead at `/authorize` too: the
+/// unknown-client page renders with zero fetches, so deactivation cannot be
+/// bypassed through the CIMD document fallback.
+#[tokio::test]
+async fn cimd_inactive_registered_row_is_dead() -> TestResult {
+  let mut h = harness().await?;
+  let url = unique("inactivedead");
+  let params =
+    Argon2Params::new(8, 1, 1, 32).map_err(|error| TestError::Unexpected(format!("{error:?}")))?;
+  let secret_hash = hash_secret("row-secret", &params)
+    .map_err(|error| TestError::Unexpected(format!("{error:?}")))?;
+  client_repo::insert(
+    &h.pool,
+    &h.ring,
+    false,
+    &NewClient {
+      id: uuid::Uuid::new_v4(),
+      client_id: url.clone(),
+      client_secret_hash: Some(secret_hash),
+      client_secret_expires_at: None,
+      client_name: "inactive row".to_owned(),
+      grant_types: vec!["authorization_code".to_owned()],
+      response_types: vec!["code".to_owned()],
+      scope: "api:read".to_owned(),
+      token_endpoint_auth_method: TokenEndpointAuthMethod::ClientSecretPost
+        .as_str()
+        .to_owned(),
+      jwks: None,
+      jwks_uri: None,
+      tls_client_auth_subject_dn: None,
+      tls_client_auth_cert_thumbprint: None,
+      request_uris: None,
+      contacts: None,
+      software_id: None,
+      dpop_bound_access_tokens: false,
+      access_token_format: "opaque".to_owned(),
+      registration_access_token_hash: None,
+    },
+  )
+  .await?;
+  if !client_repo::deactivate(&h.pool, &url).await? {
+    return Err(TestError::Unexpected(
+      "deactivate reported no change".to_owned(),
+    ));
+  }
+  let calls = Arc::new(AtomicU64::new(0));
+  let counting = Arc::clone(&calls);
+  let fetcher: CimdFetcher = Arc::new(move |_url: &str, _limits: CimdLimits| {
+    counting.fetch_add(1, Ordering::Relaxed);
+    boxed(async { Err::<CimdDocument, _>(CimdError::Fetch) })
+  });
+  let query = HashMap::from([("client_id".to_owned(), url.clone())]);
+  match 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),
+      fetcher,
+    }),
+    query: &query,
+    resources: Vec::new(),
+    resource_indicators_enabled: false,
+  })
+  .await
+  {
+    Err(failure) if failure.code() == OAuthErrorCode::InvalidClient => {}
+    Err(failure) => {
+      return Err(TestError::Unexpected(format!(
+        "inactive row must be unknown-client, got {}",
+        failure.code()
+      )));
+    }
+    Ok(_) => {
+      return Err(TestError::Unexpected("inactive row authorized".to_owned()));
+    }
+  }
+  if calls.load(Ordering::Relaxed) != 0 {
+    return Err(TestError::Unexpected(
+      "inactive row must never fetch".to_owned(),
+    ));
+  }
+  Ok(())
+}

+ 4 - 4
src/protocol/client_auth.rs

@@ -190,7 +190,7 @@ pub async fn authenticate_client(
     {
       return Err(invalid_client_for(true));
     }
-    let client = lookup_client(stores, ctx.cimd, &creds.client_id, true).await?;
+    let client = lookup_client(stores, ctx.cimd.clone(), &creds.client_id, true).await?;
     require_method(&client, TokenEndpointAuthMethod::ClientSecretBasic, true)?;
     authenticate_secret(
       stores.pool,
@@ -204,7 +204,7 @@ pub async fn authenticate_client(
     let Some(client_id) = req.client_id else {
       return Err(invalid_client_for(false));
     };
-    let client = lookup_client(stores, ctx.cimd, client_id, false).await?;
+    let client = lookup_client(stores, ctx.cimd.clone(), client_id, false).await?;
     require_method(&client, TokenEndpointAuthMethod::ClientSecretPost, false)?;
     authenticate_secret(stores.pool, &client, secret.expose_secret(), ctx.now, false).await
   } else if let Some(assertion_str) = req.assertion {
@@ -217,11 +217,11 @@ pub async fn authenticate_client(
       (None, Some(iss)) => iss,
       (None, None) => return Err(invalid_client_for(false)),
     };
-    let client = lookup_client(stores, ctx.cimd, &client_id, false).await?;
+    let client = lookup_client(stores, ctx.cimd.clone(), &client_id, false).await?;
     require_method(&client, TokenEndpointAuthMethod::PrivateKeyJwt, false)?;
     assertion::authenticate(stores, &client, assertion_str, ctx).await
   } else if let Some(client_id) = req.client_id {
-    let client = lookup_client(stores, ctx.cimd, client_id, false).await?;
+    let client = lookup_client(stores, ctx.cimd.clone(), client_id, false).await?;
     match client.auth_method() {
       Ok(TokenEndpointAuthMethod::None) => Ok(AuthenticatedClient {
         client,

+ 2 - 1
src/protocol/introspect.rs

@@ -474,7 +474,7 @@ async fn authenticate(
   let auth_ctx = AuthContext {
     token_endpoint: &endpoint,
     now,
-    cimd: ctx.cimd,
+    cimd: ctx.cimd.clone(),
   };
   let authenticated = client_auth::authenticate_client(&mut auth_stores, &auth_ctx, &request)
     .await
@@ -756,6 +756,7 @@ pub async fn handler(
         max_bytes: state.config.cimd_max_bytes,
         timeout: state.config.cimd_fetch_timeout,
         allow_localhost: state.config.allow_localhost_redirect,
+        fetcher: state.cimd_fetcher.clone(),
       }),
     service: &service,
     jwt_signer: jwt_signer.as_ref(),

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

@@ -274,6 +274,7 @@ fn app_state(h: &Harness) -> AppState {
     keys: Arc::new(h.config.keys.clone()),
     transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
+    cimd_fetcher: crate::protocol::cimd::ssrf_fetcher(),
   }
 }
 

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

@@ -162,6 +162,7 @@ fn test_state_with_config(pool: &sqlx::PgPool, config: Arc<Config>) -> TestResul
     keys,
     transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
+    cimd_fetcher: crate::protocol::cimd::ssrf_fetcher(),
   })
 }
 

+ 1 - 1
src/protocol/mtls.rs

@@ -12,7 +12,7 @@ mod cert;
 mod select;
 mod verifier;
 
-pub use cert::{subject_dn, thumbprint_b64url};
+pub use cert::{ensure_within_validity, subject_dn, thumbprint_b64url};
 pub use select::{authenticate, authenticate_by_id, select_by_subject_dn, select_by_thumbprint};
 pub use verifier::{
   TLS_PROTOCOL_VERSIONS, pki_verifier, pki_verifier_from_file, self_signed_verifier, server_config,

+ 4 - 1
src/protocol/par.rs

@@ -116,6 +116,7 @@ pub async fn push_authorization_request(
       max_bytes: settings.max_bytes,
       timeout: settings.timeout,
       allow_localhost: ctx.allow_localhost_redirect,
+      fetcher: settings.fetcher.clone(),
     }),
   };
   let authenticated = authenticate_client(&mut auth_stores, &auth_ctx, auth).await?;
@@ -207,7 +208,7 @@ async fn validate_request_rules(
         .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 {
+      None => match ctx.cimd.as_ref() {
         Some(settings) if client_id.starts_with("https://") => {
           let limits = cimd::CimdLimits {
             max_bytes: settings.max_bytes,
@@ -220,6 +221,7 @@ async fn validate_request_rules(
             client_id,
             ctx.allow_localhost_redirect,
             limits,
+            &settings.fetcher,
           )
           .await
           .map_err(AppError::from)?
@@ -310,6 +312,7 @@ pub async fn par_handler(
       token_prefix: &state.config.redis.token_key_prefix,
       max_bytes: state.config.cimd_max_bytes,
       timeout: state.config.cimd_fetch_timeout,
+      fetcher: state.cimd_fetcher.clone(),
     }),
     resource_indicators_enabled: state.config.resource_indicators_enabled,
   };

+ 2 - 1
src/protocol/revoke.rs

@@ -153,7 +153,7 @@ pub async fn revoke_tokens(
     let auth_ctx = AuthContext {
       token_endpoint: ctx.token_endpoint,
       now: ctx.now,
-      cimd: ctx.cimd,
+      cimd: ctx.cimd.clone(),
     };
     authenticate_client(&mut auth_stores, &auth_ctx, request.auth).await?
   };
@@ -431,6 +431,7 @@ pub async fn revoke_handler(
         max_bytes: state.config.cimd_max_bytes,
         timeout: state.config.cimd_fetch_timeout,
         allow_localhost: state.config.allow_localhost_redirect,
+        fetcher: state.cimd_fetcher.clone(),
       }),
     request_id: request_id.as_ref().map(RequestId::as_str),
   };

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

@@ -139,6 +139,7 @@ fn handler_state() -> Result<AppState, TestError> {
     keys: Arc::new(test_keys()?),
     transition_unsafe: Arc::new(AtomicBool::new(false)),
     clock: Clock::system(),
+    cimd_fetcher: crate::protocol::cimd::ssrf_fetcher(),
   })
 }
 

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

@@ -456,6 +456,7 @@ fn app_state(h: &PgHarness) -> AppState {
     keys: Arc::new(h.config.keys.clone()),
     transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: Clock::system(),
+    cimd_fetcher: crate::protocol::cimd::ssrf_fetcher(),
   }
 }
 

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

@@ -97,6 +97,7 @@ fn app_state(
     keys: Arc::new(test_keys()?),
     transition_unsafe: Arc::new(AtomicBool::new(false)),
     clock: Clock::system(),
+    cimd_fetcher: crate::protocol::cimd::ssrf_fetcher(),
   })
 }
 

+ 2 - 1
src/protocol/token/dispatch.rs

@@ -229,7 +229,7 @@ impl Dispatch<'_> {
     let ctx = AuthContext {
       token_endpoint: &endpoint,
       now: self.state.clock.now(),
-      cimd: self.cimd,
+      cimd: self.cimd.clone(),
     };
     client_auth::authenticate_client(&mut stores, &ctx, &request).await
   }
@@ -506,6 +506,7 @@ pub async fn handler(
         max_bytes: state.config.cimd_max_bytes,
         timeout: state.config.cimd_fetch_timeout,
         allow_localhost: state.config.allow_localhost_redirect,
+        fetcher: state.cimd_fetcher.clone(),
       }),
     now_unix,
   };

+ 2 - 2
src/protocol/token/dispatch/grants.rs

@@ -95,7 +95,7 @@ async fn authorization_code(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, T
     ttl: &d.state.config.ttl,
     token_format: format,
     jwt_signer: jwt,
-    cimd: d.cimd,
+    cimd: d.cimd.clone(),
     resource_indicators_enabled: d.state.config.resource_indicators_enabled,
     now_unix: d.now_unix,
   };
@@ -276,7 +276,7 @@ async fn device(d: &mut Dispatch<'_>) -> Result<TokenResponseBody, TokenError> {
     ttl: &d.state.config.ttl,
     token_format: format,
     jwt_signer: jwt,
-    cimd: d.cimd,
+    cimd: d.cimd.clone(),
     resource_indicators_enabled: d.state.config.resource_indicators_enabled,
     now_unix: d.now_unix,
   };

+ 2 - 2
src/protocol/token/grants/authorization_code.rs

@@ -174,7 +174,7 @@ pub async fn redeem_code(
     state.pool,
     state.keys,
     state.conn,
-    state.cimd,
+    state.cimd.clone(),
     candidate_id.as_str(),
   )
   .await
@@ -497,7 +497,7 @@ async fn authenticate_confidential(
   let auth_ctx = AuthContext {
     token_endpoint: endpoint.as_str(),
     now: OffsetDateTime::from_unix_timestamp(state.now_unix).unwrap_or(OffsetDateTime::UNIX_EPOCH),
-    cimd: state.cimd,
+    cimd: state.cimd.clone(),
   };
   client_auth::authenticate_client(&mut auth, &auth_ctx, &request).await?;
   Ok(())

+ 1 - 1
src/protocol/token/grants/device_code.rs

@@ -158,7 +158,7 @@ pub async fn poll_device(
     state.pool,
     state.keys,
     state.conn,
-    state.cimd,
+    state.cimd.clone(),
     inputs.client_id.trim(),
   )
   .await

+ 1 - 1
src/protocol/token/grants/device_code/proofs.rs

@@ -121,7 +121,7 @@ async fn authenticate_confidential(
   let auth_ctx = AuthContext {
     token_endpoint: endpoint.as_str(),
     now: OffsetDateTime::from_unix_timestamp(state.now_unix).unwrap_or(OffsetDateTime::UNIX_EPOCH),
-    cimd: state.cimd,
+    cimd: state.cimd.clone(),
   };
   client_auth::authenticate_client(&mut auth, &auth_ctx, &request).await?;
   Ok(())

+ 2 - 0
src/routes.rs

@@ -246,6 +246,7 @@ async fn authorize_entry(
       token_prefix: &state.config.redis.token_key_prefix,
       max_bytes: state.config.cimd_max_bytes,
       timeout: state.config.cimd_fetch_timeout,
+      fetcher: state.cimd_fetcher.clone(),
     }),
     query: &query,
     resources,
@@ -498,6 +499,7 @@ mod router_tests {
       keys,
       transition_unsafe: Arc::new(std::sync::atomic::AtomicBool::new(false)),
       clock: Clock::system(),
+      cimd_fetcher: crate::protocol::cimd::ssrf_fetcher(),
     })
   }
 

+ 5 - 0
src/state.rs

@@ -40,6 +40,7 @@ use time::OffsetDateTime;
 use crate::{
   config::{Config, ConfigError},
   keys::KeyRing,
+  protocol::cimd::{CimdFetcher, ssrf_fetcher},
 };
 
 /// Embedded migrations compiled from `./migrations` (currently empty; todos
@@ -124,6 +125,9 @@ pub struct AppState {
   pub transition_unsafe: Arc<AtomicBool>,
   /// Injectable clock.
   pub clock: Clock,
+  /// CIMD document fetcher: [`ssrf_fetcher`] in production, an injected stub
+  /// in tests. Cloned into every per-request CIMD settings value.
+  pub cimd_fetcher: CimdFetcher,
 }
 
 impl AppState {
@@ -144,6 +148,7 @@ impl AppState {
       keys,
       transition_unsafe: Arc::new(AtomicBool::new(transition_unsafe)),
       clock: Clock::system(),
+      cimd_fetcher: ssrf_fetcher(),
     })
   }
 }

+ 3 - 1
src/ui/consent.rs

@@ -288,7 +288,7 @@ async fn client_and_user(
     deps.pool,
     deps.keys,
     deps.conn,
-    deps.cimd,
+    deps.cimd.clone(),
     &pending.client_id,
   )
   .await
@@ -455,6 +455,7 @@ pub async fn get_consent(
     max_bytes: state.config.cimd_max_bytes,
     timeout: state.config.cimd_fetch_timeout,
     allow_localhost: state.config.allow_localhost_redirect,
+    fetcher: state.cimd_fetcher.clone(),
   });
   let mut deps = ConsentDeps {
     pool: &state.pool,
@@ -505,6 +506,7 @@ pub async fn post_consent(
     max_bytes: state.config.cimd_max_bytes,
     timeout: state.config.cimd_fetch_timeout,
     allow_localhost: state.config.allow_localhost_redirect,
+    fetcher: state.cimd_fetcher.clone(),
   });
   let mut deps = ConsentDeps {
     pool: &state.pool,

+ 1 - 0
src/ui/consent/tests.rs

@@ -111,6 +111,7 @@ fn cimd_deps_of(h: &mut Harness) -> ConsentDeps<'_> {
       max_bytes: 5_120,
       timeout: Duration::from_secs(2),
       allow_localhost: false,
+      fetcher: cimd::ssrf_fetcher(),
     }),
   }
 }

+ 1 - 0
src/ui/login/tests.rs

@@ -150,6 +150,7 @@ pub(super) fn app_state(harness: &Harness) -> crate::state::AppState {
     keys: std::sync::Arc::new(harness.config.keys.clone()),
     transition_unsafe: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
     clock: crate::state::Clock::fixed(harness.now),
+    cimd_fetcher: crate::protocol::cimd::ssrf_fetcher(),
   }
 }
 

+ 239 - 0
tests/cli_user_disable.rs

@@ -0,0 +1,239 @@
+//! `test(cli_user_disable)` — the real `user disable` CLI purges the account's
+//! Redis token state before flipping `users.disabled` (plan todo 12).
+//!
+//! The test drives the shipped `d-oauth-server` binary as a child process
+//! (`user disable --email <email>` with `D_OAUTH_CONFIG` pointing at a JSON
+//! file generated from the live [`World`]'s validated config). No shell is
+//! involved and no production code is modified.
+//!
+//! Setup mints a token pair through the REAL [`d_oauth::protocol::token::TokenService`]
+//! with the seeded user's **id** as its `subject`, because `purge_subject`
+//! matches the stored record subject against the user id (the CLI passes
+//! `record.id.to_string()`); [`World::mint`] hardcodes `e2e-subject` and would
+//! leave the token behind.
+//!
+//! Assertions:
+//! * the seeded user starts enabled and both token keys are live;
+//! * the child exits 0;
+//! * both the access and refresh Redis keys are gone (the purge ran);
+//! * `users.disabled` is true only after that purge (exit-0 path).
+
+#[allow(dead_code)]
+#[path = "e2e/harness.rs"]
+mod harness;
+
+use std::path::PathBuf;
+
+use d_oauth::{
+  config::RedisTopology,
+  protocol::token::{ClientKind, MintRequest, SenderProof, TokenFormat},
+  state::RedisConn,
+};
+use harness::{TestError, World, ensure, fail, open_rate_limit, seed_entitled_user, seed_scope};
+use secrecy::ExposeSecret as _;
+
+/// Seeded account email (canonical form the CLI resolves).
+const EMAIL: &str = "cli-disable@example.test";
+/// Seeded account password (never printed; the CLI path does not use it).
+const PASSWORD: &str = "correct-horse-28-cli-disable";
+/// Catalog scope the seeded account is entitled to.
+const SCOPE: &str = "cli:disable:read";
+/// Fixture client id the token pair is minted for.
+const CLIENT_ID: &str = "cli-disable-client";
+
+/// Locate the compiled server binary (cargo sets one of these spellings for
+/// integration targets; otherwise resolve next to the test executable).
+fn server_binary() -> Result<PathBuf, TestError> {
+  for var in [
+    "CARGO_BIN_EXE_d-oauth-server",
+    "CARGO_BIN_EXE_d_oauth_server",
+  ] {
+    if let Ok(path) = std::env::var(var) {
+      return Ok(PathBuf::from(path));
+    }
+  }
+  let mut path = std::env::current_exe()?;
+  if !path.pop() {
+    return Err(fail("test executable has no parent"));
+  }
+  if path.ends_with("deps") && !path.pop() {
+    return Err(fail("cannot leave deps dir"));
+  }
+  path.push("d-oauth-server");
+  Ok(path)
+}
+
+/// Temp directory holding the generated config; removed on drop so the
+/// secret-bearing file never outlives the test.
+struct TempConfig {
+  /// Directory created for this test (removed recursively on drop).
+  dir: PathBuf,
+}
+
+impl Drop for TempConfig {
+  fn drop(&mut self) {
+    let _ = std::fs::remove_dir_all(&self.dir);
+  }
+}
+
+/// Write a JSON config carrying the SAME validated values as `world`
+/// (database URL, Redis URL from the single-node topology, both key prefixes,
+/// issuer, and the active key ring), returning the guard plus the path stem.
+///
+/// The extension is omitted from the returned path because `Config::load`
+/// resolves `D_OAUTH_CONFIG` through `File::with_name`, which probes supported
+/// formats (`tests/bootstrap.rs` uses the same pattern).
+fn write_config(world: &World) -> Result<(TempConfig, String), TestError> {
+  let config = &world.state.config;
+  let redis_url = match &config.redis.topology {
+    RedisTopology::Single { url } => url.expose_secret().to_owned(),
+    _ => {
+      return Err(fail(
+        "CLI config fixture requires the single-node Redis topology",
+      ));
+    }
+  };
+  let (pepper_id, pepper) = config
+    .keys
+    .active_pepper()
+    .ok_or_else(|| fail("active token pepper is missing"))?;
+  let (totp_id, totp) = config
+    .keys
+    .active_totp_key()
+    .ok_or_else(|| fail("active TOTP key is missing"))?;
+  let (pii_id, pii) = config
+    .keys
+    .active_pii_key()
+    .ok_or_else(|| fail("active PII key is missing"))?;
+
+  let fixture = serde_json::json!({
+    "issuer": config.issuer,
+    "environment": "development",
+    "bind_addr": "127.0.0.1:0",
+    "health_addr": "127.0.0.1:0",
+    "tls": {"cert_path": "/tmp/e2e-cert.pem", "key_path": "/tmp/e2e-key.pem"},
+    "database": {"url": config.database.url.expose_secret()},
+    "redis": {
+      "url": redis_url,
+      "durability": "waitaof",
+      "token_key_prefix": config.redis.token_key_prefix,
+      "ratelimit_key_prefix": config.redis.ratelimit_key_prefix,
+    },
+    "token_pepper": pepper.expose_secret(),
+    "token_pepper_id": pepper_id.as_str(),
+    "totp_encryption_key": totp.expose_secret(),
+    "totp_key_id": totp_id.as_str(),
+    "pii_encryption_key": pii.expose_secret(),
+    "pii_key_id": pii_id.as_str(),
+    "pii_dual_write": config.pii_dual_write,
+    "rate_limit": open_rate_limit(),
+  });
+
+  let dir = std::env::temp_dir().join(format!("cli-disable-{}", uuid::Uuid::new_v4()));
+  std::fs::create_dir_all(&dir)?;
+  let file = dir.join("d-oauth.json");
+  std::fs::write(
+    &file,
+    serde_json::to_vec(&fixture).map_err(|error| fail(format!("config JSON failed: {error}")))?,
+  )?;
+  let stem = file.with_extension("");
+  let path = stem
+    .to_str()
+    .map(str::to_owned)
+    .ok_or_else(|| fail(format!("non-UTF-8 temp path: {}", stem.display())))?;
+  Ok((TempConfig { dir }, path))
+}
+
+/// Whether one Redis key still exists.
+async fn key_exists(conn: &mut RedisConn, key: &str) -> Result<bool, TestError> {
+  let count: i64 = redis::cmd("EXISTS").arg(key).query_async(conn).await?;
+  Ok(count > 0)
+}
+
+/// Read the Postgres `users.disabled` flag for one user.
+async fn user_disabled(world: &World, user_id: uuid::Uuid) -> Result<bool, TestError> {
+  let disabled: bool = sqlx::query_scalar("SELECT disabled FROM users WHERE id = $1")
+    .bind(user_id)
+    .fetch_one(&world.pool)
+    .await?;
+  Ok(disabled)
+}
+
+/// The real CLI purges the user's Redis tokens, then flips `users.disabled`.
+#[tokio::test]
+async fn cli_user_disable_purges_redis_and_flips_the_flag() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_scope(&world.pool, SCOPE).await?;
+  let (user_id, _totp_secret) = seed_entitled_user(&world, EMAIL, PASSWORD, SCOPE).await?;
+
+  let mut conn = world.state.redis.clone();
+  let minted = world
+    .service
+    .mint(
+      &mut conn,
+      &world.store,
+      &MintRequest {
+        client_id: CLIENT_ID.to_owned(),
+        client_kind: ClientKind::Confidential,
+        dpop_required: false,
+        subject: user_id.to_string(),
+        scope: SCOPE.to_owned(),
+        access_scope: SCOPE.to_owned(),
+        grant_id: uuid::Uuid::new_v4().to_string(),
+        proof: SenderProof {
+          dpop_jkt: None,
+          mtls_x5t_s256: None,
+        },
+        aud: Vec::new(),
+        refresh_aud: Vec::new(),
+        format: TokenFormat::Opaque,
+      },
+      time::OffsetDateTime::now_utc().unix_timestamp(),
+    )
+    .await
+    .map_err(|error| fail(format!("fixture mint failed: {error}")))?;
+
+  let access_key = world.store.access_key(&minted.access_hash);
+  let refresh_key = world.store.refresh_key(&minted.refresh_hash);
+  ensure(
+    key_exists(&mut conn, &access_key).await?,
+    "seeded access token must be live before disable",
+  )?;
+  ensure(
+    key_exists(&mut conn, &refresh_key).await?,
+    "seeded refresh token must be live before disable",
+  )?;
+  ensure(
+    !user_disabled(&world, user_id).await?,
+    "seeded user must start enabled",
+  )?;
+
+  let (_config_guard, config_path) = write_config(&world)?;
+  let output = tokio::process::Command::new(server_binary()?)
+    .args(["user", "disable", "--email", EMAIL])
+    .env("D_OAUTH_CONFIG", &config_path)
+    .output()
+    .await?;
+  ensure(
+    output.status.success(),
+    format!(
+      "user disable must exit 0: {} {}",
+      output.status,
+      String::from_utf8_lossy(&output.stderr)
+    ),
+  )?;
+
+  ensure(
+    !key_exists(&mut conn, &access_key).await?,
+    "the purge must delete the access-token key",
+  )?;
+  ensure(
+    !key_exists(&mut conn, &refresh_key).await?,
+    "the purge must delete the refresh-token key",
+  )?;
+  ensure(
+    user_disabled(&world, user_id).await?,
+    "users.disabled must be true after the purge (exit-0 path)",
+  )?;
+  Ok(())
+}

+ 566 - 0
tests/e2e/browser.rs

@@ -0,0 +1,566 @@
+//! Shared browser-flow driver for the end-to-end suites.
+//!
+//! One [`Browser`] walks the real authorization-code UI over the live router:
+//! `GET /authorize` (RFC 6749 §4.1.1 + OAuth 2.1 draft §4.1.3) → `GET /login` →
+//! `POST /login` (password + TOTP) → `GET /consent` → `POST /consent` → code on
+//! the registered redirect (RFC 9700 §4.5.3, `code` + `state` + RFC 9207
+//! `iss`), and the caller redeems it with [`Browser::verifier`].
+//!
+//! Suites include this file with `#[path = "e2e/browser.rs"] mod browser;` and
+//! must also include `e2e/harness.rs` and `e2e/dpop.rs` as siblings: the driver
+//! borrows their [`World`] and [`DpopKey`]. The world is supplied by the caller
+//! so a suite can pin its own config (CIMD fetcher, rate budgets, feature
+//! gates) before the driver touches it.
+//!
+//! A public client is used so the flow needs no PAR/JAR (a confidential client
+//! with only plain query params is rejected with `invalid_request`). Public
+//! clients are DPoP-bound end to end: the SAME [`DpopKey`] proves
+//! `GET /authorize` (its `jkt` is stashed in the pending-auth record) and
+//! `POST /token` (the redemption checks the proof `jkt` against the code's
+//! `cnf` binding), so one key spans the whole flow.
+
+// allow: SIZE_OK — the plan fixes one shared driver surface (four constructors
+// plus the exported HTTP helpers) at this single path; splitting it would
+// scatter the contract the suite modules import.
+
+use std::time::Duration;
+
+use d_oauth::db::{
+  TokenEndpointAuthMethod,
+  user_repo::{self, NewUser},
+  user_scope_repo,
+};
+
+pub use super::dpop::DpopKey;
+use super::harness::{
+  TestError, World, attach_post_logout, attach_redirect, ensure, fail, seed_client, seed_scope,
+};
+
+/// Fixture public client used by [`begin_browser`] (no secret: DPoP is its
+/// sender constraint).
+pub const CLIENT_ID: &str = "e2e-flow-public";
+/// Registered loopback redirect for [`begin_browser`] (RFC 8252 loopback
+/// exception applies).
+pub const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
+/// Catalog scope exercised by [`begin_browser`].
+pub const SCOPE: &str = "read";
+/// `state` echoed from authorize to the code redirect (unreserved chars only).
+pub const STATE: &str = "e2e-flow-state";
+/// Session cookie name (production `src/ui/login.rs`: `SESSION_COOKIE`).
+pub const SESSION_COOKIE: &str = "__Host-session_id";
+/// Uniform credential-rejection page text (`INVALID_CREDENTIALS`).
+pub const INVALID_CREDENTIALS: &str = "Invalid email, password, or code.";
+
+/// One browser driving the flow: live world, PKCE pair, DPoP key, login
+/// session material, and the provisioned user's credentials.
+pub struct Browser {
+  /// Live router world the flow runs against.
+  pub world: World,
+  /// Sender-constraint key proving both `/authorize` and `/token`.
+  pub dpop: DpopKey,
+  /// PKCE `code_verifier` matching the challenge sent to `/authorize`.
+  pub verifier: String,
+  /// Pending-auth id the authorize redirect handed to `/login`.
+  pub request_id: String,
+  /// Pre-login session id (`GET /login`).
+  pub presession: String,
+  /// Authenticated session id, when a constructor logged in already.
+  pub session: Option<String>,
+  /// Login-form CSRF token (`GET /login`).
+  pub login_csrf: String,
+  /// Provisioned user's email.
+  pub email: String,
+  /// Provisioned user's password.
+  pub password: String,
+  /// Provisioned user's TOTP secret.
+  pub totp_secret: String,
+}
+
+/// Client metadata a browser run drives authorize for.
+struct Flow<'a> {
+  /// Client id the authorize request presents.
+  client_id: &'a str,
+  /// Callback the authorize request presents.
+  redirect_uri: &'a str,
+  /// Requested scope.
+  scope: &'a str,
+  /// Provisioned user's email.
+  email: &'a str,
+  /// Provisioned user's password.
+  password: &'a str,
+}
+
+/// Seed the fixed e2e-flow client (public, code + refresh) and one
+/// TOTP-enrolled user entitled to [`SCOPE`]; drive `GET /authorize` →
+/// `GET /login` against the caller's world.
+///
+/// # Errors
+///
+/// Returns any seeding, routing, or assertion failure of the first two legs.
+pub async fn begin_browser(
+  world: World,
+  email: &str,
+  password: &str,
+) -> Result<Browser, TestError> {
+  begin_registered(
+    world,
+    Flow {
+      client_id: CLIENT_ID,
+      redirect_uri: REDIRECT_URI,
+      scope: SCOPE,
+      email,
+      password,
+    },
+  )
+  .await
+}
+
+/// Drive the first two legs for a client the world resolves another way
+/// (a CIMD document), so no client row or redirect is seeded: only the scope
+/// catalog entry and the entitled user. `GET /authorize` performs the fetch.
+///
+/// # Errors
+///
+/// Returns any seeding, routing, or assertion failure of the first two legs.
+pub async fn begin_browser_for(
+  world: World,
+  client_id: &str,
+  redirect_uri: &str,
+  scope: &str,
+  email: &str,
+  password: &str,
+) -> Result<Browser, TestError> {
+  let flow = Flow {
+    client_id,
+    redirect_uri,
+    scope,
+    email,
+    password,
+  };
+  seed_scope(&world.pool, flow.scope).await?;
+  let totp_secret = seed_user(&world, flow.email, flow.password, flow.scope).await?;
+  authorize_login(world, &flow, totp_secret).await
+}
+
+/// Seed a registered public client with both an authorize and a post-logout
+/// redirect, then drive the first two legs; `/authorize` freezes the logout
+/// binding in the session meta.
+///
+/// # Errors
+///
+/// Returns any seeding, routing, or assertion failure of the first two legs.
+pub async fn begin_browser_with_post_logout(
+  world: World,
+  client_id: &str,
+  redirect_uri: &str,
+  scope: &str,
+  email: &str,
+  password: &str,
+  post_logout_uri: &str,
+) -> Result<Browser, TestError> {
+  let flow = Flow {
+    client_id,
+    redirect_uri,
+    scope,
+    email,
+    password,
+  };
+  let totp_secret = seed_registered(&world, &flow, Some(post_logout_uri)).await?;
+  authorize_login(world, &flow, totp_secret).await
+}
+
+/// Seed one entitled user and sign in through `GET /login` → `POST /login`
+/// with no pending authorization request (the device-approval entry point).
+/// The returned [`Browser::session`] is the authenticated session id.
+///
+/// # Errors
+///
+/// Returns any seeding, routing, or assertion failure of the login legs.
+pub async fn login_session(
+  world: World,
+  email: &str,
+  password: &str,
+) -> Result<Browser, TestError> {
+  seed_scope(&world.pool, SCOPE).await?;
+  let totp_secret = seed_user(&world, email, password, SCOPE).await?;
+  let (status, headers, body) = world.get("/login", &[]).await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("standalone login page must render: {status} {body}"),
+  )?;
+  let presession = cookie_value(&headers, SESSION_COOKIE)?;
+  let login_csrf = hidden_input(&body, "csrf")?;
+  let code_2fa = totp_now(&totp_secret).await?;
+  let cookie = format!("{SESSION_COOKIE}={presession}");
+  let (status, headers, body) = world
+    .post_form(
+      "/login",
+      &[
+        ("email", email),
+        ("password", password),
+        ("code", code_2fa.as_str()),
+        ("csrf", login_csrf.as_str()),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("standalone login must 303: {status} {body}"),
+  )?;
+  let session = cookie_value(&headers, SESSION_COOKIE)?;
+  Ok(Browser {
+    world,
+    dpop: DpopKey::generate()?,
+    verifier: String::new(),
+    request_id: String::new(),
+    presession,
+    session: Some(session),
+    login_csrf,
+    email: email.to_owned(),
+    password: password.to_owned(),
+    totp_secret,
+  })
+}
+
+/// Seed a fixed-client flow and drive the first two legs.
+async fn begin_registered(world: World, flow: Flow<'_>) -> Result<Browser, TestError> {
+  let totp_secret = seed_registered(&world, &flow, None).await?;
+  authorize_login(world, &flow, totp_secret).await
+}
+
+/// Seed the client row, its catalog scope, its authorize redirect (plus an
+/// optional post-logout redirect), and one entitled user.
+async fn seed_registered(
+  world: &World,
+  flow: &Flow<'_>,
+  post_logout_uri: Option<&str>,
+) -> Result<String, TestError> {
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    flow.client_id,
+    None,
+    &["authorization_code", "refresh_token"],
+    flow.scope,
+    TokenEndpointAuthMethod::None,
+  )
+  .await?;
+  seed_scope(&world.pool, flow.scope).await?;
+  attach_redirect(
+    &world.pool,
+    &world.state.keys,
+    flow.client_id,
+    flow.redirect_uri,
+  )
+  .await?;
+  if let Some(uri) = post_logout_uri {
+    attach_post_logout(&world.pool, &world.state.keys, flow.client_id, uri).await?;
+  }
+  seed_user(world, flow.email, flow.password, flow.scope).await
+}
+
+/// Drive `GET /authorize` → pending-record binding check → `GET /login`.
+async fn authorize_login(
+  world: World,
+  flow: &Flow<'_>,
+  totp_secret: String,
+) -> Result<Browser, TestError> {
+  let (challenge, verifier) = oauth2_pkce();
+  let dpop = DpopKey::generate()?;
+  let proof = dpop.proof("GET", &format!("{}/authorize", world.state.config.issuer))?;
+  let (status, headers, body) = world
+    .get(
+      &format!(
+        "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}\
+         &code_challenge={challenge}&code_challenge_method=S256",
+        encoded(flow.client_id),
+        encoded(flow.redirect_uri),
+        encoded(flow.scope),
+        encoded(STATE)
+      ),
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("authorize must 303 to login: {status} {body}"),
+  )?;
+  let request_id = query_value(&location_of(&headers)?, "request_id")?;
+  // The authorize layer stashes the verified DPoP thumbprint in the
+  // pending-auth record the later code issuance binds (RFC 9700 §4.5.3).
+  let mut conn = world.state.redis.clone();
+  let record = d_oauth::store::pending::get(&mut conn, &world.store, &request_id)
+    .await
+    .map_err(|error| fail(format!("pending read failed: {error}")))?
+    .ok_or_else(|| fail("pending record must exist"))?;
+  let expected = d_oauth::jose::jwk::thumbprint(&dpop.jwk())
+    .map_err(|error| fail(format!("jwk thumbprint: {error}")))?;
+  ensure(
+    record.dpop_jkt.as_deref() == Some(expected.as_str()),
+    "pending record must carry the verified DPoP thumbprint",
+  )?;
+
+  let (status, headers, body) = world
+    .get(&format!("/login?request_id={request_id}"), &[])
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("login page must render: {status} {body}"),
+  )?;
+  let presession = cookie_value(&headers, SESSION_COOKIE)?;
+  let login_csrf = hidden_input(&body, "csrf")?;
+  Ok(Browser {
+    world,
+    dpop,
+    verifier,
+    request_id,
+    presession,
+    session: None,
+    login_csrf,
+    email: flow.email.to_owned(),
+    password: flow.password.to_owned(),
+    totp_secret,
+  })
+}
+
+/// Provision one enabled user (known password + TOTP secret, entitled to
+/// `scope`) through the REAL repository path, using the server's own Argon2
+/// parameters so verification matches production exactly.
+///
+/// # Errors
+///
+/// Returns a hashing, repository, or TOTP-sealing failure.
+pub async fn seed_user(
+  world: &World,
+  email: &str,
+  password: &str,
+  scope: &str,
+) -> Result<String, TestError> {
+  let raw = &world.state.config.argon2;
+  let params =
+    d_oauth::crypto::secret::Argon2Params::new(raw.memory_kib, raw.iterations, raw.parallelism, 32)
+      .map_err(|error| fail(format!("argon2 params: {error}")))?;
+  let hash = d_oauth::crypto::secret::hash_secret(password, &params)
+    .map_err(|error| fail(format!("hash password: {error}")))?;
+  let created = user_repo::create_user(
+    &world.pool,
+    &world.state.keys,
+    world.state.config.pii_dual_write,
+    &NewUser {
+      email: email.to_owned(),
+      password_hash: hash,
+      is_admin: false,
+      must_change_password: false,
+    },
+  )
+  .await?;
+  let secret = d_oauth::crypto::totp::generate_secret()
+    .map_err(|error| fail(format!("totp secret: {error}")))?;
+  let blob = d_oauth::crypto::totp::encrypt_secret(
+    &world.state.keys,
+    &secret,
+    !world.state.config.pii_dual_write,
+  )
+  .map_err(|error| fail(format!("totp seal: {error}")))?;
+  ensure(
+    user_repo::set_totp_secret(&world.pool, created.id, &blob).await?,
+    "totp enroll must update the user row",
+  )?;
+  user_scope_repo::entitle(&world.pool, created.id, scope, None).await?;
+  Ok(secret)
+}
+
+/// Current TOTP code from the wall clock, computed the way the server
+/// verifies it (RFC 6238, ±1-step window). Steps back from the step edge so
+/// the code cannot roll over between generation and the `POST /login`.
+///
+/// # Errors
+///
+/// Returns a TOTP-constructor failure.
+pub async fn totp_now(secret: &str) -> Result<String, TestError> {
+  let step = d_oauth::crypto::totp::TOTP_STEP_SECS;
+  let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64;
+  if now % step >= step - 2 {
+    tokio::time::sleep(Duration::from_secs(3)).await;
+  }
+  let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64;
+  let verifier = d_oauth::crypto::totp::build_totp(secret)
+    .map_err(|error| fail(format!("totp verifier: {error}")))?;
+  let counter = now / step;
+  Ok(format!("{:06}", verifier.generate(counter * step)))
+}
+
+/// `POST /login` as the browser: presession cookie + form credentials.
+///
+/// # Errors
+///
+/// Returns the HTTP transport failure.
+pub async fn post_login(
+  browser: &Browser,
+  password: &str,
+  code: &str,
+) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
+  let cookie = format!("{SESSION_COOKIE}={}", browser.presession);
+  browser
+    .world
+    .post_form(
+      "/login",
+      &[
+        ("email", browser.email.as_str()),
+        ("password", password),
+        ("code", code),
+        ("csrf", browser.login_csrf.as_str()),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await
+}
+
+/// `GET /consent` as the browser holding `session`.
+///
+/// # Errors
+///
+/// Returns the HTTP transport failure.
+pub async fn get_consent(
+  browser: &Browser,
+  session: &str,
+) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
+  let cookie = format!("{SESSION_COOKIE}={session}");
+  browser
+    .world
+    .get("/consent", &[("cookie", cookie.as_str())])
+    .await
+}
+
+/// `POST /consent` with the bound `request_id` and the session form token.
+///
+/// # Errors
+///
+/// Returns the HTTP transport failure.
+pub async fn post_consent(
+  browser: &Browser,
+  session: &str,
+  request_id: &str,
+  csrf: &str,
+  decision: &str,
+) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
+  let cookie = format!("{SESSION_COOKIE}={session}");
+  browser
+    .world
+    .post_form(
+      "/consent",
+      &[
+        ("request_id", request_id),
+        ("csrf", csrf),
+        ("decision", decision),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await
+}
+
+/// PKCE pair from the `oauth2` crate.
+#[must_use]
+pub fn oauth2_pkce() -> (String, String) {
+  let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
+  (challenge.as_str().to_owned(), verifier.secret().to_owned())
+}
+
+/// Percent-encode a value for query embedding.
+#[must_use]
+pub fn encoded(value: &str) -> String {
+  let mut out = String::with_capacity(value.len());
+  for byte in value.bytes() {
+    if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
+      out.push(byte as char);
+    } else {
+      out.push_str(&format!("%{byte:02X}"));
+    }
+  }
+  out
+}
+
+/// Read the `location` header or fail the test.
+///
+/// # Errors
+///
+/// Returns [`TestError::Failed`] when the header is absent or not ASCII.
+pub fn location_of(headers: &reqwest::header::HeaderMap) -> Result<String, TestError> {
+  headers
+    .get("location")
+    .and_then(|value| value.to_str().ok())
+    .map(str::to_owned)
+    .ok_or_else(|| fail("response lacks location header"))
+}
+
+/// Read a cookie value from the `set-cookie` headers or fail the test.
+///
+/// # Errors
+///
+/// Returns [`TestError::Failed`] when the cookie is absent or not ASCII.
+pub fn cookie_value(headers: &reqwest::header::HeaderMap, name: &str) -> Result<String, TestError> {
+  let prefix = format!("{name}=");
+  let mut found = None;
+  for value in headers.get_all("set-cookie").iter() {
+    let text = value
+      .to_str()
+      .map_err(|_| fail("set-cookie is not visible ascii"))?;
+    if let Some(rest) = text.strip_prefix(prefix.as_str()) {
+      let end = rest.find(';').unwrap_or(rest.len());
+      found = Some(rest[..end].to_owned());
+    }
+  }
+  found.ok_or_else(|| fail(format!("response lacks {name} cookie")))
+}
+
+/// Read a hidden form field (`name=".." value=".."`) from a page or fail.
+///
+/// # Errors
+///
+/// Returns [`TestError::Failed`] when the field is absent or unterminated.
+pub fn hidden_input(body: &str, name: &str) -> Result<String, TestError> {
+  let needle = format!("name=\"{name}\" value=\"");
+  let start = body
+    .find(needle.as_str())
+    .ok_or_else(|| fail(format!("page lacks hidden {name}")))?;
+  let rest = &body[start + needle.len()..];
+  let end = rest
+    .find('"')
+    .ok_or_else(|| fail(format!("hidden {name} is unterminated")))?;
+  Ok(rest[..end].to_owned())
+}
+
+/// Read one query value from a redirect location or fail the test.
+///
+/// # Errors
+///
+/// Returns [`TestError::Failed`] when the query or key is absent.
+pub fn query_value(location: &str, key: &str) -> Result<String, TestError> {
+  let query = location
+    .split_once('?')
+    .map(|pair| pair.1)
+    .ok_or_else(|| fail(format!("location carries no query: {location}")))?;
+  let prefix = format!("{key}=");
+  for pair in query.split('&') {
+    if let Some(value) = pair.strip_prefix(prefix.as_str()) {
+      return Ok(value.to_owned());
+    }
+  }
+  Err(fail(format!("location lacks {key}: {location}")))
+}
+
+/// Whether a redirect location carries a query key.
+#[must_use]
+pub fn has_query_key(location: &str, key: &str) -> bool {
+  let Some(query) = location.split_once('?').map(|pair| pair.1) else {
+    return false;
+  };
+  let prefix = format!("{key}=");
+  query
+    .split('&')
+    .any(|pair| pair.strip_prefix(prefix.as_str()).is_some())
+}

+ 186 - 0
tests/e2e/device.rs

@@ -0,0 +1,186 @@
+//! Device-grant wire mechanics shared by `tests/e2e_device.rs`.
+//!
+//! The suite owns the scenario assertions; this module owns the RFC 8628
+//! request shapes (authorization issue, review page + CSRF, decision, poll)
+//! and the fixture client definitions.
+
+use d_oauth::db::TokenEndpointAuthMethod;
+use reqwest::StatusCode;
+
+use super::{
+  browser,
+  dpop::DpopKey,
+  harness::{SeedClientOpts, TestError, World, ensure, fail, json_body, seed_client_bound},
+};
+
+/// Grant-type URN for the device-code grant (RFC 8628 §3.4).
+pub const DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
+/// Catalog scope the device requests and the user is entitled to.
+pub const SCOPE: &str = "read";
+/// DPoP-bound confidential client used for the approve/poll/introspect case.
+pub const DEVICE_CLIENT: &str = "e2e-device-owner";
+/// Owner secret (also the leak probe).
+pub const DEVICE_SECRET: &str = "e2e-device-secret-1a2b3c4d5e6f708192a0";
+/// Public DPoP-bound client used for the deny and normalization cases.
+pub const PUBLIC_CLIENT: &str = "e2e-device-public";
+
+/// A client that may request a device authorization.
+#[derive(Clone, Copy)]
+pub struct Client<'a> {
+  /// Registered client id.
+  pub id: &'a str,
+  /// Owner secret; `None` for a public client.
+  pub secret: Option<&'a str>,
+  /// Requested/registered scope.
+  pub scope: &'a str,
+}
+
+impl Client<'_> {
+  /// Basic-auth pair for a confidential client, `None` for a public one.
+  fn basic(&self) -> Option<(&str, &str)> {
+    self.secret.map(|secret| (self.id, secret))
+  }
+}
+
+/// One `/token` device-code poll.
+#[derive(Clone, Copy)]
+pub struct Poll<'a> {
+  /// Polling client.
+  pub client: Client<'a>,
+  /// Opaque device code returned by `/device_authorization`.
+  pub device_code: &'a str,
+}
+
+/// One approve/deny submission from the review page.
+#[derive(Clone, Copy)]
+pub struct Decision<'a> {
+  /// Authenticated session id.
+  pub session: &'a str,
+  /// User code echoed by the review form.
+  pub user_code: &'a str,
+  /// Stateless CSRF token minted for this decision.
+  pub csrf: &'a str,
+  /// `approve` or `deny`.
+  pub action: &'a str,
+}
+
+/// Seed the public DPoP-bound client used by the deny and normalization rows.
+pub async fn seed_public_client(world: &World) -> Result<(), TestError> {
+  seed_client_bound(
+    &world.pool,
+    &world.state.keys,
+    PUBLIC_CLIENT,
+    None,
+    &SeedClientOpts {
+      grants: &[DEVICE_GRANT],
+      scope: SCOPE,
+      method: TokenEndpointAuthMethod::None,
+      dpop_bound: true,
+    },
+  )
+  .await
+}
+
+/// `POST /device_authorization` with a fresh proof; returns
+/// `(device_code, user_code)`.
+pub async fn issue(
+  world: &World,
+  client: Client<'_>,
+  key: &DpopKey,
+) -> Result<(String, String), TestError> {
+  let htu = format!("{}/device_authorization", world.state.config.issuer);
+  let proof = key.proof("POST", &htu)?;
+  let (status, _, body) = world
+    .post_form(
+      "/device_authorization",
+      &[("client_id", client.id), ("scope", client.scope)],
+      client.basic(),
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("device authorization must 200: {status} {body}"),
+  )?;
+  let payload = json_body(&body)?;
+  let device_code = payload["device_code"]
+    .as_str()
+    .ok_or_else(|| fail("device authorization must return device_code"))?
+    .to_owned();
+  let user_code = payload["user_code"]
+    .as_str()
+    .ok_or_else(|| fail("device authorization must return user_code"))?
+    .to_owned();
+  Ok((device_code, user_code))
+}
+
+/// `POST /token` for the device grant with a fresh proof per attempt.
+pub async fn poll(
+  world: &World,
+  request: Poll<'_>,
+  key: &DpopKey,
+) -> Result<(StatusCode, String), TestError> {
+  let htu = format!("{}/token", world.state.config.issuer);
+  let proof = key.proof("POST", &htu)?;
+  let (status, _, body) = world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", DEVICE_GRANT),
+        ("device_code", request.device_code),
+        ("client_id", request.client.id),
+      ],
+      request.client.basic(),
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  Ok((status, body))
+}
+
+/// Session cookie header for the device UI.
+pub fn session_cookie(session: &str) -> String {
+  format!("{}={session}", browser::SESSION_COOKIE)
+}
+
+/// `POST /device` with a code and no `action`: the review page (with CSRF).
+pub async fn review(world: &World, session: &str, user_code: &str) -> Result<String, TestError> {
+  let cookie = session_cookie(session);
+  let (status, _, body) = world
+    .post_form(
+      "/device",
+      &[("user_code", user_code)],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("device review page must render: {status} {body}"),
+  )?;
+  Ok(body)
+}
+
+/// Approve or deny the pending device request through the real form.
+pub async fn decide(world: &World, decision: Decision<'_>) -> Result<(), TestError> {
+  let cookie = session_cookie(decision.session);
+  let (status, _, body) = world
+    .post_form(
+      "/device",
+      &[
+        ("user_code", decision.user_code),
+        ("action", decision.action),
+        ("csrf", decision.csrf),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!(
+      "device {} must render the outcome page: {status} {body}",
+      decision.action
+    ),
+  )?;
+  Ok(())
+}

+ 242 - 0
tests/e2e/edge.rs

@@ -0,0 +1,242 @@
+//! Request mechanics for the `tests/e2e_edge_matrix.rs` rejection matrix.
+//!
+//! Each function owns one row's wire setup against the live router; the
+//! suite owns the table and the row-level assertions. Device rows reuse the
+//! shared `e2e/device.rs` driver so `/device` and `/token` are exercised
+//! exactly as the RFC 8628 flow does.
+
+use std::time::Duration;
+
+use d_oauth::{
+  crypto::tokens::{KeyId as TokenKeyId, mint_token, token_hash},
+  db::TokenEndpointAuthMethod,
+  store::dcr::{self, InitialAccessRecord},
+};
+use reqwest::StatusCode;
+use secrecy::ExposeSecret as _;
+use serde_json::Value;
+
+use super::{
+  browser, device,
+  device::{
+    Client, DEVICE_GRANT, Decision, PUBLIC_CLIENT, Poll, SCOPE, decide, issue, poll,
+    seed_public_client,
+  },
+  dpop::DpopKey,
+  harness::{TestError, World, ensure, fail, json_body, open_rate_limit, seed_client},
+};
+
+/// User provisioned for the device approval flow.
+const DEVICE_EMAIL: &str = "edge-device@example.test";
+/// Password for [`DEVICE_EMAIL`].
+const DEVICE_PASSWORD: &str = "e2e-edge-device-password-1a2b3c";
+
+/// Seed an IAT exactly as `protocol::dcr::tests::issue_iat` does: the storage
+/// key is the active pepper's keyed hash, not a plain `sha256_hex`.
+async fn seed_iat(world: &World) -> Result<String, TestError> {
+  let raw = mint_token().map_err(|error| fail(format!("mint IAT failed: {error}")))?;
+  let (key_id, pepper) = world
+    .state
+    .config
+    .keys
+    .active_pepper()
+    .ok_or_else(|| fail("config has no active token pepper"))?;
+  let hash = token_hash(
+    &TokenKeyId::new(key_id.as_str()),
+    pepper.expose_secret().as_bytes(),
+    &raw,
+  )
+  .to_storage_string();
+  let mut conn = world.state.redis.clone();
+  dcr::issue(
+    &mut conn,
+    &world.store,
+    &hash,
+    &InitialAccessRecord {
+      uses_remaining: 1,
+      label: "edge".to_owned(),
+    },
+    Duration::from_secs(600),
+  )
+  .await
+  .map_err(|error| fail(format!("seed IAT failed: {error}")))?;
+  Ok(raw)
+}
+
+/// One DCR rejection: exact code, no client in the body, no client row.
+pub async fn dcr_rejects(body: Value, expected: &str) -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let iat = seed_iat(&world).await?;
+  let response = world
+    .client
+    .post(format!("{}/register", world.base))
+    .bearer_auth(&iat)
+    .json(&body)
+    .send()
+    .await?;
+  let status = response.status();
+  let text = response.text().await?;
+  ensure_error(status, &text, StatusCode::BAD_REQUEST, expected)?;
+  ensure(
+    json_body(&text)?.get("client_id").is_none(),
+    format!("a rejected registration must mint no client: {text}"),
+  )?;
+  let clients: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_clients")
+    .fetch_one(&world.pool)
+    .await?;
+  ensure(
+    clients == 0,
+    format!("a rejected registration must persist no client row, found {clients}"),
+  )
+}
+
+/// Assert an exact OAuth error response that minted nothing.
+pub fn ensure_error(
+  status: StatusCode,
+  body: &str,
+  expected_status: StatusCode,
+  expected: &str,
+) -> Result<(), TestError> {
+  ensure(
+    status == expected_status,
+    format!("expected {expected_status} {expected}, got {status}: {body}"),
+  )?;
+  let parsed = json_body(body)?;
+  ensure(
+    parsed["error"].as_str() == Some(expected),
+    format!("expected {expected}, got {body}"),
+  )?;
+  ensure(
+    parsed.get("access_token").is_none() && parsed.get("refresh_token").is_none(),
+    format!("a rejection must mint no token: {body}"),
+  )
+}
+
+/// A device authorization wider than the client registration is `invalid_scope`.
+pub async fn device_over_registered_scope() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    PUBLIC_CLIENT,
+    None,
+    &[DEVICE_GRANT],
+    SCOPE,
+    TokenEndpointAuthMethod::None,
+  )
+  .await?;
+  let (status, _, body) = world
+    .post_form(
+      "/device_authorization",
+      &[("client_id", PUBLIC_CLIENT), ("scope", "read write")],
+      None,
+      &[],
+    )
+    .await?;
+  ensure_error(status, &body, StatusCode::BAD_REQUEST, "invalid_scope")?;
+  ensure(
+    json_body(&body)?.get("device_code").is_none(),
+    format!("an over-scope request must mint no device code: {body}"),
+  )
+}
+
+/// An approved device interaction both consumed-code rows observe.
+struct ApprovedDevice {
+  /// Live world owned by the flow.
+  world: World,
+  /// Authenticated session id.
+  session: String,
+  /// Opaque device code.
+  device_code: String,
+  /// Display user code.
+  user_code: String,
+  /// Polling proof key.
+  key: DpopKey,
+  /// Polling client.
+  client: Client<'static>,
+}
+
+/// Issue a device code, approve it through the real UI, and return the state
+/// both consumed-code rows observe.
+async fn approve_device() -> Result<ApprovedDevice, TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_public_client(&world).await?;
+  let browser = browser::login_session(world, DEVICE_EMAIL, DEVICE_PASSWORD).await?;
+  let session = browser
+    .session
+    .clone()
+    .ok_or_else(|| fail("login_session must authenticate"))?;
+  let key = DpopKey::generate()?;
+  let client = Client {
+    id: PUBLIC_CLIENT,
+    secret: None,
+    scope: SCOPE,
+  };
+  let (device_code, user_code) = issue(&browser.world, client, &key).await?;
+  let page = device::review(&browser.world, &session, &user_code).await?;
+  let csrf = browser::hidden_input(&page, "csrf")?;
+  decide(
+    &browser.world,
+    Decision {
+      session: &session,
+      user_code: &user_code,
+      csrf: &csrf,
+      action: "approve",
+    },
+  )
+  .await?;
+  Ok(ApprovedDevice {
+    world: browser.world,
+    session,
+    device_code,
+    user_code,
+    key,
+    client,
+  })
+}
+
+/// A consumed user code at `/device` is the generic 200 miss, never
+/// `expired_token`.
+pub async fn device_user_code_consumed() -> Result<(), TestError> {
+  let approved = approve_device().await?;
+  let cookie = device::session_cookie(&approved.session);
+  let (status, _, body) = approved
+    .world
+    .post_form(
+      "/device",
+      &[("user_code", approved.user_code.as_str())],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("a consumed user_code must be a 200 miss, got {status}: {body}"),
+  )?;
+  ensure(
+    !body.contains("expired_token")
+      && !body.contains(PUBLIC_CLIENT)
+      && !body.contains("access_token"),
+    format!("the /device miss must be generic: {body}"),
+  )
+}
+
+/// A consumed device code at `/token` is `400 expired_token` and mints nothing.
+pub async fn device_code_consumed_at_token() -> Result<(), TestError> {
+  let approved = approve_device().await?;
+  let request = Poll {
+    client: approved.client,
+    device_code: &approved.device_code,
+  };
+  let (status, body) = poll(&approved.world, request, &approved.key).await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("the approved poll must mint: {status} {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["access_token"].as_str().is_some(),
+    "the first poll must mint an access token",
+  )?;
+  let (status, body) = poll(&approved.world, request, &approved.key).await?;
+  ensure_error(status, &body, StatusCode::BAD_REQUEST, "expired_token")
+}

+ 81 - 0
tests/e2e/exchange.rs

@@ -0,0 +1,81 @@
+//! Token-exchange rejection rows for `tests/e2e_edge_matrix.rs`.
+//!
+//! Both rows reach the real `/token` dispatcher: a missing `subject_token` is
+//! a grant-level `invalid_grant`, while a repeated known parameter is rejected
+//! by the pre-dispatch duplicate gate as `invalid_request`.
+
+use d_oauth::db::TokenEndpointAuthMethod;
+use reqwest::StatusCode;
+use serde_json::json;
+
+use super::{
+  dpop::DpopKey,
+  edge::ensure_error,
+  harness::{SeedClientOpts, TestError, World, open_rate_limit, seed_client_bound},
+};
+
+/// Token-exchange grant URN (RFC 8693).
+const EXCHANGE_GRANT: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
+/// Access-token type URN used for both token-type members.
+const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
+/// Exchange caller holding the exchange grant (DPoP-bound).
+const CALLER_ID: &str = "e2e-edge-exchange-caller";
+/// Exchange caller secret.
+const CALLER_SECRET: &str = "e2e-edge-exchange-secret-1a2b3c4d5e6f708192a0";
+
+/// A missing `subject_token` is `invalid_grant` at the grant, not
+/// `invalid_request`.
+pub async fn missing_subject_token() -> Result<(), TestError> {
+  let world = World::start_with(open_rate_limit(), json!({"allow_token_exchange": true})).await?;
+  seed_client_bound(
+    &world.pool,
+    &world.state.keys,
+    CALLER_ID,
+    Some(CALLER_SECRET),
+    &SeedClientOpts {
+      grants: &[EXCHANGE_GRANT],
+      scope: "read",
+      method: TokenEndpointAuthMethod::ClientSecretBasic,
+      dpop_bound: true,
+    },
+  )
+  .await?;
+  let proof =
+    DpopKey::generate()?.proof("POST", &format!("{}/token", world.state.config.issuer))?;
+  let (status, _, body) = world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", EXCHANGE_GRANT),
+        ("subject_token_type", ACCESS_TOKEN_TYPE),
+        ("requested_token_type", ACCESS_TOKEN_TYPE),
+      ],
+      Some((CALLER_ID, CALLER_SECRET)),
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure_error(status, &body, StatusCode::BAD_REQUEST, "invalid_grant")
+}
+
+/// A repeated `scope` or `grant_type` is rejected before dispatch as
+/// `invalid_request`.
+pub async fn duplicate_parameter() -> Result<(), TestError> {
+  let world = World::start_with(open_rate_limit(), json!({"allow_token_exchange": true})).await?;
+  let duplicate_scope = [
+    ("grant_type", EXCHANGE_GRANT),
+    ("subject_token", "subject"),
+    ("subject_token_type", ACCESS_TOKEN_TYPE),
+    ("scope", "read"),
+    ("scope", "read"),
+  ];
+  let duplicate_grant_type = [
+    ("grant_type", EXCHANGE_GRANT),
+    ("grant_type", "refresh_token"),
+    ("scope", "read"),
+  ];
+  for params in [&duplicate_scope[..], &duplicate_grant_type[..]] {
+    let (status, _, body) = world.post_form("/token", params, None, &[]).await?;
+    ensure_error(status, &body, StatusCode::BAD_REQUEST, "invalid_request")?;
+  }
+  Ok(())
+}

+ 217 - 5
tests/e2e/harness.rs

@@ -28,7 +28,11 @@ use std::net::SocketAddr;
 use d_oauth::{
   config::{Config, ConfigError},
   crypto::secret::{Argon2Params, hash_secret},
-  db::{NewClient, TokenEndpointAuthMethod, client_repo, redirect_repo, scope_repo},
+  db::{
+    NewClient, TokenEndpointAuthMethod, client_repo, redirect_repo, scope_repo,
+    user_repo::{self, NewUser},
+    user_scope_repo,
+  },
   protocol::token::{
     ClientKind, MintRequest, MintedTokens, SenderProof, TokenFormat, TokenService,
   },
@@ -151,6 +155,24 @@ impl World {
   pub async fn start_with(
     rate_limit: serde_json::Value,
     overrides: serde_json::Value,
+  ) -> Result<Self, TestError> {
+    Self::start_with_cimd_fetcher(
+      rate_limit,
+      overrides,
+      d_oauth::protocol::cimd::ssrf_fetcher(),
+    )
+    .await
+  }
+
+  /// Start one world with an injected CIMD document fetcher.
+  ///
+  /// The production router always carries `state.cimd_fetcher`; this entry
+  /// point swaps it for a test stub (for example a counting fetcher) while
+  /// leaving every other fixture member identical to [`World::start_with`].
+  pub async fn start_with_cimd_fetcher(
+    rate_limit: serde_json::Value,
+    overrides: serde_json::Value,
+    fetcher: d_oauth::protocol::cimd::CimdFetcher,
   ) -> Result<Self, TestError> {
     let pg_url = fresh_db_url().await?;
     let redis_url = redis_url();
@@ -201,13 +223,14 @@ impl World {
     }
     let config =
       Config::from_source(::config::Config::try_from(&fixture).map_err(ConfigError::from)?)?;
-    let state = AppState::connect(std::sync::Arc::new(config))
+    let mut state = AppState::connect(std::sync::Arc::new(config))
       .await
       .map_err(|error| {
         fail(format!(
           "cannot connect shared test services ({COMPOSE_HINT}): {error}"
         ))
       })?;
+    state.cimd_fetcher = fetcher;
     let store = Store::from_config(&state.config.redis);
     let service = TokenService::new(&state.config.ttl, &state.keys)
       .map_err(|error| fail(format!("token service failed: {error}")))?;
@@ -286,6 +309,77 @@ impl World {
     scope: &str,
     kind: ClientKind,
     jkt: Option<&str>,
+  ) -> Result<MintedTokens, TestError> {
+    self
+      .mint_for_subject(client_id, "e2e-subject", scope, kind, jkt)
+      .await
+  }
+
+  /// Mint a fixture token pair for a caller-supplied `subject`.
+  ///
+  /// [`World::mint`] pins `subject: "e2e-subject"`; token-exchange setup needs
+  /// a real account's EMAIL here, because entitlement resolution re-reads the
+  /// user with `user_repo::find_by_email` and an id-shaped subject resolves no
+  /// entitlements (fails closed).
+  pub async fn mint_for_subject(
+    &self,
+    client_id: &str,
+    subject: &str,
+    scope: &str,
+    kind: ClientKind,
+    jkt: Option<&str>,
+  ) -> Result<MintedTokens, TestError> {
+    self
+      .mint_pair(client_id, subject, scope, kind, jkt, &[])
+      .await
+  }
+
+  /// Mint a fixture token pair pinned to one RFC 8707 audit resource.
+  ///
+  /// [`World::mint`] hardcodes empty audiences; stale-catalog suites need a
+  /// grant whose access AND refresh records carry the audience under test, so
+  /// a later disable is observable at refresh.
+  // Only the stale-catalog suite mints an audience-pinned fixture pair.
+  #[allow(dead_code)]
+  pub async fn mint_with_aud(
+    &self,
+    client_id: &str,
+    scope: &str,
+    kind: ClientKind,
+    jkt: Option<&str>,
+    resource: &str,
+  ) -> Result<MintedTokens, TestError> {
+    self
+      .mint_for_subject_with_aud(client_id, "e2e-subject", scope, kind, jkt, resource)
+      .await
+  }
+
+  /// [`World::mint_for_subject`] with an audience pinned on both records.
+  // Only the stale-catalog suite mints an audience-pinned fixture pair.
+  #[allow(dead_code)]
+  pub async fn mint_for_subject_with_aud(
+    &self,
+    client_id: &str,
+    subject: &str,
+    scope: &str,
+    kind: ClientKind,
+    jkt: Option<&str>,
+    resource: &str,
+  ) -> Result<MintedTokens, TestError> {
+    self
+      .mint_pair(client_id, subject, scope, kind, jkt, &[resource.to_owned()])
+      .await
+  }
+
+  /// Shared fixture mint: the audience set feeds `aud` and `refresh_aud`.
+  async fn mint_pair(
+    &self,
+    client_id: &str,
+    subject: &str,
+    scope: &str,
+    kind: ClientKind,
+    jkt: Option<&str>,
+    aud: &[String],
   ) -> Result<MintedTokens, TestError> {
     let mut conn: RedisConn = self.state.redis.clone();
     let minted = self
@@ -297,7 +391,7 @@ impl World {
           client_id: client_id.to_owned(),
           client_kind: kind,
           dpop_required: kind == ClientKind::Public,
-          subject: "e2e-subject".to_owned(),
+          subject: subject.to_owned(),
           scope: scope.to_owned(),
           access_scope: scope.to_owned(),
           grant_id: uuid::Uuid::new_v4().to_string(),
@@ -305,8 +399,8 @@ impl World {
             dpop_jkt: jkt.map(str::to_owned),
             mtls_x5t_s256: None,
           },
-          aud: Vec::new(),
-          refresh_aud: Vec::new(),
+          aud: aud.to_vec(),
+          refresh_aud: aud.to_vec(),
           format: TokenFormat::Opaque,
         },
         time::OffsetDateTime::now_utc().unix_timestamp(),
@@ -428,6 +522,47 @@ pub async fn seed_client_bound(
   Ok(())
 }
 
+/// Seed one `tls_client_auth` client row with its registered subject DN and
+/// certificate thumbprint.
+///
+/// [`seed_client`] always writes `None` into both mTLS columns, so a
+/// `tls_client_auth` client needs this seam to enroll the CA-signed identity
+/// the token endpoint matches under RFC 8705 §2. The row registers the
+/// `client_credentials` grant and the `api:read` scope (the mTLS grant suite
+/// enables that scope in the catalog).
+// Only the mTLS grant suite seeds an mTLS-enrolled client.
+#[allow(dead_code)]
+pub async fn seed_mtls_client(
+  world: &World,
+  client_id: &str,
+  dn: &str,
+  thumbprint: &str,
+) -> Result<(), TestError> {
+  let new = NewClient {
+    id: uuid::Uuid::new_v4(),
+    client_id: client_id.to_owned(),
+    client_secret_hash: None,
+    client_secret_expires_at: None,
+    client_name: format!("{client_id} e2e"),
+    grant_types: vec!["client_credentials".to_owned()],
+    response_types: vec!["code".to_owned()],
+    scope: "api:read".to_owned(),
+    token_endpoint_auth_method: TokenEndpointAuthMethod::TlsClientAuth.as_str().to_owned(),
+    jwks: None,
+    jwks_uri: None,
+    tls_client_auth_subject_dn: Some(dn.to_owned()),
+    tls_client_auth_cert_thumbprint: Some(thumbprint.to_owned()),
+    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(&world.pool, &world.state.keys, false, &new).await?;
+  Ok(())
+}
+
 /// Seed one catalog scope (enabled).
 pub async fn seed_scope(pool: &sqlx::PgPool, name: &str) -> Result<(), TestError> {
   scope_repo::upsert(pool, name, &format!("e2e scope {name}"))
@@ -436,6 +571,58 @@ pub async fn seed_scope(pool: &sqlx::PgPool, name: &str) -> Result<(), TestError
   Ok(())
 }
 
+/// Provision one enabled user (known password + TOTP secret, entitled to
+/// `scope`) through the REAL repository path, using the server's own Argon2
+/// parameters so verification matches production exactly.
+///
+/// Returns the user id and the plaintext TOTP secret. A token minted on the
+/// user's behalf must carry the EMAIL as its `subject`: the id is only for
+/// seeding entitlements and asserting the account, never a token subject.
+///
+/// # Errors
+///
+/// Returns a hashing, repository, or TOTP-sealing failure.
+// Only the token-exchange suite drives a user-bound subject token.
+#[allow(dead_code)]
+pub async fn seed_entitled_user(
+  world: &World,
+  email: &str,
+  password: &str,
+  scope: &str,
+) -> Result<(uuid::Uuid, String), TestError> {
+  let raw = &world.state.config.argon2;
+  let params = Argon2Params::new(raw.memory_kib, raw.iterations, raw.parallelism, 32)
+    .map_err(|error| fail(format!("argon2 params: {error}")))?;
+  let hash =
+    hash_secret(password, &params).map_err(|error| fail(format!("hash password: {error}")))?;
+  let created = user_repo::create_user(
+    &world.pool,
+    &world.state.keys,
+    world.state.config.pii_dual_write,
+    &NewUser {
+      email: email.to_owned(),
+      password_hash: hash,
+      is_admin: false,
+      must_change_password: false,
+    },
+  )
+  .await?;
+  let secret = d_oauth::crypto::totp::generate_secret()
+    .map_err(|error| fail(format!("totp secret: {error}")))?;
+  let blob = d_oauth::crypto::totp::encrypt_secret(
+    &world.state.keys,
+    &secret,
+    !world.state.config.pii_dual_write,
+  )
+  .map_err(|error| fail(format!("totp seal: {error}")))?;
+  ensure(
+    user_repo::set_totp_secret(&world.pool, created.id, &blob).await?,
+    "totp enroll must update the user row",
+  )?;
+  user_scope_repo::entitle(&world.pool, created.id, scope, None).await?;
+  Ok((created.id, secret))
+}
+
 /// Register a redirect URI in the catalog and attach it to a client.
 pub async fn attach_redirect(
   pool: &sqlx::PgPool,
@@ -460,6 +647,31 @@ pub async fn attach_redirect(
   Ok(())
 }
 
+/// Register a post-logout URI in the catalog and attach it to a client.
+#[allow(dead_code)]
+pub async fn attach_post_logout(
+  pool: &sqlx::PgPool,
+  keys: &d_oauth::keys::KeyRing,
+  client_id: &str,
+  uri: &str,
+) -> Result<(), TestError> {
+  let (row, _) = redirect_repo::upsert(
+    pool,
+    keys,
+    false,
+    &redirect_repo::NewRedirectUri {
+      id: uuid::Uuid::new_v4(),
+      uri,
+      kind: redirect_repo::RedirectKind::PostLogout,
+      description: "e2e",
+      created_by: None,
+    },
+  )
+  .await?;
+  redirect_repo::attach(pool, client_id, row.id, None).await?;
+  Ok(())
+}
+
 /// Decode a JSON body, failing the test on malformed content.
 pub fn json_body(body: &str) -> Result<serde_json::Value, TestError> {
   serde_json::from_str(body).map_err(|error| fail(format!("body is not JSON ({error}): {body}")))

+ 126 - 0
tests/e2e/peer.rs

@@ -0,0 +1,126 @@
+//! Peer-certificate request helper for mTLS router tests (plan todo 7).
+//!
+//! Drives the REAL production router ([`d_oauth::routes::build_router`],
+//! unmodified) in-process via `oneshot`, injecting the verified peer chain
+//! the TLS accept loop would insert ([`d_oauth::tls::PeerCertificates`]).
+//! Both peer-identity extensions the stack reads are ALWAYS inserted
+//! (`ConnectInfo` for the limiter, `MockConnectInfo` for the `ConnectInfo`
+//! extractor; fixed `127.0.0.1:40001`, mirroring `src/routes.rs` `drive`),
+//! so the rate-limit layer never fails closed. No TLS listener is needed.
+//!
+//! Included by the mTLS grant suite as `#[path = "e2e/peer.rs"] mod peer;`
+//! alongside `#[path = "e2e/harness.rs"] mod harness;` (the happy-path
+//! check below reaches `World` as `crate::harness::World`, the same shape
+//! every existing e2e suite uses).
+
+use std::{net::SocketAddr, sync::Arc};
+
+use axum::{
+  body::Body,
+  extract::{ConnectInfo, connect_info::MockConnectInfo},
+  http::Request,
+  response::Response,
+};
+use d_oauth::{routes::build_router, state::AppState, tls::PeerCertificates};
+use rustls::pki_types::CertificateDer;
+use tower::ServiceExt as _;
+
+// PEM fixtures live in `tests/mtls/certs.rs`, one directory up from here.
+#[path = "../mtls/certs.rs"]
+mod certs;
+
+pub use certs::FixtureError;
+
+/// `x5t#S256` of the CA-signed fixture (copied from `tests/mtls.rs`).
+pub const PKI_THUMBPRINT: &str = "VSFBS4-7neqMcHVsx-dcc28rEEemzFvmeqtZSE3xbK8";
+/// Subject of the CA-signed fixture in `mtls::subject_dn` rendering
+/// (copied from `tests/mtls.rs`).
+pub const PKI_DN: &str = "CN=mtls-pki-client, O=Example";
+
+/// Build the `PEER_CHAIN`: the CA-signed client leaf
+/// ([`CLIENT_PKI_CERT_PEM`](certs::CLIENT_PKI_CERT_PEM)) chained to the
+/// client CA bundle ([`CA_CERT_PEM`](certs::CA_CERT_PEM)), the same chain
+/// the TLS accept loop would hand the router for the PKI fixture.
+pub fn peer_chain() -> Result<Vec<CertificateDer<'static>>, FixtureError> {
+  Ok(vec![
+    certs::cert_der(certs::CLIENT_PKI_CERT_PEM)?,
+    certs::cert_der(certs::CA_CERT_PEM)?,
+  ])
+}
+
+/// Drive one request through the production router with peer extensions.
+///
+/// When `peer` is `Some`, its chain is inserted as [`PeerCertificates`];
+/// when `None`, the extension is absent (the "no client certificate"
+/// shape). Both `ConnectInfo` and `MockConnectInfo` are always inserted so
+/// the rate-limit layer attributes the fixed loopback peer instead of
+/// failing closed with 503.
+pub async fn oneshot_with_peer(
+  state: AppState,
+  mut request: Request<Body>,
+  peer: Option<Vec<CertificateDer<'static>>>,
+) -> Response {
+  let addr = SocketAddr::from(([127, 0, 0, 1], 40_001));
+  if let Some(chain) = peer {
+    request
+      .extensions_mut()
+      .insert(PeerCertificates(Arc::new(chain)));
+  }
+  request.extensions_mut().insert(ConnectInfo(addr));
+  request.extensions_mut().insert(MockConnectInfo(addr));
+  match build_router(state).oneshot(request).await {
+    Ok(response) => response,
+    Err(error) => match error {},
+  }
+}
+
+/// Happy-path check for the helper. Files under `tests/e2e/` are included
+/// via `#[path]` (never standalone targets), so this module only compiles
+/// when a test target includes `peer.rs` — exactly how todo 14 will use it
+/// (`#[path = "e2e/peer.rs"] mod peer;` next to `mod harness;`).
+#[cfg(test)]
+mod tests {
+  use axum::http::{Request, StatusCode};
+  use d_oauth::protocol::mtls;
+
+  use super::{PKI_DN, PKI_THUMBPRINT, oneshot_with_peer, peer_chain};
+
+  /// Test-body result (no `unwrap`/`expect` under the deny lints).
+  type TestResult<T = ()> = Result<T, Box<dyn std::error::Error>>;
+
+  /// `GET /jwks.json` through the helper with the given peer evidence.
+  async fn get_jwks(
+    peer: Option<Vec<rustls::pki_types::CertificateDer<'static>>>,
+  ) -> TestResult<StatusCode> {
+    let world = crate::harness::World::start(crate::harness::open_rate_limit()).await?;
+    let request = Request::builder()
+      .uri("/jwks.json")
+      .body(axum::body::Body::empty())?;
+    Ok(
+      oneshot_with_peer(world.state.clone(), request, peer)
+        .await
+        .status(),
+    )
+  }
+
+  /// The helper serves `GET /jwks.json` with 200 both with and without a
+  /// peer chain (the chain only matters to mTLS-authenticated grants), and
+  /// the built chain matches the pinned PKI constants.
+  #[tokio::test]
+  async fn peer_helper_serves_jwks_with_and_without_chain() -> TestResult {
+    let chain = peer_chain()?;
+    if mtls::thumbprint_b64url(chain[0].as_ref()) != PKI_THUMBPRINT {
+      return Err("peer leaf thumbprint must match PKI_THUMBPRINT".into());
+    }
+    if mtls::subject_dn(chain[0].as_ref())? != PKI_DN {
+      return Err("peer leaf subject must match PKI_DN".into());
+    }
+    if get_jwks(None).await? != StatusCode::OK {
+      return Err("GET /jwks.json without a peer must be 200".into());
+    }
+    if get_jwks(Some(chain)).await? != StatusCode::OK {
+      return Err("GET /jwks.json with a peer chain must be 200".into());
+    }
+    Ok(())
+  }
+}

+ 89 - 0
tests/e2e/pkjwt.rs

@@ -0,0 +1,89 @@
+//! `private_key_jwt` client-auth fixture for `tests/e2e_edge_matrix.rs`.
+//!
+//! The harness `seed_client_bound` hardcodes `jwks: None`, so this module
+//! seeds the row directly with an inline JWKS and signs assertions through the
+//! same Ed25519 path the container suite uses.
+
+use base64::Engine as _;
+use d_oauth::db::{NewClient, TokenEndpointAuthMethod, client_repo};
+use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
+use ring::signature::{Ed25519KeyPair, KeyPair as _};
+use serde_json::{Value, json};
+
+use super::harness::{TestError, World, fail};
+
+/// Base64url (no pad) for the Ed25519 public key.
+const B64: base64::engine::GeneralPurpose = base64::engine::general_purpose::URL_SAFE_NO_PAD;
+/// Directly seeded `private_key_jwt` client.
+pub const CLIENT_ID: &str = "e2e-edge-pkjwt";
+
+/// An Ed25519 assertion signer plus the matching inline JWKS.
+pub struct AssertionKey {
+  /// PKCS#8 signer.
+  encoding: EncodingKey,
+  /// Public JWK set stored on the client row.
+  jwks: Value,
+}
+
+impl AssertionKey {
+  /// Generate one key pair and its inline JWKS.
+  pub fn generate(kid: &str) -> Result<Self, TestError> {
+    let rng = ring::rand::SystemRandom::new();
+    let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng).map_err(|_| fail("ed25519 keygen failed"))?;
+    let pair =
+      Ed25519KeyPair::from_pkcs8(pkcs8.as_ref()).map_err(|_| fail("ed25519 reload failed"))?;
+    let x = B64.encode(pair.public_key().as_ref());
+    Ok(Self {
+      encoding: EncodingKey::from_ed_der(pkcs8.as_ref()),
+      jwks: json!({"keys": [{"kty": "OKP", "crv": "Ed25519", "x": x, "kid": kid}]}),
+    })
+  }
+
+  /// The inline JWKS to store on the client row.
+  #[must_use]
+  pub fn jwks(&self) -> Value {
+    self.jwks.clone()
+  }
+
+  /// Sign one assertion for `client_id`/`audience` with a fixed `jti`.
+  pub fn mint(&self, client_id: &str, audience: &str, jti: &str) -> Result<String, TestError> {
+    let now = time::OffsetDateTime::now_utc().unix_timestamp();
+    let claims = json!({
+      "iss": client_id,
+      "sub": client_id,
+      "aud": audience,
+      "jti": jti,
+      "exp": now + 300,
+      "iat": now,
+    });
+    encode(&Header::new(Algorithm::EdDSA), &claims, &self.encoding)
+      .map_err(|error| fail(format!("assertion mint failed: {error}")))
+  }
+}
+
+/// Seed a `private_key_jwt` client row with an inline `jwks`.
+pub async fn seed_assertion_client(world: &World, jwks: Value) -> Result<(), TestError> {
+  let new = NewClient {
+    id: uuid::Uuid::new_v4(),
+    client_id: CLIENT_ID.to_owned(),
+    client_secret_hash: None,
+    client_secret_expires_at: None,
+    client_name: "edge private_key_jwt".to_owned(),
+    grant_types: vec!["client_credentials".to_owned()],
+    response_types: vec!["token".to_owned()],
+    scope: String::new(),
+    token_endpoint_auth_method: TokenEndpointAuthMethod::PrivateKeyJwt.as_str().to_owned(),
+    jwks: Some(jwks),
+    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(&world.pool, &world.state.keys, false, &new).await?;
+  Ok(())
+}

+ 441 - 0
tests/e2e_admin.rs

@@ -0,0 +1,441 @@
+//! `binary(e2e_admin)` disabled-user cascade over the live router.
+//!
+//! `user disable` purges the subject's Redis state BEFORE flipping the
+//! Postgres flag (`src/cli/user.rs:105-137`), so this suite drives both halves
+//! in that order. The purge removes the session, the unredeemed code, and the
+//! approved device record; the flag kills introspection and refresh at their
+//! use-time re-checks. A flag-only disable would still fail the token checks
+//! but leave the code, the device record, and the session alive — exactly the
+//! three records whose assertions here fail without the purge.
+//!
+//! The browser flow and device wire mechanics come from the shared
+//! [`browser`]/[`device`] helpers. User-token subjects are the canonical
+//! EMAIL, which is what `purge_subject` and the use-time re-checks resolve.
+//! The approved device record must carry that subject too: a merely pending
+//! record stores `subject: ""` and the purge never sees it
+//! (`src/store/device.rs:49-50`, `src/protocol/purge.rs:131-145`).
+
+// allow: SIZE_OK — the plan fixes both cascade scenarios in this one suite
+// file, and the browser/device/harness modules are owned by sibling todos.
+// The extra length is assertion messages, not duplicated driver logic.
+
+#[path = "e2e/harness.rs"]
+#[allow(dead_code)]
+mod harness;
+
+#[path = "e2e/browser.rs"]
+#[allow(dead_code)]
+mod browser;
+
+#[path = "e2e/dpop.rs"]
+#[allow(dead_code)]
+mod dpop;
+
+#[path = "e2e/device.rs"]
+#[allow(dead_code)]
+mod device;
+
+use browser::{
+  CLIENT_ID, INVALID_CREDENTIALS, REDIRECT_URI, SESSION_COOKIE, begin_browser, cookie_value,
+  encoded, get_consent, hidden_input, location_of, oauth2_pkce, post_consent, post_login,
+  query_value, totp_now,
+};
+use d_oauth::{
+  db::{TokenEndpointAuthMethod, user_repo},
+  protocol::{
+    purge::{PurgedSubject, purge_subject},
+    token::ClientKind,
+  },
+};
+use device::{
+  Client as DeviceClient, Decision, Poll, decide, issue, poll, review, seed_public_client,
+};
+use dpop::DpopKey;
+use harness::{TestError, World, ensure, fail, json_body, open_rate_limit, seed_client};
+use reqwest::StatusCode;
+
+/// Provisioned user in the cascade case.
+const CASCADE_EMAIL: &str = "e2e-admin-cascade@example.test";
+/// Provisioned password in the cascade case.
+const CASCADE_PASSWORD: &str = "e2e-admin-cascade-password-1a2b";
+/// Confidential client used to introspect the minted tokens.
+const INTROSPECT_CLIENT: &str = "e2e-admin-introspect";
+/// Secret for [`INTROSPECT_CLIENT`].
+const INTROSPECT_SECRET: &str = "e2e-admin-introspect-secret-1a2b3c4d5e6f";
+/// Provisioned user in the re-login case.
+const RELOGIN_EMAIL: &str = "e2e-admin-relogin@example.test";
+/// Provisioned password in the re-login case.
+const RELOGIN_PASSWORD: &str = "e2e-admin-relogin-password-1a2b";
+
+/// Flip the flag exactly like the CLI: purge the subject's Redis state first,
+/// then set `users.disabled`. Returns the purge summary.
+async fn disable_like_cli(world: &World, email: &str) -> Result<PurgedSubject, TestError> {
+  let record = user_repo::find_by_email(&world.pool, &world.state.keys, email)
+    .await?
+    .ok_or_else(|| fail("seeded user must resolve by email"))?;
+  let mut conn = world.state.redis.clone();
+  let summary = purge_subject(
+    &mut conn,
+    &world.store,
+    &record.email,
+    &record.id.to_string(),
+  )
+  .await?;
+  ensure(
+    user_repo::disable_user(&world.pool, record.id).await?,
+    "disable_user must update the user row",
+  )?;
+  Ok(summary)
+}
+
+/// Introspect `token` as the confidential fixture client.
+async fn introspect(world: &World, token: &str) -> Result<serde_json::Value, TestError> {
+  let (status, _, body) = world
+    .post_form(
+      "/introspect",
+      &[("token", token)],
+      Some((INTROSPECT_CLIENT, INTROSPECT_SECRET)),
+      &[],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("introspection must answer 200: {status} {body}"),
+  )?;
+  json_body(&body)
+}
+
+/// Drive one more `/authorize` → `/consent` leg on an authenticated session
+/// and return the fresh (unredeemed) code with its PKCE verifier.
+async fn consent_for_new_code(
+  browser: &browser::Browser,
+  session: &str,
+) -> Result<(String, String), TestError> {
+  let (challenge, verifier) = oauth2_pkce();
+  let issuer = &browser.world.state.config.issuer;
+  let proof = browser.dpop.proof("GET", &format!("{issuer}/authorize"))?;
+  let cookie = format!("{SESSION_COOKIE}={session}");
+  let path = format!(
+    "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}\
+     &code_challenge={challenge}&code_challenge_method=S256",
+    encoded(CLIENT_ID),
+    encoded(REDIRECT_URI),
+    encoded(browser::SCOPE),
+    encoded(browser::STATE),
+  );
+  let (status, headers, body) = browser
+    .world
+    .get(
+      &path,
+      &[("dpop", proof.as_str()), ("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("second authorize must 303 to login: {status} {body}"),
+  )?;
+  let request_id = query_value(&location_of(&headers)?, "request_id")?;
+  let (status, headers, body) = browser
+    .world
+    .get(
+      &format!("/login?request_id={request_id}"),
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("an authenticated login must 303 onward: {status} {body}"),
+  )?;
+  ensure(
+    location_of(&headers)? == "/consent",
+    "an authenticated session must land on consent",
+  )?;
+
+  let (status, _, body) = get_consent(browser, session).await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("second consent must render: {status} {body}"),
+  )?;
+  let bound = hidden_input(&body, "request_id")?;
+  let csrf = hidden_input(&body, "csrf")?;
+  let (status, headers, body) = post_consent(browser, session, &bound, &csrf, "approve").await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("second consent must 303 to the redirect: {status} {body}"),
+  )?;
+  let location = location_of(&headers)?;
+  ensure(
+    location.starts_with(REDIRECT_URI),
+    format!("the new code must return to the registered redirect: {location}"),
+  )?;
+  Ok((query_value(&location, "code")?, verifier))
+}
+
+/// Disabling a real user purges the session, the unredeemed code, and the
+/// approved device record, and every use of the old material fails closed.
+#[tokio::test]
+async fn e2e_user_disable_kills_tokens_refresh_and_code() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser(world, CASCADE_EMAIL, CASCADE_PASSWORD).await?;
+  let two_factor = totp_now(&browser.totp_secret).await?;
+  let (status, headers, body) =
+    post_login(&browser, &browser.password.clone(), &two_factor).await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("login must 303 to consent: {status} {body}"),
+  )?;
+  let session = cookie_value(&headers, SESSION_COOKIE)?;
+
+  let (status, _, body) = get_consent(&browser, &session).await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("consent must render: {status} {body}"),
+  )?;
+  let request_id = hidden_input(&body, "request_id")?;
+  let csrf = hidden_input(&body, "csrf")?;
+  let (status, headers, body) =
+    post_consent(&browser, &session, &request_id, &csrf, "approve").await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("consent approval must 303: {status} {body}"),
+  )?;
+  let code = query_value(&location_of(&headers)?, "code")?;
+
+  // Redeem the first code for the real access/refresh pair.
+  let proof = browser.dpop.proof(
+    "POST",
+    &format!("{}/token", browser.world.state.config.issuer),
+  )?;
+  let (status, _, body) = browser
+    .world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "authorization_code"),
+        ("code", code.as_str()),
+        ("redirect_uri", REDIRECT_URI),
+        ("client_id", CLIENT_ID),
+        ("code_verifier", browser.verifier.as_str()),
+      ],
+      None,
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("code redemption must mint: {status} {body}"),
+  )?;
+  let tokens = json_body(&body)?;
+  let access_token = tokens["access_token"]
+    .as_str()
+    .filter(|value| !value.is_empty())
+    .ok_or_else(|| fail("redemption must return an access token"))?
+    .to_owned();
+  let refresh_token = tokens["refresh_token"]
+    .as_str()
+    .filter(|value| !value.is_empty())
+    .ok_or_else(|| fail("redemption must return a refresh token"))?
+    .to_owned();
+
+  // A second, unredeemed code for the post-purge redemption check.
+  let (fresh_code, fresh_verifier) = consent_for_new_code(&browser, &session).await?;
+
+  // Approve a device code through the real UI so its record carries the
+  // subject the purge will match. A pending record's empty subject is
+  // invisible to `purge_subject`.
+  seed_public_client(&browser.world).await?;
+  let device_key = DpopKey::generate()?;
+  let device_client = DeviceClient {
+    id: device::PUBLIC_CLIENT,
+    secret: None,
+    scope: device::SCOPE,
+  };
+  let (device_code, user_code) = issue(&browser.world, device_client, &device_key).await?;
+  let page = review(&browser.world, &session, &user_code).await?;
+  let device_csrf = hidden_input(&page, "csrf")?;
+  decide(
+    &browser.world,
+    Decision {
+      session: session.as_str(),
+      user_code: user_code.as_str(),
+      csrf: device_csrf.as_str(),
+      action: "approve",
+    },
+  )
+  .await?;
+
+  // A confidential fixture token proves introspection reads the live subject
+  // before the cascade and the dead one after it.
+  seed_client(
+    &browser.world.pool,
+    &browser.world.state.keys,
+    INTROSPECT_CLIENT,
+    Some(INTROSPECT_SECRET),
+    &["authorization_code"],
+    browser::SCOPE,
+    TokenEndpointAuthMethod::ClientSecretBasic,
+  )
+  .await?;
+  let fixture = browser
+    .world
+    .mint_for_subject(
+      INTROSPECT_CLIENT,
+      CASCADE_EMAIL,
+      browser::SCOPE,
+      ClientKind::Confidential,
+      None,
+    )
+    .await?;
+  ensure(
+    introspect(&browser.world, &fixture.access_token).await?["active"] == true,
+    "the fixture access token must start active",
+  )?;
+
+  // CLI order: purge the Redis state, then flip the flag.
+  let summary = disable_like_cli(&browser.world, CASCADE_EMAIL).await?;
+  ensure(
+    summary.sessions >= 1,
+    format!("the purge must delete the login session, got {summary:?}"),
+  )?;
+  ensure(
+    summary.codes >= 1,
+    format!("the purge must delete the unredeemed code, got {summary:?}"),
+  )?;
+  ensure(
+    summary.devices == 1,
+    format!("the purge must delete the approved device record, got {summary:?}"),
+  )?;
+
+  // 1. Introspection: both the fixture token and the browser token are dead.
+  ensure(
+    introspect(&browser.world, &fixture.access_token).await?["active"] == false,
+    "the fixture access token must introspect inactive after disable",
+  )?;
+  ensure(
+    introspect(&browser.world, &access_token).await?["active"] == false,
+    "the browser access token must introspect inactive after disable",
+  )?;
+
+  // 2. Refresh: the record is gone, so the grant is invalid.
+  let proof = browser.dpop.proof(
+    "POST",
+    &format!("{}/token", browser.world.state.config.issuer),
+  )?;
+  let (status, _, body) = browser
+    .world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "refresh_token"),
+        ("refresh_token", refresh_token.as_str()),
+        ("client_id", CLIENT_ID),
+      ],
+      None,
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("refresh must be 400: {status} {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["error"] == "invalid_grant",
+    "refresh must be invalid_grant",
+  )?;
+
+  // 3. Authorization code: the unredeemed record is gone.
+  let proof = browser.dpop.proof(
+    "POST",
+    &format!("{}/token", browser.world.state.config.issuer),
+  )?;
+  let (status, _, body) = browser
+    .world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "authorization_code"),
+        ("code", fresh_code.as_str()),
+        ("redirect_uri", REDIRECT_URI),
+        ("client_id", CLIENT_ID),
+        ("code_verifier", fresh_verifier.as_str()),
+      ],
+      None,
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("code redemption must be 400: {status} {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["error"] == "invalid_grant",
+    "code redemption must be invalid_grant",
+  )?;
+
+  // 4. Device: the approved record is gone, so polling is terminal
+  //    `expired_token` (never a mint, never `invalid_grant`).
+  let (status, body) = poll(
+    &browser.world,
+    Poll {
+      client: device_client,
+      device_code: device_code.as_str(),
+    },
+    &device_key,
+  )
+  .await?;
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("the purged device poll must be 400: {status} {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["error"] == "expired_token",
+    "the purged device code must be expired_token",
+  )?;
+
+  // 5. Session: the old cookie no longer authenticates a consent page.
+  let (status, headers, _) = get_consent(&browser, &session).await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("a purged session must bounce from consent: {status}"),
+  )?;
+  ensure(
+    location_of(&headers)? == "/login",
+    "the purged session must bounce to /login",
+  )?;
+  Ok(())
+}
+
+/// A disabled account's correct password + TOTP is still the uniform 401 and
+/// never upgrades the pre-session.
+#[tokio::test]
+async fn e2e_disabled_user_cannot_relogin() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser(world, RELOGIN_EMAIL, RELOGIN_PASSWORD).await?;
+  disable_like_cli(&browser.world, RELOGIN_EMAIL).await?;
+
+  let two_factor = totp_now(&browser.totp_secret).await?;
+  let (status, headers, body) =
+    post_login(&browser, &browser.password.clone(), &two_factor).await?;
+  ensure(
+    status == StatusCode::UNAUTHORIZED,
+    format!("a disabled account must be 401: {status} {body}"),
+  )?;
+  ensure(
+    body.contains(INVALID_CREDENTIALS),
+    "the rejection must render the uniform credential message",
+  )?;
+  ensure(
+    cookie_value(&headers, SESSION_COOKIE).is_err(),
+    "a disabled account must not upgrade the pre-session",
+  )?;
+  let (status, headers, _) = get_consent(&browser, &browser.presession).await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("without a session consent must bounce to login: {status}"),
+  )?;
+  ensure(
+    location_of(&headers)? == "/login",
+    "the bounce target must be /login",
+  )?;
+  Ok(())
+}

+ 662 - 0
tests/e2e_cimd.rs

@@ -0,0 +1,662 @@
+//! `test(e2e_cimd)` end-to-end coverage of Client ID Metadata Documents
+//! (CIMD draft §3/§4.1/§5) through the real router and the injected fetcher
+//! seam (`AppState::cimd_fetcher`).
+//!
+//! Every world installs its own counting fetcher, so the cache behavior is
+//! observed rather than assumed: one fetch for the first resolution, zero for
+//! every cached/registered/inactive resolution within the TTL. Each invalid
+//! document row gets its own world (and its own URL) so the short-lived
+//! `cimd:invalid:` marker cannot replay across rows.
+//!
+//! The fetcher is the ONLY document source: no test performs a real network
+//! call.
+
+use std::{
+  future::Future,
+  sync::{
+    Arc,
+    atomic::{AtomicUsize, Ordering},
+  },
+};
+
+use base64::Engine as _;
+use d_oauth::{
+  db::{TokenEndpointAuthMethod, client_repo},
+  protocol::cimd::{CimdDocument, CimdError, CimdFetchFuture, CimdFetcher, CimdLimits},
+};
+use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
+use ring::signature::{Ed25519KeyPair, KeyPair as _};
+use serde_json::{Value, json};
+use time::OffsetDateTime;
+
+#[allow(dead_code)]
+#[path = "e2e/harness.rs"]
+mod harness;
+
+#[path = "e2e/dpop.rs"]
+mod dpop;
+
+#[allow(dead_code)]
+#[path = "e2e/browser.rs"]
+mod browser;
+
+use browser::{
+  Browser, SESSION_COOKIE, cookie_value, get_consent, hidden_input, location_of, oauth2_pkce,
+  post_consent, post_login, query_value, seed_user, totp_now,
+};
+use harness::{
+  TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
+  seed_scope,
+};
+
+/// Registration-free client identifier: an `https://` document URL.
+const CLIENT_URL: &str = "https://cimd.example.test/client.json";
+/// Loopback redirect declared by the fixture documents (RFC 8252 §7.3).
+const REDIRECT: &str = "http://127.0.0.1:9/cb";
+/// Catalog scope the fixture documents and users are entitled to.
+const SCOPE: &str = "read";
+/// `state` echoed from authorize to the code redirect.
+const STATE: &str = "cimd-e2e-state";
+
+/// Start a world with a counting fetcher and the CIMD feature gate.
+async fn cimd_world(fetcher: CimdFetcher, cimd_enabled: bool) -> Result<World, TestError> {
+  World::start_with_cimd_fetcher(
+    open_rate_limit(),
+    json!({"cimd_enabled": cimd_enabled, "allow_localhost_redirect": true}),
+    fetcher,
+  )
+  .await
+}
+
+/// Box one fetch so the higher-ranked [`CimdFetcher`] alias accepts the
+/// closure (mirrors the module-private helper production uses).
+fn boxed<'a>(
+  future: impl Future<Output = Result<CimdDocument, CimdError>> + Send + 'a,
+) -> CimdFetchFuture<'a> {
+  Box::pin(future)
+}
+
+/// A document fetcher that counts every call and returns `document`.
+fn document_fetcher(document: CimdDocument) -> (CimdFetcher, Arc<AtomicUsize>) {
+  let calls = Arc::new(AtomicUsize::new(0));
+  let counter = Arc::clone(&calls);
+  let fetcher: CimdFetcher = Arc::new(move |_url: &str, _limits: CimdLimits| {
+    let document = document.clone();
+    let counter = Arc::clone(&counter);
+    boxed(async move {
+      counter.fetch_add(1, Ordering::SeqCst);
+      Ok(document)
+    })
+  });
+  (fetcher, calls)
+}
+
+/// A fetcher that counts every call and always fails. Used to prove that a
+/// path never reaches the fetch at all.
+fn failing_fetcher() -> (CimdFetcher, Arc<AtomicUsize>) {
+  let calls = Arc::new(AtomicUsize::new(0));
+  let counter = Arc::clone(&calls);
+  let fetcher: CimdFetcher = Arc::new(move |_url: &str, _limits: CimdLimits| {
+    let counter = Arc::clone(&counter);
+    boxed(async move {
+      counter.fetch_add(1, Ordering::SeqCst);
+      Err(CimdError::Fetch)
+    })
+  });
+  (fetcher, calls)
+}
+
+/// A valid `none` metadata document.
+fn none_document(url: &str, dpop_bound: bool) -> CimdDocument {
+  CimdDocument {
+    client_id: url.to_owned(),
+    redirect_uris: vec![REDIRECT.to_owned()],
+    token_endpoint_auth_method: Some("none".to_owned()),
+    grant_types: Some(vec![
+      "authorization_code".to_owned(),
+      "refresh_token".to_owned(),
+    ]),
+    scope: Some(SCOPE.to_owned()),
+    jwks: None,
+    jwks_uri: None,
+    client_name: None,
+    dpop_bound_access_tokens: Some(dpop_bound),
+  }
+}
+
+/// A valid `private_key_jwt` metadata document carrying an inline JWKS.
+fn private_key_document(url: &str, jwks: Value) -> CimdDocument {
+  CimdDocument {
+    client_id: url.to_owned(),
+    redirect_uris: vec![REDIRECT.to_owned()],
+    token_endpoint_auth_method: Some("private_key_jwt".to_owned()),
+    grant_types: Some(vec![
+      "authorization_code".to_owned(),
+      "refresh_token".to_owned(),
+    ]),
+    scope: Some(SCOPE.to_owned()),
+    jwks: Some(jwks),
+    jwks_uri: None,
+    client_name: None,
+    dpop_bound_access_tokens: Some(false),
+  }
+}
+
+/// An Ed25519 signer plus the matching inline JWKS for one document.
+struct DocKey {
+  /// Signing key for request objects and client assertions.
+  encoding: EncodingKey,
+  /// Public JWKS the document publishes.
+  jwks: Value,
+}
+
+impl DocKey {
+  /// Generate a fresh Ed25519 key pair and its JWKS.
+  fn generate(kid: &str) -> Result<Self, TestError> {
+    let rng = ring::rand::SystemRandom::new();
+    let pkcs8 = Ed25519KeyPair::generate_pkcs8(&rng)
+      .map_err(|error| fail(format!("ed25519 keygen failed: {error}")))?;
+    let pair = Ed25519KeyPair::from_pkcs8(pkcs8.as_ref())
+      .map_err(|error| fail(format!("ed25519 reload failed: {error}")))?;
+    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: json!({
+        "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": x, "kid": kid }]
+      }),
+    })
+  }
+
+  /// Sign one EdDSA JWT with `kid`.
+  fn sign(&self, kid: &str, claims: &Value) -> Result<String, TestError> {
+    let mut header = Header::new(Algorithm::EdDSA);
+    header.kid = Some(kid.to_owned());
+    encode(&header, claims, &self.encoding).map_err(|error| fail(format!("jwt encode: {error}")))
+  }
+
+  /// A signed RFC 9101 request object bound to `client_id`/`issuer`.
+  fn request_object(
+    &self,
+    kid: &str,
+    client_id: &str,
+    issuer: &str,
+    extra: Value,
+  ) -> Result<String, TestError> {
+    let now = OffsetDateTime::now_utc().unix_timestamp();
+    let mut claims = json!({
+      "iss": client_id,
+      "aud": issuer,
+      "exp": now + 300,
+      "iat": now,
+    });
+    let object = claims
+      .as_object_mut()
+      .ok_or_else(|| fail("request claims must be an object"))?;
+    let extra_object = extra
+      .as_object()
+      .ok_or_else(|| fail("request extras must be an object"))?;
+    for (key, value) in extra_object {
+      object.insert(key.clone(), value.clone());
+    }
+    self.sign(kid, &claims)
+  }
+
+  /// A `private_key_jwt` client assertion (RFC 7523 §3).
+  fn assertion(
+    &self,
+    kid: &str,
+    client_id: &str,
+    audience: &str,
+    jti: &str,
+  ) -> Result<String, TestError> {
+    let now = OffsetDateTime::now_utc().unix_timestamp();
+    self.sign(
+      kid,
+      &json!({
+        "iss": client_id,
+        "sub": client_id,
+        "aud": audience,
+        "jti": jti,
+        "exp": now + 300,
+        "iat": now,
+      }),
+    )
+  }
+}
+
+/// Build a plain-query `/authorize` path (public `none` clients).
+fn authorize_path(client_id: &str, challenge: &str) -> String {
+  format!(
+    "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state={}\
+     &code_challenge={}&code_challenge_method=S256",
+    browser::encoded(client_id),
+    browser::encoded(REDIRECT),
+    browser::encoded(SCOPE),
+    browser::encoded(STATE),
+    browser::encoded(challenge),
+  )
+}
+
+/// Assert an authorize error page renders `expected`.
+fn ensure_error_page(
+  status: reqwest::StatusCode,
+  body: &str,
+  expected: &str,
+) -> Result<(), TestError> {
+  ensure(
+    status == reqwest::StatusCode::BAD_REQUEST,
+    format!("{expected} must render 400, got {status}: {body}"),
+  )?;
+  ensure(
+    body.contains(&format!("<h1>{expected}</h1>")),
+    format!("page must name {expected}: {body}"),
+  )
+}
+
+/// Drive login → consent approval for a [`Browser`] already parked on the
+/// login page and return the issued authorization code.
+async fn approve(browser: &Browser, expected_state: &str) -> Result<String, TestError> {
+  let code_2fa = totp_now(&browser.totp_secret).await?;
+  let (status, headers, body) = post_login(browser, &browser.password, &code_2fa).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("login must 303 to consent: {status} {body}"),
+  )?;
+  let session = cookie_value(&headers, SESSION_COOKIE)?;
+  let (status, _, body) = get_consent(browser, &session).await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("consent must render: {status} {body}"),
+  )?;
+  let bound = hidden_input(&body, "request_id")?;
+  ensure(
+    bound == browser.request_id,
+    "consent must serve the bound authorization request",
+  )?;
+  let consent_csrf = hidden_input(&body, "csrf")?;
+  let (status, headers, _) =
+    post_consent(browser, &session, &bound, &consent_csrf, "approve").await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("consent approval must 303 to the redirect: {status}"),
+  )?;
+  let location = location_of(&headers)?;
+  ensure(
+    location.starts_with(REDIRECT),
+    format!("code must return to the document redirect, got {location}"),
+  )?;
+  ensure(
+    query_value(&location, "state")? == expected_state,
+    "code redirect must echo state",
+  )?;
+  query_value(&location, "code")
+}
+
+/// Happy path: a `none` CIMD document carries the full browser flow through
+/// authorization-code redemption, and a second authorize inside the cache TTL
+/// performs no second fetch.
+#[tokio::test]
+async fn e2e_cimd_authorize_login_consent_token() -> Result<(), TestError> {
+  let (fetcher, calls) = document_fetcher(none_document(CLIENT_URL, true));
+  let world = cimd_world(fetcher, true).await?;
+  let browser = browser::begin_browser_for(
+    world,
+    CLIENT_URL,
+    REDIRECT,
+    SCOPE,
+    "cimd-happy@example.test",
+    "correct-horse-28-cimd-happy",
+  )
+  .await?;
+  ensure(
+    calls.load(Ordering::SeqCst) == 1,
+    format!(
+      "first authorize must fetch once, got {}",
+      calls.load(Ordering::SeqCst)
+    ),
+  )?;
+
+  let code = approve(&browser, browser::STATE).await?;
+
+  // CIMD draft §4.1: a DPoP-bound document binds both the code and the token.
+  let proof = browser.dpop.proof(
+    "POST",
+    &format!("{}/token", browser.world.state.config.issuer),
+  )?;
+  let (status, _, body) = browser
+    .world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "authorization_code"),
+        ("code", code.as_str()),
+        ("redirect_uri", REDIRECT),
+        ("client_id", CLIENT_URL),
+        ("code_verifier", browser.verifier.as_str()),
+      ],
+      None,
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("code redemption must mint: {status} {body}"),
+  )?;
+  let tokens = json_body(&body)?;
+  ensure(
+    tokens["token_type"] == "DPoP",
+    format!("a DPoP-bound document must mint DPoP tokens, got {tokens}"),
+  )?;
+  ensure(
+    tokens["access_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "redemption must return an access token",
+  )?;
+  ensure(
+    tokens["refresh_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "the document grants refresh_token, so one must be returned",
+  )?;
+  ensure(
+    tokens["scope"] == SCOPE,
+    format!("tokens must carry the document scope, got {tokens}"),
+  )?;
+
+  // A second authorize inside the 15-minute TTL is a cache hit: zero fetches.
+  let (challenge, _) = oauth2_pkce();
+  let proof = browser.dpop.proof(
+    "GET",
+    &format!("{}/authorize", browser.world.state.config.issuer),
+  )?;
+  let (status, headers, body) = browser
+    .world
+    .get(
+      &authorize_path(CLIENT_URL, &challenge),
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("cached second authorize must 303: {status} {body}"),
+  )?;
+  ensure(
+    location_of(&headers)?.starts_with("/login?request_id="),
+    "cached second authorize must reach login",
+  )?;
+  let count = calls.load(Ordering::SeqCst);
+  ensure(
+    count == 1,
+    format!("second authorize within the TTL must add zero fetches, got {count}"),
+  )?;
+  Ok(())
+}
+
+/// A `private_key_jwt` document is confidential: a plain query authorize is
+/// rejected with `invalid_request`, exactly like a registered confidential
+/// client (RFC 9101 §4, `tests/jar.rs:229-251`).
+#[tokio::test]
+async fn e2e_cimd_private_key_jwt_requires_par_or_jar() -> Result<(), TestError> {
+  let key = DocKey::generate("cimd-doc-key-plain")?;
+  let (fetcher, calls) = document_fetcher(private_key_document(CLIENT_URL, key.jwks.clone()));
+  let world = cimd_world(fetcher, true).await?;
+  seed_scope(&world.pool, SCOPE).await?;
+
+  let (challenge, _) = oauth2_pkce();
+  let (status, _, body) = world
+    .get(&authorize_path(CLIENT_URL, &challenge), &[])
+    .await?;
+  ensure_error_page(status, &body, "invalid_request")?;
+  let count = calls.load(Ordering::SeqCst);
+  ensure(
+    count == 1,
+    format!("the rejected authorize must fetch exactly once, got {count}"),
+  )?;
+  Ok(())
+}
+
+/// The same `private_key_jwt` document authorizes, logs in, consents, and
+/// redeems when the request object is signed with the document key; the token
+/// request authenticates with a document-key assertion. One fetch total.
+#[tokio::test]
+async fn e2e_cimd_private_key_jwt_par_jar_positive() -> Result<(), TestError> {
+  const KID: &str = "cimd-doc-key-jar";
+  const URL: &str = "https://cimd.example.test/private-key-jar.json";
+  let key = DocKey::generate(KID)?;
+  let (fetcher, calls) = document_fetcher(private_key_document(URL, key.jwks.clone()));
+  let world = cimd_world(fetcher, true).await?;
+  seed_scope(&world.pool, SCOPE).await?;
+  let email = "cimd-jar@example.test";
+  let password = "correct-horse-28-cimd-jar";
+  let totp_secret = seed_user(&world, email, password, SCOPE).await?;
+
+  let (challenge, verifier) = oauth2_pkce();
+  let issuer = world.state.config.issuer.clone();
+  let request = key.request_object(
+    KID,
+    URL,
+    &issuer,
+    json!({
+      "client_id": URL,
+      "response_type": "code",
+      "redirect_uri": REDIRECT,
+      "scope": SCOPE,
+      "state": STATE,
+      "code_challenge": challenge,
+      "code_challenge_method": "S256",
+    }),
+  )?;
+  let path = format!(
+    "/authorize?client_id={}&request={}",
+    browser::encoded(URL),
+    browser::encoded(&request)
+  );
+  let (status, headers, body) = world.get(&path, &[]).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("signed request object must authorize: {status} {body}"),
+  )?;
+  let request_id = query_value(&location_of(&headers)?, "request_id")?;
+  let (status, headers, body) = world
+    .get(&format!("/login?request_id={request_id}"), &[])
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("login page must render: {status} {body}"),
+  )?;
+  let presession = cookie_value(&headers, SESSION_COOKIE)?;
+  let login_csrf = hidden_input(&body, "csrf")?;
+  let browser = Browser {
+    world,
+    dpop: browser::DpopKey::generate()?,
+    verifier,
+    request_id,
+    presession,
+    session: None,
+    login_csrf,
+    email: email.to_owned(),
+    password: password.to_owned(),
+    totp_secret,
+  };
+
+  let code = approve(&browser, STATE).await?;
+  let assertion = key.assertion(KID, URL, &format!("{issuer}/token"), "cimd-jar-jti-1")?;
+  let (status, _, body) = browser
+    .world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "authorization_code"),
+        ("code", code.as_str()),
+        ("redirect_uri", REDIRECT),
+        ("client_id", URL),
+        ("code_verifier", browser.verifier.as_str()),
+        (
+          "client_assertion_type",
+          "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
+        ),
+        ("client_assertion", assertion.as_str()),
+      ],
+      None,
+      &[],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("JAR + assertion redemption must mint: {status} {body}"),
+  )?;
+  let tokens = json_body(&body)?;
+  ensure(
+    tokens["access_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "redemption must return an access token",
+  )?;
+  let count = calls.load(Ordering::SeqCst);
+  ensure(
+    count == 1,
+    format!("the whole JAR flow must fetch exactly once, got {count}"),
+  )?;
+  Ok(())
+}
+
+/// Every invalid document fails closed with its typed error, exactly one
+/// fetch per row. One world per row: a shared Redis prefix would let the
+/// two-second `cimd:invalid:` marker replay the first row's verdict.
+#[tokio::test]
+async fn e2e_cimd_invalid_documents_fail_closed() -> Result<(), TestError> {
+  let mut jwks_uri = none_document("https://cimd.example.test/jwks-uri.json", false);
+  jwks_uri.jwks_uri = Some("https://keys.example.test/jwks.json".to_owned());
+
+  let mut unsupported = none_document("https://cimd.example.test/unsupported-method.json", false);
+  unsupported.token_endpoint_auth_method = Some("client_secret_basic".to_owned());
+
+  let mut wildcard = none_document("https://cimd.example.test/wildcard.json", false);
+  wildcard.redirect_uris = vec!["https://app.example.test/*".to_owned()];
+
+  let mut mismatch = none_document("https://cimd.example.test/mismatch.json", false);
+  mismatch.client_id = "https://cimd.example.test/different.json".to_owned();
+
+  let rows = [
+    (
+      "https://cimd.example.test/jwks-uri.json",
+      jwks_uri,
+      "invalid_client_metadata",
+    ),
+    (
+      "https://cimd.example.test/unsupported-method.json",
+      unsupported,
+      "invalid_client_metadata",
+    ),
+    (
+      "https://cimd.example.test/wildcard.json",
+      wildcard,
+      "invalid_redirect_uri",
+    ),
+    (
+      "https://cimd.example.test/mismatch.json",
+      mismatch,
+      "invalid_client_metadata",
+    ),
+  ];
+
+  for (url, document, expected) in rows {
+    let (fetcher, calls) = document_fetcher(document);
+    let world = cimd_world(fetcher, true).await?;
+    let (challenge, _) = oauth2_pkce();
+    let (status, _, body) = world.get(&authorize_path(url, &challenge), &[]).await?;
+    ensure_error_page(status, &body, expected)?;
+    let count = calls.load(Ordering::SeqCst);
+    ensure(
+      count == 1,
+      format!("{url} must fetch exactly once, got {count}"),
+    )?;
+  }
+  Ok(())
+}
+
+/// Registered rows always win over CIMD: an active row never fetches, an
+/// inactive row is dead with zero fetches (todo 5 fail-closed), and a disabled
+/// feature keeps the normal unknown-client error with zero fetches.
+#[tokio::test]
+async fn e2e_cimd_registered_row_wins_and_inactive_is_dead() -> Result<(), TestError> {
+  // Active registered row with an `https://` id: zero fetches.
+  const ACTIVE_URL: &str = "https://cimd.example.test/registered.json";
+  let (fetcher, active_calls) = document_fetcher(none_document(ACTIVE_URL, true));
+  let world = cimd_world(fetcher, true).await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    ACTIVE_URL,
+    None,
+    &["authorization_code", "refresh_token"],
+    SCOPE,
+    TokenEndpointAuthMethod::None,
+  )
+  .await?;
+  attach_redirect(&world.pool, &world.state.keys, ACTIVE_URL, REDIRECT).await?;
+  let browser = browser::begin_browser_for(
+    world,
+    ACTIVE_URL,
+    REDIRECT,
+    SCOPE,
+    "cimd-registered@example.test",
+    "correct-horse-28-cimd-row",
+  )
+  .await?;
+  ensure(
+    !browser.request_id.is_empty(),
+    "the registered row must authorize to login",
+  )?;
+  let active_count = active_calls.load(Ordering::SeqCst);
+  ensure(
+    active_count == 0,
+    format!("an active registered row must not fetch, got {active_count}"),
+  )?;
+
+  // Inactive registered row with an `https://` id: dead, zero fetches.
+  const INACTIVE_URL: &str = "https://cimd.example.test/inactive.json";
+  let (fetcher, inactive_calls) = failing_fetcher();
+  let world = cimd_world(fetcher, true).await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    INACTIVE_URL,
+    None,
+    &["authorization_code"],
+    SCOPE,
+    TokenEndpointAuthMethod::None,
+  )
+  .await?;
+  ensure(
+    client_repo::deactivate(&world.pool, INACTIVE_URL).await?,
+    "deactivate must flip the row",
+  )?;
+  let (challenge, _) = oauth2_pkce();
+  let (status, _, body) = world
+    .get(&authorize_path(INACTIVE_URL, &challenge), &[])
+    .await?;
+  ensure_error_page(status, &body, "invalid_client")?;
+  let inactive_count = inactive_calls.load(Ordering::SeqCst);
+  ensure(
+    inactive_count == 0,
+    format!("an inactive row must never fall through to CIMD, got {inactive_count}"),
+  )?;
+
+  // CIMD disabled: an unknown `https://` id is the normal unknown client.
+  const UNKNOWN_URL: &str = "https://cimd.example.test/disabled.json";
+  let (fetcher, disabled_calls) = failing_fetcher();
+  let world = cimd_world(fetcher, false).await?;
+  let (challenge, _) = oauth2_pkce();
+  let (status, _, body) = world
+    .get(&authorize_path(UNKNOWN_URL, &challenge), &[])
+    .await?;
+  ensure_error_page(status, &body, "invalid_client")?;
+  let disabled_count = disabled_calls.load(Ordering::SeqCst);
+  ensure(
+    disabled_count == 0,
+    format!("a disabled CIMD feature must not fetch, got {disabled_count}"),
+  )?;
+  Ok(())
+}

+ 428 - 0
tests/e2e_dcr.rs

@@ -0,0 +1,428 @@
+//! `binary(e2e_dcr)` dynamic-client-registration lifecycle over the live
+//! router (RFC 7591 registration, RFC 7592 management).
+//!
+//! Every request goes through the production router over a loopback socket
+//! against the real shared Postgres + Redis. Initial access tokens are seeded
+//! through the same keyed-hash path the CLI uses, so the registration
+//! authorization the tests observe is the production one (not `sha256_hex`).
+//!
+//! DCR forbids `client_credentials` (`SUPPORTED_GRANTS`), so no case attempts
+//! it. Confidential `client_secret_basic` registration requires a sender
+//! constraint, hence every positive body sets `dpop_bound_access_tokens`.
+
+#[path = "e2e/harness.rs"]
+#[allow(dead_code)]
+mod harness;
+
+use std::time::Duration;
+
+use d_oauth::{
+  crypto::tokens::{KeyId as TokenKeyId, mint_token, token_hash},
+  db::TokenEndpointAuthMethod,
+  protocol::token::ClientKind,
+  store::dcr::{self, InitialAccessRecord},
+};
+use harness::{TestError, World, ensure, fail, json_body, open_rate_limit, seed_client};
+use reqwest::StatusCode;
+use secrecy::ExposeSecret as _;
+use serde_json::{Value, json};
+
+/// Persisted portion of one RFC 7591 registration response used by a test.
+struct Registered {
+  client_id: String,
+  client_secret: String,
+  registration_token: String,
+}
+
+/// RFC 7591 body for a confidential, DPoP-bound client in its one grant.
+fn registration_body(name: &str, redirect: &str) -> Value {
+  json!({
+    "redirect_uris": [redirect],
+    "client_name": name,
+    "token_endpoint_auth_method": "client_secret_basic",
+    "grant_types": ["authorization_code"],
+    "response_types": ["code"],
+    "dpop_bound_access_tokens": true,
+  })
+}
+
+/// Read a required string member of a JSON response.
+fn string_field<'a>(body: &'a Value, key: &str) -> Result<&'a str, TestError> {
+  body
+    .get(key)
+    .and_then(Value::as_str)
+    .ok_or_else(|| fail(format!("response is missing string field {key}")))
+}
+
+/// Seed an IAT exactly as `protocol::dcr::tests::issue_iat` does: the storage
+/// key is the active pepper's keyed hash, not a plain `sha256_hex`.
+async fn seed_iat(world: &World, uses: u64) -> Result<String, TestError> {
+  let raw = mint_token().map_err(|error| fail(format!("mint IAT failed: {error}")))?;
+  let (key_id, pepper) = world
+    .state
+    .config
+    .keys
+    .active_pepper()
+    .ok_or_else(|| fail("config has no active token pepper"))?;
+  let hash = token_hash(
+    &TokenKeyId::new(key_id.as_str()),
+    pepper.expose_secret().as_bytes(),
+    &raw,
+  )
+  .to_storage_string();
+  let mut conn = world.state.redis.clone();
+  dcr::issue(
+    &mut conn,
+    &world.store,
+    &hash,
+    &InitialAccessRecord {
+      uses_remaining: uses,
+      label: "e2e".to_owned(),
+    },
+    Duration::from_secs(600),
+  )
+  .await
+  .map_err(|error| fail(format!("seed IAT failed: {error}")))?;
+  Ok(raw)
+}
+
+/// `POST {path}` as JSON with a bearer credential.
+async fn post_json(
+  world: &World,
+  path: &str,
+  bearer: &str,
+  body: &Value,
+) -> Result<(StatusCode, String), TestError> {
+  let response = world
+    .client
+    .post(format!("{}{path}", world.base))
+    .bearer_auth(bearer)
+    .json(body)
+    .send()
+    .await?;
+  Ok((response.status(), response.text().await?))
+}
+
+/// `PUT {path}` as JSON with a bearer credential.
+async fn put_json(
+  world: &World,
+  path: &str,
+  bearer: &str,
+  body: &Value,
+) -> Result<(StatusCode, String), TestError> {
+  let response = world
+    .client
+    .put(format!("{}{path}", world.base))
+    .bearer_auth(bearer)
+    .json(body)
+    .send()
+    .await?;
+  Ok((response.status(), response.text().await?))
+}
+
+/// `DELETE {path}` with a bearer credential.
+async fn delete(
+  world: &World,
+  path: &str,
+  bearer: &str,
+) -> Result<(StatusCode, String), TestError> {
+  let response = world
+    .client
+    .delete(format!("{}{path}", world.base))
+    .bearer_auth(bearer)
+    .send()
+    .await?;
+  Ok((response.status(), response.text().await?))
+}
+
+/// Register one client and return the credentials the response exposes once.
+async fn register(
+  world: &World,
+  iat: &str,
+  name: &str,
+  redirect: &str,
+) -> Result<Registered, TestError> {
+  let (status, body) =
+    post_json(world, "/register", iat, &registration_body(name, redirect)).await?;
+  ensure(
+    status == StatusCode::CREATED,
+    format!("POST /register must be 201, got {status}: {body}"),
+  )?;
+  let parsed = json_body(&body)?;
+  Ok(Registered {
+    client_id: string_field(&parsed, "client_id")?.to_owned(),
+    client_secret: string_field(&parsed, "client_secret")?.to_owned(),
+    registration_token: string_field(&parsed, "registration_access_token")?.to_owned(),
+  })
+}
+
+/// Assert an exact `403 access_denied` management denial.
+fn ensure_forbidden(status: StatusCode, body: &str) -> Result<(), TestError> {
+  ensure(
+    status == StatusCode::FORBIDDEN,
+    format!("cross-client management must be 403, got {status}: {body}"),
+  )?;
+  let parsed = json_body(body)?;
+  ensure(
+    parsed["error"].as_str() == Some("access_denied"),
+    format!("cross-client denial must be access_denied, got {body}"),
+  )
+}
+
+/// RFC 7591 register → RFC 7592 read/update/delete, with the deleted client's
+/// tokens no longer introspectable.
+#[tokio::test]
+async fn e2e_dcr_register_read_update_delete_lifecycle() -> Result<(), TestError> {
+  let world = World::start_with(open_rate_limit(), json!({"dcr_redirect_policy": "open"})).await?;
+  let iat = seed_iat(&world, 3).await?;
+  let redirect = "https://app.example.test/dcr/lifecycle";
+  let registered = register(&world, &iat, "lifecycle client", redirect).await?;
+  ensure(
+    !registered.client_id.is_empty(),
+    "registration must mint a client_id",
+  )?;
+  ensure(
+    !registered.registration_token.is_empty(),
+    "registration must mint a management token",
+  )?;
+  ensure(
+    !registered.client_secret.is_empty(),
+    "registration must mint a client secret",
+  )?;
+
+  let auth = format!("Bearer {}", registered.registration_token);
+  let (status, _, body) = world
+    .get(
+      &format!("/register/{}", registered.client_id),
+      &[("Authorization", auth.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("GET /register/{{id}} must be 200, got {status}: {body}"),
+  )?;
+  let metadata = json_body(&body)?;
+  ensure(
+    metadata["client_id"].as_str() == Some(registered.client_id.as_str()),
+    "read must echo the registered client_id",
+  )?;
+  ensure(
+    metadata["client_name"].as_str() == Some("lifecycle client"),
+    "read must echo the registered client_name",
+  )?;
+  ensure(
+    metadata["dpop_bound_access_tokens"].as_bool() == Some(true),
+    "read must echo the DPoP binding",
+  )?;
+
+  let renamed = registration_body("lifecycle client renamed", redirect);
+  let (status, body) = put_json(
+    &world,
+    &format!("/register/{}", registered.client_id),
+    &registered.registration_token,
+    &renamed,
+  )
+  .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("PUT /register/{{id}} must be 200, got {status}: {body}"),
+  )?;
+  let updated = json_body(&body)?;
+  ensure(
+    updated["client_name"].as_str() == Some("lifecycle client renamed"),
+    "PUT must apply the new client_name",
+  )?;
+  let rotated = string_field(&updated, "registration_access_token")?.to_owned();
+  ensure(
+    rotated != registered.registration_token,
+    "PUT must rotate the management token",
+  )?;
+  // A successful PUT also rotates a secret-method client's secret, so the
+  // secret from the 201 body is stale from here on.
+  let rotated_secret = string_field(&updated, "client_secret")?.to_owned();
+  let minted = world
+    .mint(&registered.client_id, "", ClientKind::Confidential, None)
+    .await?;
+  let (status, _, body) = world
+    .post_form(
+      "/introspect",
+      &[("token", minted.access_token.as_str())],
+      Some((registered.client_id.as_str(), rotated_secret.as_str())),
+      &[],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("introspection must be 200, got {status}: {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["active"].as_bool() == Some(true),
+    "the minted token must introspect active before delete",
+  )?;
+
+  let (status, _) = delete(
+    &world,
+    &format!("/register/{}", registered.client_id),
+    &rotated,
+  )
+  .await?;
+  ensure(
+    status == StatusCode::NO_CONTENT,
+    format!("DELETE /register/{{id}} must be 204, got {status}"),
+  )?;
+
+  let auth = format!("Bearer {rotated}");
+  let (status, _, body) = world
+    .get(
+      &format!("/register/{}", registered.client_id),
+      &[("Authorization", auth.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::UNAUTHORIZED,
+    format!("management after delete must be 401, got {status}: {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["error"].as_str() == Some("invalid_client"),
+    format!("management after delete must be invalid_client, got {body}"),
+  )?;
+
+  let other_id = "e2e-dcr-introspector";
+  let other_secret = "e2e-dcr-introspector-secret-aaaabbbbccccdddd";
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    other_id,
+    Some(other_secret),
+    &["authorization_code"],
+    "",
+    TokenEndpointAuthMethod::ClientSecretBasic,
+  )
+  .await?;
+  let (status, _, body) = world
+    .post_form(
+      "/introspect",
+      &[("token", minted.access_token.as_str())],
+      Some((other_id, other_secret)),
+      &[],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("foreign introspection must be 200, got {status}: {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["active"].as_bool() == Some(false),
+    "the deleted client's token must introspect inactive",
+  )
+}
+
+/// Every bad-metadata row renders its documented RFC 7591 error and creates
+/// no client row.
+#[tokio::test]
+async fn e2e_dcr_rejects_bad_metadata_at_http() -> Result<(), TestError> {
+  let world = World::start_with(open_rate_limit(), json!({})).await?;
+  let iat = seed_iat(&world, 10).await?;
+
+  let wildcard = json!({
+    "redirect_uris": ["https://*.example.test/cb"],
+    "token_endpoint_auth_method": "client_secret_basic",
+    "dpop_bound_access_tokens": true,
+  });
+  let plain_http = json!({
+    "redirect_uris": ["http://app.example.test/cb"],
+    "token_endpoint_auth_method": "client_secret_basic",
+    "dpop_bound_access_tokens": true,
+  });
+  let bad_method = json!({
+    "redirect_uris": ["https://app.example.test/cb"],
+    "token_endpoint_auth_method": "bogus",
+    "dpop_bound_access_tokens": true,
+  });
+  let both_jwks = json!({
+    "redirect_uris": ["https://app.example.test/cb"],
+    "token_endpoint_auth_method": "client_secret_basic",
+    "dpop_bound_access_tokens": true,
+    "jwks": {"keys": []},
+    "jwks_uri": "https://app.example.test/jwks.json",
+  });
+
+  let rows = [
+    (&wildcard, "invalid_redirect_uri"),
+    (&plain_http, "invalid_redirect_uri"),
+    (&bad_method, "invalid_client_metadata"),
+    (&both_jwks, "invalid_client_metadata"),
+  ];
+  for (body, expected) in rows {
+    let (status, response_body) = post_json(&world, "/register", &iat, body).await?;
+    ensure(
+      status == StatusCode::BAD_REQUEST,
+      format!("{expected} row must be 400, got {status}: {response_body}"),
+    )?;
+    let parsed = json_body(&response_body)?;
+    ensure(
+      parsed["error"].as_str() == Some(expected),
+      format!("expected {expected}, got {response_body}"),
+    )?;
+  }
+
+  let clients: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM oauth_clients")
+    .fetch_one(&world.pool)
+    .await?;
+  ensure(
+    clients == 0,
+    format!("no rejected registration may persist a client row, found {clients}"),
+  )
+}
+
+/// A management token is scoped to the client it was minted for.
+#[tokio::test]
+async fn e2e_dcr_management_token_is_client_scoped() -> Result<(), TestError> {
+  let world = World::start_with(open_rate_limit(), json!({"dcr_redirect_policy": "open"})).await?;
+  let iat = seed_iat(&world, 5).await?;
+  let first = register(&world, &iat, "first client", "https://app.example.test/a").await?;
+  let second = register(&world, &iat, "second client", "https://app.example.test/b").await?;
+
+  let auth = format!("Bearer {}", second.registration_token);
+  let (status, _, body) = world
+    .get(
+      &format!("/register/{}", first.client_id),
+      &[("Authorization", auth.as_str())],
+    )
+    .await?;
+  ensure_forbidden(status, &body)?;
+
+  let rename = registration_body("attacker rename", "https://app.example.test/b");
+  let (status, body) = put_json(
+    &world,
+    &format!("/register/{}", first.client_id),
+    &second.registration_token,
+    &rename,
+  )
+  .await?;
+  ensure_forbidden(status, &body)?;
+
+  let (status, body) = delete(
+    &world,
+    &format!("/register/{}", first.client_id),
+    &second.registration_token,
+  )
+  .await?;
+  ensure_forbidden(status, &body)?;
+
+  let auth = format!("Bearer {}", first.registration_token);
+  let (status, _, body) = world
+    .get(
+      &format!("/register/{}", first.client_id),
+      &[("Authorization", auth.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("the victim client must stay manageable, got {status}: {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["client_name"].as_str() == Some("first client"),
+    "cross-client calls must not mutate the victim client",
+  )
+}

+ 293 - 0
tests/e2e_device.rs

@@ -0,0 +1,293 @@
+//! `binary(e2e_device)` device-grant flow over the live router (RFC 8628).
+//!
+//! Every request goes through the production router over a loopback socket
+//! against the real shared Postgres + Redis. The tests drive the real user
+//! half (`POST /device` entry form, review form, CSRF) through the shared
+//! browser helper, then poll `/token` with a fresh DPoP proof, exactly as a
+//! device would.
+//!
+//! The minted token is introspected with the owner client: introspection is
+//! client-scoped (`src/protocol/introspect.rs`, a foreign token collapses to
+//! `active:false`) and RFC 7662 authentication rejects the public `none`
+//! method, so the DPoP-bound device client here holds a secret for the
+//! introspection leg. Public-client device issuance and denial are covered by
+//! the other two cases.
+
+#[path = "e2e/harness.rs"]
+#[allow(dead_code)]
+mod harness;
+
+#[path = "e2e/browser.rs"]
+#[allow(dead_code)]
+mod browser;
+
+#[path = "e2e/dpop.rs"]
+mod dpop;
+
+#[path = "e2e/device.rs"]
+mod device;
+
+use d_oauth::db::TokenEndpointAuthMethod;
+use device::{
+  Client, DEVICE_CLIENT, DEVICE_GRANT, DEVICE_SECRET, Decision, PUBLIC_CLIENT, Poll, SCOPE, decide,
+  issue, poll, review, seed_public_client,
+};
+use dpop::DpopKey;
+use harness::{
+  SeedClientOpts, TestError, World, ensure, fail, json_body, open_rate_limit, seed_client_bound,
+  seed_scope,
+};
+use reqwest::StatusCode;
+
+/// Provisioned user in the approve/poll case.
+const OWNER_EMAIL: &str = "device-owner@example.test";
+/// Provisioned password in the approve/poll case.
+const OWNER_PASSWORD: &str = "e2e-device-owner-password-1a2b";
+/// Provisioned user in the deny case.
+const DENY_EMAIL: &str = "device-deny@example.test";
+/// Provisioned password in the deny case.
+const DENY_PASSWORD: &str = "e2e-device-deny-password-1a2b";
+/// Provisioned user in the normalization case.
+const NORMALIZE_EMAIL: &str = "device-normalize@example.test";
+/// Provisioned password in the normalization case.
+const NORMALIZE_PASSWORD: &str = "e2e-device-normalize-password-1a2b";
+/// Provisioned user in the guess-budget case.
+const BUDGET_EMAIL: &str = "device-budget@example.test";
+/// Provisioned password in the guess-budget case.
+const BUDGET_PASSWORD: &str = "e2e-device-budget-password-1a2b";
+
+/// Approve through the UI, then redeem exactly once and introspect the token.
+#[tokio::test]
+async fn e2e_device_grant_approve_poll_mint() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let client = Client {
+    id: DEVICE_CLIENT,
+    secret: Some(DEVICE_SECRET),
+    scope: SCOPE,
+  };
+  seed_client_bound(
+    &world.pool,
+    &world.state.keys,
+    client.id,
+    client.secret,
+    &SeedClientOpts {
+      grants: &[DEVICE_GRANT],
+      scope: SCOPE,
+      method: TokenEndpointAuthMethod::ClientSecretBasic,
+      dpop_bound: true,
+    },
+  )
+  .await?;
+  seed_scope(&world.pool, SCOPE).await?;
+  let key = DpopKey::generate()?;
+  let (device_code, user_code) = issue(&world, client, &key).await?;
+
+  // The anonymous GET /device only renders the entry form; the review page
+  // comes from POST /device without an `action`, on an authenticated session.
+  let browser = browser::login_session(world, OWNER_EMAIL, OWNER_PASSWORD).await?;
+  let session = browser
+    .session
+    .as_deref()
+    .ok_or_else(|| fail("login_session must return an authenticated session"))?;
+  let page = review(&browser.world, session, &user_code).await?;
+  let csrf = browser::hidden_input(&page, "csrf")?;
+  decide(
+    &browser.world,
+    Decision {
+      session,
+      user_code: &user_code,
+      csrf: &csrf,
+      action: "approve",
+    },
+  )
+  .await?;
+
+  let request = Poll {
+    client,
+    device_code: &device_code,
+  };
+  let (status, body) = poll(&browser.world, request, &key).await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("approved device poll must mint: {status} {body}"),
+  )?;
+  let tokens = json_body(&body)?;
+  ensure(tokens["token_type"] == "DPoP", "token must be DPoP-bound")?;
+  ensure(
+    tokens["scope"] == SCOPE,
+    "token must carry the granted scope",
+  )?;
+  let access_token = tokens["access_token"]
+    .as_str()
+    .filter(|token| !token.is_empty())
+    .ok_or_else(|| fail("redemption must return a non-empty access token"))?;
+  ensure(
+    tokens["refresh_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "redemption must return a non-empty refresh token",
+  )?;
+
+  let basic = Some((DEVICE_CLIENT, DEVICE_SECRET));
+  let (status, _, body) = browser
+    .world
+    .post_form("/introspect", &[("token", access_token)], basic, &[])
+    .await?;
+  ensure(status == StatusCode::OK, "introspection must answer 200")?;
+  let introspection = json_body(&body)?;
+  ensure(
+    introspection["active"] == true,
+    "minted token must be active",
+  )?;
+  ensure(
+    introspection["client_id"] == DEVICE_CLIENT,
+    "introspection must report the owning client",
+  )?;
+
+  // The code is single-use: the next poll is terminal `expired_token`, never a
+  // second pair and never `invalid_grant`.
+  let (status, body) = poll(&browser.world, request, &key).await?;
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("second poll must be 400: {status} {body}"),
+  )?;
+  let error = json_body(&body)?;
+  ensure(
+    error["error"] == "expired_token",
+    format!("consumed code must be expired_token: {error}"),
+  )?;
+  ensure(!body.contains("access_token"), "replay must mint nothing")?;
+  Ok(())
+}
+
+/// Denying the request makes every later poll `access_denied` with no token.
+#[tokio::test]
+async fn e2e_device_grant_deny_is_access_denied() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let client = Client {
+    id: PUBLIC_CLIENT,
+    secret: None,
+    scope: SCOPE,
+  };
+  seed_public_client(&world).await?;
+  let key = DpopKey::generate()?;
+  let (device_code, user_code) = issue(&world, client, &key).await?;
+
+  let browser = browser::login_session(world, DENY_EMAIL, DENY_PASSWORD).await?;
+  let session = browser
+    .session
+    .as_deref()
+    .ok_or_else(|| fail("login_session must return an authenticated session"))?;
+  let page = review(&browser.world, session, &user_code).await?;
+  let csrf = browser::hidden_input(&page, "csrf")?;
+  decide(
+    &browser.world,
+    Decision {
+      session,
+      user_code: &user_code,
+      csrf: &csrf,
+      action: "deny",
+    },
+  )
+  .await?;
+
+  let request = Poll {
+    client,
+    device_code: &device_code,
+  };
+  let (status, body) = poll(&browser.world, request, &key).await?;
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("denied poll must be 400: {status} {body}"),
+  )?;
+  let error = json_body(&body)?;
+  ensure(
+    error["error"] == "access_denied",
+    format!("denied poll must be access_denied: {error}"),
+  )?;
+  ensure(!body.contains("access_token"), "denial must mint nothing")?;
+  Ok(())
+}
+
+/// A lowercase, hyphenated code resolves, and the 11th lookup from one IP is
+/// rate-limited with a positive `Retry-After`.
+#[tokio::test]
+async fn e2e_device_user_code_normalization_and_guess_limit() -> Result<(), TestError> {
+  // Normalization: a real code typed as `abcd-efgh` must reach the review
+  // page. Separate worlds give the guess-budget half a fresh per-IP counter
+  // (each world has its own Redis key prefix).
+  let world = World::start(open_rate_limit()).await?;
+  let client = Client {
+    id: PUBLIC_CLIENT,
+    secret: None,
+    scope: SCOPE,
+  };
+  seed_public_client(&world).await?;
+  let key = DpopKey::generate()?;
+  let (_device_code, user_code) = issue(&world, client, &key).await?;
+  let browser = browser::login_session(world, NORMALIZE_EMAIL, NORMALIZE_PASSWORD).await?;
+  let session = browser
+    .session
+    .as_deref()
+    .ok_or_else(|| fail("login_session must return an authenticated session"))?;
+  let normalized = user_code.to_ascii_lowercase();
+  ensure(
+    normalized.contains('-'),
+    "the issued user code must have the display hyphen",
+  )?;
+  let page = review(&browser.world, session, &normalized).await?;
+  ensure(
+    page.contains(PUBLIC_CLIENT),
+    "the normalized code must resolve to the pending client",
+  )?;
+  // The review form is the observable proof the code was accepted.
+  let _csrf = browser::hidden_input(&page, "csrf")?;
+
+  // Guess budget: 10 lookups pass, the 11th from the same IP is `429` with a
+  // positive `Retry-After` (RFC 8628 §3.3 brute-force protection).
+  let world = World::start(open_rate_limit()).await?;
+  let browser = browser::login_session(world, BUDGET_EMAIL, BUDGET_PASSWORD).await?;
+  let session = browser
+    .session
+    .as_deref()
+    .ok_or_else(|| fail("login_session must return an authenticated session"))?;
+  let cookie = device::session_cookie(session);
+  for attempt in 1..=10 {
+    let code = format!("ZZZZ-{attempt:04}");
+    let (status, _, body) = browser
+      .world
+      .post_form(
+        "/device",
+        &[("user_code", code.as_str())],
+        None,
+        &[("cookie", cookie.as_str())],
+      )
+      .await?;
+    ensure(
+      status == StatusCode::OK,
+      format!("bad guess {attempt} must be allowed: {status} {body}"),
+    )?;
+  }
+  let (status, headers, body) = browser
+    .world
+    .post_form(
+      "/device",
+      &[("user_code", "ZZZZ-9999")],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::TOO_MANY_REQUESTS,
+    format!("the 11th bad guess must be 429: {status} {body}"),
+  )?;
+  let retry_after = headers
+    .get(reqwest::header::RETRY_AFTER)
+    .and_then(|value| value.to_str().ok())
+    .ok_or_else(|| fail("429 must carry Retry-After"))?;
+  let seconds: u64 = retry_after
+    .parse()
+    .map_err(|_| fail("Retry-After must be whole seconds"))?;
+  ensure(seconds >= 1, "Retry-After must be positive")?;
+  Ok(())
+}

+ 181 - 0
tests/e2e_edge_matrix.rs

@@ -0,0 +1,181 @@
+//! `binary(e2e_edge_matrix)` HTTP rejection matrix for DCR, device, and token
+//! exchange, plus a `private_key_jwt` mint/replay pair.
+//!
+//! Every case runs through the REAL production router over a loopback socket
+//! against the shared Postgres + Redis. Each row asserts the exact status and
+//! OAuth error code and that no token/code/client is issued. DCR rows seed the
+//! initial access token through the production keyed-hash path (a plain
+//! `sha256_hex` never matches `authorize_caller`).
+//!
+//! Device rows distinguish `/device` (a consumed user code renders the generic
+//! `200` miss) from `/token` (a consumed device code is `400 expired_token`).
+
+#[path = "e2e/harness.rs"]
+#[allow(dead_code)]
+mod harness;
+
+#[path = "e2e/dpop.rs"]
+mod dpop;
+
+#[path = "e2e/browser.rs"]
+#[allow(dead_code)]
+mod browser;
+
+#[path = "e2e/device.rs"]
+#[allow(dead_code)]
+mod device;
+
+#[path = "e2e/edge.rs"]
+mod edge;
+
+#[path = "e2e/exchange.rs"]
+mod exchange;
+
+#[path = "e2e/pkjwt.rs"]
+mod pkjwt;
+
+use harness::{TestError, World, ensure, fail, json_body, open_rate_limit};
+use pkjwt::{AssertionKey, CLIENT_ID, seed_assertion_client};
+use reqwest::StatusCode;
+use serde_json::json;
+
+/// `private_key_jwt` assertion-type URN (RFC 7523).
+const CLIENT_ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
+
+/// One rejection row of the matrix.
+#[derive(Clone, Copy)]
+enum EdgeCase {
+  /// DCR wildcard redirect under the default `catalog` policy.
+  DcrWildcardRedirect,
+  /// DCR `http://` non-loopback redirect under the `catalog` policy.
+  DcrPlainHttpNonLoopback,
+  /// DCR metadata carrying both `jwks` and `jwks_uri`.
+  DcrJwksAndJwksUri,
+  /// Device authorization asking beyond the client registration.
+  DeviceOverRegisteredScope,
+  /// Consumed user code at `/device` renders the generic 200 miss.
+  DeviceUserCodeConsumed,
+  /// Consumed device code at `/token` is `400 expired_token`.
+  DeviceCodeConsumedAtToken,
+  /// Token exchange without a `subject_token`.
+  ExchangeMissingSubjectToken,
+  /// Token exchange with a repeated `scope` or `grant_type`.
+  ExchangeDuplicateParameter,
+}
+
+impl EdgeCase {
+  /// Row label used in failure context.
+  const fn name(self) -> &'static str {
+    match self {
+      Self::DcrWildcardRedirect => "dcr wildcard redirect",
+      Self::DcrPlainHttpNonLoopback => "dcr http non-loopback",
+      Self::DcrJwksAndJwksUri => "dcr jwks+jwks_uri",
+      Self::DeviceOverRegisteredScope => "device over-registered scope",
+      Self::DeviceUserCodeConsumed => "device consumed user_code at /device",
+      Self::DeviceCodeConsumedAtToken => "device consumed device_code at /token",
+      Self::ExchangeMissingSubjectToken => "exchange missing subject_token",
+      Self::ExchangeDuplicateParameter => "exchange duplicate scope/grant_type",
+    }
+  }
+}
+
+/// The full matrix, one row per case.
+const CASES: [EdgeCase; 8] = [
+  EdgeCase::DcrWildcardRedirect,
+  EdgeCase::DcrPlainHttpNonLoopback,
+  EdgeCase::DcrJwksAndJwksUri,
+  EdgeCase::DeviceOverRegisteredScope,
+  EdgeCase::DeviceUserCodeConsumed,
+  EdgeCase::DeviceCodeConsumedAtToken,
+  EdgeCase::ExchangeMissingSubjectToken,
+  EdgeCase::ExchangeDuplicateParameter,
+];
+
+/// Every listed edge case rejects at the HTTP layer with its exact code.
+#[tokio::test]
+async fn e2e_edge_matrix_http_rejections() -> Result<(), TestError> {
+  for case in CASES {
+    run(case)
+      .await
+      .map_err(|error| fail(format!("row {}: {error}", case.name())))?;
+  }
+  Ok(())
+}
+
+/// Dispatch one row.
+async fn run(case: EdgeCase) -> Result<(), TestError> {
+  match case {
+    EdgeCase::DcrWildcardRedirect => {
+      edge::dcr_rejects(
+        json!({
+          "redirect_uris": ["https://*.example.test/cb"],
+          "token_endpoint_auth_method": "client_secret_basic",
+          "dpop_bound_access_tokens": true,
+        }),
+        "invalid_redirect_uri",
+      )
+      .await
+    }
+    EdgeCase::DcrPlainHttpNonLoopback => {
+      edge::dcr_rejects(
+        json!({
+          "redirect_uris": ["http://app.example.test/cb"],
+          "token_endpoint_auth_method": "client_secret_basic",
+          "dpop_bound_access_tokens": true,
+        }),
+        "invalid_redirect_uri",
+      )
+      .await
+    }
+    EdgeCase::DcrJwksAndJwksUri => {
+      edge::dcr_rejects(
+        json!({
+          "redirect_uris": ["https://app.example.test/cb"],
+          "token_endpoint_auth_method": "client_secret_basic",
+          "dpop_bound_access_tokens": true,
+          "jwks": {"keys": []},
+          "jwks_uri": "https://app.example.test/jwks.json",
+        }),
+        "invalid_client_metadata",
+      )
+      .await
+    }
+    EdgeCase::DeviceOverRegisteredScope => edge::device_over_registered_scope().await,
+    EdgeCase::DeviceUserCodeConsumed => edge::device_user_code_consumed().await,
+    EdgeCase::DeviceCodeConsumedAtToken => edge::device_code_consumed_at_token().await,
+    EdgeCase::ExchangeMissingSubjectToken => exchange::missing_subject_token().await,
+    EdgeCase::ExchangeDuplicateParameter => exchange::duplicate_parameter().await,
+  }
+}
+
+/// A valid assertion authenticates and mints; replaying its `jti` is
+/// `invalid_client`.
+#[tokio::test]
+async fn e2e_private_key_jwt_mint_and_replay() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let key = AssertionKey::generate("k1")?;
+  seed_assertion_client(&world, key.jwks()).await?;
+  let audience = format!("{}/token", world.state.config.issuer);
+  let assertion = key.mint(CLIENT_ID, &audience, "edge-pkjwt-replay-jti")?;
+  let params = [
+    ("grant_type", "client_credentials"),
+    ("client_id", CLIENT_ID),
+    ("client_assertion_type", CLIENT_ASSERTION_TYPE),
+    ("client_assertion", assertion.as_str()),
+  ];
+
+  let (status, _, body) = world.post_form("/token", &params, None, &[]).await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("a valid private_key_jwt must authenticate and mint: {status} {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["access_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "the positive leg must issue an access token",
+  )?;
+
+  let (status, _, body) = world.post_form("/token", &params, None, &[]).await?;
+  edge::ensure_error(status, &body, StatusCode::BAD_REQUEST, "invalid_client")
+}

+ 143 - 311
tests/e2e_flow.rs

@@ -10,10 +10,13 @@
 //!
 //! A public client is used so the flow needs no PAR/JAR (a confidential
 //! client with only plain query params is rejected with `invalid_request`).
-//! Public clients are DPoP-bound end to end: the SAME [`DpopKey`] proves
-//! `GET /authorize` (its `jkt` is stashed in the pending-auth record) and
-//! `POST /token` (the redemption checks the proof `jkt` against the code's
+//! Public clients are DPoP-bound end to end: the SAME [`browser::DpopKey`]
+//! proves `GET /authorize` (its `jkt` is stashed in the pending-auth record)
+//! and `POST /token` (the redemption checks the proof `jkt` against the code's
 //! `cnf` binding), so one key spans the whole flow.
+//!
+//! The driver itself lives in [`browser`] so the CIMD, device, logout, and
+//! disabled-user suites reuse one implementation instead of copying it.
 
 #[allow(dead_code)]
 #[path = "e2e/harness.rs"]
@@ -22,237 +25,29 @@ mod harness;
 #[path = "e2e/dpop.rs"]
 mod dpop;
 
-use std::time::Duration;
+#[allow(dead_code)]
+#[path = "e2e/browser.rs"]
+mod browser;
 
-use d_oauth::db::{
-  TokenEndpointAuthMethod,
-  user_repo::{self, NewUser},
-  user_scope_repo,
-};
-use dpop::DpopKey;
-use harness::{
-  TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
-  seed_scope,
+use browser::{
+  CLIENT_ID, INVALID_CREDENTIALS, REDIRECT_URI, SCOPE, SESSION_COOKIE, STATE, begin_browser,
+  cookie_value, get_consent, has_query_key, hidden_input, location_of, post_consent, post_login,
+  query_value, totp_now,
 };
-
-/// Fixture public client (no secret: DPoP is its sender constraint).
-const CLIENT_ID: &str = "e2e-flow-public";
-/// Registered loopback redirect (RFC 8252 loopback exception applies).
-const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
-/// Catalog scope exercised by the flow.
-const SCOPE: &str = "read";
-/// `state` echoed from authorize to the code redirect (unreserved chars only).
-const STATE: &str = "e2e-flow-state";
-/// Session cookie name (production `src/ui/login.rs`: `SESSION_COOKIE`).
-const SESSION_COOKIE: &str = "__Host-session_id";
-/// Uniform credential-rejection page text (`INVALID_CREDENTIALS`).
-const INVALID_CREDENTIALS: &str = "Invalid email, password, or code.";
-
-/// One browser driving the flow: live world, PKCE pair, DPoP key, login
-/// session material, and the provisioned user's credentials.
-struct Browser {
-  world: World,
-  dpop: DpopKey,
-  verifier: String,
-  request_id: String,
-  presession: String,
-  login_csrf: String,
-  email: String,
-  password: String,
-  totp_secret: String,
-}
-
-/// Seed the world (public client + scope + redirect) and one TOTP-enrolled
-/// user entitled to the scope; drive `GET /authorize` → `GET /login`.
-async fn begin_browser(email: &str, password: &str) -> Result<Browser, TestError> {
-  let world = World::start(open_rate_limit()).await?;
-  seed_client(
-    &world.pool,
-    &world.state.keys,
-    CLIENT_ID,
-    None,
-    &["authorization_code", "refresh_token"],
-    SCOPE,
-    TokenEndpointAuthMethod::None,
-  )
-  .await?;
-  seed_scope(&world.pool, SCOPE).await?;
-  attach_redirect(&world.pool, &world.state.keys, CLIENT_ID, REDIRECT_URI).await?;
-  let totp_secret = seed_user(&world, email, password).await?;
-
-  let (challenge, verifier) = oauth2_pkce();
-  let dpop = DpopKey::generate()?;
-  let proof = dpop.proof("GET", &format!("{}/authorize", world.state.config.issuer))?;
-  let (status, headers, body) = world
-    .get(
-      &format!(
-        "/authorize?client_id={CLIENT_ID}&redirect_uri={}&response_type=code&scope={SCOPE}\
-         &state={STATE}&code_challenge={challenge}&code_challenge_method=S256",
-        encoded(REDIRECT_URI)
-      ),
-      &[("dpop", proof.as_str())],
-    )
-    .await?;
-  ensure(
-    status == reqwest::StatusCode::SEE_OTHER,
-    format!("authorize must 303 to login: {status} {body}"),
-  )?;
-  let request_id = query_value(&location_of(&headers)?, "request_id")?;
-  // The authorize layer stashes the verified DPoP thumbprint in the
-  // pending-auth record the later code issuance binds (RFC 9700 §4.5.3).
-  let mut conn = world.state.redis.clone();
-  let record = d_oauth::store::pending::get(&mut conn, &world.store, &request_id)
-    .await
-    .map_err(|error| fail(format!("pending read failed: {error}")))?
-    .ok_or_else(|| fail("pending record must exist"))?;
-  let expected = d_oauth::jose::jwk::thumbprint(&dpop.jwk())
-    .map_err(|error| fail(format!("jwk thumbprint: {error}")))?;
-  ensure(
-    record.dpop_jkt.as_deref() == Some(expected.as_str()),
-    "pending record must carry the verified DPoP thumbprint",
-  )?;
-
-  let (status, headers, body) = world
-    .get(&format!("/login?request_id={request_id}"), &[])
-    .await?;
-  ensure(
-    status == reqwest::StatusCode::OK,
-    format!("login page must render: {status} {body}"),
-  )?;
-  let presession = cookie_value(&headers, SESSION_COOKIE)?;
-  let login_csrf = hidden_input(&body, "csrf")?;
-  Ok(Browser {
-    world,
-    dpop,
-    verifier,
-    request_id,
-    presession,
-    login_csrf,
-    email: email.to_owned(),
-    password: password.to_owned(),
-    totp_secret,
-  })
-}
-
-/// Provision one enabled user (known password + TOTP secret, entitled to
-/// `SCOPE`) through the REAL repository path, using the server's own Argon2
-/// parameters so verification matches production exactly.
-async fn seed_user(world: &World, email: &str, password: &str) -> Result<String, TestError> {
-  let raw = &world.state.config.argon2;
-  let params =
-    d_oauth::crypto::secret::Argon2Params::new(raw.memory_kib, raw.iterations, raw.parallelism, 32)
-      .map_err(|error| fail(format!("argon2 params: {error}")))?;
-  let hash = d_oauth::crypto::secret::hash_secret(password, &params)
-    .map_err(|error| fail(format!("hash password: {error}")))?;
-  let created = user_repo::create_user(
-    &world.pool,
-    &world.state.keys,
-    world.state.config.pii_dual_write,
-    &NewUser {
-      email: email.to_owned(),
-      password_hash: hash,
-      is_admin: false,
-      must_change_password: false,
-    },
-  )
-  .await?;
-  let secret = d_oauth::crypto::totp::generate_secret()
-    .map_err(|error| fail(format!("totp secret: {error}")))?;
-  let blob = d_oauth::crypto::totp::encrypt_secret(
-    &world.state.keys,
-    &secret,
-    !world.state.config.pii_dual_write,
-  )
-  .map_err(|error| fail(format!("totp seal: {error}")))?;
-  ensure(
-    user_repo::set_totp_secret(&world.pool, created.id, &blob).await?,
-    "totp enroll must update the user row",
-  )?;
-  user_scope_repo::entitle(&world.pool, created.id, SCOPE, None).await?;
-  Ok(secret)
-}
-
-/// Current TOTP code from the wall clock, computed the way the server
-/// verifies it (RFC 6238, ±1-step window). Steps back from the step edge so
-/// the code cannot roll over between generation and the `POST /login`.
-async fn totp_now(secret: &str) -> Result<String, TestError> {
-  let step = d_oauth::crypto::totp::TOTP_STEP_SECS;
-  let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64;
-  if now % step >= step - 2 {
-    tokio::time::sleep(Duration::from_secs(3)).await;
-  }
-  let now = time::OffsetDateTime::now_utc().unix_timestamp().max(0) as u64;
-  let verifier = d_oauth::crypto::totp::build_totp(secret)
-    .map_err(|error| fail(format!("totp verifier: {error}")))?;
-  let counter = now / step;
-  Ok(format!("{:06}", verifier.generate(counter * step)))
-}
-
-/// `POST /login` as the browser: presession cookie + form credentials.
-async fn post_login(
-  browser: &Browser,
-  password: &str,
-  code: &str,
-) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
-  let cookie = format!("{SESSION_COOKIE}={}", browser.presession);
-  browser
-    .world
-    .post_form(
-      "/login",
-      &[
-        ("email", browser.email.as_str()),
-        ("password", password),
-        ("code", code),
-        ("csrf", browser.login_csrf.as_str()),
-      ],
-      None,
-      &[("cookie", cookie.as_str())],
-    )
-    .await
-}
-
-/// `GET /consent` as the browser holding `session`.
-async fn get_consent(
-  browser: &Browser,
-  session: &str,
-) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
-  let cookie = format!("{SESSION_COOKIE}={session}");
-  browser
-    .world
-    .get("/consent", &[("cookie", cookie.as_str())])
-    .await
-}
-
-/// `POST /consent` with the bound `request_id` and the session form token.
-async fn post_consent(
-  browser: &Browser,
-  session: &str,
-  request_id: &str,
-  csrf: &str,
-  decision: &str,
-) -> Result<(reqwest::StatusCode, reqwest::header::HeaderMap, String), TestError> {
-  let cookie = format!("{SESSION_COOKIE}={session}");
-  browser
-    .world
-    .post_form(
-      "/consent",
-      &[
-        ("request_id", request_id),
-        ("csrf", csrf),
-        ("decision", decision),
-      ],
-      None,
-      &[("cookie", cookie.as_str())],
-    )
-    .await
-}
+use harness::{TestError, World, ensure, json_body, open_rate_limit};
 
 /// Happy path: the full browser flow issues a code and redeems it for
 /// DPoP-bound tokens; the code is single-use (RFC 6749 §4.1.2: a second
 /// redemption is `invalid_grant`).
 #[tokio::test]
 async fn e2e_flow_browser_authorize_login_consent_token() -> Result<(), TestError> {
-  let browser = begin_browser("flow-happy@example.test", "correct-horse-28-happy-path").await?;
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser(
+    world,
+    "flow-happy@example.test",
+    "correct-horse-28-happy-path",
+  )
+  .await?;
   let code_2fa = totp_now(&browser.totp_secret).await?;
   let (status, headers, _) = post_login(&browser, &browser.password.clone(), &code_2fa).await?;
   ensure(
@@ -386,7 +181,13 @@ async fn e2e_flow_browser_authorize_login_consent_token() -> Result<(), TestErro
 /// back to `/login` and no code can ever issue for the flow.
 #[tokio::test]
 async fn e2e_flow_login_wrong_password_rejected() -> Result<(), TestError> {
-  let browser = begin_browser("flow-badpw@example.test", "correct-horse-28-right-one").await?;
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser(
+    world,
+    "flow-badpw@example.test",
+    "correct-horse-28-right-one",
+  )
+  .await?;
   let code_2fa = totp_now(&browser.totp_secret).await?;
   let (status, headers, body) = post_login(&browser, "wrong-password-00000000", &code_2fa).await?;
   ensure(
@@ -413,12 +214,126 @@ async fn e2e_flow_login_wrong_password_rejected() -> Result<(), TestError> {
   Ok(())
 }
 
+/// A correct password with a wrong TOTP code is rejected uniformly (401, no
+/// session upgrade): the presession never becomes an authenticated session,
+/// so `/consent` bounces back to `/login`. A follow-up attempt with the live
+/// code still succeeds, proving the rejection was code-specific.
+#[tokio::test]
+async fn e2e_flow_login_wrong_totp_rejected() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser(
+    world,
+    "flow-badcode@example.test",
+    "correct-horse-28-right-one",
+  )
+  .await?;
+  // A valid-format but incorrect 6-digit code: flip the first digit of the
+  // live code (the unit-suite `wrong_code` idiom).
+  let live = totp_now(&browser.totp_secret).await?;
+  let mut digits: Vec<u8> = live.bytes().collect();
+  digits[0] = if digits[0] == b'0' { b'1' } else { b'0' };
+  let wrong: String = digits.into_iter().map(|byte| byte as char).collect();
+  let (status, headers, body) = post_login(&browser, &browser.password.clone(), &wrong).await?;
+  ensure(
+    status == reqwest::StatusCode::UNAUTHORIZED,
+    format!("wrong TOTP must be 401, got {status}: {body}"),
+  )?;
+  ensure(
+    body.contains(INVALID_CREDENTIALS),
+    "rejection must render the uniform message",
+  )?;
+  ensure(
+    cookie_value(&headers, SESSION_COOKIE).is_err(),
+    "rejection must not upgrade the session",
+  )?;
+  let (status, headers, _) = get_consent(&browser, &browser.presession).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("without a session consent must bounce to login: {status}"),
+  )?;
+  ensure(
+    location_of(&headers)? == "/login",
+    "the bounce target must be the login page",
+  )?;
+  // The live code still authenticates afterwards: the rejection was
+  // code-specific, not an account or session defect.
+  let live = totp_now(&browser.totp_secret).await?;
+  let (status, headers, body) = post_login(&browser, &browser.password.clone(), &live).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("live code must 303 to consent: {status} {body}"),
+  )?;
+  ensure(
+    location_of(&headers)? == "/consent",
+    "live code must land on consent",
+  )?;
+  Ok(())
+}
+
+/// A mismatched login CSRF is rejected fail-closed (400 `invalid_request`,
+/// no session upgrade): the presession never becomes an authenticated
+/// session, so `/consent` bounces back to `/login`.
+#[tokio::test]
+async fn e2e_flow_login_csrf_mismatch_rejected() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser(
+    world,
+    "flow-badcsrf@example.test",
+    "correct-horse-28-right-one",
+  )
+  .await?;
+  let code_2fa = totp_now(&browser.totp_secret).await?;
+  let cookie = format!("{SESSION_COOKIE}={}", browser.presession);
+  let (status, headers, body) = browser
+    .world
+    .post_form(
+      "/login",
+      &[
+        ("email", browser.email.as_str()),
+        ("password", browser.password.as_str()),
+        ("code", code_2fa.as_str()),
+        ("csrf", "tampered-token"),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::BAD_REQUEST,
+    format!("CSRF mismatch must be 400, got {status}: {body}"),
+  )?;
+  ensure(
+    body.contains("invalid_request"),
+    "CSRF mismatch must be invalid_request",
+  )?;
+  ensure(
+    cookie_value(&headers, SESSION_COOKIE).is_err(),
+    "rejection must not upgrade the session",
+  )?;
+  let (status, headers, _) = get_consent(&browser, &browser.presession).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("without a session consent must bounce to login: {status}"),
+  )?;
+  ensure(
+    location_of(&headers)? == "/login",
+    "the bounce target must be the login page",
+  )?;
+  Ok(())
+}
+
 /// Denying consent redirects to the registered redirect with
 /// `error=access_denied` + echoed `state` (RFC 6749 §4.1.2.1) and issues no
 /// code.
 #[tokio::test]
 async fn e2e_flow_consent_deny_redirects_access_denied() -> Result<(), TestError> {
-  let browser = begin_browser("flow-deny@example.test", "correct-horse-28-deny-case").await?;
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser(
+    world,
+    "flow-deny@example.test",
+    "correct-horse-28-deny-case",
+  )
+  .await?;
   let code_2fa = totp_now(&browser.totp_secret).await?;
   let (status, headers, body) = post_login(&browser, &browser.password.clone(), &code_2fa).await?;
   ensure(
@@ -459,86 +374,3 @@ async fn e2e_flow_consent_deny_redirects_access_denied() -> Result<(), TestError
   )?;
   Ok(())
 }
-
-/// PKCE pair from the `oauth2` crate (baseline-flow material only).
-fn oauth2_pkce() -> (String, String) {
-  let (challenge, verifier) = oauth2::PkceCodeChallenge::new_random_sha256();
-  (challenge.as_str().to_owned(), verifier.secret().to_owned())
-}
-
-/// Percent-encode a value for query embedding.
-fn encoded(value: &str) -> String {
-  let mut out = String::with_capacity(value.len());
-  for byte in value.bytes() {
-    if byte.is_ascii_alphanumeric() || b"-_.~".contains(&byte) {
-      out.push(byte as char);
-    } else {
-      out.push_str(&format!("%{byte:02X}"));
-    }
-  }
-  out
-}
-
-/// Read the `location` header or fail the test.
-fn location_of(headers: &reqwest::header::HeaderMap) -> Result<String, TestError> {
-  headers
-    .get("location")
-    .and_then(|value| value.to_str().ok())
-    .map(str::to_owned)
-    .ok_or_else(|| fail("response lacks location header"))
-}
-
-/// Read a cookie value from the `set-cookie` headers or fail the test.
-fn cookie_value(headers: &reqwest::header::HeaderMap, name: &str) -> Result<String, TestError> {
-  let prefix = format!("{name}=");
-  let mut found = None;
-  for value in headers.get_all("set-cookie").iter() {
-    let text = value
-      .to_str()
-      .map_err(|_| fail("set-cookie is not visible ascii"))?;
-    if let Some(rest) = text.strip_prefix(prefix.as_str()) {
-      let end = rest.find(';').unwrap_or(rest.len());
-      found = Some(rest[..end].to_owned());
-    }
-  }
-  found.ok_or_else(|| fail(format!("response lacks {name} cookie")))
-}
-
-/// Read a hidden form field (`name=".." value=".."`) from a page or fail.
-fn hidden_input(body: &str, name: &str) -> Result<String, TestError> {
-  let needle = format!("name=\"{name}\" value=\"");
-  let start = body
-    .find(needle.as_str())
-    .ok_or_else(|| fail(format!("page lacks hidden {name}")))?;
-  let rest = &body[start + needle.len()..];
-  let end = rest
-    .find('"')
-    .ok_or_else(|| fail(format!("hidden {name} is unterminated")))?;
-  Ok(rest[..end].to_owned())
-}
-
-/// Read one query value from a redirect location or fail the test.
-fn query_value(location: &str, key: &str) -> Result<String, TestError> {
-  let query = location
-    .split_once('?')
-    .map(|pair| pair.1)
-    .ok_or_else(|| fail(format!("location carries no query: {location}")))?;
-  let prefix = format!("{key}=");
-  for pair in query.split('&') {
-    if let Some(value) = pair.strip_prefix(prefix.as_str()) {
-      return Ok(value.to_owned());
-    }
-  }
-  Err(fail(format!("location lacks {key}: {location}")))
-}
-
-/// Whether a redirect location carries a query key.
-fn has_query_key(location: &str, key: &str) -> bool {
-  let Some(query) = location.split_once('?').map(|pair| pair.1) else {
-    return false;
-  };
-  let prefix = format!("{key}=");
-  query
-    .split('&')
-    .any(|pair| pair.strip_prefix(prefix.as_str()).is_some())
-}

+ 193 - 0
tests/e2e_logout.rs

@@ -0,0 +1,193 @@
+//! `test(e2e_logout)` RP-initiated logout over the live router.
+//!
+//! `GET /logout` is a read-only confirmation, and only `POST /logout` with the
+//! session-bound form token destroys the session. The return target is never
+//! caller-supplied: `/authorize` freezes the client's registered post-logout
+//! URI in session meta, and `POST /logout` honors it only while the client and
+//! that exact URI are still registered.
+
+#[allow(dead_code)]
+#[path = "e2e/harness.rs"]
+mod harness;
+
+#[path = "e2e/dpop.rs"]
+mod dpop;
+
+#[allow(dead_code)]
+#[path = "e2e/browser.rs"]
+mod browser;
+
+use browser::{
+  SESSION_COOKIE, begin_browser, begin_browser_with_post_logout, cookie_value, get_consent,
+  hidden_input, location_of, post_login, totp_now,
+};
+use harness::{TestError, World, ensure, open_rate_limit};
+
+/// Registered client the logout flow drives.
+const CLIENT_ID: &str = "e2e-logout-app";
+/// Registered authorize redirect (RFC 8252 loopback exception applies).
+const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
+/// Registered post-logout return target.
+const POST_LOGOUT_URI: &str = "https://client.example.test/signed-out";
+/// Catalog scope the client and the user share.
+const SCOPE: &str = "read";
+
+/// A valid `POST /logout` destroys the session server-side, clears the cookie,
+/// and redirects to the exact post-logout URI the authorization flow froze.
+#[tokio::test]
+async fn e2e_logout_post_destroys_session_and_redirects() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser_with_post_logout(
+    world,
+    CLIENT_ID,
+    REDIRECT_URI,
+    SCOPE,
+    "logout-post@example.test",
+    "correct-horse-28-logout-post",
+    POST_LOGOUT_URI,
+  )
+  .await?;
+  let (session, cookie) = login(&browser).await?;
+
+  let (status, _, body) = browser
+    .world
+    .get("/logout", &[("cookie", cookie.as_str())])
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("GET /logout must render the confirmation: {status} {body}"),
+  )?;
+  let logout_csrf = hidden_input(&body, "csrf")?;
+
+  let (status, headers, body) = browser
+    .world
+    .post_form(
+      "/logout",
+      &[("csrf", logout_csrf.as_str())],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("POST /logout must 303 to the bound target: {status} {body}"),
+  )?;
+  let location = location_of(&headers)?;
+  ensure(
+    location == POST_LOGOUT_URI,
+    format!("logout must return to the registered post-logout URI, got {location}"),
+  )?;
+  let prefix = format!("{SESSION_COOKIE}=");
+  let cleared = headers.get_all("set-cookie").iter().any(|value| {
+    value
+      .to_str()
+      .ok()
+      .is_some_and(|text| text.starts_with(prefix.as_str()) && text.contains("Max-Age=0"))
+  });
+  ensure(cleared, "POST /logout must expire the session cookie")?;
+
+  let (status, headers, _) = get_consent(&browser, &session).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("the destroyed session must not reach consent: {status}"),
+  )?;
+  ensure(
+    location_of(&headers)? == "/login",
+    "the destroyed session must bounce consent to login",
+  )?;
+  Ok(())
+}
+
+/// `GET /logout` only renders the confirmation form; the session survives and
+/// still serves the bound consent page.
+#[tokio::test]
+async fn e2e_logout_get_is_read_only() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser(
+    world,
+    "logout-get@example.test",
+    "correct-horse-28-logout-get",
+  )
+  .await?;
+  let (session, cookie) = login(&browser).await?;
+
+  let (status, _, body) = browser
+    .world
+    .get("/logout", &[("cookie", cookie.as_str())])
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("GET /logout must render the confirmation: {status} {body}"),
+  )?;
+  ensure(
+    body.contains("Sign out"),
+    "GET /logout must offer the sign-out form",
+  )?;
+  let logout_csrf = hidden_input(&body, "csrf")?;
+  ensure(
+    !logout_csrf.is_empty(),
+    "the confirmation form must carry a session-bound token",
+  )?;
+
+  let (status, _, body) = get_consent(&browser, &session).await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("a read-only GET must leave the session usable: {status} {body}"),
+  )?;
+  Ok(())
+}
+
+/// A POST without a valid session-bound token is rejected with
+/// `invalid_request`, touches no cookie, and leaves the session usable.
+#[tokio::test]
+async fn e2e_logout_csrf_mismatch_keeps_session() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  let browser = begin_browser(
+    world,
+    "logout-csrf@example.test",
+    "correct-horse-28-logout-csrf",
+  )
+  .await?;
+  let (session, cookie) = login(&browser).await?;
+
+  for presented in [None, Some("forged-token")] {
+    let params: Vec<(&str, &str)> = presented.map_or_else(Vec::new, |csrf| vec![("csrf", csrf)]);
+    let (status, headers, body) = browser
+      .world
+      .post_form("/logout", &params, None, &[("cookie", cookie.as_str())])
+      .await?;
+    ensure(
+      status == reqwest::StatusCode::BAD_REQUEST,
+      format!("a bad logout token must be 400: {status} {body}"),
+    )?;
+    ensure(
+      body.contains("invalid_request"),
+      "a bad logout token must be invalid_request",
+    )?;
+    ensure(
+      !headers.contains_key("set-cookie"),
+      "a rejected logout must not touch cookies",
+    )?;
+  }
+
+  let (status, _, body) = get_consent(&browser, &session).await?;
+  ensure(
+    status == reqwest::StatusCode::OK,
+    format!("a rejected logout must leave the session usable: {status} {body}"),
+  )?;
+  Ok(())
+}
+
+/// Log in through the driver and return the authenticated session id plus its
+/// cookie header value.
+async fn login(browser: &browser::Browser) -> Result<(String, String), TestError> {
+  let code_2fa = totp_now(&browser.totp_secret).await?;
+  let (status, headers, body) = post_login(browser, &browser.password.clone(), &code_2fa).await?;
+  ensure(
+    status == reqwest::StatusCode::SEE_OTHER,
+    format!("login must 303 to consent: {status} {body}"),
+  )?;
+  let session = cookie_value(&headers, SESSION_COOKIE)?;
+  let cookie = format!("{SESSION_COOKIE}={session}");
+  Ok((session, cookie))
+}

+ 171 - 0
tests/e2e_mtls_grant.rs

@@ -0,0 +1,171 @@
+//! `test(e2e_)` mTLS `tls_client_auth` token-endpoint mint and rejection
+//! matrix (plan todo 14).
+//!
+//! Every case drives the REAL production router
+//! ([`d_oauth::routes::build_router`]) in process through
+//! [`peer::oneshot_with_peer`], which injects the verified peer chain the TLS
+//! accept loop would hand the router. No listener is bound: certificate-chain
+//! trust stays covered by the real-handshake `tests/bootstrap.rs` suite, while
+//! this suite proves what the router does with an already-verified chain —
+//! subject-DN matching, `cnf.x5t#S256` derivation, and the rejection rows.
+//!
+//! Test names contain `e2e_mtls` so
+//! `cargo nextest run -E 'binary(e2e_mtls_grant)'` selects exactly them.
+
+#[allow(dead_code)]
+#[path = "e2e/harness.rs"]
+mod harness;
+
+#[allow(dead_code)]
+#[path = "e2e/peer.rs"]
+mod peer;
+
+#[allow(dead_code)]
+#[allow(clippy::duplicate_mod)]
+#[path = "mtls/certs.rs"]
+mod certs;
+
+use axum::{
+  body::Body,
+  http::{Method, Request, StatusCode, header},
+  response::Response,
+};
+use harness::{
+  TestError, World, ensure, fail, json_body, open_rate_limit, seed_mtls_client, seed_scope,
+};
+use peer::{PKI_DN, PKI_THUMBPRINT, oneshot_with_peer, peer_chain};
+use rustls::pki_types::CertificateDer;
+
+/// Registered `tls_client_auth` client id.
+const CLIENT_ID: &str = "e2e-mtls-pki-client";
+
+/// Drive one form-encoded `POST` through the production router with the given
+/// peer evidence and return the status plus the body text.
+async fn post_with_peer(
+  world: &World,
+  uri: &str,
+  form: String,
+  peer: Option<Vec<CertificateDer<'static>>>,
+) -> Result<(StatusCode, String), TestError> {
+  let request = Request::builder()
+    .method(Method::POST)
+    .uri(uri)
+    .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+    .body(Body::from(form))
+    .map_err(|error| fail(format!("request build failed: {error}")))?;
+  let response: Response = oneshot_with_peer(world.state.clone(), request, peer).await;
+  let status = response.status();
+  let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024)
+    .await
+    .map_err(|error| fail(format!("response body read failed: {error}")))?;
+  let text = String::from_utf8(bytes.to_vec())
+    .map_err(|error| fail(format!("response body is not UTF-8: {error}")))?;
+  Ok((status, text))
+}
+
+/// Assert a token-endpoint rejection is `invalid_client` and mints nothing.
+fn ensure_invalid_client(status: StatusCode, body: &str) -> Result<(), TestError> {
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("must be 400 invalid_client, got {status}: {body}"),
+  )?;
+  ensure(
+    json_body(body)?["error"] == "invalid_client",
+    format!("failure must be invalid_client: {body}"),
+  )?;
+  ensure(
+    !body.contains("access_token"),
+    "a rejected request must not mint a token",
+  )?;
+  Ok(())
+}
+
+/// A registered CA-signed subject DN mints a `Bearer` token whose `cnf`
+/// carries the leaf thumbprint, and introspection reports that binding.
+#[tokio::test]
+async fn e2e_mtls_client_credentials_mints_cnf_bound_token() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_mtls_client(&world, CLIENT_ID, PKI_DN, PKI_THUMBPRINT).await?;
+  seed_scope(&world.pool, "api:read").await?;
+
+  let (status, body) = post_with_peer(
+    &world,
+    "/token",
+    format!("grant_type=client_credentials&client_id={CLIENT_ID}"),
+    Some(peer_chain().map_err(|error| fail(format!("peer chain fixture: {error}")))?),
+  )
+  .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("mTLS client_credentials must mint, got {status}: {body}"),
+  )?;
+  let token = json_body(&body)?;
+  ensure(
+    token["token_type"] == "Bearer",
+    format!("an mTLS-only issuance stays Bearer: {token}"),
+  )?;
+  let access_token = token["access_token"]
+    .as_str()
+    .filter(|value| !value.is_empty())
+    .ok_or_else(|| fail("token response must carry an access_token"))?;
+
+  let (status, body) = post_with_peer(
+    &world,
+    "/introspect",
+    format!("token={access_token}&client_id={CLIENT_ID}"),
+    Some(peer_chain().map_err(|error| fail(format!("peer chain fixture: {error}")))?),
+  )
+  .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("introspection must 200, got {status}: {body}"),
+  )?;
+  let introspected = json_body(&body)?;
+  ensure(
+    introspected["active"] == true,
+    format!("minted token must introspect active: {introspected}"),
+  )?;
+  ensure(
+    introspected["cnf"]["x5t#S256"] == PKI_THUMBPRINT,
+    format!("introspection must report the leaf thumbprint: {introspected}"),
+  )?;
+  Ok(())
+}
+
+/// A verified chain whose subject DN is not the registered one is rejected.
+#[tokio::test]
+async fn e2e_mtls_unknown_dn_rejected() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_mtls_client(&world, CLIENT_ID, PKI_DN, PKI_THUMBPRINT).await?;
+  seed_scope(&world.pool, "api:read").await?;
+  let unknown = vec![
+    certs::cert_der(certs::CLIENT_ROGUE_CERT_PEM)
+      .map_err(|error| fail(format!("rogue cert fixture: {error}")))?,
+  ];
+
+  let (status, body) = post_with_peer(
+    &world,
+    "/token",
+    format!("grant_type=client_credentials&client_id={CLIENT_ID}"),
+    Some(unknown),
+  )
+  .await?;
+  ensure_invalid_client(status, &body)
+}
+
+/// No peer certificate is `invalid_client` for an mTLS-enrolled client.
+#[tokio::test]
+async fn e2e_mtls_missing_cert_rejected() -> Result<(), TestError> {
+  let world = World::start(open_rate_limit()).await?;
+  seed_mtls_client(&world, CLIENT_ID, PKI_DN, PKI_THUMBPRINT).await?;
+  seed_scope(&world.pool, "api:read").await?;
+
+  let (status, body) = post_with_peer(
+    &world,
+    "/token",
+    format!("grant_type=client_credentials&client_id={CLIENT_ID}"),
+    None,
+  )
+  .await?;
+  ensure_invalid_client(status, &body)
+}

+ 589 - 0
tests/e2e_stale_catalog.rs

@@ -0,0 +1,589 @@
+//! `binary(e2e_stale_catalog)` — stale RFC 8707 and scope catalogs at mint.
+//!
+//! Every test boots the REAL production router over a loopback socket against
+//! the shared Postgres + Redis, with `resource_indicators_enabled` pinned on.
+//! A resource or scope disabled AFTER issuance must be dead at the next
+//! mint-time check: refresh re-validates the grant's stored audience before
+//! rotation (`invalid_target`, presented token left live), the device poll
+//! validates before consuming, authorization-code redemption validates a
+//! presented audience before consuming the code, and refresh narrows a
+//! disabled scope into the replacement token without ever widening it back.
+//!
+//! Re-enabling each catalog row proves the disable was the cause: the same
+//! live token/code then succeeds.
+
+#[path = "e2e/harness.rs"]
+#[allow(dead_code)]
+mod harness;
+
+#[path = "e2e/browser.rs"]
+#[allow(dead_code)]
+mod browser;
+
+#[path = "e2e/dpop.rs"]
+#[allow(dead_code)]
+mod dpop;
+
+#[path = "e2e/device.rs"]
+#[allow(dead_code)]
+mod device;
+
+use d_oauth::{
+  db::{TokenEndpointAuthMethod, resource_repo, scope_repo},
+  protocol::token::ClientKind,
+};
+use dpop::DpopKey;
+use harness::{
+  TestError, World, attach_redirect, ensure, fail, json_body, open_rate_limit, seed_client,
+  seed_scope,
+};
+use reqwest::StatusCode;
+
+/// Catalog resource pinned into every grant under test.
+const RESOURCE: &str = "https://rs-stale.example.test/api";
+/// Registered loopback redirect for the authorization-code flow.
+const REDIRECT_URI: &str = "http://127.0.0.1:9/cb";
+/// Confidential refresh client.
+const REFRESH_CLIENT: &str = "e2e-stale-refresh";
+/// Refresh client secret.
+const REFRESH_SECRET: &str = "e2e-stale-refresh-secret-1a2b3c4d5e6f";
+/// Public browser client (unbound: no DPoP proof needed).
+const PUBLIC_CLIENT: &str = "e2e-stale-public";
+/// Confidential device client.
+const DEVICE_CLIENT: &str = "e2e-stale-device";
+/// Device client secret.
+const DEVICE_SECRET: &str = "e2e-stale-device-secret-6f5e4d3c2b1a";
+/// Provisioned login email.
+const EMAIL: &str = "stale-catalog@example.test";
+/// Provisioned login password.
+const PASSWORD: &str = "e2e-stale-catalog-password-1a2b";
+
+/// Start one world with the RFC 8707 gate on and one enabled catalog row.
+///
+/// Returns the world plus the row id needed to re-enable it: there is no
+/// public enable helper, and `upsert` deliberately preserves `enabled=false`.
+async fn start_world() -> Result<(World, uuid::Uuid), TestError> {
+  let world = World::start_with(
+    open_rate_limit(),
+    serde_json::json!({"resource_indicators_enabled": true}),
+  )
+  .await?;
+  let row = resource_repo::upsert(&world.pool, RESOURCE, "stale catalog e2e")
+    .await
+    .map_err(|error| fail(format!("resource upsert failed: {error}")))?;
+  Ok((world, row.id))
+}
+
+/// Re-enable a catalog row through direct SQL (no production enable helper).
+async fn enable_resource(pool: &sqlx::PgPool, id: uuid::Uuid) -> Result<(), TestError> {
+  sqlx::query("UPDATE resource_servers SET enabled = true WHERE id = $1")
+    .bind(id)
+    .execute(pool)
+    .await?;
+  Ok(())
+}
+
+/// Disable the catalog row, proving the update applied.
+async fn disable_resource(world: &World) -> Result<(), TestError> {
+  ensure(
+    resource_repo::disable(&world.pool, RESOURCE)
+      .await
+      .map_err(|error| fail(format!("resource disable failed: {error}")))?,
+    "disable must update the seeded row",
+  )
+}
+
+/// Assert a token endpoint failure carries the exact OAuth code and mints
+/// nothing.
+fn ensure_error(status: StatusCode, body: &str, code: &str) -> Result<(), TestError> {
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("must be 400, got {status}: {body}"),
+  )?;
+  let value = json_body(body)?;
+  ensure(
+    value["error"] == code,
+    format!("failure must be {code}, got {value}"),
+  )?;
+  ensure(!body.contains("access_token"), "failure must mint nothing")
+}
+
+/// Assert a refresh token still resolves live in the store.
+async fn ensure_refresh_live(world: &World, token: &str) -> Result<(), TestError> {
+  let mut conn = world.state.redis.clone();
+  ensure(
+    world
+      .service
+      .find_refresh(&mut conn, &world.store, token)
+      .await
+      .map_err(|error| fail(format!("refresh lookup failed: {error}")))?
+      .is_some(),
+    "the rejected refresh token must stay live",
+  )
+}
+
+/// One authorization-code browser flow that pins `resource`.
+struct ResourceFlow<'a> {
+  /// Public client driving the flow.
+  client_id: &'a str,
+  /// Registered callback.
+  redirect_uri: &'a str,
+  /// Requested scope.
+  scope: &'a str,
+  /// Audience the authorize request pins.
+  resource: &'a str,
+  /// Provisioned account email.
+  email: &'a str,
+  /// Provisioned account password.
+  password: &'a str,
+  /// Provisioned account TOTP secret.
+  totp_secret: &'a str,
+}
+
+/// Drive `GET /authorize` (with `resource`) through login and consent and
+/// return the issued code plus the PKCE verifier.
+async fn authorize_with_resource(
+  world: &World,
+  flow: &ResourceFlow<'_>,
+) -> Result<(String, String), TestError> {
+  let (challenge, verifier) = browser::oauth2_pkce();
+  let (status, headers, body) = world
+    .get(
+      &format!(
+        "/authorize?client_id={}&redirect_uri={}&response_type=code&scope={}&state=e2e-stale\
+         &resource={}&code_challenge={challenge}&code_challenge_method=S256",
+        browser::encoded(flow.client_id),
+        browser::encoded(flow.redirect_uri),
+        browser::encoded(flow.scope),
+        browser::encoded(flow.resource)
+      ),
+      &[],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("authorize must 303 to login: {status} {body}"),
+  )?;
+  let request_id = browser::query_value(&browser::location_of(&headers)?, "request_id")?;
+
+  let (status, headers, body) = world
+    .get(&format!("/login?request_id={request_id}"), &[])
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("login page must render: {status} {body}"),
+  )?;
+  let presession = browser::cookie_value(&headers, browser::SESSION_COOKIE)?;
+  let login_csrf = browser::hidden_input(&body, "csrf")?;
+  let code_2fa = browser::totp_now(flow.totp_secret).await?;
+  let cookie = format!("{}={presession}", browser::SESSION_COOKIE);
+  let (status, headers, body) = world
+    .post_form(
+      "/login",
+      &[
+        ("email", flow.email),
+        ("password", flow.password),
+        ("code", code_2fa.as_str()),
+        ("csrf", login_csrf.as_str()),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("login must 303 to consent: {status} {body}"),
+  )?;
+  ensure(
+    browser::location_of(&headers)? == "/consent",
+    "login must land on consent",
+  )?;
+  let session = browser::cookie_value(&headers, browser::SESSION_COOKIE)?;
+  let cookie = format!("{}={session}", browser::SESSION_COOKIE);
+  let (status, _, body) = world
+    .get("/consent", &[("cookie", cookie.as_str())])
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("consent must render: {status} {body}"),
+  )?;
+  ensure(
+    browser::hidden_input(&body, "request_id")? == request_id,
+    "consent must serve the bound authorization request",
+  )?;
+  let consent_csrf = browser::hidden_input(&body, "csrf")?;
+  let (status, headers, body) = world
+    .post_form(
+      "/consent",
+      &[
+        ("request_id", request_id.as_str()),
+        ("csrf", consent_csrf.as_str()),
+        ("decision", "approve"),
+      ],
+      None,
+      &[("cookie", cookie.as_str())],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::SEE_OTHER,
+    format!("consent approval must 303 to the redirect: {status} {body}"),
+  )?;
+  let location = browser::location_of(&headers)?;
+  ensure(
+    location.starts_with(flow.redirect_uri),
+    format!("code must return to the registered redirect, got {location}"),
+  )?;
+  Ok((browser::query_value(&location, "code")?, verifier))
+}
+
+/// A resource disabled after issuance rejects refresh with `invalid_target`
+/// before rotation, leaves the presented token live, and the same token
+/// rotates once the catalog row is re-enabled.
+#[tokio::test]
+async fn e2e_refresh_rejects_disabled_resource_as_invalid_target() -> Result<(), TestError> {
+  let (world, resource_id) = start_world().await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    REFRESH_CLIENT,
+    Some(REFRESH_SECRET),
+    &["refresh_token"],
+    "read",
+    TokenEndpointAuthMethod::ClientSecretBasic,
+  )
+  .await?;
+  seed_scope(&world.pool, "read").await?;
+  let minted = world
+    .mint_with_aud(
+      REFRESH_CLIENT,
+      "read",
+      ClientKind::Confidential,
+      None,
+      RESOURCE,
+    )
+    .await?;
+  disable_resource(&world).await?;
+
+  let refresh = [
+    ("grant_type", "refresh_token"),
+    ("refresh_token", minted.refresh_token.as_str()),
+  ];
+  let basic = Some((REFRESH_CLIENT, REFRESH_SECRET));
+  let (status, _, body) = world.post_form("/token", &refresh, basic, &[]).await?;
+  ensure_error(status, &body, "invalid_target")?;
+  ensure_refresh_live(&world, &minted.refresh_token).await?;
+
+  enable_resource(&world.pool, resource_id).await?;
+  let (status, _, body) = world.post_form("/token", &refresh, basic, &[]).await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("the re-enabled audience must rotate: {status} {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["refresh_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "the successful rotation must return a refresh token",
+  )
+}
+
+/// A device code pinned to a resource that is disabled before the poll fails
+/// `invalid_target`; the approval was already consumed, so a retry is
+/// terminal `expired_token` and no token was ever minted.
+#[tokio::test]
+async fn e2e_device_poll_rejects_disabled_resource() -> Result<(), TestError> {
+  let (world, _resource_id) = start_world().await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    DEVICE_CLIENT,
+    Some(DEVICE_SECRET),
+    &[device::DEVICE_GRANT],
+    "read",
+    TokenEndpointAuthMethod::ClientSecretBasic,
+  )
+  .await?;
+  seed_scope(&world.pool, "read").await?;
+  let (status, _, body) = world
+    .post_form(
+      "/device_authorization",
+      &[
+        ("client_id", DEVICE_CLIENT),
+        ("scope", "read"),
+        ("resource", RESOURCE),
+      ],
+      Some((DEVICE_CLIENT, DEVICE_SECRET)),
+      &[],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("device authorization must 200: {status} {body}"),
+  )?;
+  let payload = json_body(&body)?;
+  let device_code = payload["device_code"]
+    .as_str()
+    .ok_or_else(|| fail("device authorization must return device_code"))?
+    .to_owned();
+  let user_code = payload["user_code"]
+    .as_str()
+    .ok_or_else(|| fail("device authorization must return user_code"))?
+    .to_owned();
+
+  disable_resource(&world).await?;
+  let browser = browser::login_session(world, EMAIL, PASSWORD).await?;
+  let session = browser
+    .session
+    .as_deref()
+    .ok_or_else(|| fail("login_session must return an authenticated session"))?;
+  let page = device::review(&browser.world, session, &user_code).await?;
+  let csrf = browser::hidden_input(&page, "csrf")?;
+  device::decide(
+    &browser.world,
+    device::Decision {
+      session,
+      user_code: &user_code,
+      csrf: &csrf,
+      action: "approve",
+    },
+  )
+  .await?;
+
+  let client = device::Client {
+    id: DEVICE_CLIENT,
+    secret: Some(DEVICE_SECRET),
+    scope: "read",
+  };
+  let key = DpopKey::generate()?;
+  let request = device::Poll {
+    client,
+    device_code: &device_code,
+  };
+  let (status, body) = device::poll(&browser.world, request, &key).await?;
+  ensure_error(status, &body, "invalid_target")?;
+
+  let (status, body) = device::poll(&browser.world, request, &key).await?;
+  ensure(
+    status == StatusCode::BAD_REQUEST,
+    format!("the consumed device code must be 400: {status} {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["error"] == "expired_token",
+    format!("the consumed device code must be expired_token: {body}"),
+  )?;
+  ensure(
+    !body.contains("access_token"),
+    "the retry must mint nothing",
+  )
+}
+
+/// A disabled `resource` on `/authorize` redirects with `invalid_target` and
+/// issues no code.
+#[tokio::test]
+async fn e2e_authorize_rejects_disabled_resource_before_code() -> Result<(), TestError> {
+  let (world, _resource_id) = start_world().await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    PUBLIC_CLIENT,
+    None,
+    &["authorization_code", "refresh_token"],
+    "read",
+    TokenEndpointAuthMethod::None,
+  )
+  .await?;
+  seed_scope(&world.pool, "read").await?;
+  attach_redirect(&world.pool, &world.state.keys, PUBLIC_CLIENT, REDIRECT_URI).await?;
+  disable_resource(&world).await?;
+
+  let (challenge, _verifier) = browser::oauth2_pkce();
+  let (status, headers, body) = world
+    .get(
+      &format!(
+        "/authorize?client_id={PUBLIC_CLIENT}&redirect_uri={}&response_type=code&scope=read\
+         &resource={}&code_challenge={challenge}&code_challenge_method=S256",
+        browser::encoded(REDIRECT_URI),
+        browser::encoded(RESOURCE)
+      ),
+      &[],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::FOUND,
+    format!("a disabled resource must redirect, got {status}: {body}"),
+  )?;
+  let location = browser::location_of(&headers)?;
+  ensure(
+    location.starts_with(REDIRECT_URI),
+    format!("the error must redirect to the client, got {location}"),
+  )?;
+  ensure(
+    location.contains("error=invalid_target"),
+    format!("the redirect must carry invalid_target, got {location}"),
+  )?;
+  ensure(
+    !browser::has_query_key(&location, "code"),
+    "a rejected authorize must never issue a code",
+  )
+}
+
+/// A code issued with an audience stays live when that resource is disabled
+/// before redemption: the presented audience is invalid_target, never a
+/// burned code, and the same code redeems after re-enable.
+#[tokio::test]
+async fn e2e_auth_code_rejects_resource_disabled_after_authorize() -> Result<(), TestError> {
+  let (world, resource_id) = start_world().await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    PUBLIC_CLIENT,
+    None,
+    &["authorization_code", "refresh_token"],
+    "read",
+    TokenEndpointAuthMethod::None,
+  )
+  .await?;
+  attach_redirect(&world.pool, &world.state.keys, PUBLIC_CLIENT, REDIRECT_URI).await?;
+  seed_scope(&world.pool, "read").await?;
+  let totp_secret = browser::seed_user(&world, EMAIL, PASSWORD, "read").await?;
+  let (code, verifier) = authorize_with_resource(
+    &world,
+    &ResourceFlow {
+      client_id: PUBLIC_CLIENT,
+      redirect_uri: REDIRECT_URI,
+      scope: "read",
+      resource: RESOURCE,
+      email: EMAIL,
+      password: PASSWORD,
+      totp_secret: &totp_secret,
+    },
+  )
+  .await?;
+
+  disable_resource(&world).await?;
+  let redemption = [
+    ("grant_type", "authorization_code"),
+    ("code", code.as_str()),
+    ("redirect_uri", REDIRECT_URI),
+    ("client_id", PUBLIC_CLIENT),
+    ("code_verifier", verifier.as_str()),
+    ("resource", RESOURCE),
+  ];
+  let (status, _, body) = world.post_form("/token", &redemption, None, &[]).await?;
+  ensure_error(status, &body, "invalid_target")?;
+
+  enable_resource(&world.pool, resource_id).await?;
+  let (status, _, body) = world.post_form("/token", &redemption, None, &[]).await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("the live code must redeem after re-enable: {status} {body}"),
+  )?;
+  let tokens = json_body(&body)?;
+  ensure(
+    tokens["access_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "the re-enabled redeem must return an access token",
+  )?;
+  ensure(
+    tokens["refresh_token"]
+      .as_str()
+      .is_some_and(|token| !token.is_empty()),
+    "the re-enabled redeem must return a refresh token",
+  )
+}
+
+/// A scope disabled after issuance drops from the rotated refresh token while
+/// the grant stays live; re-enabling it never widens the grant back.
+#[tokio::test]
+async fn e2e_refresh_drops_disabled_scope_but_stays_live() -> Result<(), TestError> {
+  let (world, _resource_id) = start_world().await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    REFRESH_CLIENT,
+    Some(REFRESH_SECRET),
+    &["refresh_token"],
+    "read write",
+    TokenEndpointAuthMethod::ClientSecretBasic,
+  )
+  .await?;
+  seed_scope(&world.pool, "read").await?;
+  seed_scope(&world.pool, "write").await?;
+  let minted = world
+    .mint(REFRESH_CLIENT, "read write", ClientKind::Confidential, None)
+    .await?;
+  ensure(
+    scope_repo::set_enabled(&world.pool, "write", false)
+      .await
+      .map_err(|error| fail(format!("scope disable failed: {error}")))?,
+    "write must be a seeded catalog row",
+  )?;
+
+  let basic = Some((REFRESH_CLIENT, REFRESH_SECRET));
+  let (status, _, body) = world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "refresh_token"),
+        ("refresh_token", minted.refresh_token.as_str()),
+      ],
+      basic,
+      &[],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("a narrowed refresh must still rotate: {status} {body}"),
+  )?;
+  let rotated = json_body(&body)?;
+  ensure(
+    rotated["scope"] == "read",
+    format!("the disabled scope must drop, got {rotated}"),
+  )?;
+  let rotated_refresh = rotated["refresh_token"]
+    .as_str()
+    .filter(|token| !token.is_empty())
+    .ok_or_else(|| fail("the narrowed rotation must return a refresh token"))?
+    .to_owned();
+
+  ensure(
+    scope_repo::set_enabled(&world.pool, "write", true)
+      .await
+      .map_err(|error| fail(format!("scope enable failed: {error}")))?,
+    "write must still be a catalog row",
+  )?;
+  let (status, _, body) = world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "refresh_token"),
+        ("refresh_token", rotated_refresh.as_str()),
+        ("scope", "write"),
+      ],
+      basic,
+      &[],
+    )
+    .await?;
+  ensure_error(status, &body, "invalid_scope")?;
+
+  let (status, _, body) = world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", "refresh_token"),
+        ("refresh_token", rotated_refresh.as_str()),
+      ],
+      basic,
+      &[],
+    )
+    .await?;
+  ensure(
+    status == StatusCode::OK,
+    format!("the still-live refresh must rotate: {status} {body}"),
+  )?;
+  ensure(
+    json_body(&body)?["scope"] == "read",
+    "the re-enabled scope must never be widened back onto the grant",
+  )
+}

+ 300 - 0
tests/e2e_token_exchange.rs

@@ -0,0 +1,300 @@
+//! `test(e2e_)` token exchange (RFC 8693) over the live router.
+//!
+//! Same harness contract as the sibling suites: the REAL production router
+//! over a REAL loopback socket against the shared Postgres + Redis, asserting
+//! only HTTP behavior. The caller is a DPoP-bound confidential client holding
+//! the exchange grant; the subject token is a real user's access token, whose
+//! `subject` is the account EMAIL so entitlement resolution can find it.
+
+#[allow(dead_code)]
+#[path = "e2e/harness.rs"]
+mod harness;
+
+#[path = "e2e/dpop.rs"]
+mod dpop;
+
+use d_oauth::{
+  db::{TokenEndpointAuthMethod, user_repo},
+  protocol::token::{ClientKind, grants::token_exchange::exchange_ttl},
+};
+use dpop::DpopKey;
+use harness::{
+  SeedClientOpts, TestError, World, ensure, fail, json_body, open_rate_limit, seed_client,
+  seed_client_bound, seed_entitled_user, seed_scope,
+};
+
+/// Grant-type URN for token exchange (RFC 8693 §2.1).
+const EXCHANGE_GRANT: &str = "urn:ietf:params:oauth:grant-type:token-exchange";
+/// Access-token type URN used for `subject_token_type`/`requested_token_type`.
+const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token";
+/// Authenticated caller holding the exchange grant (DPoP-bound).
+const CALLER_ID: &str = "e2e-exchange-caller";
+/// Caller secret (also the leak probe).
+const CALLER_SECRET: &str = "e2e-exchange-secret-1a2b3c4d5e6f708192a0";
+/// Subject client that owns the delegated access token.
+const SUBJECT_ID: &str = "e2e-exchange-subject";
+/// Provisioned user's email: the subject-token `subject` value.
+const USER_EMAIL: &str = "exchange-user@example.test";
+/// Provisioned user's password (never used for HTTP login here).
+const USER_PASSWORD: &str = "e2e-exchange-password-1a2b3c4d";
+/// The only scope the allowlist exposes.
+const SCOPE: &str = "read";
+
+/// One world with token exchange enabled, the allowlist pair, and the clients.
+async fn setup() -> Result<World, TestError> {
+  let world = World::start_with(
+    open_rate_limit(),
+    serde_json::json!({
+      "allow_token_exchange": true,
+      "token_exchange_allowlist": [
+        {"caller": CALLER_ID, "subject": SUBJECT_ID, "scopes": ["read"]},
+        {"caller": CALLER_ID, "subject": CALLER_ID, "scopes": ["read"]}
+      ],
+    }),
+  )
+  .await?;
+  seed_client_bound(
+    &world.pool,
+    &world.state.keys,
+    CALLER_ID,
+    Some(CALLER_SECRET),
+    &SeedClientOpts {
+      grants: &[EXCHANGE_GRANT],
+      scope: "read write",
+      method: TokenEndpointAuthMethod::ClientSecretBasic,
+      dpop_bound: true,
+    },
+  )
+  .await?;
+  seed_client(
+    &world.pool,
+    &world.state.keys,
+    SUBJECT_ID,
+    None,
+    &["authorization_code"],
+    "read",
+    TokenEndpointAuthMethod::None,
+  )
+  .await?;
+  seed_scope(&world.pool, "read").await?;
+  seed_scope(&world.pool, "write").await?;
+  Ok(world)
+}
+
+/// The subject's unbound access token: user EMAIL as `subject`, `read` scope.
+async fn subject_token(world: &World) -> Result<String, TestError> {
+  let minted = world
+    .mint_for_subject(
+      SUBJECT_ID,
+      USER_EMAIL,
+      "read",
+      ClientKind::Confidential,
+      None,
+    )
+    .await?;
+  Ok(minted.access_token)
+}
+
+/// Send one exchange request with Basic auth and a fresh DPoP proof.
+async fn exchange_request(
+  world: &World,
+  subject_token: &str,
+  scope: &str,
+) -> Result<(reqwest::StatusCode, String), TestError> {
+  let key = DpopKey::generate()?;
+  let htu = format!("{}/token", world.state.config.issuer);
+  let proof = key.proof("POST", &htu)?;
+  let (status, _, body) = world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", EXCHANGE_GRANT),
+        ("subject_token", subject_token),
+        ("subject_token_type", ACCESS_TOKEN_TYPE),
+        ("requested_token_type", ACCESS_TOKEN_TYPE),
+        ("scope", scope),
+      ],
+      Some((CALLER_ID, CALLER_SECRET)),
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  Ok((status, body))
+}
+
+/// Allowlisted exchange mints a DPoP-bound, `act`-bound access token with no
+/// refresh token and the shortened exchange TTL.
+#[tokio::test]
+async fn e2e_token_exchange_allowlisted_mints_act_bound_token() -> Result<(), TestError> {
+  let world = setup().await?;
+  let (user_id, _totp) = seed_entitled_user(&world, USER_EMAIL, USER_PASSWORD, SCOPE).await?;
+  let user = user_repo::find_by_email(&world.pool, &world.state.keys, USER_EMAIL)
+    .await?
+    .ok_or_else(|| fail("seeded user must resolve by email"))?;
+  ensure(
+    user.id == user_id,
+    "the subject-token email must resolve to the seeded account",
+  )?;
+
+  let subject = subject_token(&world).await?;
+  let key = DpopKey::generate()?;
+  let htu = format!("{}/token", world.state.config.issuer);
+  let proof = key.proof("POST", &htu)?;
+  let jkt = d_oauth::jose::jwk::thumbprint(&key.jwk())
+    .map_err(|error| fail(format!("proof thumbprint: {error}")))?;
+  let (status, _, body) = world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", EXCHANGE_GRANT),
+        ("subject_token", subject.as_str()),
+        ("subject_token_type", ACCESS_TOKEN_TYPE),
+        ("requested_token_type", ACCESS_TOKEN_TYPE),
+        ("scope", SCOPE),
+      ],
+      Some((CALLER_ID, CALLER_SECRET)),
+      &[("dpop", proof.as_str())],
+    )
+    .await?;
+  ensure(
+    status.is_success(),
+    format!("allowlisted exchange must 200, got {status}: {body}"),
+  )?;
+  let value = json_body(&body)?;
+  ensure(
+    value["token_type"] == "DPoP",
+    format!("exchange must mint a DPoP token: {value}"),
+  )?;
+  ensure(
+    value["issued_token_type"] == ACCESS_TOKEN_TYPE,
+    format!("exchange must report the access-token type: {value}"),
+  )?;
+  ensure(
+    value.get("refresh_token").is_none(),
+    "exchange must never issue a refresh token",
+  )?;
+  ensure(
+    value["expires_in"] == exchange_ttl(&world.service).as_secs(),
+    format!(
+      "exchange must use the shortened TTL {}: {value}",
+      exchange_ttl(&world.service).as_secs()
+    ),
+  )?;
+  let access = value["access_token"]
+    .as_str()
+    .ok_or_else(|| fail("exchange must return an access token"))?;
+
+  let (status, _, body) = world
+    .post_form(
+      "/introspect",
+      &[("token", access)],
+      Some((CALLER_ID, CALLER_SECRET)),
+      &[],
+    )
+    .await?;
+  ensure(status.is_success(), "introspect must 200")?;
+  let introspected = json_body(&body)?;
+  ensure(
+    introspected["active"] == true,
+    format!("exchanged token must be active: {introspected}"),
+  )?;
+  ensure(
+    introspected["act"]["sub"] == SUBJECT_ID,
+    format!("act.sub must name the subject-token client: {introspected}"),
+  )?;
+  ensure(
+    introspected["cnf"]["jkt"] == jkt,
+    format!("cnf.jkt must carry the proof key thumbprint: {introspected}"),
+  )?;
+  ensure(
+    !body.contains(CALLER_SECRET),
+    "introspection must not leak the caller secret",
+  )?;
+  Ok(())
+}
+
+/// A request wider than the allowlist is `invalid_scope` and mints nothing.
+#[tokio::test]
+async fn e2e_token_exchange_rejects_scope_widening() -> Result<(), TestError> {
+  let world = setup().await?;
+  seed_entitled_user(&world, USER_EMAIL, USER_PASSWORD, SCOPE).await?;
+  let subject = subject_token(&world).await?;
+  let (status, body) = exchange_request(&world, &subject, "read write").await?;
+  ensure(
+    status == reqwest::StatusCode::BAD_REQUEST,
+    format!("scope widening must 400, got {status}: {body}"),
+  )?;
+  let value = json_body(&body)?;
+  ensure(
+    value["error"] == "invalid_scope",
+    format!("scope widening must be invalid_scope: {value}"),
+  )?;
+  ensure(
+    value.get("access_token").is_none(),
+    "a rejected exchange must mint nothing",
+  )?;
+  Ok(())
+}
+
+/// An already-`act`-bound token is not exchangeable (no chained exchanges).
+#[tokio::test]
+async fn e2e_token_exchange_rejects_multi_hop() -> Result<(), TestError> {
+  let world = setup().await?;
+  seed_entitled_user(&world, USER_EMAIL, USER_PASSWORD, SCOPE).await?;
+  let subject = subject_token(&world).await?;
+  let key = DpopKey::generate()?;
+  let htu = format!("{}/token", world.state.config.issuer);
+
+  let first = key.proof("POST", &htu)?;
+  let (status, _, body) = world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", EXCHANGE_GRANT),
+        ("subject_token", subject.as_str()),
+        ("subject_token_type", ACCESS_TOKEN_TYPE),
+        ("requested_token_type", ACCESS_TOKEN_TYPE),
+        ("scope", SCOPE),
+      ],
+      Some((CALLER_ID, CALLER_SECRET)),
+      &[("dpop", first.as_str())],
+    )
+    .await?;
+  ensure(
+    status.is_success(),
+    format!("first exchange must 200, got {status}: {body}"),
+  )?;
+  let exchanged = json_body(&body)?;
+  let act_bound = exchanged["access_token"]
+    .as_str()
+    .ok_or_else(|| fail("first exchange must return an access token"))?;
+
+  let second = key.proof("POST", &htu)?;
+  let (status, _, body) = world
+    .post_form(
+      "/token",
+      &[
+        ("grant_type", EXCHANGE_GRANT),
+        ("subject_token", act_bound),
+        ("subject_token_type", ACCESS_TOKEN_TYPE),
+        ("requested_token_type", ACCESS_TOKEN_TYPE),
+        ("scope", SCOPE),
+      ],
+      Some((CALLER_ID, CALLER_SECRET)),
+      &[("dpop", second.as_str())],
+    )
+    .await?;
+  ensure(
+    status == reqwest::StatusCode::BAD_REQUEST,
+    format!("multi-hop exchange must 400, got {status}: {body}"),
+  )?;
+  let value = json_body(&body)?;
+  ensure(
+    value["error"] == "invalid_grant",
+    format!("multi-hop exchange must be invalid_grant: {value}"),
+  )?;
+  ensure(
+    value.get("access_token").is_none(),
+    "a chained exchange must mint nothing",
+  )?;
+  Ok(())
+}

+ 280 - 0
tests/proptest_inputs.rs

@@ -0,0 +1,280 @@
+//! `test(proptest_inputs)` — wire, TOTP, and at+jwt input properties (todo 19).
+//!
+//! Pure `proptest` properties over the untrusted-input seams:
+//! [`parse_pairs`]/[`values_of`]/[`single_valued_map`] (form wire order and
+//! last-wins), [`verify_window`] (a generated code verifies only inside the
+//! ±1-step window), and [`JwtAccessSigner::verify`] (arbitrary tokens never
+//! panic, an accepted token carries the expected audience, and a wrong
+//! audience always rejects). No `unwrap`/`expect`/`panic` anywhere.
+//!
+//! TOTP note: a far-off 6-digit code can collide with a windowed candidate
+//! (about 3e-6 per case), so the property never asserts `None` for a far code;
+//! it only bounds any `Some(step)` to the counter ± 1.
+
+use std::sync::OnceLock;
+
+use d_oauth::{
+  config::{JwtSigningAlg, JwtSigningKey},
+  crypto::totp::{TOTP_SKEW_STEPS, TOTP_STEP_SECS, build_totp, verify_window},
+  jose::jwt_access::{JwtAccessSigner, JwtClaims},
+  keys::KeyId,
+  protocol::params::{MAX_FORM_PAIRS, parse_pairs, single_valued_map, values_of},
+};
+use proptest::prelude::*;
+use secrecy::SecretString;
+use time::OffsetDateTime;
+
+/// Issuer every at+jwt fixture token asserts.
+const TEST_ISSUER: &str = "https://as.example.test";
+/// Audience the fixture token is minted for.
+const TEST_AUDIENCE: &str = "https://rs.example.test";
+/// Audience the fixture token must never satisfy.
+const WRONG_AUDIENCE: &str = "https://other.example.test";
+/// Fixture token lifetime: one day, so a test process stays inside `exp`.
+const FIXTURE_TTL_SECS: i64 = 86_400;
+
+/// RFC 4648 base32 alphabet, uppercase, no padding.
+const BASE32_ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+
+/// Characters a mutated token position may carry: all still base64url/decode
+/// clean, so the mutation is well-formed UTF-8 and the decoder is reached.
+const JWT_MUTATION_ALPHABET: &[u8] = b"-_.A0az";
+
+/// Encode raw bytes as canonical RFC 4648 base32 (uppercase, no padding).
+///
+/// Canonical output always decodes under [`build_totp`], so the TOTP property
+/// exercises the verifier rather than the malformed-secret error path.
+fn to_base32(bytes: &[u8]) -> String {
+  let mut out = String::with_capacity(bytes.len().div_ceil(5) * 8);
+  let mut accumulator = 0u32;
+  let mut bits = 0u32;
+  for byte in bytes {
+    accumulator = (accumulator << 8) | u32::from(*byte);
+    bits += 8;
+    while bits >= 5 {
+      bits -= 5;
+      let index = ((accumulator >> bits) & 0x1f) as usize;
+      out.push(char::from(BASE32_ALPHABET[index]));
+    }
+    // Keep only the unflushed low bits so the accumulator cannot overflow.
+    accumulator &= (1u32 << bits) - 1;
+  }
+  if bits > 0 {
+    let index = ((accumulator << (5 - bits)) & 0x1f) as usize;
+    out.push(char::from(BASE32_ALPHABET[index]));
+  }
+  out
+}
+
+/// Bounded arbitrary UTF-8 input, a shape that exceeds the pair cap so both
+/// the `Ok` and cap-error branches of [`parse_pairs`] are reachable, and a
+/// duplicate-heavy shape so last-wins is genuinely exercised.
+fn form_input() -> impl Strategy<Value = String> {
+  prop_oneof![
+    proptest::collection::vec(any::<char>(), 0..=512)
+      .prop_map(|chars| chars.into_iter().collect::<String>()),
+    proptest::collection::vec("[A-Za-z0-9_.~%-]{0,8}", 0..=(MAX_FORM_PAIRS + 8))
+      .prop_map(|segments| segments.join("&")),
+    proptest::collection::vec(
+      (
+        prop_oneof![Just("a"), Just("resource"), Just("b"), Just("")],
+        "[A-Za-z0-9]{0,6}",
+      ),
+      0..=40,
+    )
+    .prop_map(|segments| {
+      segments
+        .into_iter()
+        .map(|(name, value)| format!("{name}={value}"))
+        .collect::<Vec<String>>()
+        .join("&")
+    }),
+  ]
+}
+
+/// The fixed at+jwt fixture: one signer and one already-signed valid token.
+struct JwtFixture {
+  /// ES256 signer over a fixed 32-byte scalar.
+  signer: JwtAccessSigner,
+  /// A valid token minted once for this process.
+  token: String,
+  /// The audience the fixture token carries.
+  audience: Vec<String>,
+}
+
+static JWT_FIXTURE: OnceLock<Option<JwtFixture>> = OnceLock::new();
+
+/// Build the fixture once; a key-construction failure is tolerated as "skip".
+fn jwt_fixture() -> Option<&'static JwtFixture> {
+  JWT_FIXTURE
+    .get_or_init(|| {
+      let raw = SecretString::from("C".repeat(32));
+      let key = JwtSigningKey::from_raw(
+        KeyId::new("v2"),
+        JwtSigningAlg::Es256,
+        &raw,
+        "proptest_jwt_signing_key",
+      )
+      .ok()?;
+      let signer = JwtAccessSigner::new(TEST_ISSUER.to_owned(), key, Vec::new());
+      let now = OffsetDateTime::now_utc().unix_timestamp();
+      let claims = JwtClaims {
+        iss: TEST_ISSUER.to_owned(),
+        sub: "proptest-subject@example.test".to_owned(),
+        aud: vec![TEST_AUDIENCE.to_owned()],
+        scope: "orders:read".to_owned(),
+        exp: now + FIXTURE_TTL_SECS,
+        iat: now,
+        jti: "proptest-jti".to_owned(),
+        client_id: "proptest-client".to_owned(),
+        cnf: None,
+        act: None,
+      };
+      let token = signer.sign(&claims).ok()?;
+      Some(JwtFixture {
+        signer,
+        token,
+        audience: claims.aud,
+      })
+    })
+    .as_ref()
+}
+
+proptest! {
+  #[test]
+  fn params_parse_pairs_never_panics_and_preserves_wire_order(raw in form_input()) {
+    // Only two outcomes exist: the parsed pairs, or the single cap error.
+    let pairs = match parse_pairs(&raw) {
+      Ok(pairs) => pairs,
+      Err(_) => return Ok(()),
+    };
+
+    // `values_of("resource")` keeps the wire order and is a subset of all
+    // values (one entry per matching pair, no reordering, no loss).
+    let resource_values = values_of(&pairs, "resource");
+    let expected: Vec<String> = pairs
+      .iter()
+      .filter(|(name, _)| name == "resource")
+      .map(|(_, value)| value.clone())
+      .collect();
+    prop_assert_eq!(&resource_values, &expected);
+    prop_assert!(resource_values.len() <= pairs.len());
+
+    // `single_valued_map` is last-wins: one entry per distinct name, and each
+    // entry equals the final pair carrying that name.
+    let map = single_valued_map(&pairs);
+    let distinct: std::collections::HashSet<&str> =
+      pairs.iter().map(|(name, _)| name.as_str()).collect();
+    prop_assert_eq!(map.len(), distinct.len());
+    for (name, value) in &map {
+      let last = pairs
+        .iter()
+        .rev()
+        .find(|(candidate, _)| candidate == name)
+        .map(|(_, value)| value);
+      prop_assert_eq!(Some(value), last);
+    }
+  }
+
+  #[test]
+  fn totp_verify_window_accepts_only_generated_codes(
+    secret_bytes in proptest::collection::vec(any::<u8>(), 10..=32),
+    counter in 0u64..=(u64::MAX / TOTP_STEP_SECS),
+    arbitrary_code in proptest::collection::vec(any::<char>(), 0..=16)
+      .prop_map(|chars| chars.into_iter().collect::<String>()),
+  ) {
+    let secret = to_base32(&secret_bytes);
+    let totp = match build_totp(&secret) {
+      Ok(totp) => totp,
+      Err(_) => return Ok(()),
+    };
+    let now = counter.saturating_mul(TOTP_STEP_SECS);
+    let generated = totp.generate(now).to_string();
+    let skew = u64::from(TOTP_SKEW_STEPS);
+
+    // A code generated for this step verifies, at this step or an adjacent one.
+    match verify_window(&secret, &generated, now) {
+      Ok(Some(step)) => prop_assert!(step.abs_diff(counter) <= skew),
+      Ok(None) => prop_assert!(false, "a generated code must verify"),
+      Err(_) => prop_assert!(false, "a canonical base32 secret must build"),
+    }
+
+    // Any arbitrary code is handled without panic; a match must be adjacent
+    // to the counter (a far code is NOT asserted absent: 6-digit collisions
+    // are possible at roughly 3e-6 per case).
+    if let Ok(Some(step)) = verify_window(&secret, &arbitrary_code, now) {
+      prop_assert!(step.abs_diff(counter) <= skew);
+    }
+  }
+
+  #[test]
+  fn jwt_verify_never_panics_on_arbitrary_tokens(
+    raw in proptest::collection::vec(any::<char>(), 0..=512)
+      .prop_map(|chars| chars.into_iter().collect::<String>()),
+    selector in 0u8..3,
+    position in any::<usize>(),
+    replacement in 0u8..8,
+  ) {
+    let Some(fixture) = jwt_fixture() else {
+      return Ok(());
+    };
+
+    // Selector 0 replays the fixed valid token (accepted branch); selector 1
+    // mutates it at one base64url position; selector 2 is arbitrary UTF-8.
+    let candidate = match selector {
+      0 => fixture.token.clone(),
+      1 => {
+        let mut bytes = fixture.token.clone().into_bytes();
+        if bytes.is_empty() {
+          return Ok(());
+        }
+        let index = position % bytes.len();
+        let index_in_alphabet = usize::from(replacement) % JWT_MUTATION_ALPHABET.len();
+        let mut next = JWT_MUTATION_ALPHABET[index_in_alphabet];
+        if next == bytes[index] {
+          next = if bytes[index] == b'A' { b'B' } else { b'A' };
+        }
+        bytes[index] = next;
+        match String::from_utf8(bytes) {
+          Ok(candidate) => candidate,
+          Err(_) => return Ok(()),
+        }
+      }
+      _ => raw,
+    };
+
+    // Arbitrary strings never panic: the outcome is a typed error or a token.
+    match fixture.signer.verify(&candidate, Some(fixture.audience.as_slice())) {
+      Ok(verified) => {
+        prop_assert!(
+          verified
+            .claims
+            .aud
+            .iter()
+            .any(|audience| audience.as_str() == TEST_AUDIENCE)
+        );
+        if selector == 0 {
+          // The accepted branch is reachable only from the fixed token, and
+          // it round-trips the exact audience set.
+          prop_assert_eq!(verified.claims.aud.as_slice(), fixture.audience.as_slice());
+        }
+      }
+      Err(_) => {
+        if selector == 0 {
+          prop_assert!(false, "the fixed signed token must verify");
+        }
+      }
+    }
+
+    // A wrong audience always rejects, whether the token is valid or not.
+    prop_assert!(
+      fixture
+        .signer
+        .verify(
+          &candidate,
+          Some(&[WRONG_AUDIENCE.to_owned()]),
+        )
+        .is_err()
+    );
+  }
+}