Efren Yevale Varela 1 mēnesi atpakaļ
revīzija
b0e14c9d2f
89 mainītis faili ar 22215 papildinājumiem un 0 dzēšanām
  1. 1 0
      .codegraph
  2. 41 0
      .dockerignore
  3. 18 0
      .gitignore
  4. 3682 0
      Cargo.lock
  5. 3 0
      Cargo.toml
  6. 110 0
      Dockerfile
  7. 5 0
      LICENSE.md
  8. 313 0
      README.md
  9. 16 0
      config/config.example.json
  10. 24 0
      crates/oauth-core/Cargo.toml
  11. 352 0
      crates/oauth-core/src/capabilities.rs
  12. 68 0
      crates/oauth-core/src/client_auth.rs
  13. 331 0
      crates/oauth-core/src/config.rs
  14. 416 0
      crates/oauth-core/src/discovery.rs
  15. 349 0
      crates/oauth-core/src/dpop/key.rs
  16. 7 0
      crates/oauth-core/src/dpop/mod.rs
  17. 340 0
      crates/oauth-core/src/dpop/proof.rs
  18. 221 0
      crates/oauth-core/src/error.rs
  19. 422 0
      crates/oauth-core/src/introspect.rs
  20. 41 0
      crates/oauth-core/src/lib.rs
  21. 368 0
      crates/oauth-core/src/par.rs
  22. 156 0
      crates/oauth-core/src/pkce.rs
  23. 118 0
      crates/oauth-core/src/redact.rs
  24. 170 0
      crates/oauth-core/src/revoke.rs
  25. 599 0
      crates/oauth-core/src/token.rs
  26. 18 0
      crates/provision/Cargo.toml
  27. 417 0
      crates/provision/src/cli_driver.rs
  28. 491 0
      crates/provision/src/dcr.rs
  29. 219 0
      crates/provision/src/e2e.rs
  30. 65 0
      crates/provision/src/keygen.rs
  31. 8 0
      crates/provision/src/lib.rs
  32. 341 0
      crates/provision/src/main.rs
  33. 214 0
      crates/provision/tests/cli_driver.rs
  34. 471 0
      crates/provision/tests/dcr.rs
  35. 145 0
      crates/provision/tests/e2e.rs
  36. 123 0
      crates/provision/tests/fake-d-oauth.sh
  37. 75 0
      crates/provision/tests/keygen.rs
  38. 44 0
      crates/server/Cargo.toml
  39. 148 0
      crates/server/src/api.rs
  40. 33 0
      crates/server/src/app.rs
  41. 827 0
      crates/server/src/config.rs
  42. 22 0
      crates/server/src/health.rs
  43. 379 0
      crates/server/src/http_client.rs
  44. 19 0
      crates/server/src/lib.rs
  45. 147 0
      crates/server/src/main.rs
  46. 1250 0
      crates/server/src/oauth.rs
  47. 703 0
      crates/server/src/session.rs
  48. 138 0
      crates/server/src/state.rs
  49. 104 0
      crates/server/src/static_files.rs
  50. 66 0
      crates/server/tests/health.rs
  51. 766 0
      crates/server/tests/integration.rs
  52. 1115 0
      crates/server/tests/oauth.rs
  53. 321 0
      crates/server/tests/static_config.rs
  54. 31 0
      crates/web/Cargo.toml
  55. 53 0
      crates/web/Trunk.toml
  56. 33 0
      crates/web/bootstrap.js
  57. 32 0
      crates/web/index.html
  58. 171 0
      crates/web/src/api.rs
  59. 162 0
      crates/web/src/app.rs
  60. 547 0
      crates/web/src/components/dashboard.rs
  61. 126 0
      crates/web/src/components/deployment.rs
  62. 235 0
      crates/web/src/components/feedback.rs
  63. 212 0
      crates/web/src/components/icons.rs
  64. 186 0
      crates/web/src/components/landing.rs
  65. 9 0
      crates/web/src/components/mod.rs
  66. 16 0
      crates/web/src/components/styles.rs
  67. 84 0
      crates/web/src/components/theme.rs
  68. 22 0
      crates/web/src/lib.rs
  69. 21 0
      crates/web/src/main.rs
  70. 600 0
      crates/web/src/model.rs
  71. 1211 0
      crates/web/src/viewmodel.rs
  72. 176 0
      crates/web/style/input.css
  73. 15 0
      crates/xtask/Cargo.toml
  74. 290 0
      crates/xtask/src/main.rs
  75. 153 0
      crates/xtask/tests/no_secret_output.rs
  76. 165 0
      demo/README.md
  77. 22 0
      demo/config-sync.json
  78. 24 0
      docker-bake.hcl
  79. 23 0
      e2e/auth.setup.ts
  80. 208 0
      e2e/flow.js
  81. 52 0
      e2e/happy.spec.ts
  82. 119 0
      e2e/helpers.js
  83. 114 0
      e2e/negative.spec.ts
  84. 193 0
      e2e/package-lock.json
  85. 15 0
      e2e/package.json
  86. 38 0
      e2e/playwright.config.js
  87. 11 0
      package.json
  88. 4 0
      rust-toolchain.toml
  89. 2 0
      rustfmt.toml

+ 1 - 0
.codegraph

@@ -0,0 +1 @@
+/home/efren/.omo/codegraph/projects/d-OAuth-Client-132707124bb8a345

+ 41 - 0
.dockerignore

@@ -0,0 +1,41 @@
+# Keep the build context minimal and secrets out. The workspace
+# Cargo.toml/Cargo.lock, crates/** sources (incl. Trunk.toml, index.html,
+# style/, bootstrap.js), rust-toolchain.toml, rustfmt.toml and the secret-free
+# config template are REQUIRED to build, so they are deliberately NOT ignored.
+
+# VCS / tooling state
+.codegraph/
+.git/
+.github/
+.omo/
+.opencode/
+.playwright-mcp/
+
+# Build output
+/target
+crates/web/dist
+
+# Node / end-to-end tests (all four sub-artifacts under e2e/)
+e2e/
+node_modules/
+
+# Provisioning runbook only
+demo/
+
+# Editor / OS noise
+*.orig
+*.rej
+.DS_Store
+__pycache__/
+
+# Secrets and local/derived config: never bake into the image
+.env
+.env.*
+config/*.local.json
+config/client.json
+config/config.json
+config/dpop-key.json
+config/e2e.json
+
+# Keep the secret-free template available for operators
+!config/config.example.json

+ 18 - 0
.gitignore

@@ -0,0 +1,18 @@
+.codegraph/
+.env
+.env.*
+.omo/
+.opencode/
+.playwright-mcp/
+/target
+__pycache__/
+config/*.local.json
+config/client.json
+config/config.json
+config/dpop-key.json
+config/e2e.json
+crates/web/dist
+e2e/.auth/
+e2e/playwright-report
+e2e/test-results
+node_modules

+ 3682 - 0
Cargo.lock

@@ -0,0 +1,3682 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "aho-corasick"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "anstream"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
+dependencies = [
+ "anstyle",
+ "anstyle-parse",
+ "anstyle-query",
+ "anstyle-wincon",
+ "colorchoice",
+ "is_terminal_polyfill",
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
+
+[[package]]
+name = "anstyle-parse"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
+dependencies = [
+ "utf8parse",
+]
+
+[[package]]
+name = "anstyle-query"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "anstyle-wincon"
+version = "3.0.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
+dependencies = [
+ "anstyle",
+ "once_cell_polyfill",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "any_spawner"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d"
+dependencies = [
+ "futures",
+ "thiserror 2.0.21",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "asn1-rs"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f43a50ac4fdca5df8e885c21b835997f0a1cdee65494a6847694a98652d9d8"
+dependencies = [
+ "asn1-rs-derive",
+ "asn1-rs-impl",
+ "displaydoc",
+ "nom",
+ "num-traits",
+ "rusticata-macros",
+ "thiserror 2.0.21",
+ "time",
+]
+
+[[package]]
+name = "asn1-rs-derive"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure 0.13.2",
+]
+
+[[package]]
+name = "asn1-rs-impl"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "assert-json-diff"
+version = "2.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12"
+dependencies = [
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-once-cell"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
+
+[[package]]
+name = "async-trait"
+version = "0.1.92"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "attribute-derive"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77"
+dependencies = [
+ "attribute-derive-macro",
+ "derive-where",
+ "manyhow",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "attribute-derive-macro"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61"
+dependencies = [
+ "collection_literals",
+ "interpolator",
+ "manyhow",
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+ "quote-use",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "base16"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8"
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64"
+version = "0.23.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bit-vec"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bitflags"
+version = "2.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+
+[[package]]
+name = "camino"
+version = "1.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbbad30e4b4c14a39e3cc8aed085a12a327257c316619c93581e017bc52be591"
+
+[[package]]
+name = "cc"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.1",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "clap"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
+dependencies = [
+ "clap_builder",
+ "clap_derive",
+]
+
+[[package]]
+name = "clap_builder"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
+dependencies = [
+ "anstream",
+ "anstyle",
+ "clap_lex",
+ "strsim",
+]
+
+[[package]]
+name = "clap_derive"
+version = "4.6.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "clap_lex"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
+
+[[package]]
+name = "codee"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "collection_literals"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084"
+
+[[package]]
+name = "colorchoice"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+
+[[package]]
+name = "config"
+version = "0.15.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38e9fbc53afcec54441422f1c596cd054655b32e70e1cbac956651b7b175a404"
+dependencies = [
+ "convert_case 0.6.0",
+ "pathdiff",
+ "serde_core",
+ "toml",
+ "winnow",
+]
+
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "const-str"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f"
+
+[[package]]
+name = "const_format"
+version = "0.2.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
+dependencies = [
+ "const_format_proc_macros",
+ "konst",
+]
+
+[[package]]
+name = "const_format_proc_macros"
+version = "0.2.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "const_str_slice_concat"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
+
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "convert_case_extras"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb"
+dependencies = [
+ "convert_case 0.11.0",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
+
+[[package]]
+name = "deadpool"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b"
+dependencies = [
+ "deadpool-runtime",
+ "lazy_static",
+ "num_cpus",
+ "tokio",
+]
+
+[[package]]
+name = "deadpool-runtime"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "der-parser"
+version = "10.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07da5016415d5a3c4dd39b11ed26f915f52fc4e0dc197d87908bc916e51bc1a6"
+dependencies = [
+ "asn1-rs",
+ "displaydoc",
+ "nom",
+ "num-bigint",
+ "num-traits",
+ "rusticata-macros",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+
+[[package]]
+name = "derive-where"
+version = "1.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "const-oid",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "drain_filter_polyfill"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[package]]
+name = "either"
+version = "1.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+
+[[package]]
+name = "either_of"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd"
+dependencies = [
+ "paste",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest",
+ "ff",
+ "generic-array",
+ "group",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "erased"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d"
+
+[[package]]
+name = "futures-task"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
+
+[[package]]
+name = "futures-util"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-io",
+ "futures-macro",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+ "zeroize",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "r-efi 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gloo-net"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-sink",
+ "gloo-utils",
+ "http",
+ "js-sys",
+ "pin-project",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "gloo-utils"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
+dependencies = [
+ "js-sys",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "guardian"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f"
+
+[[package]]
+name = "h2"
+version = "0.4.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hermit-abi"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "html-escape"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
+
+[[package]]
+name = "http"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hydration_context"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bbbeb23ee808258cef2c5585ff0dc8e41da21a8dde943f6b290da153a042a96"
+dependencies = [
+ "futures",
+ "or_poisoned",
+ "pin-project-lite",
+ "serde",
+ "throw_error",
+]
+
+[[package]]
+name = "hyper"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
+dependencies = [
+ "base64 0.23.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "httparse",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+
+[[package]]
+name = "icu_properties"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+dependencies = [
+ "displaydoc",
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+
+[[package]]
+name = "icu_provider"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "interpolator"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8"
+
+[[package]]
+name = "ipnet"
+version = "2.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
+
+[[package]]
+name = "is_terminal_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "konst"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
+dependencies = [
+ "konst_macro_rules",
+]
+
+[[package]]
+name = "konst_macro_rules"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
+
+[[package]]
+name = "leptos"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae0b16d491eff5674a34a99bdba7cc9190c596c08dd6d99c89d01cd582f040b1"
+dependencies = [
+ "any_spawner",
+ "cfg-if",
+ "either_of",
+ "futures",
+ "getrandom 0.4.3",
+ "hydration_context",
+ "leptos_config",
+ "leptos_dom",
+ "leptos_hot_reload",
+ "leptos_macro",
+ "leptos_server",
+ "oco_ref",
+ "or_poisoned",
+ "paste",
+ "reactive_graph",
+ "rustc-hash",
+ "rustc_version",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "serde_qs",
+ "server_fn",
+ "slotmap",
+ "tachys",
+ "thiserror 2.0.21",
+ "throw_error",
+ "typed-builder",
+ "typed-builder-macro",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm_split_helpers",
+ "web-sys",
+]
+
+[[package]]
+name = "leptos_config"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3"
+dependencies = [
+ "config",
+ "regex",
+ "serde",
+ "thiserror 2.0.21",
+ "typed-builder",
+]
+
+[[package]]
+name = "leptos_dom"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884"
+dependencies = [
+ "js-sys",
+ "or_poisoned",
+ "reactive_graph",
+ "send_wrapper",
+ "tachys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "leptos_hot_reload"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1"
+dependencies = [
+ "anyhow",
+ "camino",
+ "indexmap",
+ "or_poisoned",
+ "proc-macro2",
+ "quote",
+ "rstml",
+ "serde",
+ "syn 2.0.119",
+ "walkdir",
+]
+
+[[package]]
+name = "leptos_macro"
+version = "0.8.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfefedc5b4bd150b2669b9d3169b5795008b1cca8147ef1a6591eda0d57c94c"
+dependencies = [
+ "attribute-derive",
+ "cfg-if",
+ "convert_case 0.11.0",
+ "convert_case_extras",
+ "html-escape",
+ "itertools",
+ "leptos_hot_reload",
+ "prettyplease",
+ "proc-macro2",
+ "quote",
+ "rstml",
+ "rustc_version",
+ "server_fn_macro",
+ "syn 2.0.119",
+ "uuid",
+]
+
+[[package]]
+name = "leptos_router"
+version = "0.8.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ffad5341e4164e1ed9a43e385ed2f9925903dc7a15fba00133a103f3bc8665a2"
+dependencies = [
+ "any_spawner",
+ "either_of",
+ "futures",
+ "gloo-net",
+ "js-sys",
+ "leptos",
+ "leptos_router_macro",
+ "or_poisoned",
+ "reactive_graph",
+ "rustc_version",
+ "send_wrapper",
+ "tachys",
+ "thiserror 2.0.21",
+ "url",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "leptos_router_macro"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bb5c81012dcab0d5cc032a2da0a364a18ce74a0fbeb88f091e9b53a0679bc85"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "leptos_server"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202b2d1261c22d886f307934760a6bb566444f68f39b24f6f10defdc23633049"
+dependencies = [
+ "any_spawner",
+ "base64 0.22.1",
+ "codee",
+ "futures",
+ "hydration_context",
+ "or_poisoned",
+ "reactive_graph",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "server_fn",
+ "tachys",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.190"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78"
+
+[[package]]
+name = "linux-raw-sys"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
+
+[[package]]
+name = "litemap"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+
+[[package]]
+name = "log"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
+
+[[package]]
+name = "manyhow"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587"
+dependencies = [
+ "manyhow-macros",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "manyhow-macros"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495"
+dependencies = [
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "matchers"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
+dependencies = [
+ "regex-automata",
+]
+
+[[package]]
+name = "matchit"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
+[[package]]
+name = "minimal-lexical"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
+
+[[package]]
+name = "mio"
+version = "1.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "next_tuple"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28"
+
+[[package]]
+name = "nom"
+version = "7.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
+dependencies = [
+ "memchr",
+ "minimal-lexical",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-bigint"
+version = "0.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367"
+dependencies = [
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[package]]
+name = "num-integer"
+version = "0.1.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "oauth-core"
+version = "0.1.0"
+dependencies = [
+ "base64 0.22.1",
+ "p256",
+ "rand 0.9.5",
+ "reqwest",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sha2",
+ "subtle",
+ "thiserror 2.0.21",
+ "tokio",
+ "url",
+ "wiremock",
+ "zeroize",
+]
+
+[[package]]
+name = "oco_ref"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d"
+dependencies = [
+ "serde",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "oid-registry"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "12f40cff3dde1b6087cc5d5f5d4d65712f34016a03ed60e9c08dcc392736b5b7"
+dependencies = [
+ "asn1-rs",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "once_cell_polyfill"
+version = "1.70.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
+
+[[package]]
+name = "or_poisoned"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd"
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2",
+]
+
+[[package]]
+name = "parking"
+version = "2.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[package]]
+name = "pem"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d354a98a3d1251555de99e8fdd8afda05573c31b82f59063a7b0a29b5527f120"
+dependencies = [
+ "base64 0.23.1",
+ "serde_core",
+]
+
+[[package]]
+name = "pem-rfc7468"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
+dependencies = [
+ "base64ct",
+]
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkcs8"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
+dependencies = [
+ "der",
+ "spki",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "prettyplease"
+version = "0.2.37"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
+dependencies = [
+ "proc-macro2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro-error-attr2"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "proc-macro-error2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
+dependencies = [
+ "proc-macro-error-attr2",
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "proc-macro-utils"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "smallvec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "version_check",
+ "yansi",
+]
+
+[[package]]
+name = "provision"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "oauth-core",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "tempfile",
+ "thiserror 2.0.21",
+ "tokio",
+ "wiremock",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror 2.0.21",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.3",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.21",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "quote-use"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e"
+dependencies = [
+ "quote",
+ "quote-use-macros",
+]
+
+[[package]]
+name = "quote-use-macros"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35"
+dependencies = [
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom 0.2.17",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "rcgen"
+version = "0.14.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8774e05a7d0de114588e6a28fe7e71694b82614ed569d86d8b389dfbc98b8ad8"
+dependencies = [
+ "pem",
+ "ring",
+ "rustls-pki-types",
+ "time",
+ "x509-parser",
+ "yasna",
+]
+
+[[package]]
+name = "reactive_graph"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "79f11fac67625645add76313dab8dae510fca0654d96ef98fd8f961a7d2e22c1"
+dependencies = [
+ "any_spawner",
+ "async-lock",
+ "futures",
+ "guardian",
+ "hydration_context",
+ "indexmap",
+ "or_poisoned",
+ "paste",
+ "pin-project-lite",
+ "rustc-hash",
+ "rustc_version",
+ "send_wrapper",
+ "serde",
+ "slotmap",
+ "thiserror 2.0.21",
+ "web-sys",
+]
+
+[[package]]
+name = "reactive_stores"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a29e469b556f25486678e9f5f65fa21a0351d98affde9a7eea9fee286bf8ebd6"
+dependencies = [
+ "guardian",
+ "indexmap",
+ "itertools",
+ "or_poisoned",
+ "paste",
+ "reactive_graph",
+ "reactive_stores_macro",
+ "rustc-hash",
+ "send_wrapper",
+]
+
+[[package]]
+name = "reactive_stores_macro"
+version = "0.4.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d11f1b82859d94577892b8037c35f8a4f066cbb3864583e22e68def0af49a88"
+dependencies = [
+ "convert_case 0.11.0",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http 0.6.11",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac",
+ "subtle",
+]
+
+[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "rstml"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56"
+dependencies = [
+ "derive-where",
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn 2.0.119",
+ "syn_derive",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
+name = "rustc_version"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
+dependencies = [
+ "semver",
+]
+
+[[package]]
+name = "rusticata-macros"
+version = "4.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632"
+dependencies = [
+ "nom",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+dependencies = [
+ "bitflags",
+ "errno",
+ "libc",
+ "linux-raw-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+dependencies = [
+ "log",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "same-file"
+version = "1.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
+dependencies = [
+ "winapi-util",
+]
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "secrecy"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
+dependencies = [
+ "serde",
+ "zeroize",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_qs"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352"
+dependencies = [
+ "percent-encoding",
+ "serde",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "server"
+version = "0.1.0"
+dependencies = [
+ "axum",
+ "base64 0.22.1",
+ "oauth-core",
+ "rand 0.9.5",
+ "rcgen",
+ "reqwest",
+ "rustls",
+ "secrecy",
+ "serde",
+ "serde_json",
+ "subtle",
+ "tempfile",
+ "thiserror 2.0.21",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http 0.7.1",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+ "wiremock",
+]
+
+[[package]]
+name = "server_fn"
+version = "0.8.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "const-str",
+ "const_format",
+ "futures",
+ "gloo-net",
+ "http",
+ "js-sys",
+ "or_poisoned",
+ "pin-project-lite",
+ "rustc_version",
+ "rustversion",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "serde_qs",
+ "server_fn_macro_default",
+ "thiserror 2.0.21",
+ "throw_error",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "server_fn_macro"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4ef7e82840fddcd17e15a62640b6d7d3b3a905e65b07854a903393db2aced76"
+dependencies = [
+ "const_format",
+ "convert_case 0.11.0",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.119",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "server_fn_macro_default"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00"
+dependencies = [
+ "server_fn_macro",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.16.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn_derive"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219"
+dependencies = [
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "tachys"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e47e2220bb9d2a3be976a22a0fb7685a0b84499bb11ea5cb3bbc82907675ac68"
+dependencies = [
+ "any_spawner",
+ "async-trait",
+ "const_str_slice_concat",
+ "drain_filter_polyfill",
+ "either_of",
+ "erased",
+ "futures",
+ "html-escape",
+ "indexmap",
+ "itertools",
+ "js-sys",
+ "next_tuple",
+ "oco_ref",
+ "or_poisoned",
+ "paste",
+ "reactive_graph",
+ "reactive_stores",
+ "rustc-hash",
+ "rustc_version",
+ "send_wrapper",
+ "slotmap",
+ "throw_error",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "tempfile"
+version = "3.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
+dependencies = [
+ "fastrand",
+ "getrandom 0.4.3",
+ "once_cell",
+ "rustix",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "thiserror"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
+dependencies = [
+ "thiserror-impl 1.0.69",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
+dependencies = [
+ "thiserror-impl 2.0.21",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "1.0.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "throw_error"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64"
+dependencies = [
+ "pin-project-lite",
+]
+
+[[package]]
+name = "time"
+version = "0.3.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
+
+[[package]]
+name = "tokio"
+version = "1.53.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "libc",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.6+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
+dependencies = [
+ "indexmap",
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "toml_writer",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[package]]
+name = "toml_writer"
+version = "1.1.2+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+
+[[package]]
+name = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "typed-builder"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda"
+dependencies = [
+ "typed-builder-macro",
+]
+
+[[package]]
+name = "typed-builder-macro"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "utf8parse"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+
+[[package]]
+name = "uuid"
+version = "1.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "walkdir"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
+dependencies = [
+ "same-file",
+ "winapi-util",
+]
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.79"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e"
+dependencies = [
+ "js-sys",
+ "tokio",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.129"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm_split_helpers"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba"
+dependencies = [
+ "async-once-cell",
+ "wasm_split_macros",
+]
+
+[[package]]
+name = "wasm_split_macros"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8"
+dependencies = [
+ "base16",
+ "quote",
+ "sha2",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "web"
+version = "0.1.0"
+dependencies = [
+ "console_error_panic_hook",
+ "gloo-net",
+ "js-sys",
+ "leptos",
+ "leptos_router",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "winapi-util"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wiremock"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031"
+dependencies = [
+ "assert-json-diff",
+ "base64 0.22.1",
+ "deadpool",
+ "futures",
+ "http",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "log",
+ "once_cell",
+ "regex",
+ "serde",
+ "serde_json",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc"
+
+[[package]]
+name = "x509-parser"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202"
+dependencies = [
+ "asn1-rs",
+ "data-encoding",
+ "der-parser",
+ "lazy_static",
+ "nom",
+ "oid-registry",
+ "ring",
+ "rusticata-macros",
+ "thiserror 2.0.21",
+ "time",
+]
+
+[[package]]
+name = "xtask"
+version = "0.1.0"
+dependencies = [
+ "clap",
+ "oauth-core",
+ "secrecy",
+ "serde_json",
+ "server",
+ "tempfile",
+ "thiserror 2.0.21",
+]
+
+[[package]]
+name = "xxhash-rust"
+version = "0.8.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
+
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
+[[package]]
+name = "yasna"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282"
+dependencies = [
+ "bit-vec",
+ "time",
+]
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "synstructure 0.14.0",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.59"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+ "synstructure 0.14.0",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.6",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"

+ 3 - 0
Cargo.toml

@@ -0,0 +1,3 @@
+[workspace]
+resolver = "2"
+members = ["crates/*"]

+ 110 - 0
Dockerfile

@@ -0,0 +1,110 @@
+# syntax=docker/dockerfile:1.7
+# Minimal static-musl image for the oauth2-wasm-demo-client BFF + Leptos UI.
+#
+# Stages: web -> chef -> planner -> builder -> runtime.
+# - `web`: builds the Leptos CSR/WASM bundle with a pinned, prebuilt Trunk
+#   (which fetches its pinned wasm-bindgen/tailwindcss tools), producing
+#   `/app/crates/web/dist`.
+# - `chef`: pinned rust toolchain + cargo-chef + musl targets.
+# - `planner`: captures the dependency recipe so dependency layers cache.
+# - `builder`: cooks dependencies, then builds the locked, fully-static musl
+#   `server` binary (rustls `ring` provider only; no aws-lc-sys/OpenSSL).
+# - `runtime`: pinned-digest distroless `static-debian12:nonroot` (no shell,
+#   no package manager, CA bundle + nonroot /etc/passwd entry included). It
+#   carries the BFF binary and the built WASM assets.
+#
+# BuildKit cache mounts keep the cargo registry/git cache and the target
+# directory warm across rebuilds without baking them into any layer.
+
+# syntax=docker/dockerfile:1.7
+
+# Pinned bases (multi-arch index digests; buildx selects per-platform):
+# - rust 1.99-bookworm (matches rust-toolchain.toml stable; rustc 1.99.0)
+# - distroless static-debian12:nonroot (UID:GID 65532, no shell)
+ARG RUST_BASE=docker.io/library/rust:1.99-bookworm@sha256:59037199c44290f2befcdd58dcc540164763fc296950255aaefeef096a1866b0
+ARG RUNTIME_BASE=gcr.io/distroless/static-debian12:nonroot@sha256:afa5c872c891853ca7fcf1f12c3edb23f7eeef36189728842dd51042ff57f7ab
+ARG TRUNK_VERSION=0.21.14
+
+# ---------------------------------------------------------------------------
+# web: prebuilt Trunk builds the CSR/WASM UI. Trunk downloads its pinned
+# wasm-bindgen (0.2.129) and tailwindcss (4.3.3) tools at build time.
+# ---------------------------------------------------------------------------
+FROM ${RUST_BASE} AS web
+ARG TARGETARCH
+ARG TRUNK_VERSION
+WORKDIR /app
+COPY . .
+RUN set -eux; \
+    rustup target add wasm32-unknown-unknown; \
+    case "${TARGETARCH}" in \
+      arm64) trunk_target=aarch64-unknown-linux-musl ;; \
+      *)     trunk_target=x86_64-unknown-linux-musl ;; \
+    esac; \
+    curl -fsSL -o /tmp/trunk.tar.gz \
+      "https://github.com/trunk-rs/trunk/releases/download/v${TRUNK_VERSION}/trunk-${trunk_target}.tar.gz"; \
+    tar -xzf /tmp/trunk.tar.gz -C /usr/local/bin; \
+    rm /tmp/trunk.tar.gz; \
+    cd crates/web && trunk build --release
+
+FROM ${RUST_BASE} AS chef
+WORKDIR /app
+# Reproducible builds: fixed epoch (overridden by bake) and no incremental.
+ARG SOURCE_DATE_EPOCH=0
+ENV SOURCE_DATE_EPOCH=${SOURCE_DATE_EPOCH} \
+  CARGO_INCREMENTAL=0 \
+  CARGO_NET_RETRY=10
+RUN cargo install cargo-chef --locked --version 0.1.71 \
+  && apt-get update \
+  && apt-get install -y --no-install-recommends musl-tools \
+  && rm -rf /var/lib/apt/lists/* \
+  && rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl wasm32-unknown-unknown
+
+FROM chef AS planner
+COPY . .
+RUN cargo chef prepare --recipe-path recipe.json
+
+FROM chef AS builder
+ARG TARGETARCH
+COPY --from=planner /app/recipe.json recipe.json
+# Cook (compile) third-party dependencies only: this layer reuses the cache
+# unless Cargo.toml/Cargo.lock change.
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git \
+    --mount=type=cache,target=/app/target \
+    sh -c 'if [ "$TARGETARCH" = "arm64" ]; then export MUSL_TARGET=aarch64-unknown-linux-musl; else export MUSL_TARGET=x86_64-unknown-linux-musl; fi && \
+    rustup target add "$MUSL_TARGET" && \
+    cargo chef cook --locked --release --target "$MUSL_TARGET" --recipe-path recipe.json --bin server'
+COPY . .
+# Locked, offline release build of the server binary only. The target dir is
+# a cache mount, so the binary is copied out to /app/dist in the same layer.
+RUN --mount=type=cache,target=/usr/local/cargo/registry \
+    --mount=type=cache,target=/usr/local/cargo/git \
+    --mount=type=cache,target=/app/target \
+    sh -c 'if [ "$TARGETARCH" = "arm64" ]; then export MUSL_TARGET=aarch64-unknown-linux-musl; else export MUSL_TARGET=x86_64-unknown-linux-musl; fi && \
+    rustup target add "$MUSL_TARGET" && \
+    cargo build --locked --release --target "$MUSL_TARGET" --bin server && \
+    mkdir -p /app/dist && \
+    cp "/app/target/$MUSL_TARGET/release/server" /app/dist/server'
+
+FROM ${RUNTIME_BASE} AS runtime
+WORKDIR /app
+# Static BFF binary plus the release WASM assets it serves from dist_dir.
+COPY --from=builder /app/dist/server /app/server
+COPY --from=web /app/crates/web/dist /app/crates/web/dist
+# Single-origin demo bind; the SPA is served from the committed dist path.
+# Secret-bearing config/client.json, config/dpop-key.json and config.json are
+# mounted at runtime, never baked in.
+ENV SERVER_BIND_ADDR=0.0.0.0:3000 \
+  SERVER_DIST_DIR=/app/crates/web/dist
+# Non-root, exec-form entrypoint (no shell in the image to interpret one).
+USER 65532:65532
+EXPOSE 3000
+# Shell-less self-probe: the `healthcheck` subcommand GETs /healthz over
+# loopback and exits 0/1 without starting the HTTP server.
+HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD ["/app/server","healthcheck"]
+ENTRYPOINT ["/app/server"]
+LABEL org.opencontainers.image.title="oauth2-wasm-demo-client" \
+  org.opencontainers.image.description="OAuth 2.1 BFF + Leptos WASM demo client (static musl, non-root, no shell)" \
+  org.opencontainers.image.licenses="MIT OR Apache-2.0" \
+  org.opencontainers.image.revision="unknown" \
+  org.opencontainers.image.vendor="d-oauth"

+ 5 - 0
LICENSE.md

@@ -0,0 +1,5 @@
+Copyright 2026 Syavne.Net
+
+Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.

+ 313 - 0
README.md

@@ -0,0 +1,313 @@
+# oauth2-wasm-demo-client
+
+A Rust workspace demo client for an OAuth 2.1 authorization server: a
+server-side BFF handles the confidential OAuth exchange while a Leptos
+CSR/WASM front end renders the signed-in identity, granted scopes, and token
+metadata.
+
+This file is the entry point. It covers the architecture, prerequisites, the
+two run modes, provisioning, configuration/portability, and the security
+posture. The full d-OAuth provisioning runbook lives in
+[`demo/README.md`](demo/README.md).
+
+## Architecture
+
+The client is split into two halves that never share a secret:
+
+- **`crates/server` (BFF)** is the confidential OAuth 2.1 client. It owns the
+  client secret, the DPoP private key, the PKCE verifier, and the server-side
+  session store. It pushes the authorization request (PAR when available),
+  handles `/callback`, exchanges the code, introspects the token, and refreshes
+  or revokes it.
+- **`crates/web` (Leptos CSR/WASM)** is a thin UI over the BFF JSON API. It
+  renders `/api/config` and `/api/session`, starts sign-in, and posts refresh
+  and logout with a CSRF token. It never sees a token or a key.
+
+Why the split: a browser app cannot keep a client secret confidential, and
+storing tokens in `localStorage` or JS memory widens the XSS blast radius. By
+keeping the secret and the DPoP key on the server and giving the browser only
+an opaque, `HttpOnly` session cookie, the browser never holds credential
+material. In dev, Trunk serves the UI on `:3000` and proxies `/oauth/`,
+`/callback`, and `/api/` to the BFF on `:3001`; in demo/prod the BFF serves
+the built assets directly on `:3000`. Either way the browser sees a single
+origin, so there is no CORS and cookies stay same-site.
+
+Request path (both modes):
+
+1. The browser hits `GET /oauth/start`; the BFF creates a session and redirects
+   to the authorization server with `client_id` and, when PAR is available,
+   `request_uri`.
+2. The AS returns to `GET /callback`; the BFF validates `state`, exchanges the
+   code with a DPoP proof, and rotates the session cookie.
+3. The UI reads `GET /api/session` for identity and token metadata.
+4. `POST /api/refresh` and `POST /api/logout` are CSRF-guarded and return only
+   status, never tokens.
+5. `Log out` clears the BFF session, then the browser navigates to the provider
+   logout (`<issuer>/logout`) so the authorization-server session ends too.
+
+BFF surface: `/healthz`, `/oauth/start`, `/callback`, `/api/config`,
+`/api/session`, `/api/refresh`, `/api/logout`.
+
+## Prerequisites
+
+- Rust stable, as pinned by `rust-toolchain.toml` (it also declares the
+  `rustfmt` and `clippy` components).
+- WebAssembly target:
+  `rustup target add wasm32-unknown-unknown`.
+- Next-generation test runner:
+  `cargo install cargo-nextest --locked`.
+- WASM build tooling:
+  `cargo install trunk` and `cargo install wasm-pack`.
+- Node.js (for the Playwright end-to-end tests):
+  `npx playwright install --with-deps chromium`.
+- The read-only d-OAuth reference checkout at
+  `/home/efren/Repositories/d-OAuth` (the authorization server the demo
+  provisions against; never modified from this workspace).
+
+## Workspace layout
+
+- `crates/oauth-core` - OAuth 2.1 protocol (PKCE, DPoP, PAR, token,
+  introspection) and the capability matrix.
+- `crates/server` - BFF HTTP server.
+- `crates/web` - Leptos CSR/WASM UI.
+- `crates/provision` - one-shot client registration and demo kit driver.
+- `crates/xtask` - development/build/check helper.
+- `demo/` - d-OAuth provisioning runbook and config sync file.
+- `e2e/` - Playwright end-to-end tests and fixtures.
+- `config/` - example config plus local, gitignored secrets.
+
+## Run modes
+
+### Dev (Trunk UI on `:3000` + BFF on `:3001`)
+
+Two terminals:
+
+```bash
+# Terminal 1: BFF on 127.0.0.1:3001 (health: /healthz).
+APP_START_URL=http://127.0.0.1:3001/oauth/start cargo run -p server
+
+# Terminal 2: UI on 127.0.0.1:3000; Trunk proxies /oauth/, /callback, /api/ to :3001.
+cd crates/web && trunk serve --port 3000
+```
+
+`APP_START_URL` is required in dev: Trunk's proxy follows the BFF's
+`/oauth/start` 302 to the authorization server and swallows it (the browser
+gets a `200` with an empty body and never leaves `:3000`), so the landing CTA
+navigates straight at the BFF origin instead. `/api/config` advertises it as
+the non-secret `start_url` (default: relative `/oauth/start`). Cookies are
+host-scoped, not port-scoped, so the top-level `:3000` -> `:3001` navigation
+keeps `SameSite=Lax` semantics.
+
+Open `http://127.0.0.1:3000`. `cargo run -p xtask -- dev` prints these two
+commands without starting anything.
+
+### Demo / prod (BFF serves the SPA on `:3000`)
+
+Build the assets once, then let the BFF serve `crates/web/dist` itself. No
+Trunk process, no proxies:
+
+```bash
+cd crates/web && trunk build
+cd ../.. && SERVER_BIND_ADDR=127.0.0.1:3000 cargo run -p server
+```
+
+Open `http://127.0.0.1:3000`. `SERVER_BIND_ADDR` overrides the default
+`127.0.0.1:3001`, which is what makes demo mode single-origin on `:3000`.
+
+### Redirect URI (fixed)
+
+The redirect URI is exactly:
+
+```
+http://127.0.0.1:3000/callback
+```
+
+Both modes expose it on `:3000`: Trunk proxies `/callback` to the BFF in dev,
+and the BFF serves it directly in demo/prod. It must match
+`config/config.example.json` and the demo kit (scope `demo.read`, user
+`demo@example.com`). Do not change it; the AS catalog and the DCR registration
+both pin this exact string.
+
+## Signed-in behavior
+
+The dashboard is a thin view over the BFF session; none of these behaviors put
+a token in the browser.
+
+- **Logout is RP-initiated.** `Log out` first clears the BFF session
+  (`POST /api/logout`, best-effort token revocation), then navigates the
+  browser to the provider logout endpoint (`<issuer>/logout`, advertised as
+  `provider_logout_url` by `/api/config`). d-OAuth ends its own session and
+  `302`s back to the app root (`http://127.0.0.1:3000/`, the client's registered
+  post-logout return URI), so the next sign-in can use a different account.
+- **Token renewal is transparent.** `GET /api/session` renews the access token
+  with the refresh token whenever the token is inactive or within 30 seconds of
+  expiry, rotating the refresh token and keeping the session. The dashboard
+  mirrors the same 30-second threshold and re-probes as the countdown crosses
+  it. Only an unrecoverable refresh (`invalid_grant`, or no refresh token) ends
+  the session; a transient upstream failure leaves a still-active token usable.
+- **A missing scope is not a failed login.** If the account authenticates but is
+  not granted the configured scope (for example `demo.read`), the dashboard
+  keeps the session and shows a `Signed in without the required scope` notice.
+  If the authorization server refuses the scope outright, `/callback` renders a
+  `Scope not granted` page.
+- **The dashboard is auth-gated.** The header shows the Dashboard link only
+  once `/api/session` reports an active session; `/dashboard` redirects to `/`
+  when unauthenticated, and a successful login lands on `/dashboard`.
+- **Theme.** Dark is the default. The header toggle switches light/dark and
+  persists the choice in `localStorage["theme"]`, the only key the UI writes.
+
+## Container
+
+The image carries the static musl `server` binary and the release WASM bundle;
+the BFF serves the SPA itself on `:3000` (no Trunk process, no proxies). Config
+and secrets (`config/config.json`, `config/client.json`, `config/dpop-key.json`)
+are **mounted at runtime and never baked into the image**.
+
+Build both architectures, or a single one to skip arm64 emulation:
+
+```bash
+# Both architectures (linux/amd64 + linux/arm64)
+docker buildx bake image
+
+# Single architecture (no arm64 QEMU emulation needed)
+docker buildx bake image --set image.platform=linux/amd64
+
+# Or a plain single-platform build
+docker buildx build --load --platform linux/amd64 -t oauth2-wasm-demo-client:dev .
+```
+
+Run it, mounting the local `config/` directory read-only on port `3000`:
+
+```bash
+docker run --rm -p 3000:3000 \
+  -v "$PWD/config:/app/config:ro" \
+  oauth2-wasm-demo-client:latest
+```
+
+Open `http://127.0.0.1:3000`. Without the mount the server still answers
+`/healthz` with `200`, but reports a degraded (`server_misconfigured`) profile.
+
+The redirect URI is fixed at `http://127.0.0.1:3000/callback` (see
+[Redirect URI (fixed)](#redirect-uri-fixed)): the authorization server catalog
+and the DCR registration pin that exact string, so a container published on a
+different host or port cannot complete the flow without re-provisioning.
+
+The image is non-root (`65532:65532`), has no shell, and ships a `HEALTHCHECK`
+that runs the binary's shell-less `healthcheck` subcommand against `/healthz`
+(no `curl` needed).
+
+## Provisioning
+
+The workspace does not provision the AS itself; it drives the d-OAuth CLI and
+registers a confidential client over DCR. Copy the example config first, then
+run the three `provision` subcommands from the repo root:
+
+```bash
+cp config/config.example.json config/config.json
+
+# 1. Drive d-OAuth: scope, user, grant, redirect URI, initial access token.
+cargo run -p provision -- setup --cli <path-to-d-oauth-server> --env-file <d-OAuth/.env> \
+  --email demo@example.com --password-file <path> --iat-out /tmp/demo-iat.txt
+
+# 2. Register (or reconcile) the confidential client; writes config/client.json (0600).
+cargo run -p provision -- register --issuer https://localhost:8080 --iat-file /tmp/demo-iat.txt
+
+# 3. Write the gitignored E2E fixtures to config/e2e.json (only when absent).
+cargo run -p provision -- bootstrap --password-file <path> --out config/e2e.json
+```
+
+- `setup` runs an argv-only sequence (`scope create demo.read`, `user create`
+  create-or-skip, `user grant-scope`, `redirect-uri create`, and
+  `initial-access-token issue`). The raw IAT is written to `--iat-out` (mode
+  `0600`) and never printed.
+- `register` POSTs the RFC 7591 body with `client_secret_basic` and
+  `dpop_bound_access_tokens=true`, then persists `config/client.json` (mode
+  `0600`). On rerun it reconciles drift: it probes the stored
+  `registration_client_uri`, keeps a live client untouched, and mints a fresh
+  single-use IAT to re-register a stale one.
+- A DCR `invalid_redirect_uri` rejection means the runtime catalog does not yet
+  hold the URI. Provision it first with `redirect-uri create`, or, only outside
+  production, relax the AS to `dcr_redirect_policy=catalog_loopback`. Never use
+  `open`.
+- `bootstrap` writes `{email, password}` to `config/e2e.json` (mode `0600`)
+  only when the file is absent, so it never clobbers the Playwright-captured
+  TOTP state.
+
+The full manual runbook, including forced password change and one-time TOTP
+setup, is in [`demo/README.md`](demo/README.md).
+
+## Configuration and portability
+
+`config/config.example.json` is the template, copied to `config/config.json`
+(or pointed at with `--config` / `APP_CONFIG`). It holds no secrets:
+
+```json
+{
+  "server": {
+    "bind_addr": "127.0.0.1:3001",
+    "dist_dir": "crates/web/dist",
+    "cookie_secure": false,
+    "session_ttl_secs": 3600
+  },
+  "oauth": {
+    "issuer": "https://localhost:8080",
+    "redirect_uri": "http://127.0.0.1:3000/callback",
+    "scope": "demo.read",
+    "dev": {
+      "insecure_tls": true
+    }
+  }
+}
+```
+
+Client credentials live in `config/client.json`, written by `provision
+register` (mode `0600`), not in the example. Precedence is environment >
+`client.json` > `config.json` > built-in dev defaults. Relevant environment
+overrides include `SERVER_BIND_ADDR`, `SERVER_DIST_DIR`, `APP_CONFIG`,
+`APP_CLIENT_FILE`, `APP_ISSUER` / `OAUTH_ISSUER`, `APP_REDIRECT_URI`,
+`APP_SCOPE`, `APP_CLIENT_ID`, `APP_CLIENT_SECRET`, `APP_INSECURE_TLS`,
+`APP_COOKIE_SECURE`, `APP_CA_CERT`, `APP_SESSION_TTL_SECS`, and
+`APP_START_URL` (dev only: absolute BFF sign-in URL, e.g.
+`http://127.0.0.1:3001/oauth/start`; unset means relative `/oauth/start`).
+A missing
+default config file is not an error; a missing explicit `--config` path is.
+
+The client discovers its target at runtime (RFC 8414 with OIDC fallback) and
+degrades explicitly when a capability is absent:
+
+| Capability    | Discovery signal                              | When absent                                                    |
+|---------------|-----------------------------------------------|----------------------------------------------------------------|
+| DCR           | `registration_endpoint` present               | Require a pre-registered `client_id`; no dynamic registration  |
+| PAR           | `pushed_authorization_request_endpoint`       | Plain `/authorize` redirect with query params                  |
+| DPoP          | `dpop_signing_alg_values_supported` has ES256 | d-OAuth sender-constrained flows do not apply                  |
+| Introspection | `introspection_endpoint` present              | Typed "identity unavailable" state; never fabricate identity   |
+| Userinfo      | OIDC `userinfo_endpoint` present              | Optional enrichment only, gated on discovery                   |
+| Refresh       | `grant_types_supported` has `refresh_token`   | No refresh calls; expired sessions re-authenticate             |
+
+Each capability resolves through a typed helper that names the missing feature,
+so the UI branches instead of failing obscurely. Portability beyond d-OAuth is
+unverified: the matrix describes graceful degradation, not certification.
+
+## Security posture
+
+- **Secret stays server-side.** The client secret and the DPoP private key
+  (`config/dpop-key.json`) never leave the BFF, never enter a redirect, and
+  never reach the browser bundle.
+- **No browser token storage.** The browser holds only an opaque `HttpOnly`
+  session cookie. Access and refresh tokens live in the server-side session;
+  `/api/session` exposes identity and metadata (subject, scopes, `exp`,
+  `cnf.jkt`), never raw tokens.
+- **Same-origin only.** The browser talks to the BFF alone. State-changing
+  POSTs require a CSRF token and a same-origin `Origin` header.
+- **Locked-down config.** `provision` writes `config/client.json`,
+  `config/dpop-key.json`, and `config/e2e.json` with mode `0600`; all three are
+  gitignored. `config/*.local.json` and `.env` are ignored too.
+- **No secrets in artifacts or logs.** The example config declares no secret
+  fields; redaction keeps credentials out of `Debug` output and request logs;
+  the WASM bundle carries no secret by construction.
+- **Nothing is committed by the tooling.** Provisioning writes local files
+  only. Review and commit by hand if and when you choose.
+
+## License
+
+MIT OR Apache-2.0. See [LICENSE.md](LICENSE.md).

+ 16 - 0
config/config.example.json

@@ -0,0 +1,16 @@
+{
+  "server": {
+    "bind_addr": "127.0.0.1:3001",
+    "dist_dir": "crates/web/dist",
+    "cookie_secure": false,
+    "session_ttl_secs": 3600
+  },
+  "oauth": {
+    "issuer": "https://localhost:8080",
+    "redirect_uri": "http://127.0.0.1:3000/callback",
+    "scope": "demo.read",
+    "dev": {
+      "insecure_tls": true
+    }
+  }
+}

+ 24 - 0
crates/oauth-core/Cargo.toml

@@ -0,0 +1,24 @@
+[package]
+name = "oauth-core"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+base64 = "0.22"
+p256 = { version = "0.13", features = ["ecdsa"] }
+rand = "0.9"
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
+secrecy = { version = "0.10.3", features = ["serde"] }
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+sha2 = "0.10"
+subtle = "2.6"
+thiserror = "2.0.21"
+tokio = { version = "1", features = ["rt", "time"] }
+url = "2.5"
+zeroize = "1.9.0"
+
+[dev-dependencies]
+serde_urlencoded = "0.7"
+tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
+wiremock = "0.6"

+ 352 - 0
crates/oauth-core/src/capabilities.rs

@@ -0,0 +1,352 @@
+//! Capability matrix and graceful degradation (todo 33).
+//!
+//! The demo client is built against d-OAuth but discovers its target at
+//! runtime (RFC 8414 with OIDC fallback), so every optional feature degrades
+//! explicitly instead of failing obscurely or — worse — faking success:
+//!
+//! | Capability     | Discovery signal                          | When absent                                                     |
+//! |----------------|---------------------------------------------|-----------------------------------------------------------------|
+//! | DCR            | `registration_endpoint` present             | Require a pre-registered `client_id` (no dynamic registration)  |
+//! | PAR            | `pushed_authorization_request_endpoint`     | Plain `/authorize` redirect with query params                    |
+//! | DPoP           | `dpop_signing_alg_values_supported` has ES256 | d-OAuth-specific sender-constrained flows do not apply         |
+//! | Introspection  | `introspection_endpoint` present            | Typed "unsupported" identity state — never fabricate identity   |
+//! | Userinfo       | OIDC `userinfo_endpoint` present            | Optional profile enrichment only, gated on discovery            |
+//! | Refresh        | `grant_types_supported` has `refresh_token` | No refresh calls; expired sessions re-authenticate              |
+//!
+//! Each `require_*` helper below resolves one capability to its endpoint (or
+//! unit success) and maps absence to a distinct
+//! [`OAuthError::Unsupported`](crate::error::OAuthError::Unsupported) naming
+//! the capability, so callers and the UI can branch on it. Portability beyond
+//! d-OAuth is explicitly unverified: these helpers describe degradation, not
+//! certification against other servers.
+
+use crate::{
+  discovery::ServerMetadata,
+  error::{OAuthError, Result},
+};
+
+/// Capability name used when dynamic client registration is unavailable.
+pub const CAPABILITY_DCR: &str = "dcr";
+/// Capability name used when pushed authorization requests are unavailable.
+pub const CAPABILITY_PAR: &str = "par";
+/// Capability name used when DPoP sender-constraint is unavailable.
+pub const CAPABILITY_DPOP: &str = "dpop";
+/// Capability name used when token introspection is unavailable.
+pub const CAPABILITY_INTROSPECTION: &str = "introspection";
+/// Capability name used when the OIDC userinfo endpoint is unavailable.
+pub const CAPABILITY_USERINFO: &str = "userinfo";
+/// Capability name used when refresh tokens are unsupported.
+pub const CAPABILITY_REFRESH: &str = "refresh";
+
+/// How the authorization request leaves the BFF.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum AuthorizeMode<'a> {
+  /// PAR is available: push first, then redirect with `request_uri`.
+  Par {
+    /// The PAR endpoint to push to.
+    endpoint: &'a str,
+  },
+  /// No PAR: redirect straight to `/authorize` with query params.
+  Plain {
+    /// The authorization endpoint to redirect to.
+    endpoint: &'a str,
+  },
+}
+
+/// Where the BFF takes the displayed identity from — or nowhere.
+///
+/// A missing introspection endpoint is a typed [`IdentitySource::Unsupported`]
+/// state: the UI renders "identity unavailable" instead of inventing one.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum IdentitySource<'a> {
+  /// RFC 7662 introspection is available; identity comes from the BFF.
+  Introspection {
+    /// The introspection endpoint to call.
+    endpoint: &'a str,
+  },
+  /// No introspection: identity cannot be established. Never fabricate one.
+  Unsupported,
+}
+
+/// Resolve the DCR endpoint, or require a pre-registered `client_id` instead.
+///
+/// Absence yields `Unsupported { capability: "dcr" }`: the caller must fall
+/// back to credentials provisioned out of band.
+pub fn registration_endpoint(metadata: &ServerMetadata) -> Result<&str> {
+  metadata
+    .registration_endpoint
+    .as_deref()
+    .ok_or(OAuthError::Unsupported {
+      capability: CAPABILITY_DCR.to_owned(),
+    })
+}
+
+/// Resolve the PAR endpoint.
+///
+/// Absence yields `Unsupported { capability: "par" }`: the caller must use
+/// [`AuthorizeMode::Plain`] instead.
+pub fn par_endpoint(metadata: &ServerMetadata) -> Result<&str> {
+  metadata
+    .pushed_authorization_request_endpoint
+    .as_deref()
+    .ok_or(OAuthError::Unsupported {
+      capability: CAPABILITY_PAR.to_owned(),
+    })
+}
+
+/// Resolve the DPoP signing algorithm the AS accepts (`ES256`).
+///
+/// Absence yields `Unsupported { capability: "dpop" }`: d-OAuth-specific
+/// sender-constrained flows do not apply to this target.
+pub fn dpop_algorithm(metadata: &ServerMetadata) -> Result<&str> {
+  let supported = metadata
+    .dpop_signing_alg_values_supported
+    .as_deref()
+    .unwrap_or_default()
+    .iter()
+    .any(|alg| alg == "ES256");
+  if supported {
+    Ok("ES256")
+  } else {
+    Err(OAuthError::Unsupported {
+      capability: CAPABILITY_DPOP.to_owned(),
+    })
+  }
+}
+
+/// Resolve the introspection endpoint.
+///
+/// Absence yields `Unsupported { capability: "introspection" }`: the caller
+/// must surface [`IdentitySource::Unsupported`], never a fabricated identity.
+pub fn introspection_endpoint(metadata: &ServerMetadata) -> Result<&str> {
+  metadata
+    .introspection_endpoint
+    .as_deref()
+    .ok_or(OAuthError::Unsupported {
+      capability: CAPABILITY_INTROSPECTION.to_owned(),
+    })
+}
+
+/// Resolve the OIDC userinfo endpoint for optional profile enrichment.
+///
+/// Absence yields `Unsupported { capability: "userinfo" }`: enrichment is
+/// skipped. Userinfo is never a substitute for introspection-based identity.
+pub fn userinfo_endpoint(metadata: &ServerMetadata) -> Result<&str> {
+  metadata
+    .userinfo_endpoint
+    .as_deref()
+    .ok_or(OAuthError::Unsupported {
+      capability: CAPABILITY_USERINFO.to_owned(),
+    })
+}
+
+/// Require refresh-token support at the token endpoint.
+///
+/// Absence yields `Unsupported { capability: "refresh" }`: the caller must
+/// not call refresh and must re-authenticate expired sessions instead.
+pub fn require_refresh(metadata: &ServerMetadata) -> Result<()> {
+  if metadata.capabilities().refresh {
+    Ok(())
+  } else {
+    Err(OAuthError::Unsupported {
+      capability: CAPABILITY_REFRESH.to_owned(),
+    })
+  }
+}
+
+/// Pick the authorization-request mode: PAR when available, plain
+/// `/authorize` otherwise. A missing authorization endpoint is itself a
+/// typed `Unsupported { capability: "authorization" }`.
+pub fn authorize_mode(metadata: &ServerMetadata) -> Result<AuthorizeMode<'_>> {
+  if let Some(endpoint) = metadata.pushed_authorization_request_endpoint.as_deref() {
+    return Ok(AuthorizeMode::Par { endpoint });
+  }
+  metadata.authorization_endpoint.as_deref().map_or(
+    Err(OAuthError::Unsupported {
+      capability: "authorization".to_owned(),
+    }),
+    |endpoint| Ok(AuthorizeMode::Plain { endpoint }),
+  )
+}
+
+/// Pick the identity source: introspection when available, the explicit
+/// unsupported state otherwise. Userinfo may enrich a known identity but
+/// never establishes one.
+pub fn identity_source(metadata: &ServerMetadata) -> IdentitySource<'_> {
+  match metadata.introspection_endpoint.as_deref() {
+    Some(endpoint) => IdentitySource::Introspection { endpoint },
+    None => IdentitySource::Unsupported,
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use serde_json::json;
+
+  use super::*;
+
+  fn metadata_from(value: serde_json::Value) -> ServerMetadata {
+    serde_json::from_value(value).expect("test metadata parses")
+  }
+
+  fn d_oauth_metadata() -> ServerMetadata {
+    metadata_from(json!({
+      "issuer": "https://localhost:8080",
+      "authorization_endpoint": "https://localhost:8080/authorize",
+      "token_endpoint": "https://localhost:8080/token",
+      "pushed_authorization_request_endpoint": "https://localhost:8080/par",
+      "introspection_endpoint": "https://localhost:8080/introspect",
+      "revocation_endpoint": "https://localhost:8080/revoke",
+      "registration_endpoint": "https://localhost:8080/register",
+      "grant_types_supported": ["authorization_code", "refresh_token"],
+      "dpop_signing_alg_values_supported": ["ES256"],
+      "scopes_supported": ["demo.read"]
+    }))
+  }
+
+  fn unsupported_capability(result: Result<impl std::fmt::Debug>) -> String {
+    match result {
+      Err(OAuthError::Unsupported { capability }) => capability,
+      other => panic!("expected Unsupported, got {other:?}"),
+    }
+  }
+
+  #[test]
+  fn capabilities_d_oauth_matrix_enables_all_but_userinfo() {
+    let metadata = d_oauth_metadata();
+    let matrix = metadata.capabilities();
+    assert!(matrix.par);
+    assert!(matrix.dpop);
+    assert!(matrix.introspection);
+    assert!(matrix.dcr);
+    assert!(matrix.refresh);
+    assert!(!matrix.userinfo);
+
+    assert!(registration_endpoint(&metadata).is_ok());
+    assert!(par_endpoint(&metadata).is_ok());
+    assert_eq!(dpop_algorithm(&metadata).expect("dpop available"), "ES256");
+    assert!(introspection_endpoint(&metadata).is_ok());
+    assert!(require_refresh(&metadata).is_ok());
+    assert!(matches!(
+      authorize_mode(&metadata),
+      Ok(AuthorizeMode::Par { .. })
+    ));
+    assert!(matches!(
+      identity_source(&metadata),
+      IdentitySource::Introspection { .. }
+    ));
+    // Userinfo is the only absent capability on d-OAuth: enrichment is off.
+    assert_eq!(
+      unsupported_capability(userinfo_endpoint(&metadata).map(|_| ())),
+      CAPABILITY_USERINFO
+    );
+  }
+
+  #[test]
+  fn capabilities_missing_dcr_requires_preregistered_client_id() {
+    let mut metadata = d_oauth_metadata();
+    metadata.registration_endpoint = None;
+    assert!(!metadata.capabilities().dcr);
+    assert_eq!(
+      unsupported_capability(registration_endpoint(&metadata).map(|_| ())),
+      CAPABILITY_DCR
+    );
+  }
+
+  #[test]
+  fn capabilities_missing_par_falls_back_to_plain_authorize() {
+    let mut metadata = d_oauth_metadata();
+    metadata.pushed_authorization_request_endpoint = None;
+    assert!(!metadata.capabilities().par);
+    assert_eq!(
+      unsupported_capability(par_endpoint(&metadata).map(|_| ())),
+      CAPABILITY_PAR
+    );
+    assert!(matches!(
+      authorize_mode(&metadata),
+      Ok(AuthorizeMode::Plain { .. })
+    ));
+  }
+
+  #[test]
+  fn capabilities_missing_dpop_is_a_distinct_typed_error() {
+    let mut metadata = d_oauth_metadata();
+    metadata.dpop_signing_alg_values_supported = Some(vec!["RS256".to_owned()]);
+    assert!(!metadata.capabilities().dpop);
+    assert_eq!(
+      unsupported_capability(dpop_algorithm(&metadata).map(|_| ())),
+      CAPABILITY_DPOP
+    );
+  }
+
+  #[test]
+  fn capabilities_missing_introspection_yields_unsupported_identity() {
+    let mut metadata = d_oauth_metadata();
+    metadata.introspection_endpoint = None;
+    assert!(!metadata.capabilities().introspection);
+    assert_eq!(
+      unsupported_capability(introspection_endpoint(&metadata).map(|_| ())),
+      CAPABILITY_INTROSPECTION
+    );
+    // The identity source degrades to the explicit unsupported state: the UI
+    // renders "identity unavailable" instead of fabricating identity.
+    assert_eq!(identity_source(&metadata), IdentitySource::Unsupported);
+  }
+
+  #[test]
+  fn capabilities_userinfo_is_optional_enrichment_gated_on_discovery() {
+    let mut metadata = d_oauth_metadata();
+    metadata.userinfo_endpoint = Some("https://localhost:8080/userinfo".to_owned());
+    assert!(metadata.capabilities().userinfo);
+    assert_eq!(
+      userinfo_endpoint(&metadata).expect("userinfo available"),
+      "https://localhost:8080/userinfo"
+    );
+    // Enrichment never substitutes for identity: introspection still owns it.
+    assert!(matches!(
+      identity_source(&metadata),
+      IdentitySource::Introspection { .. }
+    ));
+  }
+
+  #[test]
+  fn capabilities_missing_refresh_is_a_distinct_typed_error() {
+    let mut metadata = d_oauth_metadata();
+    metadata.grant_types_supported = Some(vec!["authorization_code".to_owned()]);
+    assert!(!metadata.capabilities().refresh);
+    assert_eq!(
+      unsupported_capability(require_refresh(&metadata).map(|_| ())),
+      CAPABILITY_REFRESH
+    );
+  }
+
+  #[test]
+  fn capabilities_absent_mechanisms_yield_distinct_states() {
+    let metadata = metadata_from(json!({
+      "issuer": "https://minimal.example",
+      "authorization_endpoint": "https://minimal.example/authorize",
+      "token_endpoint": "https://minimal.example/token",
+      "grant_types_supported": ["authorization_code"]
+    }));
+    let mut seen = std::collections::HashSet::new();
+    for capability in [
+      unsupported_capability(registration_endpoint(&metadata).map(|_| ())),
+      unsupported_capability(par_endpoint(&metadata).map(|_| ())),
+      unsupported_capability(dpop_algorithm(&metadata).map(|_| ())),
+      unsupported_capability(introspection_endpoint(&metadata).map(|_| ())),
+      unsupported_capability(userinfo_endpoint(&metadata).map(|_| ())),
+      unsupported_capability(require_refresh(&metadata).map(|_| ())),
+    ] {
+      assert!(seen.insert(capability), "capability names must be distinct");
+    }
+    assert_eq!(seen.len(), 6);
+    // Refresh defaults to supported when the grant list is absent (RFC 8414
+    // §2: the field is optional, absence conveys no restriction).
+    let unlisted = metadata_from(json!({
+      "issuer": "https://unlisted.example",
+      "authorization_endpoint": "https://unlisted.example/authorize",
+      "token_endpoint": "https://unlisted.example/token"
+    }));
+    assert!(require_refresh(&unlisted).is_ok());
+  }
+}

+ 68 - 0
crates/oauth-core/src/client_auth.rs

@@ -0,0 +1,68 @@
+//! Confidential-client authentication (RFC 6749 §2.3.1).
+//!
+//! [`basic_auth_header`] builds the `Authorization: Basic <base64>` value the
+//! token, PAR, introspection, and revocation calls share (todos 10-12 reuse
+//! it).
+//!
+//! # Encoding
+//!
+//! Standard RFC 4648 base64 **with padding** over the UTF-8 bytes of
+//! `client_id:client_secret` — not base64url. This matches RFC 6749 §2.3.1 and
+//! the d-OAuth server, which decodes the header with standard base64 and
+//! splits on the first `:` (`src/protocol/client_auth.rs::parse_basic_header`).
+//!
+//! # Secret safety
+//!
+//! The returned header value is a `String` the caller attaches directly to the
+//! request. It must never be formatted into logs or into [`OAuthError`]
+//! payloads — error paths in this crate carry reason strings only.
+
+use base64::{Engine as _, engine::general_purpose::STANDARD};
+use secrecy::ExposeSecret;
+
+use crate::config::ClientCredentials;
+
+/// Build the `Basic <base64(client_id:client_secret)>` header value.
+///
+/// Standard base64 (not URL-safe), per RFC 6749 §2.3.1. The secret is read via
+/// [`ExposeSecret`] and the encoded value is returned for immediate use as the
+/// `Authorization` header — never log it.
+pub fn basic_auth_header(credentials: &ClientCredentials) -> String {
+  let raw = format!(
+    "{}:{}",
+    credentials.client_id,
+    credentials.client_secret.expose_secret()
+  );
+  format!("Basic {}", STANDARD.encode(raw.as_bytes()))
+}
+
+#[cfg(test)]
+mod tests {
+  use secrecy::SecretString;
+
+  use super::*;
+
+  fn credentials(id: &str, secret: &str) -> ClientCredentials {
+    ClientCredentials {
+      client_id: id.to_owned(),
+      client_secret: SecretString::from(secret.to_owned()),
+    }
+  }
+
+  #[test]
+  fn rfc6749_section_231_vector() {
+    // RFC 6749 §2.3.1 example: s6BhdRkqt3 / 7Fjfp0ZBr1KtDRbnfVdmIw.
+    let header = basic_auth_header(&credentials("s6BhdRkqt3", "7Fjfp0ZBr1KtDRbnfVdmIw"));
+    assert_eq!(header, "Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3");
+  }
+
+  #[test]
+  fn uses_standard_base64_not_url_safe() {
+    // A secret chosen so the digest contains `+`/`/` under standard base64.
+    let header = basic_auth_header(&credentials("client", "~~~secret~~~"));
+    let encoded = header.strip_prefix("Basic ").unwrap();
+    let decoded = STANDARD.decode(encoded).unwrap();
+    assert_eq!(decoded, b"client:~~~secret~~~");
+    assert!(!encoded.contains('-') && !encoded.contains('_'));
+  }
+}

+ 331 - 0
crates/oauth-core/src/config.rs

@@ -0,0 +1,331 @@
+//! Runtime configuration for an OAuth client profile.
+//!
+//! [`OAuthProfile`] is the single source of truth for *where* to authenticate
+//! (`issuer`), *how* the callback returns (`redirect_uri`), *what* is requested
+//! (`scope`, `client`), *which optional features* are available
+//! (`capabilities`), and *how permissive* the developer environment is
+//! (`dev`).
+//!
+//! Values may be supplied programmatically, loaded from a file, or overlaid
+//! from environment variables via [`OAuthProfile::from_env`] and
+//! [`OAuthProfile::apply_env_overrides`]. Secret material (the client secret)
+//! is held in a [`SecretString`] and is never emitted by [`fmt::Debug`].
+
+use std::{env, fmt};
+
+use secrecy::{ExposeSecret, SecretString};
+use serde::{Deserialize, Serialize};
+
+use crate::{
+  error::{ConfigError, Result},
+  redact::redact_secret,
+};
+
+/// The complete client configuration for one authorization server.
+#[derive(Clone, Default)]
+pub struct OAuthProfile {
+  /// The authorization server issuer identifier.
+  pub issuer: String,
+  /// The registered redirect URI the authorization server calls back.
+  pub redirect_uri: String,
+  /// The space-delimited scope string to request.
+  pub scope: String,
+  /// The confidential client's credentials.
+  pub client: ClientCredentials,
+  /// Optional server features discovered or configured for this issuer.
+  pub capabilities: Capabilities,
+  /// Development-only settings.
+  pub dev: DevSettings,
+}
+
+/// The confidential client's credentials.
+///
+/// `client_secret` is wrapped in a [`SecretString`] so it can only be read
+/// through a deliberate [`ExposeSecret`] call, and this type's [`fmt::Debug`]
+/// renders the secret as a redaction marker.
+#[derive(Clone)]
+pub struct ClientCredentials {
+  /// The registered public client identifier.
+  pub client_id: String,
+  /// The registered client secret.
+  pub client_secret: SecretString,
+}
+
+/// Boolean capability flags for a target authorization server.
+///
+/// These are derived from discovery metadata (or overridden in configuration)
+/// and let the flow degrade explicitly instead of silently faking success.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct Capabilities {
+  /// Pushed authorization requests (RFC 9126) are available.
+  pub par: bool,
+  /// DPoP sender-constrained tokens (RFC 9449) are available.
+  pub dpop: bool,
+  /// Token introspection (RFC 7662) is available.
+  pub introspection: bool,
+  /// An OIDC `userinfo_endpoint` is available.
+  pub userinfo: bool,
+  /// Dynamic client registration (RFC 7591) is available.
+  pub dcr: bool,
+  /// Refresh tokens are supported by the token endpoint.
+  pub refresh: bool,
+}
+
+/// Development-only settings. All defaults are fail-closed.
+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
+pub struct DevSettings {
+  /// Trust the authorization server's certificate even if it is self-signed
+  /// or otherwise unverifiable. Defaults to `false`; only enable for a local
+  /// development server.
+  pub insecure_tls: bool,
+}
+
+impl OAuthProfile {
+  /// Environment variable for the issuer.
+  pub const ENV_ISSUER: &'static str = "OAUTH_ISSUER";
+  /// Environment variable for the redirect URI.
+  pub const ENV_REDIRECT_URI: &'static str = "OAUTH_REDIRECT_URI";
+  /// Environment variable for the requested scope.
+  pub const ENV_SCOPE: &'static str = "OAUTH_SCOPE";
+  /// Environment variable for the client id.
+  pub const ENV_CLIENT_ID: &'static str = "OAUTH_CLIENT_ID";
+  /// Environment variable for the client secret.
+  pub const ENV_CLIENT_SECRET: &'static str = "OAUTH_CLIENT_SECRET";
+  /// Environment variable to opt into insecure TLS (dev only).
+  pub const ENV_DEV_INSECURE_TLS: &'static str = "OAUTH_DEV_INSECURE_TLS";
+
+  /// Build a profile from the process environment, starting from
+  /// [`OAuthProfile::default`].
+  ///
+  /// Missing variables leave the corresponding default in place; this does not
+  /// validate that required values are present (callers that need strict
+  /// validation should use [`ConfigError::Missing`]).
+  pub fn from_env() -> Result<Self> {
+    let mut profile = Self::default();
+    profile.apply_env_overrides()?;
+    Ok(profile)
+  }
+
+  /// Overlay the process environment onto this profile.
+  ///
+  /// Reads [`OAuthProfile::ENV_ISSUER`], [`OAuthProfile::ENV_REDIRECT_URI`],
+  /// [`OAuthProfile::ENV_SCOPE`], [`OAuthProfile::ENV_CLIENT_ID`],
+  /// [`OAuthProfile::ENV_CLIENT_SECRET`], and
+  /// [`OAuthProfile::ENV_DEV_INSECURE_TLS`]. Absent variables are ignored.
+  ///
+  /// # Errors
+  ///
+  /// Returns [`ConfigError::Invalid`] if a present value is not valid UTF-8 or
+  /// the TLS flag is not a boolean.
+  pub fn apply_env_overrides(&mut self) -> Result<()> {
+    self.apply_env_overrides_from(|key| match env::var(key) {
+      Ok(value) => Ok(Some(value)),
+      Err(env::VarError::NotPresent) => Ok(None),
+      Err(env::VarError::NotUnicode(_)) => Err(
+        ConfigError::Invalid {
+          key: key.to_owned(),
+          reason: "environment value is not valid UTF-8".to_owned(),
+        }
+        .into(),
+      ),
+    })
+  }
+
+  /// Overlay values from an arbitrary lookup, keyed by the same environment
+  /// variable names [`OAuthProfile::apply_env_overrides`] uses.
+  ///
+  /// This is the testable, side-effect-free form of the env overlay; the real
+  /// loader passes a closure over [`env::var`].
+  ///
+  /// # Errors
+  ///
+  /// Propagates lookup errors and rejects non-boolean TLS values.
+  pub fn apply_env_overrides_from<F>(&mut self, lookup: F) -> Result<()>
+  where
+    F: Fn(&str) -> Result<Option<String>>,
+  {
+    if let Some(value) = lookup(Self::ENV_ISSUER)? {
+      self.issuer = value;
+    }
+    if let Some(value) = lookup(Self::ENV_REDIRECT_URI)? {
+      self.redirect_uri = value;
+    }
+    if let Some(value) = lookup(Self::ENV_SCOPE)? {
+      self.scope = value;
+    }
+    if let Some(value) = lookup(Self::ENV_CLIENT_ID)? {
+      self.client.client_id = value;
+    }
+    if let Some(value) = lookup(Self::ENV_CLIENT_SECRET)? {
+      self.client.client_secret = SecretString::from(value);
+    }
+    if let Some(value) = lookup(Self::ENV_DEV_INSECURE_TLS)? {
+      self.dev.insecure_tls = parse_bool(Self::ENV_DEV_INSECURE_TLS, &value)?;
+    }
+    Ok(())
+  }
+}
+
+impl Default for ClientCredentials {
+  fn default() -> Self {
+    Self {
+      client_id: String::new(),
+      client_secret: SecretString::from(String::new()),
+    }
+  }
+}
+
+impl fmt::Debug for OAuthProfile {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("OAuthProfile")
+      .field("issuer", &self.issuer)
+      .field("redirect_uri", &self.redirect_uri)
+      .field("scope", &self.scope)
+      .field("client", &self.client)
+      .field("capabilities", &self.capabilities)
+      .field("dev", &self.dev)
+      .finish()
+  }
+}
+
+impl fmt::Debug for ClientCredentials {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("ClientCredentials")
+      .field("client_id", &self.client_id)
+      .field(
+        "client_secret",
+        &redact_secret(self.client_secret.expose_secret()),
+      )
+      .finish()
+  }
+}
+
+/// Parse a permissive boolean (`true`/`false`/`1`/`0`, case-insensitive).
+fn parse_bool(key: &str, value: &str) -> Result<bool> {
+  match value.trim().to_ascii_lowercase().as_str() {
+    "true" | "1" => Ok(true),
+    "false" | "0" => Ok(false),
+    _ => Err(
+      ConfigError::Invalid {
+        key: key.to_owned(),
+        reason: format!("expected a boolean, got `{value}`"),
+      }
+      .into(),
+    ),
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use std::collections::HashMap;
+
+  use super::*;
+  use crate::error::OAuthError;
+
+  fn profile_with_secret(secret: &str) -> OAuthProfile {
+    OAuthProfile {
+      issuer: "https://as.example".to_owned(),
+      redirect_uri: "http://127.0.0.1:3000/callback".to_owned(),
+      scope: "demo.read".to_owned(),
+      client: ClientCredentials {
+        client_id: "demo-client".to_owned(),
+        client_secret: SecretString::from(secret.to_owned()),
+      },
+      capabilities: Capabilities::default(),
+      dev: DevSettings::default(),
+    }
+  }
+
+  #[test]
+  fn debug_omits_client_secret() {
+    let profile = profile_with_secret("S3CRET");
+    let rendered = format!("{profile:?}");
+
+    // Assert absence of the secret; the marker must take its place.
+    assert!(
+      !rendered.contains("S3CRET"),
+      "client secret leaked into Debug output: {rendered}"
+    );
+    assert!(rendered.contains("[REDACTED]"));
+    assert!(rendered.contains("demo-client"));
+    assert!(rendered.contains("https://as.example"));
+  }
+
+  #[test]
+  fn debug_omits_client_secret_when_nested_directly() {
+    let credentials = ClientCredentials {
+      client_id: "demo-client".to_owned(),
+      client_secret: SecretString::from("S3CRET".to_owned()),
+    };
+    let rendered = format!("{credentials:?}");
+    assert!(!rendered.contains("S3CRET"));
+    assert!(rendered.contains("[REDACTED]"));
+  }
+
+  #[test]
+  fn insecure_tls_defaults_to_false() {
+    assert!(!DevSettings::default().insecure_tls);
+    assert!(!OAuthProfile::default().dev.insecure_tls);
+  }
+
+  #[test]
+  fn env_overlay_from_lookup_updates_fields() {
+    let env = HashMap::from([
+      (OAuthProfile::ENV_ISSUER, "https://env.example"),
+      (
+        OAuthProfile::ENV_REDIRECT_URI,
+        "http://127.0.0.1:4280/callback",
+      ),
+      (OAuthProfile::ENV_SCOPE, "env.read"),
+      (OAuthProfile::ENV_CLIENT_ID, "env-client"),
+      (OAuthProfile::ENV_CLIENT_SECRET, "ENVS3CRET"),
+      (OAuthProfile::ENV_DEV_INSECURE_TLS, "true"),
+    ]);
+    let mut profile = OAuthProfile::default();
+    profile
+      .apply_env_overrides_from(|key| Ok(env.get(key).map(|value| (*value).to_owned())))
+      .expect("lookup cannot fail");
+
+    assert_eq!(profile.issuer, "https://env.example");
+    assert_eq!(profile.redirect_uri, "http://127.0.0.1:4280/callback");
+    assert_eq!(profile.scope, "env.read");
+    assert_eq!(profile.client.client_id, "env-client");
+    assert_eq!(profile.client.client_secret.expose_secret(), "ENVS3CRET");
+    assert!(profile.dev.insecure_tls);
+    assert!(!format!("{profile:?}").contains("ENVS3CRET"));
+  }
+
+  #[test]
+  fn env_overlay_reads_the_process_environment() {
+    // SAFETY: this is the only test that mutates these process-wide variables,
+    // and no other test reads them concurrently.
+    unsafe {
+      env::set_var(OAuthProfile::ENV_SCOPE, "process.read");
+    }
+
+    let result = OAuthProfile::from_env();
+
+    unsafe {
+      env::remove_var(OAuthProfile::ENV_SCOPE);
+    }
+
+    let profile = result.expect("from_env reads only present variables");
+    assert_eq!(profile.scope, "process.read");
+  }
+
+  #[test]
+  fn non_boolean_tls_value_is_a_typed_error() {
+    let env = HashMap::from([(OAuthProfile::ENV_DEV_INSECURE_TLS, "yes")]);
+    let mut profile = OAuthProfile::default();
+    let result =
+      profile.apply_env_overrides_from(|key| Ok(env.get(key).map(|value| (*value).to_owned())));
+
+    assert!(matches!(
+      result,
+      Err(OAuthError::Config(ConfigError::Invalid { key, .. }))
+        if key == OAuthProfile::ENV_DEV_INSECURE_TLS
+    ));
+    assert!(!profile.dev.insecure_tls);
+  }
+}

+ 416 - 0
crates/oauth-core/src/discovery.rs

@@ -0,0 +1,416 @@
+//! Authorization-server discovery (RFC 8414 with OIDC fallback).
+//!
+//! [`discover`] fetches `{issuer}/.well-known/oauth-authorization-server` and,
+//! when that document is absent (`404`), falls back to
+//! `{issuer}/.well-known/openid-configuration`. The parsed [`ServerMetadata`]
+//! exposes every endpoint verbatim — callers must use these URLs and never
+//! construct endpoint URLs manually.
+//!
+//! [`ServerMetadata::capabilities`] derives the [`Capabilities`] flags the rest
+//! of the client (and the `server` crate) degrade on.
+//!
+//! # Refresh rule
+//!
+//! `refresh` is true when `grant_types_supported` contains `refresh_token`. The
+//! field is OPTIONAL per RFC 8414 §2, so when it is absent we default to
+//! `true`: absence conveys no restriction, and the token endpoint still fails
+//! closed with `invalid_grant` if refresh is unsupported.
+//!
+//! # Retry rule
+//!
+//! Only `503 Service Unavailable` is retried ([`MAX_ATTEMPTS`] total tries with
+//! exponential backoff from [`RETRY_BASE_DELAY`]). Any other status is handled
+//! immediately (`404` on the RFC 8414 path triggers the OIDC fallback; other
+//! failures become [`OAuthError::Discovery`]). Exhausting the budget surfaces
+//! the typed [`OAuthError::DiscoveryUnavailable`].
+//!
+//! # Shared HTTP client contract
+//!
+//! [`build_client`] is the single reqwest construction point for `oauth-core`
+//! and the `server` crate (todos 11/12 reuse it for token/introspection): plain
+//! `rustls-tls`, no custom roots, no `danger_accept_invalid_certs`. TLS trust
+//! customization (custom CA / dev `insecure_tls`) lives in the `server` crate
+//! (todo 19), not here.
+
+use std::time::Duration;
+
+use serde::Deserialize;
+
+use crate::{
+  config::Capabilities,
+  error::{OAuthError, Result},
+};
+
+/// Total GET attempts per well-known URL before giving up (initial + retries).
+pub const MAX_ATTEMPTS: u32 = 3;
+/// Base delay for the 503 backoff; attempt `n` (1-based) waits `base * 2^(n-1)`.
+pub const RETRY_BASE_DELAY: Duration = Duration::from_millis(20);
+
+/// Build the shared reqwest client for `oauth-core` protocol calls.
+///
+/// Plain `rustls-tls`, JSON + form support, no `native-tls`. Timeouts are set
+/// so a hung AS fails fast instead of stalling the BFF.
+pub fn build_client() -> Result<reqwest::Client> {
+  reqwest::Client::builder()
+    .timeout(Duration::from_secs(10))
+    .build()
+    .map_err(|err| OAuthError::Http {
+      reason: format!("failed to build http client: {err}"),
+    })
+}
+
+/// Authorization-server metadata (RFC 8414 §2 / OIDC Discovery).
+///
+/// Every endpoint is `Option`: a compliant doc always carries
+/// `authorization_endpoint` and `token_endpoint`, but capability derivation
+/// keys off presence, so absence must be representable rather than a parse
+/// failure.
+#[derive(Clone, Debug, Default, Deserialize)]
+#[serde(default)]
+pub struct ServerMetadata {
+  /// The issuer identifier the document was fetched for.
+  pub issuer: String,
+  /// RFC 8414 §2 / OIDC: authorization endpoint.
+  pub authorization_endpoint: Option<String>,
+  /// RFC 8414 §2 / OIDC: token endpoint.
+  pub token_endpoint: Option<String>,
+  /// RFC 9126: pushed authorization request endpoint.
+  pub pushed_authorization_request_endpoint: Option<String>,
+  /// RFC 7662: introspection endpoint.
+  pub introspection_endpoint: Option<String>,
+  /// RFC 7009: revocation endpoint.
+  pub revocation_endpoint: Option<String>,
+  /// RFC 7591: dynamic client registration endpoint.
+  pub registration_endpoint: Option<String>,
+  /// OIDC Discovery: userinfo endpoint (d-OAuth omits it).
+  pub userinfo_endpoint: Option<String>,
+  /// RFC 9449: DPoP proof signing algorithms accepted by the AS.
+  pub dpop_signing_alg_values_supported: Option<Vec<String>>,
+  /// Scope catalog advertised by the AS.
+  pub scopes_supported: Option<Vec<String>>,
+  /// Grant types the token endpoint accepts (optional per RFC 8414 §2).
+  pub grant_types_supported: Option<Vec<String>>,
+}
+
+impl ServerMetadata {
+  /// Derive [`Capabilities`] from endpoint/algorithm presence.
+  ///
+  /// - `par` = PAR endpoint present.
+  /// - `dpop` = `dpop_signing_alg_values_supported` contains `ES256`.
+  /// - `introspection` / `userinfo` / `dcr` = respective endpoint present.
+  /// - `refresh` = `grant_types_supported` contains `refresh_token` when the
+  ///   field is present, else `true` (see the module-level refresh rule).
+  pub fn capabilities(&self) -> Capabilities {
+    Capabilities {
+      par: self.pushed_authorization_request_endpoint.is_some(),
+      dpop: self
+        .dpop_signing_alg_values_supported
+        .as_deref()
+        .is_some_and(|algs| algs.iter().any(|alg| alg == "ES256")),
+      introspection: self.introspection_endpoint.is_some(),
+      userinfo: self.userinfo_endpoint.is_some(),
+      dcr: self.registration_endpoint.is_some(),
+      refresh: self
+        .grant_types_supported
+        .as_deref()
+        .is_none_or(|grants| grants.iter().any(|grant| grant == "refresh_token")),
+    }
+  }
+}
+
+/// Run RFC 8414 discovery against `issuer` with the OIDC fallback.
+///
+/// Uses [`build_client`] internally. Prefer [`discover_with_client`] when the
+/// caller already holds the shared client.
+pub async fn discover(issuer: &str) -> Result<ServerMetadata> {
+  let client = build_client()?;
+  discover_with_client(&client, issuer).await
+}
+
+/// Run RFC 8414 discovery against `issuer` with `client`.
+///
+/// Tries `{issuer}/.well-known/oauth-authorization-server` first; on `404`
+/// falls back to `{issuer}/.well-known/openid-configuration`. A document that
+/// parses but carries no `token_endpoint` is a [`OAuthError::Discovery`]
+/// failure — endpoint URLs are never synthesized.
+pub async fn discover_with_client(
+  client: &reqwest::Client,
+  issuer: &str,
+) -> Result<ServerMetadata> {
+  let issuer = issuer.trim_end_matches('/');
+  let oauth_url = format!("{issuer}/.well-known/oauth-authorization-server");
+  match get_metadata(client, issuer, &oauth_url).await? {
+    Some(metadata) => Ok(metadata),
+    None => {
+      let oidc_url = format!("{issuer}/.well-known/openid-configuration");
+      get_metadata(client, issuer, &oidc_url)
+        .await?
+        .ok_or_else(|| OAuthError::Discovery {
+          issuer: issuer.to_owned(),
+          reason: "no RFC 8414 or OIDC discovery document found".to_owned(),
+        })
+    }
+  }
+}
+
+/// GET one well-known URL with bounded 503 retry.
+///
+/// Returns `Ok(None)` only for `404` (absent document → caller falls back or
+/// reports). `503` is retried up to [`MAX_ATTEMPTS`]; exhaustion yields
+/// [`OAuthError::DiscoveryUnavailable`].
+async fn get_metadata(
+  client: &reqwest::Client,
+  issuer: &str,
+  url: &str,
+) -> Result<Option<ServerMetadata>> {
+  let mut attempt: u32 = 0;
+  loop {
+    attempt += 1;
+    let response = client.get(url).send().await.map_err(|err| {
+      if err.is_timeout() || err.is_connect() {
+        OAuthError::Discovery {
+          issuer: issuer.to_owned(),
+          reason: format!("discovery request failed: {err}"),
+        }
+      } else {
+        OAuthError::Http {
+          reason: format!("discovery request failed: {err}"),
+        }
+      }
+    })?;
+    let status = response.status();
+    if status.as_u16() == 503 && attempt < MAX_ATTEMPTS {
+      let backoff = RETRY_BASE_DELAY * 2_u32.pow(attempt - 1);
+      tokio::time::sleep(backoff).await;
+      continue;
+    }
+    if status.as_u16() == 503 {
+      return Err(OAuthError::DiscoveryUnavailable {
+        reason: format!("discovery endpoint at {url} still unavailable after {attempt} attempts"),
+      });
+    }
+    if status.as_u16() == 404 {
+      return Ok(None);
+    }
+    if !status.is_success() {
+      return Err(OAuthError::Discovery {
+        issuer: issuer.to_owned(),
+        reason: format!("discovery endpoint returned status {status}"),
+      });
+    }
+    let metadata: ServerMetadata = response.json().await.map_err(|err| OAuthError::Discovery {
+      issuer: issuer.to_owned(),
+      reason: format!("discovery document is not valid metadata: {err}"),
+    })?;
+    if metadata.token_endpoint.is_none() {
+      return Err(OAuthError::Discovery {
+        issuer: issuer.to_owned(),
+        reason: "discovery document omits token_endpoint".to_owned(),
+      });
+    }
+    return Ok(Some(metadata));
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use serde_json::json;
+  use wiremock::{
+    Mock, MockServer, ResponseTemplate,
+    matchers::{method, path},
+  };
+
+  use super::*;
+
+  const OAUTH_PATH: &str = "/.well-known/oauth-authorization-server";
+  const OIDC_PATH: &str = "/.well-known/openid-configuration";
+
+  /// d-OAuth-shaped metadata: PAR + introspection + DPoP ES256, no userinfo.
+  fn d_oauth_doc(issuer: &str) -> serde_json::Value {
+    json!({
+      "issuer": issuer,
+      "authorization_endpoint": format!("{issuer}/authorize"),
+      "token_endpoint": format!("{issuer}/token"),
+      "registration_endpoint": format!("{issuer}/register"),
+      "introspection_endpoint": format!("{issuer}/introspect"),
+      "revocation_endpoint": format!("{issuer}/revoke"),
+      "pushed_authorization_request_endpoint": format!("{issuer}/par"),
+      "response_types_supported": ["code"],
+      "grant_types_supported": [
+        "authorization_code",
+        "refresh_token",
+        "client_credentials",
+        "urn:ietf:params:oauth:grant-type:device_code",
+        "urn:ietf:params:oauth:grant-type:token-exchange"
+      ],
+      "code_challenge_methods_supported": ["S256"],
+      "dpop_signing_alg_values_supported": ["ES256", "EdDSA", "RS256"],
+      "scopes_supported": ["demo.read"]
+    })
+  }
+
+  fn oidc_doc(issuer: &str) -> serde_json::Value {
+    json!({
+      "issuer": issuer,
+      "authorization_endpoint": format!("{issuer}/authorize"),
+      "token_endpoint": format!("{issuer}/token"),
+      "userinfo_endpoint": format!("{issuer}/userinfo"),
+      "grant_types_supported": ["authorization_code", "client_credentials"],
+      "code_challenge_methods_supported": ["S256"],
+      "dpop_signing_alg_values_supported": ["RS256"],
+      "scopes_supported": ["openid", "profile"]
+    })
+  }
+
+  async fn mock_json(server: &MockServer, route: &str, status: u16, body: serde_json::Value) {
+    Mock::given(method("GET"))
+      .and(path(route))
+      .respond_with(ResponseTemplate::new(status).set_body_json(body))
+      .mount(server)
+      .await;
+  }
+
+  #[tokio::test]
+  async fn d_oauth_doc_enables_par_introspection_and_dpop() {
+    let server = MockServer::start().await;
+    let issuer = server.uri();
+    mock_json(&server, OAUTH_PATH, 200, d_oauth_doc(&issuer)).await;
+
+    let client = build_client().unwrap();
+    let metadata = discover_with_client(&client, &issuer).await.unwrap();
+    let capabilities = metadata.capabilities();
+
+    assert_eq!(metadata.token_endpoint, Some(format!("{issuer}/token")));
+    assert!(capabilities.par);
+    assert!(capabilities.introspection);
+    assert!(capabilities.dpop);
+    assert!(!capabilities.userinfo);
+    assert!(capabilities.dcr);
+    assert!(capabilities.refresh);
+    assert_eq!(
+      metadata.scopes_supported,
+      Some(vec!["demo.read".to_owned()])
+    );
+  }
+
+  #[tokio::test]
+  async fn oidc_fallback_doc_disables_par_and_dpop() {
+    let server = MockServer::start().await;
+    let issuer = server.uri();
+    Mock::given(method("GET"))
+      .and(path(OAUTH_PATH))
+      .respond_with(ResponseTemplate::new(404))
+      .mount(&server)
+      .await;
+    mock_json(&server, OIDC_PATH, 200, oidc_doc(&issuer)).await;
+
+    let client = build_client().unwrap();
+    let metadata = discover_with_client(&client, &issuer).await.unwrap();
+    let capabilities = metadata.capabilities();
+
+    assert!(!capabilities.par);
+    assert!(!capabilities.dpop);
+    assert!(capabilities.userinfo);
+    // Grant list present without refresh_token → refresh unsupported.
+    assert!(!capabilities.refresh);
+  }
+
+  #[tokio::test]
+  async fn doc_without_par_disables_par_but_keeps_refresh_default() {
+    let server = MockServer::start().await;
+    let issuer = server.uri();
+    let mut doc = d_oauth_doc(&issuer);
+    doc
+      .as_object_mut()
+      .unwrap()
+      .remove("pushed_authorization_request_endpoint");
+    doc.as_object_mut().unwrap().remove("grant_types_supported");
+    mock_json(&server, OAUTH_PATH, 200, doc).await;
+
+    let client = build_client().unwrap();
+    let metadata = discover_with_client(&client, &issuer).await.unwrap();
+    let capabilities = metadata.capabilities();
+
+    assert!(!capabilities.par);
+    assert!(capabilities.refresh);
+    assert!(capabilities.dpop);
+  }
+
+  #[tokio::test]
+  async fn flaky_503_then_200_succeeds() {
+    let server = MockServer::start().await;
+    let issuer = server.uri();
+    Mock::given(method("GET"))
+      .and(path(OAUTH_PATH))
+      .respond_with(ResponseTemplate::new(503))
+      .up_to_n_times(1)
+      .mount(&server)
+      .await;
+    mock_json(&server, OAUTH_PATH, 200, d_oauth_doc(&issuer)).await;
+
+    let client = build_client().unwrap();
+    let metadata = discover_with_client(&client, &issuer).await.unwrap();
+
+    assert_eq!(metadata.token_endpoint, Some(format!("{issuer}/token")));
+  }
+
+  #[tokio::test]
+  async fn persistent_503_returns_discovery_unavailable() {
+    let server = MockServer::start().await;
+    let issuer = server.uri();
+    Mock::given(method("GET"))
+      .and(path(OAUTH_PATH))
+      .respond_with(ResponseTemplate::new(503))
+      .mount(&server)
+      .await;
+
+    let client = build_client().unwrap();
+    let result = discover_with_client(&client, &issuer).await;
+
+    assert!(
+      matches!(result, Err(OAuthError::DiscoveryUnavailable { .. })),
+      "expected DiscoveryUnavailable, got {result:?}"
+    );
+  }
+
+  #[tokio::test]
+  async fn missing_both_docs_is_a_typed_discovery_error() {
+    let server = MockServer::start().await;
+    let issuer = server.uri();
+    Mock::given(method("GET"))
+      .and(path(OAUTH_PATH))
+      .respond_with(ResponseTemplate::new(404))
+      .mount(&server)
+      .await;
+    Mock::given(method("GET"))
+      .and(path(OIDC_PATH))
+      .respond_with(ResponseTemplate::new(404))
+      .mount(&server)
+      .await;
+
+    let client = build_client().unwrap();
+    let result = discover_with_client(&client, &issuer).await;
+
+    assert!(
+      matches!(result, Err(OAuthError::Discovery { .. })),
+      "expected Discovery, got {result:?}"
+    );
+  }
+
+  #[tokio::test]
+  async fn doc_without_token_endpoint_is_a_typed_discovery_error() {
+    let server = MockServer::start().await;
+    let issuer = server.uri();
+    let mut doc = d_oauth_doc(&issuer);
+    doc.as_object_mut().unwrap().remove("token_endpoint");
+    mock_json(&server, OAUTH_PATH, 200, doc).await;
+
+    let client = build_client().unwrap();
+    let result = discover_with_client(&client, &issuer).await;
+
+    assert!(
+      matches!(result, Err(OAuthError::Discovery { .. })),
+      "a doc without a token endpoint is unusable, got {result:?}"
+    );
+  }
+}

+ 349 - 0
crates/oauth-core/src/dpop/key.rs

@@ -0,0 +1,349 @@
+//! DPoP ES256 keypair (RFC 9449 §4.2, RFC 7638 §3).
+//!
+//! [`DpopKey`] owns a P-256 signing key: generation from the OS CSPRNG,
+//! persistence as a private JWK (file mode `0600` on Unix), public-JWK export,
+//! and the RFC 7638 thumbprint used as `cnf.jkt`.
+//!
+//! The private scalar never appears in [`fmt::Debug`] output, error values,
+//! or logs. [`SigningKey`] zeroizes on drop; serialized private-JWK buffers
+//! are zeroized after the file write.
+
+use std::{fmt, fs, path::Path};
+
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use p256::ecdsa::SigningKey;
+use rand::TryRngCore;
+use sha2::{Digest as _, Sha256};
+use zeroize::Zeroize;
+
+use crate::error::{OAuthError, Result};
+
+fn dpop_err(reason: &str) -> OAuthError {
+  OAuthError::Dpop {
+    reason: reason.to_owned(),
+  }
+}
+
+/// P-256 DPoP signing key. [`fmt::Debug`] is redacted (see [`crate::redact`]).
+pub struct DpopKey {
+  signing_key: SigningKey,
+}
+
+impl DpopKey {
+  /// Generate a fresh P-256 keypair from the OS CSPRNG.
+  pub fn generate() -> Result<Self> {
+    let mut raw = [0u8; 32];
+    rand::rngs::OsRng
+      .try_fill_bytes(&mut raw)
+      .map_err(|_| dpop_err("csprng unavailable"))?;
+    let key = Self::from_scalar_bytes(&raw);
+    raw.zeroize();
+    key
+  }
+
+  fn from_scalar_bytes(raw: &[u8; 32]) -> Result<Self> {
+    let signing_key =
+      SigningKey::from_slice(raw).map_err(|_| dpop_err("invalid dpop key material"))?;
+    Ok(Self { signing_key })
+  }
+
+  /// Borrow the signing key (crate-internal; [`proof`](super::proof) signs).
+  pub(crate) fn signing_key(&self) -> &SigningKey {
+    &self.signing_key
+  }
+
+  /// Export the public JWK: `{"kty":"EC","crv":"P-256","x":..,"y":..}`.
+  ///
+  /// `x`/`y` are base64url-nopad 32-byte big-endian coordinates. The value
+  /// never carries private material.
+  pub fn public_jwk(&self) -> serde_json::Value {
+    let point = self.signing_key.verifying_key().to_encoded_point(false);
+    let (x, y) = coordinates(&point).unwrap_or_default();
+    serde_json::json!({
+      "kty": "EC",
+      "crv": "P-256",
+      "x": URL_SAFE_NO_PAD.encode(x),
+      "y": URL_SAFE_NO_PAD.encode(y),
+    })
+  }
+
+  /// RFC 7638 thumbprint (`jkt`) of this key's public JWK.
+  pub fn thumbprint(&self) -> Result<String> {
+    thumbprint_of(&self.public_jwk())
+  }
+
+  /// Persist the private JWK (`kty`/`crv`/`x`/`y`/`d`) to `path`.
+  ///
+  /// On Unix the file is created (or reset) with mode `0600`. Never commit
+  /// the resulting file: `config/dpop-key.json` is gitignored.
+  pub fn save_to_file(&self, path: &Path) -> Result<()> {
+    let point = self.signing_key.verifying_key().to_encoded_point(false);
+    let (x, y) = coordinates(&point).unwrap_or_default();
+    let mut private_jwk = serde_json::json!({
+      "kty": "EC",
+      "crv": "P-256",
+      "x": URL_SAFE_NO_PAD.encode(x),
+      "y": URL_SAFE_NO_PAD.encode(y),
+      "d": URL_SAFE_NO_PAD.encode(self.signing_key.to_bytes()),
+    });
+    let mut json = serde_json::to_string_pretty(&private_jwk)
+      .map_err(|_| dpop_err("cannot encode dpop key file"))?;
+    private_jwk.as_object_mut().map(|object| object.remove("d"));
+    write_private_file(path, json.as_bytes())?;
+    json.zeroize();
+    Ok(())
+  }
+
+  /// Load a private JWK previously written by [`DpopKey::save_to_file`].
+  pub fn load_from_file(path: &Path) -> Result<Self> {
+    let raw = fs::read_to_string(path).map_err(|_| dpop_err("cannot read dpop key file"))?;
+    let value: serde_json::Value =
+      serde_json::from_str(&raw).map_err(|_| dpop_err("malformed dpop key file"))?;
+    let scalar = value
+      .as_object()
+      .and_then(|object| object.get("d"))
+      .and_then(serde_json::Value::as_str)
+      .ok_or_else(|| dpop_err("dpop key file is missing private material"))?;
+    let bytes = URL_SAFE_NO_PAD
+      .decode(scalar)
+      .map_err(|_| dpop_err("malformed dpop key file"))?;
+    if bytes.len() != 32 {
+      return Err(dpop_err("malformed dpop key file"));
+    }
+    let mut raw_scalar = [0u8; 32];
+    raw_scalar.copy_from_slice(&bytes);
+    let key = Self::from_scalar_bytes(&raw_scalar);
+    raw_scalar.zeroize();
+    key
+  }
+}
+
+impl fmt::Debug for DpopKey {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("DpopKey")
+      .field("key", &crate::redact::REDACTED)
+      .finish()
+  }
+}
+
+fn coordinates(point: &p256::EncodedPoint) -> Option<([u8; 32], [u8; 32])> {
+  let x = point.x()?.as_slice();
+  let y = point.y()?.as_slice();
+  if x.len() != 32 || y.len() != 32 {
+    return None;
+  }
+  let mut out_x = [0u8; 32];
+  let mut out_y = [0u8; 32];
+  out_x.copy_from_slice(x);
+  out_y.copy_from_slice(y);
+  Some((out_x, out_y))
+}
+
+/// Write `contents` to `path` with owner-only permissions on Unix.
+#[cfg(unix)]
+fn write_private_file(path: &Path, contents: &[u8]) -> Result<()> {
+  use std::{
+    io::Write,
+    os::unix::fs::{OpenOptionsExt, PermissionsExt},
+  };
+
+  let mut options = fs::OpenOptions::new();
+  options.write(true).create(true).truncate(true).mode(0o600);
+  let mut file = options
+    .open(path)
+    .map_err(|_| dpop_err("cannot write dpop key file"))?;
+  file
+    .write_all(contents)
+    .map_err(|_| dpop_err("cannot write dpop key file"))?;
+  file
+    .sync_all()
+    .map_err(|_| dpop_err("cannot write dpop key file"))?;
+  // Reset the mode in case the file already existed with wider permissions.
+  fs::set_permissions(path, fs::Permissions::from_mode(0o600))
+    .map_err(|_| dpop_err("cannot secure dpop key file"))?;
+  Ok(())
+}
+
+/// Non-Unix fallback: plain write (no portable permission primitive).
+#[cfg(not(unix))]
+fn write_private_file(path: &Path, contents: &[u8]) -> Result<()> {
+  fs::write(path, contents).map_err(|_| dpop_err("cannot write dpop key file"))?;
+  Ok(())
+}
+
+/// RFC 7638 thumbprint of a public JWK value.
+///
+/// The canonical form keeps only the required public members in
+/// lexicographic order with no whitespace — `EC`: `{"crv","kty","x","y"}`;
+/// `RSA`: `{"e","kty","n"}` — then SHA-256, base64url-encoded without
+/// padding. JWKs carrying private material or another `kty` are rejected.
+pub fn thumbprint_of(jwk: &serde_json::Value) -> Result<String> {
+  let object = jwk
+    .as_object()
+    .ok_or_else(|| dpop_err("jwk is not an object"))?;
+  let member = |name: &str| {
+    object
+      .get(name)
+      .and_then(serde_json::Value::as_str)
+      .ok_or_else(|| dpop_err("jwk is missing a required member"))
+  };
+  if object.contains_key("d") {
+    return Err(dpop_err("jwk must not contain private key material"));
+  }
+  let canonical = match member("kty")? {
+    "EC" => {
+      if member("crv")? != "P-256" {
+        return Err(dpop_err("unsupported jwk curve"));
+      }
+      let x = member("x")?;
+      let y = member("y")?;
+      for coordinate in [x, y] {
+        let decoded = URL_SAFE_NO_PAD
+          .decode(coordinate)
+          .map_err(|_| dpop_err("malformed jwk coordinate"))?;
+        if decoded.len() != 32 {
+          return Err(dpop_err("malformed jwk coordinate"));
+        }
+      }
+      format!(r#"{{"crv":"P-256","kty":"EC","x":"{x}","y":"{y}"}}"#)
+    }
+    "RSA" => {
+      let e = member("e")?;
+      let n = member("n")?;
+      if URL_SAFE_NO_PAD.decode(e).is_err() || URL_SAFE_NO_PAD.decode(n).is_err() {
+        return Err(dpop_err("malformed jwk member"));
+      }
+      format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#)
+    }
+    _ => return Err(dpop_err("unsupported jwk key type")),
+  };
+  Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(canonical.as_bytes())))
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  /// RFC 7638 §3.1 JWK (an RSA key) and its published thumbprint.
+  /// (The plan text says "EC JWK", but the RFC's §3.1 example — and the
+  /// published thumbprint it demands — is this RSA key.)
+  fn rfc_jwk() -> serde_json::Value {
+    serde_json::from_str(
+      r#"{"kty":"RSA","n":"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw","e":"AQAB","alg":"RS256","kid":"2011-04-29"}"#,
+    )
+    .expect("rfc7638 fixture is valid json")
+  }
+
+  #[test]
+  fn rfc7638_vector() {
+    assert_eq!(
+      thumbprint_of(&rfc_jwk()).unwrap(),
+      "NzbLsXh8uDCcd-6MNwXF4W_7noWXFZAfHkxZsRGC9Xs"
+    );
+  }
+
+  #[test]
+  fn distinct_keys_distinct_jkt() {
+    let first = DpopKey::generate().unwrap();
+    let second = DpopKey::generate().unwrap();
+    assert_ne!(first.thumbprint().unwrap(), second.thumbprint().unwrap());
+  }
+
+  #[test]
+  fn public_jwk_shape_has_no_private_material() {
+    let key = DpopKey::generate().unwrap();
+    let jwk = key.public_jwk();
+    assert_eq!(jwk.get("kty").and_then(|v| v.as_str()), Some("EC"));
+    assert_eq!(jwk.get("crv").and_then(|v| v.as_str()), Some("P-256"));
+    assert!(jwk.get("d").is_none());
+    for name in ["x", "y"] {
+      let raw = URL_SAFE_NO_PAD
+        .decode(jwk.get(name).and_then(|v| v.as_str()).unwrap())
+        .unwrap();
+      assert_eq!(raw.len(), 32);
+    }
+  }
+
+  #[test]
+  fn private_jwk_is_rejected_for_thumbprint() {
+    let mut jwk = rfc_jwk();
+    jwk
+      .as_object_mut()
+      .unwrap()
+      .insert("d".to_owned(), serde_json::Value::String("x".to_owned()));
+    assert!(thumbprint_of(&jwk).is_err());
+  }
+
+  #[test]
+  fn debug_redacts_key_material() {
+    let key = DpopKey::generate().unwrap();
+    let rendered = format!("{key:?}");
+    let jwk = key.public_jwk();
+    let x = jwk.get("x").and_then(|v| v.as_str()).unwrap_or("");
+    assert!(rendered.contains(crate::redact::REDACTED));
+    assert!(!rendered.contains(x));
+    assert!(!rendered.contains("\"d\""));
+  }
+
+  #[test]
+  fn save_and_load_round_trip() {
+    let dir = std::env::temp_dir().join(format!(
+      "oauth-core-dpop-key-{}-{}",
+      std::process::id(),
+      "roundtrip"
+    ));
+    std::fs::create_dir_all(&dir).unwrap();
+    let path = dir.join("dpop-key.json");
+    let key = DpopKey::generate().unwrap();
+    key.save_to_file(&path).unwrap();
+    let loaded = DpopKey::load_from_file(&path).unwrap();
+    assert_eq!(loaded.thumbprint().unwrap(), key.thumbprint().unwrap());
+    std::fs::remove_file(&path).unwrap();
+    std::fs::remove_dir(&dir).unwrap();
+  }
+
+  #[cfg(unix)]
+  #[test]
+  fn saved_key_file_is_owner_only() {
+    use std::os::unix::fs::PermissionsExt;
+
+    let dir = std::env::temp_dir().join(format!(
+      "oauth-core-dpop-key-{}-{}",
+      std::process::id(),
+      "mode"
+    ));
+    std::fs::create_dir_all(&dir).unwrap();
+    let path = dir.join("dpop-key.json");
+    DpopKey::generate().unwrap().save_to_file(&path).unwrap();
+    let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
+    assert_eq!(mode, 0o600);
+    std::fs::remove_file(&path).unwrap();
+    std::fs::remove_dir(&dir).unwrap();
+  }
+
+  #[test]
+  fn load_missing_file_and_unsupported_key_type_are_typed_errors() {
+    let missing = std::env::temp_dir().join(format!(
+      "oauth-core-dpop-key-{}-{}",
+      std::process::id(),
+      "absent.json"
+    ));
+    let _ = std::fs::remove_file(&missing);
+    assert!(
+      matches!(
+        DpopKey::load_from_file(&missing),
+        Err(OAuthError::Dpop { .. })
+      ),
+      "loading an absent key file must fail closed"
+    );
+    let oct = serde_json::json!({"kty": "oct", "k": "GawgguFyGrWKav7AX4VKUg"});
+    assert!(thumbprint_of(&oct).is_err());
+    let ec_wrong_curve = serde_json::json!({
+      "kty": "EC",
+      "crv": "P-384",
+      "x": "x",
+      "y": "y"
+    });
+    assert!(thumbprint_of(&ec_wrong_curve).is_err());
+  }
+}

+ 7 - 0
crates/oauth-core/src/dpop/mod.rs

@@ -0,0 +1,7 @@
+//! DPoP proof support for the demo client (RFC 9449).
+//!
+//! [`key`] owns the P-256 keypair, its private-JWK persistence, public-JWK
+//! export, and RFC 7638 thumbprint; [`proof`] builds `dpop+jwt` proofs.
+
+pub mod key;
+pub mod proof;

+ 340 - 0
crates/oauth-core/src/dpop/proof.rs

@@ -0,0 +1,340 @@
+//! DPoP proof builder (RFC 9449 §4.2-4.3).
+//!
+//! [`build_proof`] emits a `dpop+jwt` with header
+//! `{"typ":"dpop+jwt","alg":"ES256","jwk":<public JWK>}` and payload
+//! `{"jti","htm","htu","iat"}` — no `ath` (this client is not a resource
+//! server). The `htu` must carry no query or fragment and is rejected before
+//! signing. Signatures are ES256 with the IEEE-P1363 `r||s` encoding, which
+//! is the JWS raw signature form.
+//!
+//! [`verify_proof`] checks a proof's header and ES256 signature against its
+//! embedded JWK; freshness/replay checks belong to the verifier (Wave 3).
+
+use std::time::{SystemTime, UNIX_EPOCH};
+
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use p256::ecdsa::{
+  Signature, SigningKey, VerifyingKey,
+  signature::{Signer, Verifier},
+};
+use rand::TryRngCore;
+
+use super::key::DpopKey;
+use crate::error::{OAuthError, Result};
+
+/// Required JOSE header `typ` for a DPoP proof (RFC 9449 §4.2).
+pub const DPOP_TYP: &str = "dpop+jwt";
+/// The only signing algorithm this client emits or accepts.
+pub const DPOP_ALG: &str = "ES256";
+
+fn dpop_err(reason: &str) -> OAuthError {
+  OAuthError::Dpop {
+    reason: reason.to_owned(),
+  }
+}
+
+fn now_unix() -> Result<i64> {
+  SystemTime::now()
+    .duration_since(UNIX_EPOCH)
+    .map(|elapsed| elapsed.as_secs() as i64)
+    .map_err(|_| dpop_err("clock unavailable"))
+}
+
+fn fresh_jti() -> Result<String> {
+  let mut raw = [0u8; 16];
+  rand::rngs::OsRng
+    .try_fill_bytes(&mut raw)
+    .map_err(|_| dpop_err("csprng unavailable"))?;
+  Ok(URL_SAFE_NO_PAD.encode(raw))
+}
+
+/// Build a `dpop+jwt` proof for (`htm`, `htu`) with a fresh `jti` and `iat`.
+pub fn build_proof(key: &DpopKey, htm: &str, htu: &str) -> Result<String> {
+  build_proof_with(key, htm, htu, now_unix()?, &fresh_jti()?)
+}
+
+/// Build a proof with caller-supplied `iat`/`jti` (deterministic; tests).
+///
+/// `htu` containing `?` or `#` is rejected before any signing happens.
+pub fn build_proof_with(
+  key: &DpopKey,
+  htm: &str,
+  htu: &str,
+  iat: i64,
+  jti: &str,
+) -> Result<String> {
+  if htm.is_empty() {
+    return Err(dpop_err("htm is missing"));
+  }
+  if htu.is_empty() {
+    return Err(dpop_err("htu is missing"));
+  }
+  if htu.bytes().any(|byte| byte == b'?' || byte == b'#') {
+    return Err(dpop_err("htu must not contain query or fragment"));
+  }
+  if jti.is_empty() {
+    return Err(dpop_err("jti is missing"));
+  }
+  let header = serde_json::json!({
+    "typ": DPOP_TYP,
+    "alg": DPOP_ALG,
+    "jwk": key.public_jwk(),
+  });
+  let payload = serde_json::json!({
+    "jti": jti,
+    "htm": htm,
+    "htu": htu,
+    "iat": iat,
+  });
+  let header_b64 = URL_SAFE_NO_PAD
+    .encode(serde_json::to_string(&header).map_err(|_| dpop_err("cannot encode proof header"))?);
+  let payload_b64 = URL_SAFE_NO_PAD
+    .encode(serde_json::to_string(&payload).map_err(|_| dpop_err("cannot encode proof payload"))?);
+  let signing_input = format!("{header_b64}.{payload_b64}");
+  let signature: Signature = key.signing_key().sign(signing_input.as_bytes());
+  let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
+  Ok(format!("{signing_input}.{signature_b64}"))
+}
+
+/// A proof whose header and ES256 signature verified against its embedded JWK.
+#[derive(Debug)]
+pub struct VerifiedProof {
+  /// The decoded JOSE header (`typ`, `alg`, `jwk`).
+  pub header: serde_json::Value,
+  /// The proof claims (`jti`, `htm`, `htu`, `iat`).
+  pub jti: String,
+  /// The HTTP method the proof is bound to.
+  pub htm: String,
+  /// The HTTP URI the proof is bound to (no query or fragment).
+  pub htu: String,
+  /// Issued-at, seconds since the Unix epoch.
+  pub iat: i64,
+}
+
+/// Verify a proof's header (`typ`/`alg`) and ES256 signature.
+///
+/// The key comes from the proof's embedded public JWK, which must be
+/// `EC`/`P-256` with no private material. Freshness, `htm`/`htu` binding,
+/// and replay checks are the caller's job.
+pub fn verify_proof(proof: &str) -> Result<VerifiedProof> {
+  let segments: Vec<&str> = proof.split('.').collect();
+  if segments.len() != 3 {
+    return Err(dpop_err("malformed dpop proof"));
+  }
+  let header_bytes = URL_SAFE_NO_PAD
+    .decode(segments[0])
+    .map_err(|_| dpop_err("malformed dpop proof"))?;
+  let header: serde_json::Value =
+    serde_json::from_slice(&header_bytes).map_err(|_| dpop_err("malformed dpop proof"))?;
+  if header.get("typ").and_then(|v| v.as_str()) != Some(DPOP_TYP) {
+    return Err(dpop_err("dpop proof has an invalid type"));
+  }
+  if header.get("alg").and_then(|v| v.as_str()) != Some(DPOP_ALG) {
+    return Err(dpop_err("dpop proof uses an unsupported algorithm"));
+  }
+  let jwk = header
+    .get("jwk")
+    .ok_or_else(|| dpop_err("dpop proof is missing jwk"))?;
+  let verifying_key = verifying_key_from_jwk(jwk)?;
+  let signature_bytes = URL_SAFE_NO_PAD
+    .decode(segments[2])
+    .map_err(|_| dpop_err("malformed dpop signature"))?;
+  let signature =
+    Signature::from_slice(&signature_bytes).map_err(|_| dpop_err("malformed dpop signature"))?;
+  let signing_input = format!("{}.{}", segments[0], segments[1]);
+  verifying_key
+    .verify(signing_input.as_bytes(), &signature)
+    .map_err(|_| dpop_err("dpop proof signature is invalid"))?;
+  let payload_bytes = URL_SAFE_NO_PAD
+    .decode(segments[1])
+    .map_err(|_| dpop_err("malformed dpop proof"))?;
+  let payload: serde_json::Value =
+    serde_json::from_slice(&payload_bytes).map_err(|_| dpop_err("malformed dpop proof"))?;
+  let claim = |name: &str| {
+    payload
+      .get(name)
+      .and_then(|v| v.as_str())
+      .filter(|v| !v.is_empty())
+      .map(str::to_owned)
+      .ok_or_else(|| dpop_err("dpop proof is missing a claim"))
+  };
+  let iat = payload
+    .get("iat")
+    .and_then(serde_json::Value::as_i64)
+    .ok_or_else(|| dpop_err("dpop proof is missing a claim"))?;
+  Ok(VerifiedProof {
+    header,
+    jti: claim("jti")?,
+    htm: claim("htm")?,
+    htu: claim("htu")?,
+    iat,
+  })
+}
+
+/// Rebuild a P-256 verifying key from an embedded public JWK.
+///
+/// Rejects non-objects, non-`EC`/`P-256` keys, private material, and
+/// malformed coordinates without echoing key material into errors.
+fn verifying_key_from_jwk(jwk: &serde_json::Value) -> Result<VerifyingKey> {
+  let object = jwk
+    .as_object()
+    .ok_or_else(|| dpop_err("jwk is not an object"))?;
+  let member = |name: &str| {
+    object
+      .get(name)
+      .and_then(serde_json::Value::as_str)
+      .ok_or_else(|| dpop_err("jwk is missing a required member"))
+  };
+  if object.contains_key("d") {
+    return Err(dpop_err("jwk must not contain private key material"));
+  }
+  if member("kty")? != "EC" || member("crv")? != "P-256" {
+    return Err(dpop_err("unsupported jwk key"));
+  }
+  let decode_coordinate = |name: &str| {
+    let decoded = URL_SAFE_NO_PAD
+      .decode(member(name)?)
+      .map_err(|_| dpop_err("malformed jwk coordinate"))?;
+    if decoded.len() != 32 {
+      return Err(dpop_err("malformed jwk coordinate"));
+    }
+    Ok(decoded)
+  };
+  let x = decode_coordinate("x")?;
+  let y = decode_coordinate("y")?;
+  let mut sec1 = [0u8; 65];
+  sec1[0] = 0x04;
+  sec1[1..33].copy_from_slice(&x);
+  sec1[33..65].copy_from_slice(&y);
+  VerifyingKey::from_sec1_bytes(&sec1).map_err(|_| dpop_err("malformed jwk coordinate"))
+}
+
+/// Exposed for tests: the crate-internal signing-key type name.
+#[allow(dead_code)]
+fn _signing_key_type(_key: &SigningKey) {}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  const TOKEN_HTU: &str = "https://as.example/token";
+
+  #[test]
+  fn proof_verifies_and_decodes() {
+    let key = DpopKey::generate().unwrap();
+    let proof = build_proof_with(&key, "POST", TOKEN_HTU, 1_700_000_000, "test-jti-1").unwrap();
+    let verified = verify_proof(&proof).unwrap();
+    assert_eq!(verified.htm, "POST");
+    assert_eq!(verified.htu, TOKEN_HTU);
+    assert_eq!(verified.jti, "test-jti-1");
+    assert_eq!(verified.iat, 1_700_000_000);
+    assert_eq!(
+      verified.header.get("typ").and_then(|v| v.as_str()),
+      Some(DPOP_TYP)
+    );
+    assert_eq!(
+      verified.header.get("alg").and_then(|v| v.as_str()),
+      Some(DPOP_ALG)
+    );
+    assert!(verified.header.get("jwk").is_some());
+  }
+
+  #[test]
+  fn fresh_proofs_carry_fresh_jti() {
+    let key = DpopKey::generate().unwrap();
+    let first = build_proof(&key, "POST", TOKEN_HTU).unwrap();
+    let second = build_proof(&key, "POST", TOKEN_HTU).unwrap();
+    let decode_jti = |proof: &str| {
+      let payload_b64 = proof.split('.').nth(1).unwrap();
+      let payload: serde_json::Value =
+        serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload_b64).unwrap()).unwrap();
+      payload
+        .get("jti")
+        .and_then(|v| v.as_str())
+        .unwrap()
+        .to_owned()
+    };
+    assert_ne!(decode_jti(&first), decode_jti(&second));
+  }
+
+  #[test]
+  fn payload_has_no_ath() {
+    let key = DpopKey::generate().unwrap();
+    let proof = build_proof(&key, "POST", TOKEN_HTU).unwrap();
+    let payload_b64 = proof.split('.').nth(1).unwrap();
+    let payload: serde_json::Value =
+      serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload_b64).unwrap()).unwrap();
+    assert!(payload.get("ath").is_none());
+    for name in ["jti", "htm", "htu", "iat"] {
+      assert!(payload.get(name).is_some());
+    }
+  }
+
+  #[test]
+  fn htu_with_query_rejected_before_signing() {
+    let key = DpopKey::generate().unwrap();
+    assert!(build_proof(&key, "POST", "https://as.example/token?x=1").is_err());
+    assert!(build_proof(&key, "POST", "https://as.example/token#frag").is_err());
+    assert!(
+      build_proof_with(
+        &key,
+        "POST",
+        "https://as.example/token?x=1",
+        1_700_000_000,
+        "jti"
+      )
+      .is_err()
+    );
+  }
+
+  #[test]
+  fn tampered_proof_fails_verification() {
+    let key = DpopKey::generate().unwrap();
+    let proof = build_proof(&key, "POST", TOKEN_HTU).unwrap();
+    let mut tampered = proof.clone();
+    tampered.push('A');
+    assert!(verify_proof(&tampered).is_err());
+  }
+
+  #[test]
+  fn mistyped_malformed_and_claimless_proofs_are_rejected() {
+    use p256::ecdsa::signature::Signer as _;
+
+    let key = DpopKey::generate().unwrap();
+    let resign = |header: serde_json::Value, payload: serde_json::Value| {
+      let input = format!(
+        "{}.{}",
+        URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
+        URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap())
+      );
+      let signature: Signature = key.signing_key().sign(input.as_bytes());
+      format!("{}.{}", input, URL_SAFE_NO_PAD.encode(signature.to_bytes()))
+    };
+    let payload = serde_json::json!({
+      "jti": "jti-1",
+      "htm": "POST",
+      "htu": TOKEN_HTU,
+      "iat": 1_700_000_000
+    });
+    // Wrong typ / wrong alg fail even with a valid signature over the header.
+    for header in [
+      serde_json::json!({"typ": "jwt", "alg": DPOP_ALG, "jwk": key.public_jwk()}),
+      serde_json::json!({"typ": DPOP_TYP, "alg": "RS256", "jwk": key.public_jwk()}),
+      serde_json::json!({"typ": DPOP_TYP, "alg": DPOP_ALG}),
+    ] {
+      assert!(verify_proof(&resign(header, payload.clone())).is_err());
+    }
+    // Malformed segment counts and non-base64 headers fail before any crypto.
+    assert!(verify_proof("only-two.segments").is_err());
+    assert!(verify_proof("a.b.c.d").is_err());
+    assert!(verify_proof("!!!.e30.e30").is_err());
+    // A signed proof missing a required claim fails on the claim check.
+    let claimless = serde_json::json!({"jti": "jti-1", "htm": "POST", "iat": 1_700_000_000});
+    let header = serde_json::json!({"typ": DPOP_TYP, "alg": DPOP_ALG, "jwk": key.public_jwk()});
+    assert!(verify_proof(&resign(header, claimless)).is_err());
+    // Empty htm/htu/jti are rejected before signing.
+    assert!(build_proof_with(&key, "", TOKEN_HTU, 1_700_000_000, "jti").is_err());
+    assert!(build_proof_with(&key, "POST", "", 1_700_000_000, "jti").is_err());
+    assert!(build_proof_with(&key, "POST", TOKEN_HTU, 1_700_000_000, "").is_err());
+  }
+}

+ 221 - 0
crates/oauth-core/src/error.rs

@@ -0,0 +1,221 @@
+//! Typed errors for the OAuth 2.1 client.
+//!
+//! Every fallible operation in `oauth-core` returns [`Result`] carrying an
+//! [`OAuthError`]. The variants are grouped by protocol stage (configuration,
+//! discovery, PAR, token, introspection, DPoP, PKCE, transport, and
+//! unsupported-capability), so later modules can add behavior without changing
+//! the error surface.
+//!
+//! # Secret safety
+//!
+//! No variant stores a token, verifier, client secret, or any other secret in
+//! a field that is rendered by [`std::fmt::Debug`]. Error payloads are limited
+//! to RFC error codes, human-readable descriptions supplied by the
+//! authorization server, and non-secret identifiers. Callers that need to
+//! attach a secret must redact it first with [`crate::redact`].
+
+use thiserror::Error;
+
+/// Convenience alias for results produced by `oauth-core`.
+pub type Result<T> = std::result::Result<T, OAuthError>;
+
+/// The top-level error type for every `oauth-core` operation.
+///
+/// Variants are deliberately coarse: each protocol stage has one variant so
+/// callers can branch on the stage, and a nested typed error carries the
+/// precise RFC failure when the stage exposes one.
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum OAuthError {
+  /// The profile or client configuration is missing or malformed.
+  #[error(transparent)]
+  Config(#[from] ConfigError),
+
+  /// RFC 8414 / OIDC discovery failed against a specific issuer.
+  #[error("discovery failed for issuer `{issuer}`: {reason}")]
+  Discovery {
+    /// The issuer whose metadata could not be fetched or parsed.
+    issuer: String,
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+
+  /// The discovery endpoint stayed unavailable (for example repeated `503`s)
+  /// after the bounded retry budget was exhausted.
+  #[error("discovery endpoint temporarily unavailable: {reason}")]
+  DiscoveryUnavailable {
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+
+  /// A pushed authorization request (RFC 9126) failed.
+  #[error("pushed authorization request failed: {reason}")]
+  Par {
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+
+  /// The token endpoint (RFC 6749 §5) returned an error.
+  #[error(transparent)]
+  Token(#[from] TokenError),
+
+  /// Token introspection (RFC 7662) failed.
+  #[error(transparent)]
+  Introspection(#[from] IntrospectionError),
+
+  /// A DPoP proof could not be built, parsed, or verified (RFC 9449).
+  #[error("dpop proof error: {reason}")]
+  Dpop {
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+
+  /// A PKCE verifier or challenge was invalid (RFC 7636).
+  #[error("pkce error: {reason}")]
+  Pkce {
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+
+  /// The HTTP transport failed before a protocol response could be read.
+  #[error("http transport error: {reason}")]
+  Http {
+    /// A non-secret explanation (for example a TLS or connection failure).
+    reason: String,
+  },
+
+  /// The target server does not offer a capability the flow requires.
+  #[error("unsupported capability: {capability}")]
+  Unsupported {
+    /// The capability name (for example `par` or `introspection`).
+    capability: String,
+  },
+}
+
+/// A configuration error: a required value is absent or cannot be parsed.
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum ConfigError {
+  /// A required configuration key was not provided.
+  #[error("missing required configuration: {key}")]
+  Missing {
+    /// The key (or environment variable) that is required.
+    key: String,
+  },
+
+  /// A provided value could not be parsed or is not valid UTF-8.
+  #[error("invalid value for `{key}`: {reason}")]
+  Invalid {
+    /// The key whose value was rejected.
+    key: String,
+    /// A non-secret explanation of why the value was rejected.
+    reason: String,
+  },
+}
+
+/// A typed error returned by the token endpoint.
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum TokenError {
+  /// `invalid_grant` (RFC 6749 §5.2); typically an expired code or a reused
+  /// refresh token.
+  #[error("invalid_grant: {description}")]
+  InvalidGrant {
+    /// The server-provided description.
+    description: String,
+  },
+
+  /// `invalid_client` (RFC 6749 §5.2); the client authentication failed.
+  #[error("invalid_client: {description}")]
+  InvalidClient {
+    /// The server-provided description.
+    description: String,
+  },
+
+  /// `invalid_request` (RFC 6749 §5.2); a required parameter is missing.
+  #[error("invalid_request: {description}")]
+  InvalidRequest {
+    /// The server-provided description.
+    description: String,
+  },
+
+  /// `unsupported_grant_type` (RFC 6749 §5.2).
+  #[error("unsupported_grant_type: {description}")]
+  UnsupportedGrantType {
+    /// The server-provided description.
+    description: String,
+  },
+
+  /// `invalid_dpop_proof` (RFC 9449 §5).
+  #[error("invalid_dpop_proof: {description}")]
+  InvalidDpopProof {
+    /// The server-provided description.
+    description: String,
+  },
+
+  /// Any other RFC 6749 §5.2 error code.
+  #[error("token endpoint returned `{code}`: {description}")]
+  Other {
+    /// The raw RFC error code.
+    code: String,
+    /// The server-provided description.
+    description: String,
+  },
+}
+
+/// A typed error returned by the introspection endpoint.
+#[derive(Debug, Error)]
+#[non_exhaustive]
+pub enum IntrospectionError {
+  /// `invalid_client` (RFC 7662 §2.3); the client authentication failed.
+  #[error("invalid_client: {description}")]
+  InvalidClient {
+    /// The server-provided description.
+    description: String,
+  },
+
+  /// Any other introspection error code.
+  #[error("introspection endpoint returned `{code}`: {description}")]
+  Other {
+    /// The raw RFC error code.
+    code: String,
+    /// The server-provided description.
+    description: String,
+  },
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn display_includes_non_secret_context() {
+    let err = OAuthError::Discovery {
+      issuer: "https://as.example".to_owned(),
+      reason: "connection refused".to_owned(),
+    };
+    let rendered = err.to_string();
+    assert!(rendered.contains("https://as.example"));
+    assert!(rendered.contains("connection refused"));
+  }
+
+  #[test]
+  fn token_error_converts_into_oauth_error() {
+    let err: OAuthError = TokenError::InvalidGrant {
+      description: "code expired".to_owned(),
+    }
+    .into();
+    assert!(matches!(
+      err,
+      OAuthError::Token(TokenError::InvalidGrant { .. })
+    ));
+  }
+
+  #[test]
+  fn unsupported_capability_names_the_capability() {
+    let err = OAuthError::Unsupported {
+      capability: "par".to_owned(),
+    };
+    assert!(err.to_string().contains("par"));
+  }
+}

+ 422 - 0
crates/oauth-core/src/introspect.rs

@@ -0,0 +1,422 @@
+//! Token introspection (RFC 7662 §2).
+//!
+//! [`introspect`] POSTs a form-encoded (`application/x-www-form-urlencoded`)
+//! body with `token` and an optional `token_type_hint` to the
+//! discovery-provided `introspection_endpoint` with HTTP Basic client auth.
+//! The hint is purely advisory — it is never required.
+//!
+//! The response shape is conditional (mirroring d-OAuth
+//! `src/protocol/introspect.rs`): `active` is always present; `scope`,
+//! `client_id`, `exp`, and `iat` accompany an active token; `token_type` is
+//! present **only for access tokens** (d-OAuth always reports `"Bearer"` here,
+//! never `"DPoP"` — the `server` crate derives the displayed type from
+//! `cnf.jkt` instead); `sub` is omitted for client-credentials tokens;
+//! `cnf` (with `jkt`) appears only when the token is sender-constrained; `act`
+//! appears only for token-exchange tokens. A foreign or unknown token yields
+//! `{"active":false}` with HTTP 200 — that is a normal inactive answer, not an
+//! error. A 401 maps to [`IntrospectionError::InvalidClient`]. No retry: the
+//! call is a read, but the AS answer is authoritative per attempt.
+
+use secrecy::{ExposeSecret, SecretString};
+use serde::Deserialize;
+
+use crate::{
+  client_auth::basic_auth_header,
+  config::ClientCredentials,
+  discovery::ServerMetadata,
+  error::{IntrospectionError, OAuthError, Result},
+};
+
+/// Sender-constraint confirmation (`cnf`) for a DPoP-bound token.
+///
+/// Present only when the token is sender-constrained; `jkt` is the RFC 7638
+/// thumbprint of the DPoP public JWK the token is bound to.
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub struct Cnf {
+  /// JWK SHA-256 thumbprint of the bound DPoP key.
+  pub jkt: String,
+}
+
+/// An RFC 7662 §2.2 introspection answer.
+///
+/// All members except `active` are optional to cover the conditional shapes:
+/// inactive (`{"active":false}`), refresh tokens (no `token_type`),
+/// client-credentials tokens (no `sub`), unbound tokens (no `cnf`), and
+/// non-exchange tokens (no `act`). `expires_in` is deliberately absent — the
+/// AS returns `exp`/`iat` and the caller computes remaining lifetime.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+pub struct IntrospectionResponse {
+  /// Whether the presented token is currently active.
+  pub active: bool,
+  /// Space-joined scope, when the token is active.
+  pub scope: Option<String>,
+  /// Client the token was issued to, when the token is active.
+  pub client_id: Option<String>,
+  /// Token type (`"Bearer"` for access tokens; absent for refresh tokens).
+  pub token_type: Option<String>,
+  /// Expiry as Unix seconds, when the token is active.
+  pub exp: Option<i64>,
+  /// Issuance as Unix seconds, when the token is active.
+  pub iat: Option<i64>,
+  /// Subject (user email on the auth-code path); omitted when empty, e.g. for
+  /// client-credentials tokens.
+  pub sub: Option<String>,
+  /// Sender-constraint binding, only when the token is bound.
+  pub cnf: Option<Cnf>,
+  /// Actor claim, only for token-exchange tokens.
+  pub act: Option<serde_json::Value>,
+}
+
+/// Raw wire shape of an introspection error body.
+#[derive(Deserialize)]
+struct IntrospectionErrorBody {
+  error: String,
+  #[serde(default)]
+  error_description: Option<String>,
+}
+
+/// Introspect `token` at the discovery `introspection_endpoint`.
+///
+/// Form-encoded POST of `token` plus the optional advisory
+/// `token_type_hint`, Basic client auth, exactly one request, no retry.
+/// `{"active":false}` (HTTP 200) is returned as-is; a 401 maps to
+/// [`IntrospectionError::InvalidClient`].
+pub async fn introspect(
+  client: &reqwest::Client,
+  metadata: &ServerMetadata,
+  credentials: &ClientCredentials,
+  token: &SecretString,
+  token_type_hint: Option<&str>,
+) -> Result<IntrospectionResponse> {
+  let Some(endpoint) = metadata.introspection_endpoint.as_deref() else {
+    return Err(OAuthError::Unsupported {
+      capability: "introspection".to_owned(),
+    });
+  };
+  let mut fields = vec![("token".to_owned(), token.expose_secret().to_owned())];
+  if let Some(hint) = token_type_hint {
+    fields.push(("token_type_hint".to_owned(), hint.to_owned()));
+  }
+  let response = client
+    .post(endpoint)
+    .header(
+      reqwest::header::AUTHORIZATION,
+      basic_auth_header(credentials),
+    )
+    .form(&fields)
+    .send()
+    .await
+    .map_err(|err| OAuthError::Http {
+      reason: format!("introspection request failed: {err}"),
+    })?;
+  if response.status().as_u16() == 401 {
+    let body: IntrospectionErrorBody = response.json().await.map_err(|_| {
+      OAuthError::Introspection(IntrospectionError::InvalidClient {
+        description: "introspection endpoint returned 401".to_owned(),
+      })
+    })?;
+    let description = body.error_description.unwrap_or_default();
+    return match body.error.as_str() {
+      "invalid_client" => Err(OAuthError::Introspection(
+        IntrospectionError::InvalidClient { description },
+      )),
+      _ => Err(OAuthError::Introspection(IntrospectionError::Other {
+        code: body.error,
+        description,
+      })),
+    };
+  }
+  if !response.status().is_success() {
+    let status = response.status();
+    return Err(OAuthError::Http {
+      reason: format!("introspection endpoint returned status {status}"),
+    });
+  }
+  response
+    .json::<IntrospectionResponse>()
+    .await
+    .map_err(|err| {
+      OAuthError::Introspection(IntrospectionError::Other {
+        code: "unparseable_response".to_owned(),
+        description: format!("introspection response is not an introspection document: {err}"),
+      })
+    })
+}
+
+#[cfg(test)]
+mod tests {
+  use wiremock::{
+    Mock, MockServer, ResponseTemplate,
+    matchers::{method, path},
+  };
+
+  use super::*;
+  use crate::discovery::build_client;
+
+  fn credentials() -> ClientCredentials {
+    ClientCredentials {
+      client_id: "demo-client".to_owned(),
+      client_secret: SecretString::from("demo-secret".to_owned()),
+    }
+  }
+
+  fn metadata_for(server: &MockServer) -> ServerMetadata {
+    ServerMetadata {
+      issuer: server.uri(),
+      introspection_endpoint: Some(format!("{}/introspect", server.uri())),
+      ..ServerMetadata::default()
+    }
+  }
+
+  fn token() -> SecretString {
+    SecretString::from("presented-token".to_owned())
+  }
+
+  async fn introspect_ok(server: &MockServer, body: serde_json::Value) -> IntrospectionResponse {
+    Mock::given(method("POST"))
+      .and(path("/introspect"))
+      .respond_with(ResponseTemplate::new(200).set_body_json(body))
+      .mount(server)
+      .await;
+    let client = build_client().unwrap();
+    introspect(
+      &client,
+      &metadata_for(server),
+      &credentials(),
+      &token(),
+      None,
+    )
+    .await
+    .unwrap()
+  }
+
+  #[tokio::test]
+  async fn foreign_token_is_inactive_not_an_error() {
+    let server = MockServer::start().await;
+    let answer = introspect_ok(&server, serde_json::json!({"active": false})).await;
+    assert!(!answer.active);
+    assert_eq!(answer.scope, None);
+    assert_eq!(answer.sub, None);
+    assert_eq!(answer.cnf, None);
+  }
+
+  #[tokio::test]
+  async fn access_token_carries_bearer_type_and_subject() {
+    let server = MockServer::start().await;
+    let answer = introspect_ok(
+      &server,
+      serde_json::json!({
+        "active": true,
+        "scope": "demo.read",
+        "client_id": "demo-client",
+        "token_type": "Bearer",
+        "exp": 1_700_000_300,
+        "iat": 1_700_000_000,
+        "sub": "user@example.com"
+      }),
+    )
+    .await;
+    assert!(answer.active);
+    assert_eq!(answer.token_type.as_deref(), Some("Bearer"));
+    assert_eq!(answer.sub.as_deref(), Some("user@example.com"));
+    assert_eq!(answer.exp, Some(1_700_000_300));
+    assert_eq!(answer.iat, Some(1_700_000_000));
+  }
+
+  #[tokio::test]
+  async fn refresh_token_omits_token_type() {
+    let server = MockServer::start().await;
+    let answer = introspect_ok(
+      &server,
+      serde_json::json!({
+        "active": true,
+        "scope": "demo.read",
+        "client_id": "demo-client",
+        "exp": 1_700_086_400,
+        "iat": 1_700_000_000,
+        "sub": "user@example.com"
+      }),
+    )
+    .await;
+    assert!(answer.active);
+    assert_eq!(answer.token_type, None);
+  }
+
+  #[tokio::test]
+  async fn client_credentials_token_omits_sub() {
+    let server = MockServer::start().await;
+    let answer = introspect_ok(
+      &server,
+      serde_json::json!({
+        "active": true,
+        "scope": "demo.read",
+        "client_id": "demo-client",
+        "exp": 1_700_000_300,
+        "iat": 1_700_000_000
+      }),
+    )
+    .await;
+    assert!(answer.active);
+    assert_eq!(answer.sub, None);
+  }
+
+  #[tokio::test]
+  async fn dpop_bound_token_carries_cnf_jkt() {
+    let server = MockServer::start().await;
+    let answer = introspect_ok(
+      &server,
+      serde_json::json!({
+        "active": true,
+        "scope": "demo.read",
+        "client_id": "demo-client",
+        "token_type": "Bearer",
+        "exp": 1_700_000_300,
+        "iat": 1_700_000_000,
+        "sub": "user@example.com",
+        "cnf": {"jkt": "thumbprint-abc"}
+      }),
+    )
+    .await;
+    assert_eq!(
+      answer.cnf,
+      Some(Cnf {
+        jkt: "thumbprint-abc".to_owned()
+      })
+    );
+  }
+
+  #[tokio::test]
+  async fn unauthorized_maps_to_invalid_client() {
+    let server = MockServer::start().await;
+    Mock::given(method("POST"))
+      .and(path("/introspect"))
+      .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
+        "error": "invalid_client",
+        "error_description": "bad credentials"
+      })))
+      .mount(&server)
+      .await;
+    let client = build_client().unwrap();
+    let result = introspect(
+      &client,
+      &metadata_for(&server),
+      &credentials(),
+      &token(),
+      None,
+    )
+    .await;
+    assert!(
+      matches!(
+        result,
+        Err(OAuthError::Introspection(
+          IntrospectionError::InvalidClient { .. }
+        ))
+      ),
+      "expected InvalidClient, got {result:?}"
+    );
+    assert_eq!(server.received_requests().await.unwrap().len(), 1);
+  }
+
+  #[tokio::test]
+  async fn hint_is_optional_and_forwarded_when_given() {
+    let server = MockServer::start().await;
+    Mock::given(method("POST"))
+      .and(path("/introspect"))
+      .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({"active": false})))
+      .mount(&server)
+      .await;
+    let client = build_client().unwrap();
+    let answer = introspect(
+      &client,
+      &metadata_for(&server),
+      &credentials(),
+      &token(),
+      Some("access_token"),
+    )
+    .await
+    .unwrap();
+    assert!(!answer.active);
+    let received = server.received_requests().await.unwrap();
+    assert_eq!(received.len(), 1);
+    let body: std::collections::HashMap<String, String> =
+      serde_urlencoded::from_str(&String::from_utf8_lossy(&received[0].body)).unwrap();
+    assert_eq!(
+      body.get("token_type_hint").map(String::as_str),
+      Some("access_token")
+    );
+  }
+
+  #[tokio::test]
+  async fn introspection_without_discovered_endpoint_is_unsupported() {
+    let client = build_client().unwrap();
+    let result = introspect(
+      &client,
+      &ServerMetadata::default(),
+      &credentials(),
+      &token(),
+      None,
+    )
+    .await;
+    assert!(
+      matches!(
+        result,
+        Err(OAuthError::Unsupported { ref capability })
+        if capability == "introspection"
+      ),
+      "expected Unsupported(introspection), got {result:?}"
+    );
+  }
+
+  #[tokio::test]
+  async fn unexpected_statuses_map_to_typed_errors() {
+    // A 401 carrying any code other than invalid_client is an Other error.
+    let server = MockServer::start().await;
+    Mock::given(method("POST"))
+      .and(path("/introspect"))
+      .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
+        "error": "invalid_request",
+        "error_description": "malformed introspection call"
+      })))
+      .mount(&server)
+      .await;
+    let client = build_client().unwrap();
+    let result = introspect(
+      &client,
+      &metadata_for(&server),
+      &credentials(),
+      &token(),
+      None,
+    )
+    .await;
+    assert!(
+      matches!(
+        result,
+        Err(OAuthError::Introspection(IntrospectionError::Other {
+          ref code,
+          ..
+        }))
+        if code == "invalid_request"
+      ),
+      "expected Other(invalid_request), got {result:?}"
+    );
+    // A 5xx is a transport-level Http error, never an introspection document.
+    let server = MockServer::start().await;
+    Mock::given(method("POST"))
+      .and(path("/introspect"))
+      .respond_with(ResponseTemplate::new(500))
+      .mount(&server)
+      .await;
+    let result = introspect(
+      &client,
+      &metadata_for(&server),
+      &credentials(),
+      &token(),
+      None,
+    )
+    .await;
+    assert!(
+      matches!(result, Err(OAuthError::Http { .. })),
+      "expected Http, got {result:?}"
+    );
+  }
+}

+ 41 - 0
crates/oauth-core/src/lib.rs

@@ -0,0 +1,41 @@
+//! OAuth 2.1 protocol support for the demo client.
+//!
+//! This crate holds the protocol pieces shared by the BFF and (indirectly) the
+//! WASM UI. The current skeleton provides the error surface, the client
+//! configuration model, and the redaction helpers that every later module
+//! (PKCE, DPoP, PAR, token, introspection, discovery) builds on.
+//!
+//! # Modules
+//!
+//! - [`error`] - typed [`OAuthError`] and per-stage errors.
+//! - [`config`] - [`OAuthProfile`] and its environment overlay.
+//! - [`redact`] - helpers that keep secrets out of formatted output.
+//! - [`discovery`] - RFC 8414 discovery with OIDC fallback + [`Capabilities`].
+//! - [`client_auth`] - RFC 6749 §2.3.1 HTTP Basic client authentication.
+//! - [`par`] - RFC 9126 pushed authorization requests.
+//! - [`token`] - RFC 6749 token exchange + refresh rotation (DPoP-bound).
+//! - [`introspect`] - RFC 7662 token introspection.
+//! - [`revoke`] - RFC 7009 token revocation.
+
+pub mod capabilities;
+pub mod client_auth;
+pub mod config;
+pub mod discovery;
+pub mod dpop;
+pub mod error;
+pub mod introspect;
+pub mod par;
+pub mod pkce;
+pub mod redact;
+pub mod revoke;
+pub mod token;
+
+pub use client_auth::basic_auth_header;
+pub use config::{Capabilities, ClientCredentials, DevSettings, OAuthProfile};
+pub use discovery::{ServerMetadata, build_client, discover, discover_with_client};
+pub use error::{ConfigError, IntrospectionError, OAuthError, Result, TokenError};
+pub use introspect::{Cnf, IntrospectionResponse, introspect};
+pub use par::{ParRequest, ParResponse, push_authorization_request};
+pub use redact::{REDACTED, Redacted, redact_secret};
+pub use revoke::revoke;
+pub use token::{TokenResponse, exchange_code, refresh};

+ 368 - 0
crates/oauth-core/src/par.rs

@@ -0,0 +1,368 @@
+//! Pushed authorization requests (RFC 9126 §2).
+//!
+//! [`push_authorization_request`] POSTs a form-encoded
+//! (`application/x-www-form-urlencoded`) body to the discovery-provided
+//! `pushed_authorization_request_endpoint` with HTTP Basic client auth and
+//! parses `{request_uri, expires_in}`.
+//!
+//! The request carries `response_type=code`, `client_id`, `redirect_uri`,
+//! `scope`, `state`, `code_challenge`, and `code_challenge_method=S256` (7
+//! form fields). **No DPoP header is sent at PAR** — d-OAuth only verifies
+//! client auth here (`src/protocol/par.rs`), and DPoP proofs are bound at the
+//! token endpoint instead (todo 11).
+
+use serde::Deserialize;
+
+use crate::{
+  client_auth::basic_auth_header,
+  config::ClientCredentials,
+  discovery::ServerMetadata,
+  error::{OAuthError, Result},
+  pkce::verify_method,
+};
+
+/// A PAR request before it is form-encoded.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct ParRequest {
+  client_id: String,
+  redirect_uri: String,
+  scope: String,
+  state: String,
+  code_challenge: String,
+  code_challenge_method: String,
+}
+
+/// The AS response to a successful PAR push.
+#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+pub struct ParResponse {
+  /// The one-time `urn:ietf:params:oauth:request_uri:...` handle.
+  pub request_uri: String,
+  /// Lifetime of the handle in seconds.
+  pub expires_in: u64,
+}
+
+fn par_err(reason: &str) -> OAuthError {
+  OAuthError::Par {
+    reason: reason.to_owned(),
+  }
+}
+
+impl ParRequest {
+  /// Build a PAR request.
+  ///
+  /// `code_challenge_method` must be present and exactly `S256`
+  /// ([`verify_method`]); a missing or non-S256 method is rejected here,
+  /// client-side, before any HTTP request is made.
+  pub fn new(
+    client_id: &str,
+    redirect_uri: &str,
+    scope: &str,
+    state: &str,
+    code_challenge: &str,
+    code_challenge_method: Option<&str>,
+  ) -> Result<Self> {
+    let Some(method) = code_challenge_method else {
+      return Err(par_err("code_challenge_method is required"));
+    };
+    verify_method(method)
+      .map_err(|err| par_err(&format!("unsupported code_challenge_method: {err}")))?;
+    if redirect_uri.is_empty() {
+      return Err(par_err("redirect_uri is required"));
+    }
+    if state.is_empty() {
+      return Err(par_err("state is required"));
+    }
+    if code_challenge.is_empty() {
+      return Err(par_err("code_challenge is required"));
+    }
+    Ok(Self {
+      client_id: client_id.to_owned(),
+      redirect_uri: redirect_uri.to_owned(),
+      scope: scope.to_owned(),
+      state: state.to_owned(),
+      code_challenge: code_challenge.to_owned(),
+      code_challenge_method: method.to_owned(),
+    })
+  }
+
+  /// The 7 form fields sent to the PAR endpoint.
+  pub fn form_fields(&self) -> Vec<(String, String)> {
+    vec![
+      ("response_type".to_owned(), "code".to_owned()),
+      ("client_id".to_owned(), self.client_id.clone()),
+      ("redirect_uri".to_owned(), self.redirect_uri.clone()),
+      ("scope".to_owned(), self.scope.clone()),
+      ("state".to_owned(), self.state.clone()),
+      ("code_challenge".to_owned(), self.code_challenge.clone()),
+      (
+        "code_challenge_method".to_owned(),
+        self.code_challenge_method.clone(),
+      ),
+    ]
+  }
+}
+
+/// Push `request` to the PAR endpoint from `metadata`.
+///
+/// Uses the discovery-provided `pushed_authorization_request_endpoint`
+/// verbatim (never constructed manually); returns [`OAuthError::Unsupported`]
+/// with capability `par` when the server advertises none. Authenticates with
+/// the Basic header from [`basic_auth_header`] and never attaches a `DPoP`
+/// header.
+pub async fn push_authorization_request(
+  client: &reqwest::Client,
+  metadata: &ServerMetadata,
+  credentials: &ClientCredentials,
+  request: &ParRequest,
+) -> Result<ParResponse> {
+  let Some(endpoint) = metadata.pushed_authorization_request_endpoint.as_deref() else {
+    return Err(OAuthError::Unsupported {
+      capability: "par".to_owned(),
+    });
+  };
+  let response = client
+    .post(endpoint)
+    .header(
+      reqwest::header::AUTHORIZATION,
+      basic_auth_header(credentials),
+    )
+    .form(&request.form_fields())
+    .send()
+    .await
+    .map_err(|err| OAuthError::Http {
+      reason: format!("par request failed: {err}"),
+    })?;
+  if !response.status().is_success() {
+    let status = response.status();
+    return Err(par_err(&format!("par endpoint returned status {status}")));
+  }
+  response.json::<ParResponse>().await.map_err(|err| {
+    par_err(&format!(
+      "par response is not a request_uri document: {err}"
+    ))
+  })
+}
+
+#[cfg(test)]
+mod tests {
+  use std::collections::HashMap;
+
+  use secrecy::SecretString;
+  use serde::Serialize;
+  use wiremock::{
+    Mock, MockServer, ResponseTemplate,
+    matchers::{header, method, path},
+  };
+
+  use super::*;
+  use crate::{discovery::build_client, pkce::challenge_from_verifier};
+
+  /// Serde helper to assert the exact wire encoding.
+  #[derive(Serialize)]
+  struct WireForm<'a> {
+    response_type: &'a str,
+    client_id: &'a str,
+    redirect_uri: &'a str,
+    scope: &'a str,
+    state: &'a str,
+    code_challenge: &'a str,
+    code_challenge_method: &'a str,
+  }
+
+  const VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+  const CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+  fn credentials() -> ClientCredentials {
+    ClientCredentials {
+      client_id: "demo-client".to_owned(),
+      client_secret: SecretString::from("demo-secret".to_owned()),
+    }
+  }
+
+  fn request() -> ParRequest {
+    ParRequest::new(
+      "demo-client",
+      "http://127.0.0.1:3000/callback",
+      "demo.read",
+      "state-123",
+      CHALLENGE,
+      Some("S256"),
+    )
+    .unwrap()
+  }
+
+  fn metadata_for(server: &MockServer) -> ServerMetadata {
+    ServerMetadata {
+      issuer: server.uri(),
+      authorization_endpoint: Some(format!("{}/authorize", server.uri())),
+      token_endpoint: Some(format!("{}/token", server.uri())),
+      pushed_authorization_request_endpoint: Some(format!("{}/par", server.uri())),
+      ..ServerMetadata::default()
+    }
+  }
+
+  #[test]
+  fn encoded_form_decodes_to_the_expected_fields() {
+    let fields = request().form_fields();
+    let encoded = serde_urlencoded::to_string(&fields).unwrap();
+    let decoded: HashMap<String, String> = serde_urlencoded::from_str(&encoded).unwrap();
+
+    let expected: HashMap<String, String> = HashMap::from([
+      ("response_type".to_owned(), "code".to_owned()),
+      ("client_id".to_owned(), "demo-client".to_owned()),
+      (
+        "redirect_uri".to_owned(),
+        "http://127.0.0.1:3000/callback".to_owned(),
+      ),
+      ("scope".to_owned(), "demo.read".to_owned()),
+      ("state".to_owned(), "state-123".to_owned()),
+      ("code_challenge".to_owned(), CHALLENGE.to_owned()),
+      ("code_challenge_method".to_owned(), "S256".to_owned()),
+    ]);
+    assert_eq!(decoded, expected);
+    assert_eq!(decoded.len(), 7);
+    assert_eq!(challenge_from_verifier(VERIFIER).unwrap(), CHALLENGE);
+
+    // The struct form used below encodes identically to the field list.
+    let req = request();
+    let wire = WireForm {
+      response_type: "code",
+      client_id: "demo-client",
+      redirect_uri: "http://127.0.0.1:3000/callback",
+      scope: "demo.read",
+      state: "state-123",
+      code_challenge: &req.code_challenge,
+      code_challenge_method: &req.code_challenge_method,
+    };
+    let wire_decoded: HashMap<String, String> =
+      serde_urlencoded::from_str(&serde_urlencoded::to_string(&wire).unwrap()).unwrap();
+    assert_eq!(wire_decoded, expected);
+  }
+
+  #[tokio::test]
+  async fn par_post_uses_form_content_type_and_no_dpop_header() {
+    let server = MockServer::start().await;
+    Mock::given(method("POST"))
+      .and(path("/par"))
+      .and(header("content-type", "application/x-www-form-urlencoded"))
+      .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
+        "request_uri": "urn:ietf:params:oauth:request_uri:abc123",
+        "expires_in": 90
+      })))
+      .mount(&server)
+      .await;
+
+    let client = build_client().unwrap();
+    let response =
+      push_authorization_request(&client, &metadata_for(&server), &credentials(), &request())
+        .await
+        .unwrap();
+
+    assert_eq!(
+      response.request_uri,
+      "urn:ietf:params:oauth:request_uri:abc123"
+    );
+    assert_eq!(response.expires_in, 90);
+
+    let received = server.received_requests().await.unwrap();
+    assert_eq!(received.len(), 1);
+    let sent = &received[0];
+    assert!(
+      sent.headers.get("dpop").is_none(),
+      "PAR must never carry a DPoP header"
+    );
+    let auth = sent
+      .headers
+      .get("authorization")
+      .expect("PAR requires Basic client auth");
+    assert!(
+      auth.to_str().unwrap().starts_with("Basic "),
+      "unexpected auth scheme: {auth:?}"
+    );
+    let body: HashMap<String, String> =
+      serde_urlencoded::from_str(&String::from_utf8_lossy(&sent.body)).unwrap();
+    assert_eq!(body.get("response_type").map(String::as_str), Some("code"));
+    assert_eq!(
+      body.get("code_challenge_method").map(String::as_str),
+      Some("S256")
+    );
+    assert_eq!(body.len(), 7);
+  }
+
+  #[test]
+  fn missing_challenge_method_rejected_before_any_request() {
+    let result = ParRequest::new(
+      "demo-client",
+      "http://127.0.0.1:3000/callback",
+      "demo.read",
+      "state-123",
+      CHALLENGE,
+      None,
+    );
+    assert!(
+      matches!(result, Err(OAuthError::Par { .. })),
+      "expected Par error, got {result:?}"
+    );
+  }
+
+  #[test]
+  fn plain_challenge_method_rejected() {
+    let result = ParRequest::new(
+      "demo-client",
+      "http://127.0.0.1:3000/callback",
+      "demo.read",
+      "state-123",
+      CHALLENGE,
+      Some("plain"),
+    );
+    assert!(matches!(result, Err(OAuthError::Par { .. })));
+  }
+
+  #[tokio::test]
+  async fn par_without_discovered_endpoint_is_unsupported() {
+    let server = MockServer::start().await;
+    let client = build_client().unwrap();
+    let result = push_authorization_request(
+      &client,
+      &ServerMetadata::default(),
+      &credentials(),
+      &request(),
+    )
+    .await;
+    assert!(
+      matches!(
+        result,
+        Err(OAuthError::Unsupported { ref capability })
+          if capability == "par"
+      ),
+      "expected Unsupported(par), got {result:?}"
+    );
+    assert!(
+      server
+        .received_requests()
+        .await
+        .is_none_or(|sent| sent.is_empty()),
+      "no request may be sent when PAR is unsupported"
+    );
+  }
+
+  #[tokio::test]
+  async fn par_error_status_is_a_typed_par_error() {
+    let server = MockServer::start().await;
+    Mock::given(method("POST"))
+      .and(path("/par"))
+      .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
+        "error": "invalid_request",
+        "error_description": "redirect_uri mismatch"
+      })))
+      .mount(&server)
+      .await;
+    let client = build_client().unwrap();
+    let result =
+      push_authorization_request(&client, &metadata_for(&server), &credentials(), &request()).await;
+    assert!(
+      matches!(result, Err(OAuthError::Par { .. })),
+      "a rejected PAR must surface Par, got {result:?}"
+    );
+  }
+}

+ 156 - 0
crates/oauth-core/src/pkce.rs

@@ -0,0 +1,156 @@
+//! PKCE S256 for the demo client (RFC 7636, RFC 9700 §2.1.1).
+//!
+//! Only `S256` is supported: `plain` and unknown methods are rejected.
+//! The challenge is `BASE64URL-NOPAD(SHA256(verifier))`, compared with a
+//! constant-time equality check. No function here stores a secret; callers
+//! hold verifiers in [`Redacted`](crate::redact::Redacted).
+
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use rand::TryRngCore;
+use sha2::{Digest as _, Sha256};
+use subtle::ConstantTimeEq;
+
+use crate::error::{OAuthError, Result};
+
+/// Minimum `code_verifier` length (RFC 7636 §4.1).
+pub const VERIFIER_MIN_LEN: usize = 43;
+/// Maximum `code_verifier` length (RFC 7636 §4.1).
+pub const VERIFIER_MAX_LEN: usize = 128;
+/// Exact S256 `code_challenge` length: base64url-nopad of a 32-byte digest.
+pub const CHALLENGE_LEN: usize = 43;
+/// The only accepted `code_challenge_method`.
+pub const S256_METHOD: &str = "S256";
+
+fn pkce_err(reason: &str) -> OAuthError {
+  OAuthError::Pkce {
+    reason: reason.to_owned(),
+  }
+}
+
+/// RFC 7636 §4.1: 43–128 characters from the unreserved set
+/// `ALPHA / DIGIT / "-" / "." / "_" / "~"`.
+fn is_valid_verifier(verifier: &str) -> bool {
+  (VERIFIER_MIN_LEN..=VERIFIER_MAX_LEN).contains(&verifier.len())
+    && verifier
+      .bytes()
+      .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~'))
+}
+
+/// Generate a fresh random verifier from the OS CSPRNG.
+///
+/// 32 random bytes encode to exactly 43 base64url characters (no padding),
+/// all inside the RFC 7636 unreserved set.
+pub fn generate_verifier() -> Result<String> {
+  let mut raw = [0u8; 32];
+  rand::rngs::OsRng
+    .try_fill_bytes(&mut raw)
+    .map_err(|_| pkce_err("csprng unavailable"))?;
+  Ok(URL_SAFE_NO_PAD.encode(raw))
+}
+
+/// Derive the S256 challenge `BASE64URL-NOPAD(SHA256(verifier))`.
+pub fn challenge_from_verifier(verifier: &str) -> Result<String> {
+  if !is_valid_verifier(verifier) {
+    return Err(pkce_err("malformed code_verifier"));
+  }
+  Ok(URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes())))
+}
+
+/// Validate an authorize-time `code_challenge_method`.
+///
+/// Only the exact, case-sensitive `"S256"` passes; `"plain"` and every other
+/// value are rejected.
+pub fn verify_method(method: &str) -> Result<()> {
+  if method == S256_METHOD {
+    Ok(())
+  } else {
+    Err(pkce_err(
+      "unsupported code_challenge_method; only S256 is accepted",
+    ))
+  }
+}
+
+/// Verify a verifier against a stored S256 `code_challenge`.
+///
+/// The recomputed challenge is compared in constant time; `==` is never used
+/// on the digest bytes.
+pub fn verify_s256(verifier: &str, challenge: &str) -> Result<()> {
+  if !is_valid_verifier(verifier) {
+    return Err(pkce_err("malformed code_verifier"));
+  }
+  if challenge.len() != CHALLENGE_LEN
+    || !challenge
+      .bytes()
+      .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
+  {
+    return Err(pkce_err("malformed code_challenge"));
+  }
+  let expected = URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()));
+  if expected.as_bytes().ct_eq(challenge.as_bytes()).into() {
+    Ok(())
+  } else {
+    Err(pkce_err("code_verifier does not match code_challenge"))
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  /// RFC 7636 Appendix B test vector.
+  const RFC_VERIFIER: &str = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+  const RFC_CHALLENGE: &str = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
+
+  #[test]
+  fn rfc7636_appendix_b_vector() {
+    assert_eq!(
+      challenge_from_verifier(RFC_VERIFIER).unwrap(),
+      RFC_CHALLENGE
+    );
+    verify_s256(RFC_VERIFIER, RFC_CHALLENGE).unwrap();
+  }
+
+  #[test]
+  fn short_verifier_rejected() {
+    let short = &RFC_VERIFIER[..42];
+    assert_eq!(short.len(), 42);
+    assert!(challenge_from_verifier(short).is_err());
+    assert!(verify_s256(short, RFC_CHALLENGE).is_err());
+  }
+
+  #[test]
+  fn plain_method_rejected() {
+    assert!(verify_method("plain").is_err());
+    assert!(verify_method("s256").is_err());
+    assert!(verify_method("").is_err());
+    assert!(verify_method("S256").is_ok());
+  }
+
+  #[test]
+  fn wrong_verifier_mismatches() {
+    let wrong = "aBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+    assert!(verify_s256(wrong, RFC_CHALLENGE).is_err());
+  }
+
+  #[test]
+  fn malformed_challenge_rejected() {
+    assert!(verify_s256(RFC_VERIFIER, &RFC_CHALLENGE[..42]).is_err());
+    assert!(verify_s256(RFC_VERIFIER, &"!".repeat(CHALLENGE_LEN)).is_err());
+  }
+
+  #[test]
+  fn bad_charset_rejected() {
+    let mut bad = "a".repeat(VERIFIER_MIN_LEN);
+    bad.pop();
+    bad.push(' ');
+    assert!(challenge_from_verifier(&bad).is_err());
+  }
+
+  #[test]
+  fn generated_verifier_round_trips() {
+    let verifier = generate_verifier().unwrap();
+    assert!(is_valid_verifier(&verifier));
+    let challenge = challenge_from_verifier(&verifier).unwrap();
+    verify_s256(&verifier, &challenge).unwrap();
+  }
+}

+ 118 - 0
crates/oauth-core/src/redact.rs

@@ -0,0 +1,118 @@
+//! Redaction helpers.
+//!
+//! Tokens, secrets, verifiers, and proofs must never reach `Debug`, `Display`,
+//! logs, metrics, or evidence files. This module provides a reusable
+//! [`Redacted`] newtype and the [`redact_secret`] helper so any crate in the
+//! workspace can guarantee that behavior. The `server` crate reuses these to
+//! scrub request/response logging in later todos.
+
+use std::fmt;
+
+use zeroize::Zeroize;
+
+/// The literal shown wherever a secret value would otherwise be printed.
+pub const REDACTED: &str = "[REDACTED]";
+
+/// Discard a secret and return the stable redaction marker.
+///
+/// Accepting the secret by value lets callers pass an `&str` without holding
+/// on to it; the returned `&'static str` is always [`REDACTED`].
+#[must_use]
+pub fn redact_secret(_secret: impl AsRef<str>) -> &'static str {
+  REDACTED
+}
+
+/// A string wrapper that never reveals its contents via [`fmt::Debug`] or
+/// [`fmt::Display`].
+///
+/// The wrapped value can only be read through the explicit [`Redacted::expose`]
+/// call, and it is zeroized on drop. Use this for any value derived from a
+/// token, verifier, code, or proof before it enters a struct whose `Debug` may
+/// be formatted.
+#[derive(Clone, Default, PartialEq, Eq)]
+pub struct Redacted(String);
+
+impl Redacted {
+  /// Wrap a value to be kept out of formatted output.
+  #[must_use]
+  pub fn new(value: impl Into<String>) -> Self {
+    Self(value.into())
+  }
+
+  /// Whether the wrapped value is empty.
+  #[must_use]
+  pub fn is_empty(&self) -> bool {
+    self.0.is_empty()
+  }
+
+  /// Explicitly borrow the wrapped value.
+  ///
+  /// Callers must treat the result as a secret: never log it, never format it,
+  /// and never copy it into a non-secret field.
+  #[must_use]
+  pub fn expose(&self) -> &str {
+    &self.0
+  }
+}
+
+impl fmt::Debug for Redacted {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.write_str(REDACTED)
+  }
+}
+
+impl fmt::Display for Redacted {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.write_str(REDACTED)
+  }
+}
+
+impl Drop for Redacted {
+  fn drop(&mut self) {
+    self.0.zeroize();
+  }
+}
+
+impl From<String> for Redacted {
+  fn from(value: String) -> Self {
+    Self::new(value)
+  }
+}
+
+impl From<&str> for Redacted {
+  fn from(value: &str) -> Self {
+    Self::new(value)
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn redacted_debug_prints_marker_not_value() {
+    let secret = Redacted::new("S3CRET");
+    let rendered = format!("{secret:?}");
+    assert_eq!(rendered, REDACTED);
+    assert!(!rendered.contains("S3CRET"));
+  }
+
+  #[test]
+  fn redacted_display_prints_marker_not_value() {
+    let secret = Redacted::new("S3CRET");
+    let rendered = format!("{secret}");
+    assert_eq!(rendered, REDACTED);
+    assert!(!rendered.contains("S3CRET"));
+  }
+
+  #[test]
+  fn expose_returns_the_wrapped_value() {
+    let secret = Redacted::new("S3CRET");
+    assert_eq!(secret.expose(), "S3CRET");
+  }
+
+  #[test]
+  fn redact_secret_returns_marker() {
+    assert_eq!(redact_secret("S3CRET"), REDACTED);
+  }
+}

+ 170 - 0
crates/oauth-core/src/revoke.rs

@@ -0,0 +1,170 @@
+//! Token revocation (RFC 7009 §2.1).
+//!
+//! [`revoke`] POSTs a form-encoded (`application/x-www-form-urlencoded`) body
+//! with `token` and an optional `token_type_hint` to the
+//! discovery-provided `revocation_endpoint` with HTTP Basic client auth. An
+//! HTTP 200 is success (RFC 7009 §2.2.1 treats unknown tokens as success too,
+//! so callers must not infer token validity from revocation). No retry.
+
+use secrecy::{ExposeSecret, SecretString};
+
+use crate::{
+  client_auth::basic_auth_header,
+  config::ClientCredentials,
+  discovery::ServerMetadata,
+  error::{OAuthError, Result},
+};
+
+/// Revoke `token` at the discovery `revocation_endpoint`.
+///
+/// Form-encoded POST of `token` plus the optional `token_type_hint`, Basic
+/// client auth, exactly one request, no retry. HTTP 200 is success.
+pub async fn revoke(
+  client: &reqwest::Client,
+  metadata: &ServerMetadata,
+  credentials: &ClientCredentials,
+  token: &SecretString,
+  token_type_hint: Option<&str>,
+) -> Result<()> {
+  let Some(endpoint) = metadata.revocation_endpoint.as_deref() else {
+    return Err(OAuthError::Unsupported {
+      capability: "revocation".to_owned(),
+    });
+  };
+  let mut fields = vec![("token".to_owned(), token.expose_secret().to_owned())];
+  if let Some(hint) = token_type_hint {
+    fields.push(("token_type_hint".to_owned(), hint.to_owned()));
+  }
+  let response = client
+    .post(endpoint)
+    .header(
+      reqwest::header::AUTHORIZATION,
+      basic_auth_header(credentials),
+    )
+    .form(&fields)
+    .send()
+    .await
+    .map_err(|err| OAuthError::Http {
+      reason: format!("revocation request failed: {err}"),
+    })?;
+  if !response.status().is_success() {
+    let status = response.status();
+    return Err(OAuthError::Http {
+      reason: format!("revocation endpoint returned status {status}"),
+    });
+  }
+  Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+  use std::collections::HashMap;
+
+  use wiremock::{
+    Mock, MockServer, ResponseTemplate,
+    matchers::{method, path},
+  };
+
+  use super::*;
+  use crate::discovery::build_client;
+
+  fn credentials() -> ClientCredentials {
+    ClientCredentials {
+      client_id: "demo-client".to_owned(),
+      client_secret: SecretString::from("demo-secret".to_owned()),
+    }
+  }
+
+  fn metadata_for(server: &MockServer) -> ServerMetadata {
+    ServerMetadata {
+      issuer: server.uri(),
+      revocation_endpoint: Some(format!("{}/revoke", server.uri())),
+      ..ServerMetadata::default()
+    }
+  }
+
+  #[tokio::test]
+  async fn revoke_posts_token_form_and_succeeds_on_200() {
+    let server = MockServer::start().await;
+    Mock::given(method("POST"))
+      .and(path("/revoke"))
+      .respond_with(ResponseTemplate::new(200))
+      .mount(&server)
+      .await;
+    let client = build_client().unwrap();
+    revoke(
+      &client,
+      &metadata_for(&server),
+      &credentials(),
+      &SecretString::from("opaque-refresh-1".to_owned()),
+      Some("refresh_token"),
+    )
+    .await
+    .unwrap();
+
+    let received = server.received_requests().await.unwrap();
+    assert_eq!(received.len(), 1);
+    let sent = &received[0];
+    assert!(
+      sent
+        .headers
+        .get("authorization")
+        .is_some_and(|value| value.to_str().unwrap().starts_with("Basic ")),
+      "revocation requires Basic client auth"
+    );
+    let body: HashMap<String, String> =
+      serde_urlencoded::from_str(&String::from_utf8_lossy(&sent.body)).unwrap();
+    assert_eq!(
+      body.get("token").map(String::as_str),
+      Some("opaque-refresh-1")
+    );
+    assert_eq!(
+      body.get("token_type_hint").map(String::as_str),
+      Some("refresh_token")
+    );
+  }
+
+  #[tokio::test]
+  async fn revoke_without_discovered_endpoint_is_unsupported() {
+    let client = build_client().unwrap();
+    let result = revoke(
+      &client,
+      &ServerMetadata::default(),
+      &credentials(),
+      &SecretString::from("token".to_owned()),
+      None,
+    )
+    .await;
+    assert!(
+      matches!(
+        result,
+        Err(OAuthError::Unsupported { ref capability })
+        if capability == "revocation"
+      ),
+      "expected Unsupported(revocation), got {result:?}"
+    );
+  }
+
+  #[tokio::test]
+  async fn revoke_failure_status_is_a_typed_http_error() {
+    let server = MockServer::start().await;
+    Mock::given(method("POST"))
+      .and(path("/revoke"))
+      .respond_with(ResponseTemplate::new(500))
+      .mount(&server)
+      .await;
+    let client = build_client().unwrap();
+    let result = revoke(
+      &client,
+      &metadata_for(&server),
+      &credentials(),
+      &SecretString::from("opaque-refresh-1".to_owned()),
+      None,
+    )
+    .await;
+    assert!(
+      matches!(result, Err(OAuthError::Http { .. })),
+      "a failing revocation must surface Http, got {result:?}"
+    );
+  }
+}

+ 599 - 0
crates/oauth-core/src/token.rs

@@ -0,0 +1,599 @@
+//! Token exchange + refresh rotation (RFC 6749 §4.1.3/§5-§6, RFC 9449 §5).
+//!
+//! [`exchange_code`] POSTs a form-encoded (`application/x-www-form-urlencoded`)
+//! body to the discovery-provided `token_endpoint` with
+//! `grant_type=authorization_code`, `code`, `redirect_uri` (**required by
+//! d-OAuth** — the exact registered value is echoed), `code_verifier`, and
+//! `client_id`, plus HTTP Basic client auth and a fresh DPoP proof
+//! (`htm=POST`, `htu=` the discovery `token_endpoint` exactly, no `ath`).
+//!
+//! [`refresh`] POSTs `grant_type=refresh_token`, `refresh_token`, and an
+//! optional `scope` with Basic auth and a fresh DPoP proof, and parses the
+//! rotated response (a NEW `refresh_token` may be returned).
+//!
+//! Neither call retries: token requests are non-idempotent, so a failure
+//! surfaces a typed [`TokenError`] (with `invalid_grant` for expired codes and
+//! reused refresh tokens) instead of re-sending the one-time credential.
+
+use std::fmt;
+
+use secrecy::{ExposeSecret, SecretString};
+use serde::Deserialize;
+
+use crate::{
+  client_auth::basic_auth_header,
+  config::ClientCredentials,
+  discovery::ServerMetadata,
+  dpop::{key::DpopKey, proof::build_proof},
+  error::{OAuthError, Result, TokenError},
+};
+
+/// A successful token-endpoint response (RFC 6749 §5.1).
+///
+/// `access_token`/`refresh_token` are [`SecretString`] and are redacted from
+/// [`fmt::Debug`]; `token_type` is kept verbatim (d-OAuth returns `"DPoP"` at
+/// exchange time — never assume `Bearer`-only).
+pub struct TokenResponse {
+  /// The issued access token (opaque, sender-constrained with DPoP here).
+  pub access_token: SecretString,
+  /// Token type as returned by the AS (`"DPoP"` from d-OAuth at exchange).
+  pub token_type: String,
+  /// Lifetime of the access token in seconds.
+  pub expires_in: u64,
+  /// The (possibly rotated) refresh token, when the grant returns one.
+  pub refresh_token: Option<SecretString>,
+  /// Granted scope string, when the AS echoes it.
+  pub scope: Option<String>,
+}
+
+impl fmt::Debug for TokenResponse {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("TokenResponse")
+      .field("access_token", &crate::redact::REDACTED)
+      .field("token_type", &self.token_type)
+      .field("expires_in", &self.expires_in)
+      .field(
+        "refresh_token",
+        &self.refresh_token.as_ref().map(|_| crate::redact::REDACTED),
+      )
+      .field("scope", &self.scope)
+      .finish()
+  }
+}
+
+impl Clone for TokenResponse {
+  fn clone(&self) -> Self {
+    Self {
+      access_token: SecretString::from(self.access_token.expose_secret().to_owned()),
+      token_type: self.token_type.clone(),
+      expires_in: self.expires_in,
+      refresh_token: self
+        .refresh_token
+        .as_ref()
+        .map(|secret| SecretString::from(secret.expose_secret().to_owned())),
+      scope: self.scope.clone(),
+    }
+  }
+}
+
+/// Raw wire shape of the token response; secrets stay out of error paths.
+#[derive(Deserialize)]
+struct TokenWireResponse {
+  access_token: String,
+  token_type: String,
+  expires_in: u64,
+  #[serde(default)]
+  refresh_token: Option<String>,
+  #[serde(default)]
+  scope: Option<String>,
+}
+
+/// Raw wire shape of an RFC 6749 §5.2 error body.
+#[derive(Deserialize)]
+struct TokenErrorBody {
+  error: String,
+  #[serde(default)]
+  error_description: Option<String>,
+}
+
+fn token_endpoint_of(metadata: &ServerMetadata) -> Result<&str> {
+  metadata
+    .token_endpoint
+    .as_deref()
+    .ok_or(OAuthError::Unsupported {
+      capability: "token".to_owned(),
+    })
+}
+
+fn map_token_error(body: &TokenErrorBody) -> TokenError {
+  let description = body.error_description.clone().unwrap_or_default();
+  match body.error.as_str() {
+    "invalid_grant" => TokenError::InvalidGrant { description },
+    "invalid_client" => TokenError::InvalidClient { description },
+    "invalid_request" => TokenError::InvalidRequest { description },
+    "unsupported_grant_type" => TokenError::UnsupportedGrantType { description },
+    "invalid_dpop_proof" => TokenError::InvalidDpopProof { description },
+    _ => TokenError::Other {
+      code: body.error.clone(),
+      description,
+    },
+  }
+}
+
+async fn post_token_form(
+  client: &reqwest::Client,
+  endpoint: &str,
+  credentials: &ClientCredentials,
+  dpop_key: &DpopKey,
+  fields: &[(String, String)],
+) -> Result<reqwest::Response> {
+  let proof = build_proof(dpop_key, "POST", endpoint)?;
+  client
+    .post(endpoint)
+    .header(
+      reqwest::header::AUTHORIZATION,
+      basic_auth_header(credentials),
+    )
+    .header("DPoP", proof)
+    .form(fields)
+    .send()
+    .await
+    .map_err(|err| OAuthError::Http {
+      reason: format!("token request failed: {err}"),
+    })
+}
+
+async fn parse_token_response(response: reqwest::Response) -> Result<TokenResponse> {
+  if !response.status().is_success() {
+    let status = response.status();
+    let body: TokenErrorBody = response.json().await.map_err(|_| {
+      OAuthError::Token(TokenError::Other {
+        code: "unparseable_error".to_owned(),
+        description: format!("token endpoint returned status {status}"),
+      })
+    })?;
+    return Err(OAuthError::Token(map_token_error(&body)));
+  }
+  let wire: TokenWireResponse = response.json().await.map_err(|err| {
+    OAuthError::Token(TokenError::Other {
+      code: "unparseable_response".to_owned(),
+      description: format!("token response is not a token document: {err}"),
+    })
+  })?;
+  Ok(TokenResponse {
+    access_token: SecretString::from(wire.access_token),
+    token_type: wire.token_type,
+    expires_in: wire.expires_in,
+    refresh_token: wire.refresh_token.map(SecretString::from),
+    scope: wire.scope,
+  })
+}
+
+/// Exchange an authorization code for tokens (RFC 6749 §4.1.3, RFC 9449 §5).
+///
+/// Sends `grant_type=authorization_code`, `code`, `redirect_uri` (the exact
+/// registered value — required by d-OAuth), `code_verifier`, and `client_id`
+/// form-encoded to the discovery `token_endpoint` with Basic auth and a fresh
+/// DPoP proof bound to that endpoint. Exactly one HTTP request is made.
+pub async fn exchange_code(
+  client: &reqwest::Client,
+  metadata: &ServerMetadata,
+  credentials: &ClientCredentials,
+  dpop_key: &DpopKey,
+  code: &str,
+  redirect_uri: &str,
+  code_verifier: &str,
+) -> Result<TokenResponse> {
+  let endpoint = token_endpoint_of(metadata)?.to_owned();
+  if code.is_empty() {
+    return Err(OAuthError::Token(TokenError::InvalidRequest {
+      description: "code is required".to_owned(),
+    }));
+  }
+  if redirect_uri.is_empty() {
+    return Err(OAuthError::Token(TokenError::InvalidRequest {
+      description: "redirect_uri is required".to_owned(),
+    }));
+  }
+  if code_verifier.is_empty() {
+    return Err(OAuthError::Token(TokenError::InvalidRequest {
+      description: "code_verifier is required".to_owned(),
+    }));
+  }
+  let fields = vec![
+    ("grant_type".to_owned(), "authorization_code".to_owned()),
+    ("code".to_owned(), code.to_owned()),
+    ("redirect_uri".to_owned(), redirect_uri.to_owned()),
+    ("code_verifier".to_owned(), code_verifier.to_owned()),
+    ("client_id".to_owned(), credentials.client_id.clone()),
+  ];
+  let response = post_token_form(client, &endpoint, credentials, dpop_key, &fields).await?;
+  parse_token_response(response).await
+}
+
+/// Refresh (and rotate) tokens (RFC 6749 §6, RFC 9449 §5).
+///
+/// Sends `grant_type=refresh_token`, `refresh_token`, and an optional `scope`
+/// form-encoded with Basic auth and a fresh DPoP proof. The response may carry
+/// a NEW `refresh_token` that replaces the presented one. A 400
+/// `invalid_grant` (reuse detected / family revoked) maps to
+/// [`TokenError::InvalidGrant`] with no retry. Exactly one HTTP request.
+pub async fn refresh(
+  client: &reqwest::Client,
+  metadata: &ServerMetadata,
+  credentials: &ClientCredentials,
+  dpop_key: &DpopKey,
+  refresh_token: &SecretString,
+  scope: Option<&str>,
+) -> Result<TokenResponse> {
+  let endpoint = token_endpoint_of(metadata)?.to_owned();
+  let mut fields = vec![
+    ("grant_type".to_owned(), "refresh_token".to_owned()),
+    (
+      "refresh_token".to_owned(),
+      refresh_token.expose_secret().to_owned(),
+    ),
+  ];
+  if let Some(scope) = scope {
+    fields.push(("scope".to_owned(), scope.to_owned()));
+  }
+  let response = post_token_form(client, &endpoint, credentials, dpop_key, &fields).await?;
+  parse_token_response(response).await
+}
+
+#[cfg(test)]
+mod tests {
+  use std::collections::HashMap;
+
+  use secrecy::SecretString;
+  use wiremock::{
+    Mock, MockServer, ResponseTemplate,
+    matchers::{header, method, path},
+  };
+
+  use super::*;
+  use crate::{discovery::build_client, dpop::proof::verify_proof};
+
+  const REDIRECT_URI: &str = "http://127.0.0.1:3000/callback";
+
+  fn credentials() -> ClientCredentials {
+    ClientCredentials {
+      client_id: "demo-client".to_owned(),
+      client_secret: SecretString::from("demo-secret".to_owned()),
+    }
+  }
+
+  fn metadata_for(server: &MockServer) -> ServerMetadata {
+    ServerMetadata {
+      issuer: server.uri(),
+      token_endpoint: Some(format!("{}/token", server.uri())),
+      ..ServerMetadata::default()
+    }
+  }
+
+  fn token_ok(body: serde_json::Value) -> ResponseTemplate {
+    ResponseTemplate::new(200).set_body_json(body)
+  }
+
+  fn decode_dpop_htu(proof: &str) -> String {
+    verify_proof(proof).unwrap().htu
+  }
+
+  #[tokio::test]
+  async fn exchange_sends_redirect_uri_verifier_and_bound_dpop_proof() {
+    let server = MockServer::start().await;
+    let endpoint = format!("{}/token", server.uri());
+    Mock::given(method("POST"))
+      .and(path("/token"))
+      .and(header("content-type", "application/x-www-form-urlencoded"))
+      .respond_with(token_ok(serde_json::json!({
+        "access_token": "opaque-access-1",
+        "token_type": "DPoP",
+        "expires_in": 300,
+        "refresh_token": "opaque-refresh-1",
+        "scope": "demo.read"
+      })))
+      .mount(&server)
+      .await;
+
+    let key = DpopKey::generate().unwrap();
+    let client = build_client().unwrap();
+    let tokens = exchange_code(
+      &client,
+      &metadata_for(&server),
+      &credentials(),
+      &key,
+      "auth-code-1",
+      REDIRECT_URI,
+      "verifier-abc",
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(tokens.token_type, "DPoP");
+    assert_eq!(tokens.expires_in, 300);
+    assert_eq!(tokens.scope.as_deref(), Some("demo.read"));
+    assert_eq!(
+      tokens.refresh_token.as_ref().unwrap().expose_secret(),
+      "opaque-refresh-1"
+    );
+    // Secrets never leak through Debug.
+    let rendered = format!("{tokens:?}");
+    assert!(!rendered.contains("opaque-access-1"));
+    assert!(!rendered.contains("opaque-refresh-1"));
+
+    let received = server.received_requests().await.unwrap();
+    assert_eq!(received.len(), 1);
+    let sent = &received[0];
+    let body: HashMap<String, String> =
+      serde_urlencoded::from_str(&String::from_utf8_lossy(&sent.body)).unwrap();
+    assert_eq!(
+      body.get("grant_type").map(String::as_str),
+      Some("authorization_code")
+    );
+    assert_eq!(body.get("code").map(String::as_str), Some("auth-code-1"));
+    assert_eq!(
+      body.get("redirect_uri").map(String::as_str),
+      Some(REDIRECT_URI)
+    );
+    assert_eq!(
+      body.get("code_verifier").map(String::as_str),
+      Some("verifier-abc")
+    );
+    assert_eq!(
+      body.get("client_id").map(String::as_str),
+      Some("demo-client")
+    );
+    let proof = sent
+      .headers
+      .get("dpop")
+      .expect("token exchange requires a DPoP proof")
+      .to_str()
+      .unwrap()
+      .to_owned();
+    assert_eq!(decode_dpop_htu(&proof), endpoint);
+    // No ath in a token-call proof (client is not a resource server).
+    let payload_b64 = proof.split('.').nth(1).unwrap().to_owned();
+    let payload: serde_json::Value = serde_json::from_slice(
+      &base64::Engine::decode(
+        &base64::engine::general_purpose::URL_SAFE_NO_PAD,
+        payload_b64,
+      )
+      .unwrap(),
+    )
+    .unwrap();
+    assert!(payload.get("ath").is_none());
+  }
+
+  #[tokio::test]
+  async fn refresh_parses_rotated_refresh_token() {
+    let server = MockServer::start().await;
+    let endpoint = format!("{}/token", server.uri());
+    Mock::given(method("POST"))
+      .and(path("/token"))
+      .respond_with(token_ok(serde_json::json!({
+        "access_token": "opaque-access-2",
+        "token_type": "DPoP",
+        "expires_in": 300,
+        "refresh_token": "opaque-refresh-2",
+        "scope": "demo.read"
+      })))
+      .mount(&server)
+      .await;
+
+    let key = DpopKey::generate().unwrap();
+    let client = build_client().unwrap();
+    let tokens = refresh(
+      &client,
+      &metadata_for(&server),
+      &credentials(),
+      &key,
+      &SecretString::from("opaque-refresh-1".to_owned()),
+      None,
+    )
+    .await
+    .unwrap();
+
+    assert_eq!(
+      tokens.refresh_token.as_ref().unwrap().expose_secret(),
+      "opaque-refresh-2"
+    );
+    let received = server.received_requests().await.unwrap();
+    assert_eq!(received.len(), 1);
+    let body: HashMap<String, String> =
+      serde_urlencoded::from_str(&String::from_utf8_lossy(&received[0].body)).unwrap();
+    assert_eq!(
+      body.get("grant_type").map(String::as_str),
+      Some("refresh_token")
+    );
+    assert_eq!(
+      body.get("refresh_token").map(String::as_str),
+      Some("opaque-refresh-1")
+    );
+    assert!(!body.contains_key("scope"));
+    let proof = received[0]
+      .headers
+      .get("dpop")
+      .unwrap()
+      .to_str()
+      .unwrap()
+      .to_owned();
+    assert_eq!(decode_dpop_htu(&proof), endpoint);
+  }
+
+  #[tokio::test]
+  async fn refresh_invalid_grant_maps_without_retry() {
+    let server = MockServer::start().await;
+    Mock::given(method("POST"))
+      .and(path("/token"))
+      .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
+        "error": "invalid_grant",
+        "error_description": "refresh token reuse detected"
+      })))
+      .mount(&server)
+      .await;
+
+    let key = DpopKey::generate().unwrap();
+    let client = build_client().unwrap();
+    let result = refresh(
+      &client,
+      &metadata_for(&server),
+      &credentials(),
+      &key,
+      &SecretString::from("reused-refresh".to_owned()),
+      None,
+    )
+    .await;
+
+    assert!(
+      matches!(
+        result,
+        Err(OAuthError::Token(TokenError::InvalidGrant { .. }))
+      ),
+      "expected InvalidGrant, got {result:?}"
+    );
+    let received = server.received_requests().await.unwrap();
+    assert_eq!(received.len(), 1, "token calls must never retry");
+  }
+
+  #[tokio::test]
+  async fn exchange_without_token_endpoint_is_unsupported() {
+    let server = MockServer::start().await;
+    let key = DpopKey::generate().unwrap();
+    let client = build_client().unwrap();
+    let result = exchange_code(
+      &client,
+      &ServerMetadata::default(),
+      &credentials(),
+      &key,
+      "code",
+      REDIRECT_URI,
+      "verifier",
+    )
+    .await;
+    assert!(
+      matches!(
+        result,
+        Err(OAuthError::Unsupported { ref capability })
+        if capability == "token"
+      ),
+      "expected Unsupported(token), got {result:?}"
+    );
+    assert!(
+      server
+        .received_requests()
+        .await
+        .is_none_or(|sent| sent.is_empty()),
+      "no request may be sent without a token endpoint"
+    );
+  }
+
+  #[tokio::test]
+  async fn exchange_rejects_empty_fields_before_any_request() {
+    let server = MockServer::start().await;
+    let key = DpopKey::generate().unwrap();
+    let client = build_client().unwrap();
+    let metadata = metadata_for(&server);
+    for (code, redirect_uri, verifier) in [
+      ("", REDIRECT_URI, "verifier-abc"),
+      ("auth-code-1", "", "verifier-abc"),
+      ("auth-code-1", REDIRECT_URI, ""),
+    ] {
+      let result = exchange_code(
+        &client,
+        &metadata,
+        &credentials(),
+        &key,
+        code,
+        redirect_uri,
+        verifier,
+      )
+      .await;
+      assert!(
+        matches!(
+          result,
+          Err(OAuthError::Token(TokenError::InvalidRequest { .. }))
+        ),
+        "empty exchange field must fail client-side, got {result:?}"
+      );
+    }
+    assert!(
+      server
+        .received_requests()
+        .await
+        .is_none_or(|sent| sent.is_empty()),
+      "client-side validation must not emit a request"
+    );
+  }
+
+  #[tokio::test]
+  async fn token_error_codes_map_to_typed_variants() {
+    for code in [
+      "invalid_client",
+      "invalid_request",
+      "unsupported_grant_type",
+      "invalid_dpop_proof",
+      "server_error",
+    ] {
+      let server = MockServer::start().await;
+      Mock::given(method("POST"))
+        .and(path("/token"))
+        .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
+          "error": code,
+          "error_description": format!("{code} from the fake AS")
+        })))
+        .mount(&server)
+        .await;
+      let key = DpopKey::generate().unwrap();
+      let client = build_client().unwrap();
+      let result = refresh(
+        &client,
+        &metadata_for(&server),
+        &credentials(),
+        &key,
+        &SecretString::from("opaque-refresh-1".to_owned()),
+        None,
+      )
+      .await;
+      match result {
+        Err(OAuthError::Token(ref err)) => {
+          let expected = match code {
+            "invalid_client" => matches!(err, TokenError::InvalidClient { .. }),
+            "invalid_request" => matches!(err, TokenError::InvalidRequest { .. }),
+            "unsupported_grant_type" => {
+              matches!(err, TokenError::UnsupportedGrantType { .. })
+            }
+            "invalid_dpop_proof" => matches!(err, TokenError::InvalidDpopProof { .. }),
+            _ => matches!(err, TokenError::Other { code: other, .. } if other == code),
+          };
+          assert!(expected, "code {code} mapped to {err:?}");
+        }
+        other => panic!("code {code} must map to a typed error, got {other:?}"),
+      }
+      assert_eq!(server.received_requests().await.unwrap().len(), 1);
+    }
+  }
+
+  #[tokio::test]
+  async fn refresh_without_token_endpoint_is_unsupported() {
+    let key = DpopKey::generate().unwrap();
+    let client = build_client().unwrap();
+    let result = refresh(
+      &client,
+      &ServerMetadata::default(),
+      &credentials(),
+      &key,
+      &SecretString::from("opaque-refresh-1".to_owned()),
+      None,
+    )
+    .await;
+    assert!(
+      matches!(
+        result,
+        Err(OAuthError::Unsupported { ref capability })
+        if capability == "token"
+      ),
+      "expected Unsupported(token), got {result:?}"
+    );
+  }
+}

+ 18 - 0
crates/provision/Cargo.toml

@@ -0,0 +1,18 @@
+[package]
+name = "provision"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+clap = { version = "4", features = ["derive"] }
+oauth-core = { path = "../oauth-core" }
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
+serde = { version = "1.0.229", features = ["derive"] }
+serde_json = "1.0.151"
+thiserror = "2"
+tokio = { version = "1", features = ["rt", "macros"] }
+
+[dev-dependencies]
+tempfile = "3"
+tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+wiremock = "0.6"

+ 417 - 0
crates/provision/src/cli_driver.rs

@@ -0,0 +1,417 @@
+//! d-OAuth CLI driver (todo 29).
+//!
+//! Drives the `d-oauth-server` binary through the demo provisioning sequence,
+//! in order:
+//!
+//! 1. `scope create demo.read` (upsert; enabled by default).
+//! 2. `user scopes --email <email>` (probe), then
+//!    `user create --email <email> --password-file <path>` with
+//!    **create-or-skip**: the `already exists` failure is swallowed because
+//!    `user create` is a plain `INSERT`, not an upsert.
+//! 3. `user grant-scope --email <email> --scope demo.read` (upsert).
+//! 4. `redirect-uri create http://127.0.0.1:3000/callback`.
+//! 5. `redirect-uri create http://127.0.0.1:3000/ --kind post_logout`, then
+//!    `redirect-uri enable ... --kind post_logout` (RP-initiated-logout
+//!    catalog entry).
+//! 6. `initial-access-token issue --uses 1 --ttl 600`; the raw IAT is captured
+//!    from **stdout** and returned to the caller — never logged, never passed
+//!    via argv.
+//!
+//! Every invocation passes arguments as argv (no shell, no interpolation), and
+//! the d-OAuth `.env` file is parsed here and applied to the child's
+//! environment (never exported into our own process).
+
+use std::{
+  collections::HashMap,
+  path::{Path, PathBuf},
+  process::Command,
+};
+
+use thiserror::Error;
+
+/// Demo scope the whole workspace provisions against.
+pub const DEMO_SCOPE: &str = "demo.read";
+/// Demo redirect URI (Trunk dev `:3000` + BFF `/callback`).
+pub const DEMO_REDIRECT_URI: &str = "http://127.0.0.1:3000/callback";
+/// Default demo post-logout redirect URI (app root on `:3000`).
+pub const DEMO_POST_LOGOUT_URI: &str = "http://127.0.0.1:3000/";
+/// Catalog kind for the RP-initiated-logout redirect URI.
+pub const POST_LOGOUT_KIND: &str = "post_logout";
+/// Default demo account email.
+pub const DEFAULT_EMAIL: &str = "demo@example.com";
+/// Single-use budget for the minted initial access token.
+pub const IAT_USES: &str = "1";
+/// IAT lifetime in seconds.
+pub const IAT_TTL_SECS: &str = "600";
+
+/// Typed failures for the CLI driver step.
+#[derive(Debug, Error)]
+pub enum CliDriverError {
+  /// The d-OAuth binary could not be spawned.
+  #[error("failed to run `{cli}` step `{step}`: {reason}")]
+  Spawn {
+    /// Which provisioning step failed to spawn.
+    step: &'static str,
+    /// Path of the d-OAuth binary.
+    cli: PathBuf,
+    /// Low-level reason (never contains the IAT).
+    reason: String,
+  },
+  /// A provisioning step exited non-zero (excluding the create-or-skip case).
+  #[error("`{step}` failed with status {status}: {stderr}")]
+  Step {
+    /// Which provisioning step failed.
+    step: &'static str,
+    /// Child exit status code (`-1` when signalled).
+    status: i32,
+    /// Child stderr, trimmed (never contains the IAT: the IAT is only ever
+    /// printed by the `issue` step on stdout, and that step must succeed).
+    stderr: String,
+  },
+  /// The `.env` file could not be read or parsed.
+  #[error("cannot load env file `{path}`: {reason}")]
+  EnvFile {
+    /// Path that failed to load.
+    path: PathBuf,
+    /// Low-level reason.
+    reason: String,
+  },
+  /// The `issue` step succeeded but stdout carried no token.
+  #[error("`initial-access-token issue` printed no token on stdout")]
+  EmptyIat,
+  /// A password file had to be created and the write failed.
+  #[error("cannot write generated password file: {reason}")]
+  PasswordFile {
+    /// Low-level reason.
+    reason: String,
+  },
+}
+
+/// Inputs for one full driver run.
+#[derive(Debug, Clone)]
+pub struct SetupInputs {
+  /// Path to the `d-oauth-server` binary.
+  pub cli: PathBuf,
+  /// Path to the d-OAuth `.env` file applied to every child.
+  pub env_file: PathBuf,
+  /// Demo account email.
+  pub email: String,
+  /// File holding the demo password (passed to the child via
+  /// `--password-file`, never via argv/stdin contents we log).
+  pub password_file: PathBuf,
+  /// Post-logout redirect URI (created + enabled in the catalog).
+  pub post_logout_uri: String,
+}
+
+/// Outcome of [`run_setup`]: the raw IAT for the DCR step.
+#[derive(Debug)]
+pub struct SetupOutcome {
+  /// Raw initial access token, captured from the `issue` stdout.
+  pub initial_access_token: String,
+}
+
+/// Run the full provisioning sequence, returning the raw IAT.
+pub fn run_setup(inputs: &SetupInputs) -> Result<SetupOutcome, CliDriverError> {
+  let env = load_env_file(&inputs.env_file)?;
+  let driver = Driver {
+    cli: inputs.cli.clone(),
+    env,
+  };
+  driver.run(
+    &inputs.email,
+    &inputs.password_file,
+    &inputs.post_logout_uri,
+  )
+}
+
+/// Attach a catalog `post_logout` redirect URI to a registered client.
+///
+/// Uses the same argv-only driver as [`run_setup`]; the client id and URI are
+/// non-secret, and no token is read or printed.
+pub fn attach_post_logout(
+  cli: &Path,
+  env_file: &Path,
+  client_id: &str,
+  uri: &str,
+) -> Result<(), CliDriverError> {
+  let env = load_env_file(env_file)?;
+  Driver {
+    cli: cli.to_owned(),
+    env,
+  }
+  .attach_redirect_uri(client_id, uri)
+}
+
+/// Mint a fresh single-use IAT without running the rest of the sequence.
+///
+/// Used by the DCR reconcile path: a burned IAT is never reused, so a stale
+/// client record always mints a new token before re-registering.
+pub fn issue_fresh_iat(cli: &Path, env_file: &Path) -> Result<String, CliDriverError> {
+  let env = load_env_file(env_file)?;
+  Driver {
+    cli: cli.to_owned(),
+    env,
+  }
+  .issue_iat()
+}
+#[derive(Debug, Clone)]
+pub struct Driver {
+  /// Path to the `d-oauth-server` binary.
+  pub cli: PathBuf,
+  /// Environment applied to every child process.
+  pub env: HashMap<String, String>,
+}
+
+impl Driver {
+  /// Execute the ordered provisioning sequence.
+  pub fn run(
+    &self,
+    email: &str,
+    password_file: &Path,
+    post_logout_uri: &str,
+  ) -> Result<SetupOutcome, CliDriverError> {
+    self.exec("scope create", &["scope", "create", DEMO_SCOPE])?;
+    // Probe first so a second run can skip creation cleanly; the probe result
+    // itself is advisory — `user create` failure handling is authoritative.
+    let _ = self.exec("user scopes", &["user", "scopes", "--email", email]);
+    self.create_or_skip(email, password_file)?;
+    self.exec(
+      "user grant-scope",
+      &[
+        "user",
+        "grant-scope",
+        "--email",
+        email,
+        "--scope",
+        DEMO_SCOPE,
+      ],
+    )?;
+    self.exec(
+      "redirect-uri create",
+      &["redirect-uri", "create", DEMO_REDIRECT_URI],
+    )?;
+    self.exec(
+      "redirect-uri create post_logout",
+      &[
+        "redirect-uri",
+        "create",
+        post_logout_uri,
+        "--kind",
+        POST_LOGOUT_KIND,
+      ],
+    )?;
+    self.exec(
+      "redirect-uri enable post_logout",
+      &[
+        "redirect-uri",
+        "enable",
+        post_logout_uri,
+        "--kind",
+        POST_LOGOUT_KIND,
+      ],
+    )?;
+    let iat = self.issue_iat()?;
+    Ok(SetupOutcome {
+      initial_access_token: iat,
+    })
+  }
+
+  /// `user create`, swallowing the already-exists failure (plain INSERT).
+  fn create_or_skip(&self, email: &str, password_file: &Path) -> Result<(), CliDriverError> {
+    let password_file = password_file.to_string_lossy().into_owned();
+    match self.exec(
+      "user create",
+      &[
+        "user",
+        "create",
+        "--email",
+        email,
+        "--password-file",
+        password_file.as_str(),
+      ],
+    ) {
+      Ok(_) => Ok(()),
+      Err(CliDriverError::Step {
+        step,
+        status,
+        stderr,
+      }) if is_already_exists(&stderr) => {
+        eprintln!("provision: {step} skipped (already exists): {email}");
+        let _ = status;
+        Ok(())
+      }
+      Err(other) => Err(other),
+    }
+  }
+
+  /// Attach a catalog `post_logout` URI to an existing client.
+  pub fn attach_redirect_uri(&self, client_id: &str, uri: &str) -> Result<(), CliDriverError> {
+    self.exec(
+      "client attach-redirect-uri",
+      &[
+        "client",
+        "attach-redirect-uri",
+        "--client-id",
+        client_id,
+        "--uri",
+        uri,
+        "--kind",
+        POST_LOGOUT_KIND,
+      ],
+    )?;
+    Ok(())
+  }
+
+  /// Mint a single-use IAT; the raw token comes from stdout only.
+  ///
+  /// The real CLI prints a human label on the same stdout line
+  /// (`"<message>: <token>"`), so the token is extracted with
+  /// [`extract_iat`], never by trimming the whole line.
+  pub fn issue_iat(&self) -> Result<String, CliDriverError> {
+    let out = self.exec(
+      "initial-access-token issue",
+      &[
+        "initial-access-token",
+        "issue",
+        "--uses",
+        IAT_USES,
+        "--ttl",
+        IAT_TTL_SECS,
+      ],
+    )?;
+    extract_iat(&out)
+  }
+
+  /// Spawn the CLI with argv (no shell) and the loaded env; return stdout.
+  fn exec(&self, step: &'static str, args: &[&str]) -> Result<String, CliDriverError> {
+    let output = Command::new(&self.cli)
+      .args(args)
+      .envs(&self.env)
+      .output()
+      .map_err(|err| CliDriverError::Spawn {
+        step,
+        cli: self.cli.clone(),
+        reason: err.to_string(),
+      })?;
+    if !output.status.success() {
+      let code = output.status.code().unwrap_or(-1);
+      let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
+      return Err(CliDriverError::Step {
+        step,
+        status: code,
+        stderr,
+      });
+    }
+    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
+  }
+}
+
+/// Whether stderr reports the d-OAuth `AlreadyExists` failure
+/// (`"already exists: user <email>"`).
+fn is_already_exists(stderr: &str) -> bool {
+  stderr.to_lowercase().contains("already exists")
+}
+
+/// Extract the raw IAT from `initial-access-token issue` stdout.
+///
+/// The real CLI prints `<human message>: <token>` on one line, so the whole
+/// line is never the token: the last `: `-delimited field wins, falling back
+/// to the last whitespace-delimited field, and the winner must look like a
+/// base64url token (`[A-Za-z0-9_-]{43,}`). Anything else is [`EmptyIat`],
+/// which carries no token material.
+pub fn extract_iat(output: &str) -> Result<String, CliDriverError> {
+  let line = output.trim();
+  if line.is_empty() {
+    return Err(CliDriverError::EmptyIat);
+  }
+  let candidate = line
+    .rsplit(": ")
+    .next()
+    .and_then(|field| field.split_whitespace().next_back())
+    .unwrap_or_default();
+  if is_token_text(candidate) {
+    Ok(candidate.to_owned())
+  } else {
+    Err(CliDriverError::EmptyIat)
+  }
+}
+
+/// Whether `text` looks like a base64url IAT (43+ alphabet chars, no label).
+fn is_token_text(text: &str) -> bool {
+  text.len() >= 43
+    && text
+      .bytes()
+      .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
+}
+
+/// Parse a dotenv file into child env vars: `KEY=VALUE`, `#` comments and
+/// blank lines skipped, surrounding single/double quotes stripped.
+pub fn load_env_file(path: &Path) -> Result<HashMap<String, String>, CliDriverError> {
+  let raw = std::fs::read_to_string(path).map_err(|err| CliDriverError::EnvFile {
+    path: path.to_owned(),
+    reason: err.to_string(),
+  })?;
+  let mut env = HashMap::new();
+  for line in raw.lines() {
+    let line = line.trim();
+    if line.is_empty() || line.starts_with('#') {
+      continue;
+    }
+    let Some((key, value)) = line.split_once('=') else {
+      continue;
+    };
+    let key = key.trim().to_owned();
+    if key.is_empty() {
+      continue;
+    }
+    env.insert(key, unquote(value.trim()));
+  }
+  Ok(env)
+}
+
+/// Strip one layer of matching surrounding quotes from a dotenv value.
+fn unquote(value: &str) -> String {
+  let bytes = value.as_bytes();
+  if bytes.len() >= 2
+    && ((bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"')
+      || (bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\''))
+  {
+    value[1..value.len() - 1].to_owned()
+  } else {
+    value.to_owned()
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  const TOKEN: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmno12";
+
+  #[test]
+  fn extract_bare_token() {
+    assert_eq!(extract_iat(TOKEN).expect("bare token"), TOKEN);
+  }
+
+  #[test]
+  fn extract_labeled_token() {
+    let line = format!("issued initial access token (1 use): {TOKEN}");
+    assert_eq!(extract_iat(&line).expect("labeled token"), TOKEN);
+  }
+
+  #[test]
+  fn extract_padded_output() {
+    let line = format!("  issued token (1 use, 600s ttl): {TOKEN}  \n");
+    assert_eq!(extract_iat(&line).expect("padded output"), TOKEN);
+  }
+
+  #[test]
+  fn extract_malformed_output_is_typed_error() {
+    for bad in ["", "   \n", "issued token: not-a-token!", "short: abc123"] {
+      assert!(
+        matches!(extract_iat(bad), Err(CliDriverError::EmptyIat)),
+        "must reject {bad:?}"
+      );
+    }
+  }
+}

+ 491 - 0
crates/provision/src/dcr.rs

@@ -0,0 +1,491 @@
+//! DCR registration + persist + drift reconciliation (todo 30).
+//!
+//! - `POST {issuer}/register` (RFC 7591 §2) with `Authorization: Bearer <IAT>`
+//!   registers the confidential demo client. A spent IAT use is **burned even
+//!   when validation fails**, so a fresh IAT is minted for every attempt —
+//!   never reuse one.
+//! - The `{client_id, client_secret, registration_access_token,
+//!   registration_client_uri}` record persists to `config/client.json` with
+//!   mode `0600`.
+//! - On rerun, reconcile via RFC 7592 §2 `GET {registration_client_uri}` with
+//!   the stored registration token: `200` keeps the file; `401`/`404` (or an
+//!   `invalid_client` probe body) re-registers with a **fresh** IAT.
+//! - A `400 invalid_redirect_uri` surfaces the catalog-policy remediation
+//!   (`dcr_redirect_policy=catalog` vs `catalog_loopback`) instead of a bare
+//!   status.
+//!
+//! The IAT and the client secret are never logged.
+
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use crate::cli_driver::{DEMO_REDIRECT_URI, DEMO_SCOPE};
+
+/// Default human-readable client name for the demo registration.
+pub const DEFAULT_CLIENT_NAME: &str = "oauth2-wasm-demo";
+/// Token-endpoint auth method for the confidential BFF client.
+pub const AUTH_METHOD: &str = "client_secret_basic";
+
+/// Remediation printed when the AS rejects the redirect URI under the default
+/// `catalog` policy.
+pub const REDIRECT_POLICY_REMEDIATION: &str = "DCR rejected the redirect_uri \
+  (invalid_redirect_uri): the AS enforces `dcr_redirect_policy=catalog` by \
+  default, so only redirect URIs present in the runtime catalog may register. \
+  Either provision the URI first (`redirect-uri create \
+  http://127.0.0.1:3000/callback`), or relax the AS to \
+  `dcr_redirect_policy=catalog_loopback` (catalog URIs plus dynamic loopback \
+  URIs, RFC 8252) — never `open` in production.";
+
+/// Typed failures for the DCR step.
+#[derive(Debug, Error)]
+pub enum DcrError {
+  /// The HTTP client could not be built.
+  #[error("cannot build http client: {reason}")]
+  HttpClient {
+    /// Low-level reason.
+    reason: String,
+  },
+  /// The HTTP exchange failed.
+  #[error("dcr request to {uri} failed: {reason}")]
+  Http {
+    /// Request URI (contains no secret).
+    uri: String,
+    /// Low-level reason (never contains the IAT or secret).
+    reason: String,
+  },
+  /// Registration was rejected; carries the RFC error code when present.
+  #[error("{message}")]
+  Rejected {
+    /// Human message; for `invalid_redirect_uri` this is the catalog-policy
+    /// remediation.
+    message: String,
+    /// RFC 7591 error code (`invalid_redirect_uri`, `invalid_client`, ...).
+    code: Option<String>,
+    /// HTTP status.
+    status: u16,
+  },
+  /// The AS response did not parse.
+  #[error("cannot decode dcr response: {reason}")]
+  Decode {
+    /// Low-level reason.
+    reason: String,
+  },
+  /// The persisted client record is missing or corrupt.
+  #[error("cannot load client record from `{path}`: {reason}")]
+  Record {
+    /// Path that failed to load.
+    path: PathBuf,
+    /// Low-level reason.
+    reason: String,
+  },
+  /// Persisting the client record failed.
+  #[error("cannot write client record to `{path}`: {reason}")]
+  Persist {
+    /// Target path.
+    path: PathBuf,
+    /// Low-level reason.
+    reason: String,
+  },
+  /// A fresh IAT is required (stale record) but none was supplied.
+  #[error(
+    "stored client is stale (management read failed with {status}); supply a fresh initial access token via --iat-file or --cli/--env-file (IATs are single-use and never reused)"
+  )]
+  FreshIatRequired {
+    /// Management-read HTTP status that proved staleness.
+    status: u16,
+  },
+  /// No IAT source was configured for a registration that needs one.
+  #[error("no initial access token supplied (use --iat-file or --cli with --env-file)")]
+  NoIat,
+  /// The post-registration `post_logout` attach step failed.
+  #[error("cannot attach post_logout URI to client `{client_id}`: {reason}")]
+  Attach {
+    /// Client the URI was being attached to.
+    client_id: String,
+    /// Low-level reason (never a secret or an IAT).
+    reason: String,
+  },
+}
+
+use std::path::PathBuf;
+
+/// Inputs for a registration attempt.
+#[derive(Debug, Clone)]
+pub struct RegisterInputs {
+  /// Issuer base URL, e.g. `https://localhost:8080`.
+  pub issuer: String,
+  /// Human-readable client name.
+  pub client_name: String,
+  /// Redirect URI (must be in the AS catalog under the default policy).
+  pub redirect_uri: String,
+  /// Space-delimited scope (subset of the enabled catalog).
+  pub scope: String,
+}
+
+impl Default for RegisterInputs {
+  fn default() -> Self {
+    Self {
+      issuer: String::new(),
+      client_name: DEFAULT_CLIENT_NAME.to_owned(),
+      redirect_uri: DEMO_REDIRECT_URI.to_owned(),
+      scope: DEMO_SCOPE.to_owned(),
+    }
+  }
+}
+
+/// Persisted client record: the `config/client.json` schema.
+///
+/// `Debug` is redacted: the secret and the management token never appear in
+/// logs even when the record itself is formatted.
+#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ClientRecord {
+  /// Minted public client identifier.
+  pub client_id: String,
+  /// Minted client secret (confidential clients only).
+  pub client_secret: String,
+  /// RFC 7592 management token for `GET/PUT /register/{client_id}`.
+  pub registration_access_token: String,
+  /// RFC 7592 management URI for this client.
+  pub registration_client_uri: String,
+}
+
+impl std::fmt::Debug for ClientRecord {
+  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+    f.debug_struct("ClientRecord")
+      .field("client_id", &self.client_id)
+      .field("client_secret", &"[REDACTED]")
+      .field("registration_access_token", &"[REDACTED]")
+      .field("registration_client_uri", &self.registration_client_uri)
+      .finish()
+  }
+}
+
+/// Outcome of [`reconcile`].
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ReconcileOutcome {
+  /// Stored record is live; the file was left untouched.
+  Kept,
+  /// Stored record was stale; re-registered with a fresh IAT and persisted.
+  ReRegistered,
+}
+
+/// The `post_logout` attach step: the optional d-OAuth CLI plus the URI.
+///
+/// Register can run with only `--iat-file`; in that case the attach step is
+/// skipped (the catalog entry still exists from `setup`).
+#[derive(Debug, Clone)]
+pub struct PostLogoutAttach {
+  /// d-OAuth CLI binary (`--cli`); required alongside `env_file` to attach.
+  pub cli: Option<PathBuf>,
+  /// d-OAuth `.env` file (`--env-file`).
+  pub env_file: Option<PathBuf>,
+  /// Catalog `post_logout` URI to attach.
+  pub uri: String,
+}
+
+/// Outcome of the `post_logout` attach step.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum PostLogoutOutcome {
+  /// The URI was attached to the client via the d-OAuth CLI.
+  Attached,
+  /// No CLI was configured; the attach step was skipped.
+  SkippedNoCli,
+}
+
+/// Printed when `register` ran without `--cli`/`--env-file`.
+pub const POST_LOGOUT_NOT_ATTACHED_NOTE: &str = "post_logout URI not attached \
+  (register ran without --cli/--env-file); the catalog entry still exists \
+  from `setup`";
+
+impl PostLogoutAttach {
+  /// Attach [`Self::uri`] to `client_id`, or report the skip without failing.
+  pub fn apply(&self, client_id: &str) -> Result<PostLogoutOutcome, DcrError> {
+    match (&self.cli, &self.env_file) {
+      (Some(cli), Some(env_file)) => {
+        crate::cli_driver::attach_post_logout(cli, env_file, client_id, &self.uri).map_err(
+          |err| DcrError::Attach {
+            client_id: client_id.to_owned(),
+            reason: err.to_string(),
+          },
+        )?;
+        Ok(PostLogoutOutcome::Attached)
+      }
+      _ => Ok(PostLogoutOutcome::SkippedNoCli),
+    }
+  }
+}
+
+/// One registration attempt bundled with its persist path and attach step.
+pub struct Registration<'a> {
+  /// Registration inputs (issuer, client name, redirect URI, scope).
+  pub inputs: &'a RegisterInputs,
+  /// Where the persisted client record is written.
+  pub out: &'a Path,
+  /// The `post_logout` attach step (skipped when no CLI is configured).
+  pub attach: &'a PostLogoutAttach,
+}
+
+impl Registration<'_> {
+  /// Register, persist the record, then attach the `post_logout` URI.
+  pub async fn register_and_attach(
+    &self,
+    client: &reqwest::Client,
+    iat: &str,
+  ) -> Result<(ClientRecord, PostLogoutOutcome), DcrError> {
+    let record = register(client, self.inputs, iat).await?;
+    persist(&record, self.out)?;
+    let outcome = self.attach.apply(&record.client_id)?;
+    Ok((record, outcome))
+  }
+
+  /// Reconcile; on a stale record re-register then attach the `post_logout` URI.
+  ///
+  /// A live (`Kept`) record is left untouched and no attach runs.
+  pub async fn reconcile_and_attach(
+    &self,
+    client: &reqwest::Client,
+    fresh_iat: impl FnOnce(u16) -> Result<String, DcrError>,
+  ) -> Result<(ReconcileOutcome, Option<PostLogoutOutcome>), DcrError> {
+    match reconcile(client, self.inputs, self.out, fresh_iat).await? {
+      ReconcileOutcome::Kept => Ok((ReconcileOutcome::Kept, None)),
+      ReconcileOutcome::ReRegistered => {
+        let record = load(self.out)?;
+        let outcome = self.attach.apply(&record.client_id)?;
+        Ok((ReconcileOutcome::ReRegistered, Some(outcome)))
+      }
+    }
+  }
+}
+
+/// Register the demo client with a single-use IAT (never reused afterwards).
+pub async fn register(
+  client: &reqwest::Client,
+  inputs: &RegisterInputs,
+  initial_access_token: &str,
+) -> Result<ClientRecord, DcrError> {
+  let uri = join(&inputs.issuer, "/register");
+  let body = serde_json::json!({
+    "client_name": inputs.client_name,
+    "token_endpoint_auth_method": AUTH_METHOD,
+    "grant_types": ["authorization_code", "refresh_token"],
+    "response_types": ["code"],
+    "redirect_uris": [inputs.redirect_uri],
+    "scope": inputs.scope,
+    "dpop_bound_access_tokens": true,
+  });
+  let response = client
+    .post(&uri)
+    .bearer_auth(initial_access_token)
+    .json(&body)
+    .send()
+    .await
+    .map_err(|err| DcrError::Http {
+      uri: uri.clone(),
+      reason: err.to_string(),
+    })?;
+  let status = response.status().as_u16();
+  if !response.status().is_success() {
+    return Err(map_rejection(status, response).await);
+  }
+  let value: serde_json::Value = response.json().await.map_err(|err| DcrError::Decode {
+    reason: err.to_string(),
+  })?;
+  decode_record(value)
+}
+
+/// Decode the RFC 7591 §3.2.1 response into the persisted record schema.
+fn decode_record(value: serde_json::Value) -> Result<ClientRecord, DcrError> {
+  let client_id = field(&value, "client_id")?;
+  let client_secret = field(&value, "client_secret")?;
+  let registration_access_token = field(&value, "registration_access_token")?;
+  let registration_client_uri = field(&value, "registration_client_uri")?;
+  Ok(ClientRecord {
+    client_id,
+    client_secret,
+    registration_access_token,
+    registration_client_uri,
+  })
+}
+
+/// Extract a required string field from the registration response.
+fn field(value: &serde_json::Value, name: &str) -> Result<String, DcrError> {
+  value
+    .get(name)
+    .and_then(serde_json::Value::as_str)
+    .map(str::to_owned)
+    .ok_or_else(|| DcrError::Decode {
+      reason: format!("response is missing `{name}`"),
+    })
+}
+
+/// Map a non-2xx registration response to a typed error, translating
+/// `invalid_redirect_uri` into the catalog-policy remediation.
+async fn map_rejection(status: u16, response: reqwest::Response) -> DcrError {
+  let body = response.text().await.unwrap_or_default();
+  let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or(serde_json::Value::Null);
+  let code = parsed
+    .get("error")
+    .and_then(serde_json::Value::as_str)
+    .map(str::to_owned);
+  if code.as_deref() == Some("invalid_redirect_uri") {
+    return DcrError::Rejected {
+      message: REDIRECT_POLICY_REMEDIATION.to_owned(),
+      code,
+      status,
+    };
+  }
+  let description = parsed
+    .get("error_description")
+    .and_then(serde_json::Value::as_str)
+    .unwrap_or("client registration rejected");
+  DcrError::Rejected {
+    message: description.to_owned(),
+    code,
+    status,
+  }
+}
+
+/// Persist the client record as JSON with mode `0600` (Unix).
+pub fn persist(record: &ClientRecord, out: &Path) -> Result<(), DcrError> {
+  if let Some(parent) = out.parent()
+    && !parent.as_os_str().is_empty()
+  {
+    std::fs::create_dir_all(parent).map_err(|err| DcrError::Persist {
+      path: out.to_owned(),
+      reason: err.to_string(),
+    })?;
+  }
+  let json = serde_json::to_string_pretty(record).map_err(|err| DcrError::Persist {
+    path: out.to_owned(),
+    reason: err.to_string(),
+  })?;
+  #[cfg(unix)]
+  {
+    use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
+    let mut options = std::fs::OpenOptions::new();
+    options.write(true).create(true).truncate(true).mode(0o600);
+    std::io::Write::write_all(
+      &mut options.open(out).map_err(|err| DcrError::Persist {
+        path: out.to_owned(),
+        reason: err.to_string(),
+      })?,
+      json.as_bytes(),
+    )
+    .map_err(|err| DcrError::Persist {
+      path: out.to_owned(),
+      reason: err.to_string(),
+    })?;
+    // Harden against a pre-existing file whose mode was wider: the
+    // `OpenOptionsExt::mode` only applies at creation.
+    let perms = std::fs::Permissions::from_mode(0o600);
+    std::fs::set_permissions(out, perms).map_err(|err| DcrError::Persist {
+      path: out.to_owned(),
+      reason: err.to_string(),
+    })?;
+  }
+  #[cfg(not(unix))]
+  {
+    std::fs::write(out, json).map_err(|err| DcrError::Persist {
+      path: out.to_owned(),
+      reason: err.to_string(),
+    })?;
+  }
+  Ok(())
+}
+
+/// Load a persisted client record.
+pub fn load(out: &Path) -> Result<ClientRecord, DcrError> {
+  let raw = std::fs::read_to_string(out).map_err(|err| DcrError::Record {
+    path: out.to_owned(),
+    reason: err.to_string(),
+  })?;
+  serde_json::from_str(&raw).map_err(|err| DcrError::Record {
+    path: out.to_owned(),
+    reason: err.to_string(),
+  })
+}
+
+/// Reconcile the persisted record against the AS (RFC 7592 §2):
+/// `GET {registration_client_uri}` with the stored token.
+/// `200` keeps the file; `401`/`404` (or an `invalid_client` body) consumes
+/// `fresh_iat` — a closure minting a **fresh** single-use IAT, receiving the
+/// stale status for error context — and re-registers, persisting the new
+/// record.
+pub async fn reconcile(
+  client: &reqwest::Client,
+  inputs: &RegisterInputs,
+  out: &Path,
+  fresh_iat: impl FnOnce(u16) -> Result<String, DcrError>,
+) -> Result<ReconcileOutcome, DcrError> {
+  let stored = load(out)?;
+  match management_read(client, &stored).await? {
+    ReadState::Live => Ok(ReconcileOutcome::Kept),
+    ReadState::Stale { status } => {
+      let iat = fresh_iat(status)?;
+      let record = register(client, inputs, &iat).await?;
+      persist(&record, out)?;
+      Ok(ReconcileOutcome::ReRegistered)
+    }
+  }
+}
+
+/// Outcome of one RFC 7592 management read.
+enum ReadState {
+  /// The stored registration token is live; the file stays untouched.
+  Live,
+  /// The stored record is stale; carries the proving HTTP status.
+  Stale {
+    /// Proving status (`401`/`404`, or the status behind `invalid_client`).
+    status: u16,
+  },
+}
+
+/// Probe the RFC 7592 read endpoint: live when the stored token still works.
+async fn management_read(
+  client: &reqwest::Client,
+  stored: &ClientRecord,
+) -> Result<ReadState, DcrError> {
+  let response = client
+    .get(&stored.registration_client_uri)
+    .bearer_auth(&stored.registration_access_token)
+    .send()
+    .await
+    .map_err(|err| DcrError::Http {
+      uri: stored.registration_client_uri.clone(),
+      reason: err.to_string(),
+    })?;
+  let status = response.status();
+  if status.is_success() {
+    return Ok(ReadState::Live);
+  }
+  if status.as_u16() == 401 || status.as_u16() == 404 {
+    return Ok(ReadState::Stale {
+      status: status.as_u16(),
+    });
+  }
+  let raw_status = status.as_u16();
+  let body = response.text().await.unwrap_or_default();
+  let code = serde_json::from_str::<serde_json::Value>(&body)
+    .ok()
+    .and_then(|value| {
+      value
+        .get("error")
+        .and_then(serde_json::Value::as_str)
+        .map(str::to_owned)
+    });
+  if code.as_deref() == Some("invalid_client") {
+    return Ok(ReadState::Stale { status: raw_status });
+  }
+  Err(DcrError::Rejected {
+    message: "client management read failed".to_owned(),
+    code,
+    status: raw_status,
+  })
+}
+
+/// Join an issuer base URL and a path without producing `//`.
+fn join(issuer: &str, path: &str) -> String {
+  format!("{}{path}", issuer.trim_end_matches('/'))
+}

+ 219 - 0
crates/provision/src/e2e.rs

@@ -0,0 +1,219 @@
+//! E2E bootstrap fixtures (todo 31).
+//!
+//! # Single-writer contract
+//!
+//! `config/e2e.json` (gitignored, mode `0600`) has exactly two writers, split
+//! by field:
+//!
+//! - `provision bootstrap` writes `{email, password}` **only when the file is
+//!   absent**. When the file already exists it is left byte-for-byte untouched
+//!   ([`WriteOutcome::Kept`]).
+//! - The Playwright spec (todo 37) is the single writer of the mutable fields:
+//!   on its first run it completes the forced password change, sets a new
+//!   known password, captures the `otpauth://` secret from the
+//!   `/login/totp/setup` page, and persists
+//!   `{email, password: <new>, totp_secret}` for reuse on later runs.
+//!
+//! There is never a second writer of `{email, password}`, the TOTP secret is
+//! never stored in git, and TOTP entry is always programmatic (pinned
+//! `otplib` in `e2e/package.json`) — never manual.
+//!
+//! # One-time account path (d-OAuth, dev mode)
+//!
+//! A freshly created user starts with `must_change_password = true` and no
+//! TOTP enrolment; its session is restricted until both complete, so
+//! `/authorize` (and therefore the OAuth flow) denies it first:
+//!
+//! 1. `POST /login` with [`LOGIN_EMAIL_FIELD`], [`LOGIN_PASSWORD_FIELD`],
+//!    hidden [`CSRF_FIELD`] (and [`LOGIN_CODE_FIELD`] left empty on the first
+//!    login) → redirected to `/login/change-password`.
+//! 2. `POST /login/change-password` with [`CHANGE_NEW_PASSWORD_FIELD`],
+//!    [`CHANGE_CONFIRM_FIELD`], hidden [`CSRF_FIELD`] (minimum 12 characters).
+//! 3. `GET /login/totp/setup` renders the secret and the `otpauth://` URI
+//!    (shown once) plus the `POST /login/totp/confirm` form.
+//! 4. `POST /login/totp/confirm` with [`TOTP_CODE_FIELD`], hidden
+//!    [`CSRF_FIELD`] → full session re-issued.
+//! 5. Later in the OAuth flow, `POST /consent` approves with hidden
+//!    [`CONSENT_REQUEST_ID_FIELD`], hidden [`CSRF_FIELD`], and
+//!    [`CONSENT_DECISION_FIELD`]=[`CONSENT_APPROVE_VALUE`].
+//!
+//! See `e2e/helpers.js` for the same contract in JavaScript.
+
+use std::path::Path;
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+/// Default fixture path, relative to the workspace root (gitignored).
+pub const DEFAULT_E2E_PATH: &str = "config/e2e.json";
+
+/// Hint attached to every "fixture missing" failure so a missing file reads
+/// as "run provision first", never as a false pass.
+pub const MISSING_FIXTURE_HINT: &str =
+  "E2E fixture is missing: run `cargo run -p provision -- bootstrap --password-file <path>` first";
+
+/// Login form: account identifier (`src/ui/login.rs` `LoginTemplate`).
+pub const LOGIN_EMAIL_FIELD: &str = "email";
+/// Login form: current password (`src/ui/login.rs` `LoginTemplate`).
+pub const LOGIN_PASSWORD_FIELD: &str = "password";
+/// Login / TOTP-confirm forms: authenticator or recovery code.
+pub const LOGIN_CODE_FIELD: &str = "code";
+/// TOTP-confirm form field (same wire name as the login code field).
+pub const TOTP_CODE_FIELD: &str = "code";
+/// Session-bound form token, rendered as a hidden input on every form.
+pub const CSRF_FIELD: &str = "csrf";
+/// Password-change form: the new password (minimum 12 characters).
+pub const CHANGE_NEW_PASSWORD_FIELD: &str = "new_password";
+/// Password-change form: confirmation of the new password.
+pub const CHANGE_CONFIRM_FIELD: &str = "confirm";
+/// Consent form: pending-auth id the decision applies to (hidden input).
+pub const CONSENT_REQUEST_ID_FIELD: &str = "request_id";
+/// Consent form: the verdict submit button name (`src/ui/consent.rs`).
+pub const CONSENT_DECISION_FIELD: &str = "decision";
+/// Consent form: the approve verdict value.
+pub const CONSENT_APPROVE_VALUE: &str = "approve";
+
+/// The `config/e2e.json` schema.
+///
+/// `provision bootstrap` writes `email` + `password` only; the Playwright spec
+/// owns `password` rotation and `totp_secret` afterwards. `Debug` is redacted
+/// so the password and the TOTP secret never appear in logs.
+#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct E2eFixture {
+  /// Demo account email (stable across runs).
+  pub email: String,
+  /// Current known password (rotated once by the Playwright spec).
+  pub password: String,
+  /// TOTP secret captured from `/login/totp/setup` (spec-written, reused).
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub totp_secret: Option<String>,
+}
+
+impl std::fmt::Debug for E2eFixture {
+  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+    f.debug_struct("E2eFixture")
+      .field("email", &self.email)
+      .field("password", &"[REDACTED]")
+      .field(
+        "totp_secret",
+        &self.totp_secret.as_deref().map(|_| "[REDACTED]"),
+      )
+      .finish()
+  }
+}
+
+/// Outcome of [`write_initial`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum WriteOutcome {
+  /// The fixture file was absent and has now been written (`0600`).
+  Created,
+  /// The fixture file already existed and was left untouched.
+  Kept,
+}
+
+/// Typed failures for the E2E fixture step. No variant carries secret material.
+#[derive(Debug, Error)]
+pub enum E2eError {
+  /// The fixture file could not be read or written.
+  #[error("cannot access E2E fixture at `{path}`: {reason}")]
+  Io {
+    /// Path that failed.
+    path: PathBuf,
+    /// Low-level reason.
+    reason: String,
+  },
+  /// The fixture file is not valid JSON.
+  #[error("cannot parse E2E fixture at `{path}`: {reason}")]
+  Parse {
+    /// Path that failed to parse.
+    path: PathBuf,
+    /// Low-level reason.
+    reason: String,
+  },
+  /// The fixture file does not exist yet.
+  #[error("{MISSING_FIXTURE_HINT} (looked at `{path}`)")]
+  Missing {
+    /// Path that was looked up.
+    path: PathBuf,
+  },
+}
+
+use std::path::PathBuf;
+
+/// Write `{email, password}` to `out` **only when the file is absent**.
+///
+/// An existing file is returned as [`WriteOutcome::Kept`] without reading or
+/// modifying it, so the Playwright-owned `password`/`totp_secret` values are
+/// never clobbered. A fresh file is written with mode `0600` on Unix.
+pub fn write_initial(out: &Path, email: &str, password: &str) -> Result<WriteOutcome, E2eError> {
+  if out.exists() {
+    return Ok(WriteOutcome::Kept);
+  }
+  if let Some(parent) = out.parent()
+    && !parent.as_os_str().is_empty()
+  {
+    std::fs::create_dir_all(parent).map_err(|err| E2eError::Io {
+      path: out.to_owned(),
+      reason: err.to_string(),
+    })?;
+  }
+  let fixture = E2eFixture {
+    email: email.to_owned(),
+    password: password.to_owned(),
+    totp_secret: None,
+  };
+  let json = serde_json::to_string_pretty(&fixture).map_err(|err| E2eError::Io {
+    path: out.to_owned(),
+    reason: err.to_string(),
+  })?;
+  #[cfg(unix)]
+  {
+    use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
+    let mut options = std::fs::OpenOptions::new();
+    options.write(true).create(true).truncate(true).mode(0o600);
+    std::io::Write::write_all(
+      &mut options.open(out).map_err(|err| E2eError::Io {
+        path: out.to_owned(),
+        reason: err.to_string(),
+      })?,
+      json.as_bytes(),
+    )
+    .map_err(|err| E2eError::Io {
+      path: out.to_owned(),
+      reason: err.to_string(),
+    })?;
+    // Harden against a pre-existing file whose mode was wider: the
+    // `OpenOptionsExt::mode` only applies at creation.
+    let perms = std::fs::Permissions::from_mode(0o600);
+    std::fs::set_permissions(out, perms).map_err(|err| E2eError::Io {
+      path: out.to_owned(),
+      reason: err.to_string(),
+    })?;
+  }
+  #[cfg(not(unix))]
+  {
+    std::fs::write(out, json).map_err(|err| E2eError::Io {
+      path: out.to_owned(),
+      reason: err.to_string(),
+    })?;
+  }
+  Ok(WriteOutcome::Created)
+}
+
+/// Load the fixture, or fail with [`E2eError::Missing`] (carrying the
+/// "run provision first" hint) when it does not exist yet.
+pub fn load(out: &Path) -> Result<E2eFixture, E2eError> {
+  if !out.exists() {
+    return Err(E2eError::Missing {
+      path: out.to_owned(),
+    });
+  }
+  let raw = std::fs::read_to_string(out).map_err(|err| E2eError::Io {
+    path: out.to_owned(),
+    reason: err.to_string(),
+  })?;
+  serde_json::from_str(&raw).map_err(|err| E2eError::Parse {
+    path: out.to_owned(),
+    reason: err.to_string(),
+  })
+}

+ 65 - 0
crates/provision/src/keygen.rs

@@ -0,0 +1,65 @@
+//! DPoP key generation for the BFF (`config/dpop-key.json`).
+//!
+//! The BFF loads this file at startup and refuses sign-in when it is absent,
+//! so `provision keygen` is the single writer. Crypto is never re-implemented
+//! here: generation and persistence both delegate to
+//! [`oauth_core::dpop::key::DpopKey`], which writes mode `0600` on Unix and
+//! zeroizes private buffers. Only the public thumbprint (`jkt`) ever leaves
+//! this module for display; the private scalar (`d`) is never printed.
+
+use std::path::{Path, PathBuf};
+
+use thiserror::Error;
+
+/// Default DPoP key path, relative to the workspace root (gitignored).
+pub const DEFAULT_DPOP_KEY_PATH: &str = "config/dpop-key.json";
+
+/// One-line hint printed when the DPoP key file is absent.
+pub const KEYGEN_HINT: &str = "run `cargo run -p provision -- keygen`";
+
+/// Typed failures for DPoP key generation.
+#[derive(Debug, Error)]
+pub enum KeygenError {
+  /// The key file already exists and `--force` was not passed.
+  #[error("DPoP key file `{path}` already exists; rerun with `--force` to overwrite")]
+  Exists {
+    /// Existing file that was left untouched.
+    path: PathBuf,
+  },
+  /// A DPoP crypto or persistence failure (never carries key material).
+  #[error(transparent)]
+  Dpop(#[from] oauth_core::error::OAuthError),
+  /// The parent directory of the output path could not be created.
+  #[error("cannot create parent directory for `{path}`: {reason}")]
+  ParentDir {
+    /// Requested output path.
+    path: PathBuf,
+    /// Underlying I/O failure.
+    reason: String,
+  },
+}
+
+/// Generate a fresh P-256 DPoP key and persist it to `out`.
+///
+/// Refuses to overwrite an existing file unless `force` is set, so a rerun
+/// can never silently rotate an in-use key. Returns the public thumbprint
+/// (`jkt`) for the non-secret confirmation line.
+pub fn generate_key(out: &Path, force: bool) -> Result<String, KeygenError> {
+  if !force && out.exists() {
+    return Err(KeygenError::Exists {
+      path: out.to_path_buf(),
+    });
+  }
+  if let Some(parent) = out.parent()
+    && !parent.as_os_str().is_empty()
+  {
+    std::fs::create_dir_all(parent).map_err(|err| KeygenError::ParentDir {
+      path: out.to_path_buf(),
+      reason: err.to_string(),
+    })?;
+  }
+  let key = oauth_core::dpop::key::DpopKey::generate()?;
+  key.save_to_file(out)?;
+  let thumbprint = key.thumbprint()?;
+  Ok(thumbprint)
+}

+ 8 - 0
crates/provision/src/lib.rs

@@ -0,0 +1,8 @@
+//! `provision` library surface: the d-OAuth CLI driver (todo 29), the
+//! DCR registration step (todo 30), and the E2E fixture contract (todo 31),
+//! shared by the binary and the tests.
+
+pub mod cli_driver;
+pub mod dcr;
+pub mod e2e;
+pub mod keygen;

+ 341 - 0
crates/provision/src/main.rs

@@ -0,0 +1,341 @@
+//! `provision` - one-shot d-OAuth provisioning and demo-kit driver.
+//!
+//! - `setup` (todo 29) drives the d-OAuth CLI through the demo scope, user,
+//!   grant, redirect URI, and IAT sequence.
+//! - `register` (todo 30) registers the confidential client over DCR and
+//!   persists `config/client.json`, reconciling stale records on rerun.
+//! - `bootstrap` (todo 31) writes `{email, password}` to `config/e2e.json`
+//!   only when absent; the Playwright spec owns the mutable fields after.
+
+use std::path::PathBuf;
+use std::process::ExitCode;
+
+use clap::{Parser, Subcommand};
+use thiserror::Error;
+
+use provision::{cli_driver, dcr, e2e, keygen};
+
+use cli_driver::{DEFAULT_EMAIL, issue_fresh_iat, run_setup};
+use dcr::{
+  AUTH_METHOD, DcrError, POST_LOGOUT_NOT_ATTACHED_NOTE, PostLogoutAttach, PostLogoutOutcome,
+  REDIRECT_POLICY_REMEDIATION, RegisterInputs, Registration,
+};
+
+/// Typed failures surfaced by the provisioning driver.
+#[derive(Debug, Error)]
+enum ProvisionError {
+  /// The d-OAuth CLI driver step failed.
+  #[error(transparent)]
+  Setup(#[from] cli_driver::CliDriverError),
+  /// The DCR registration step failed.
+  #[error(transparent)]
+  Dcr(#[from] dcr::DcrError),
+  /// The E2E bootstrap step failed.
+  #[error(transparent)]
+  E2e(#[from] e2e::E2eError),
+  /// The DPoP keygen step failed.
+  #[error(transparent)]
+  Keygen(#[from] keygen::KeygenError),
+}
+
+/// One-shot client registration and demo-kit driver for the OAuth workspace.
+#[derive(Debug, Parser)]
+#[command(
+  name = "provision",
+  version,
+  about = "One-shot d-OAuth client registration and demo-kit driver",
+  propagate_version = true
+)]
+struct Cli {
+  #[command(subcommand)]
+  command: Command,
+}
+
+/// Provisioning steps, in the order a full run must execute them.
+#[derive(Debug, Subcommand)]
+enum Command {
+  /// Create the demo scope, user, redirect URI, and an initial access token.
+  Setup(SetupArgs),
+  /// Register (or reconcile) the confidential client via DCR.
+  Register(RegisterArgs),
+  /// Write the gitignored E2E fixtures to `config/e2e.json`.
+  Bootstrap(BootstrapArgs),
+  /// Generate the BFF DPoP signing key (`config/dpop-key.json`).
+  Keygen(KeygenArgs),
+}
+
+/// Inputs for the d-OAuth CLI driver step (todo 29).
+#[derive(Debug, clap::Args)]
+struct SetupArgs {
+  /// Path to the d-OAuth server binary.
+  #[arg(long, value_name = "PATH")]
+  cli: PathBuf,
+  /// Path to the d-OAuth `.env` file.
+  #[arg(long, value_name = "PATH")]
+  env_file: PathBuf,
+  /// Demo account email.
+  #[arg(long, value_name = "EMAIL", default_value = DEFAULT_EMAIL)]
+  email: String,
+  /// File holding the demo password (passed via `--password-file`, never argv).
+  #[arg(long, value_name = "PATH")]
+  password_file: PathBuf,
+  /// Post-logout redirect URI (created + enabled in the AS catalog).
+  #[arg(long, value_name = "URL", default_value = cli_driver::DEMO_POST_LOGOUT_URI)]
+  post_logout_uri: String,
+  /// Where to write the raw IAT (mode 0600); the token is never printed.
+  #[arg(long, value_name = "PATH")]
+  iat_out: Option<PathBuf>,
+}
+
+/// Inputs for the DCR registration step (todo 30).
+#[derive(Debug, clap::Args)]
+struct RegisterArgs {
+  /// Issuer base URL, e.g. `https://localhost:8080`.
+  #[arg(long, value_name = "URL")]
+  issuer: String,
+  /// Human-readable client name.
+  #[arg(long, value_name = "NAME", default_value = dcr::DEFAULT_CLIENT_NAME)]
+  client_name: String,
+  /// Redirect URI (must be in the AS catalog under the default policy).
+  #[arg(long, value_name = "URL", default_value = cli_driver::DEMO_REDIRECT_URI)]
+  redirect_uri: String,
+  /// Space-delimited scope.
+  #[arg(long, value_name = "SCOPE", default_value = cli_driver::DEMO_SCOPE)]
+  scope: String,
+  /// Post-logout redirect URI attached to the client after DCR.
+  #[arg(long, value_name = "URL", default_value = cli_driver::DEMO_POST_LOGOUT_URI)]
+  post_logout_uri: String,
+  /// File holding a single-use IAT for registration.
+  #[arg(long, value_name = "PATH")]
+  iat_file: Option<PathBuf>,
+  /// Path to the d-OAuth server binary (mints a fresh IAT when needed).
+  #[arg(long, value_name = "PATH")]
+  cli: Option<PathBuf>,
+  /// Path to the d-OAuth `.env` file (with `--cli`).
+  #[arg(long, value_name = "PATH")]
+  env_file: Option<PathBuf>,
+  /// Where the persisted client record is written.
+  #[arg(long, value_name = "PATH", default_value = "config/client.json")]
+  out: PathBuf,
+}
+
+/// Inputs for the E2E bootstrap step (todo 31).
+#[derive(Debug, clap::Args)]
+struct BootstrapArgs {
+  /// Demo account email (must match the `setup` step).
+  #[arg(long, value_name = "EMAIL", default_value = cli_driver::DEFAULT_EMAIL)]
+  email: String,
+  /// File holding the initial demo password (same file passed to `setup`).
+  #[arg(long, value_name = "PATH")]
+  password_file: PathBuf,
+  /// Where the E2E fixtures are written.
+  #[arg(long, value_name = "PATH", default_value = e2e::DEFAULT_E2E_PATH)]
+  out: PathBuf,
+}
+
+/// Inputs for the DPoP keygen step.
+#[derive(Debug, clap::Args)]
+struct KeygenArgs {
+  /// Where the DPoP private JWK is written (mode 0600 on Unix).
+  #[arg(long, value_name = "PATH", default_value = keygen::DEFAULT_DPOP_KEY_PATH)]
+  out: PathBuf,
+  /// Overwrite an existing key file (otherwise a rerun fails).
+  #[arg(long, default_value_t = false)]
+  force: bool,
+}
+
+#[tokio::main(flavor = "current_thread")]
+async fn main() -> ExitCode {
+  let cli = Cli::parse();
+  match run(cli.command).await {
+    Ok(()) => ExitCode::SUCCESS,
+    Err(error) => {
+      eprintln!("provision: {error}");
+      ExitCode::FAILURE
+    }
+  }
+}
+
+/// Dispatch a subcommand.
+async fn run(command: Command) -> Result<(), ProvisionError> {
+  match command {
+    Command::Setup(args) => run_setup_cmd(&args),
+    Command::Register(args) => run_register_cmd(&args).await,
+    Command::Bootstrap(args) => run_bootstrap_cmd(&args),
+    Command::Keygen(args) => run_keygen_cmd(&args),
+  }
+}
+
+/// Execute the CLI driver sequence; optionally persist the raw IAT to a file.
+fn run_setup_cmd(args: &SetupArgs) -> Result<(), ProvisionError> {
+  let outcome = run_setup(&cli_driver::SetupInputs {
+    cli: args.cli.clone(),
+    env_file: args.env_file.clone(),
+    email: args.email.clone(),
+    password_file: args.password_file.clone(),
+    post_logout_uri: args.post_logout_uri.clone(),
+  })?;
+  if let Some(path) = &args.iat_out {
+    write_secret_file(path, outcome.initial_access_token.as_bytes())
+      .map_err(|reason| cli_driver::CliDriverError::PasswordFile { reason })?;
+    println!("setup: complete; IAT stored for the DCR step");
+  } else {
+    println!("setup: complete; rerun with `--iat-out <path>` to hand the IAT to `register`");
+  }
+  Ok(())
+}
+
+/// Execute the E2E bootstrap: write `{email, password}` only when absent.
+///
+/// The password is read from `--password-file` (the same file handed to
+/// `setup`); it is never passed via argv and never printed. An existing file
+/// is left untouched: the Playwright spec owns `password`/`totp_secret` after
+/// this point.
+fn run_bootstrap_cmd(args: &BootstrapArgs) -> Result<(), ProvisionError> {
+  let raw = std::fs::read_to_string(&args.password_file).map_err(|err| {
+    cli_driver::CliDriverError::PasswordFile {
+      reason: format!("cannot read `{}`: {err}", args.password_file.display()),
+    }
+  })?;
+  let password = raw.trim();
+  match e2e::write_initial(&args.out, &args.email, password)? {
+    e2e::WriteOutcome::Created => println!(
+      "bootstrap: E2E fixture for `{}` written to `{}`",
+      args.email,
+      args.out.display()
+    ),
+    e2e::WriteOutcome::Kept => println!(
+      "bootstrap: E2E fixture at `{}` already exists; left untouched",
+      args.out.display()
+    ),
+  }
+  Ok(())
+}
+/// Execute DPoP key generation; print path plus public thumbprint only.
+fn run_keygen_cmd(args: &KeygenArgs) -> Result<(), ProvisionError> {
+  let thumbprint = keygen::generate_key(&args.out, args.force)?;
+  println!(
+    "keygen: DPoP key written to `{}` (jkt `{thumbprint}`)",
+    args.out.display()
+  );
+  Ok(())
+}
+/// Execute registration or reconciliation, persisting the client record.
+async fn run_register_cmd(args: &RegisterArgs) -> Result<(), ProvisionError> {
+  if !std::path::Path::new(keygen::DEFAULT_DPOP_KEY_PATH).exists() {
+    eprintln!("provision: DPoP key missing; {}", keygen::KEYGEN_HINT);
+  }
+  let client = oauth_core::discovery::build_client().map_err(|err| DcrError::HttpClient {
+    reason: err.to_string(),
+  })?;
+  let inputs = RegisterInputs {
+    issuer: args.issuer.clone(),
+    client_name: args.client_name.clone(),
+    redirect_uri: args.redirect_uri.clone(),
+    scope: args.scope.clone(),
+  };
+  let attach = PostLogoutAttach {
+    cli: args.cli.clone(),
+    env_file: args.env_file.clone(),
+    uri: args.post_logout_uri.clone(),
+  };
+  let registration = Registration {
+    inputs: &inputs,
+    out: &args.out,
+    attach: &attach,
+  };
+  if args.out.exists() {
+    let (outcome, attached) = registration
+      .reconcile_and_attach(&client, |status| {
+        resolve_iat(args).map_err(|err| match err {
+          DcrError::NoIat => DcrError::FreshIatRequired { status },
+          other => other,
+        })
+      })
+      .await?;
+    match outcome {
+      dcr::ReconcileOutcome::Kept => println!("register: stored client is live; kept"),
+      dcr::ReconcileOutcome::ReRegistered => {
+        println!("register: stale record re-registered");
+        if let Some(outcome) = attached {
+          report_attach(&args.post_logout_uri, outcome);
+        }
+      }
+    }
+    return Ok(());
+  }
+  let iat = resolve_iat(args)?;
+  let (record, attach_outcome) = registration
+    .register_and_attach(&client, &iat)
+    .await
+    .inspect_err(|err| {
+      if matches!(
+        err,
+        DcrError::Rejected {
+          code: Some(code),
+          ..
+        } if code == "invalid_redirect_uri"
+      ) {
+        eprintln!("provision: {REDIRECT_POLICY_REMEDIATION} (auth_method={AUTH_METHOD})");
+      }
+    })?;
+  println!("register: client `{}` persisted", record.client_id);
+  report_attach(&args.post_logout_uri, attach_outcome);
+  Ok(())
+}
+
+/// Print the typed `post_logout` attach result.
+fn report_attach(uri: &str, outcome: PostLogoutOutcome) {
+  match outcome {
+    PostLogoutOutcome::Attached => {
+      println!("register: attached post_logout URI `{uri}`");
+    }
+    PostLogoutOutcome::SkippedNoCli => {
+      println!("register: {POST_LOGOUT_NOT_ATTACHED_NOTE} (`{uri}`)");
+    }
+  }
+}
+
+/// Resolve a single-use IAT from `--iat-file`, or mint a fresh one via the CLI.
+fn resolve_iat(args: &RegisterArgs) -> Result<String, DcrError> {
+  if let Some(path) = &args.iat_file {
+    let raw = std::fs::read_to_string(path).map_err(|err| DcrError::Record {
+      path: path.clone(),
+      reason: err.to_string(),
+    })?;
+    let token = raw.trim().to_owned();
+    if token.is_empty() {
+      return Err(DcrError::Record {
+        path: path.clone(),
+        reason: "IAT file is empty".to_owned(),
+      });
+    }
+    return Ok(token);
+  }
+  match (&args.cli, &args.env_file) {
+    (Some(cli), Some(env_file)) => issue_fresh_iat(cli, env_file).map_err(|err| DcrError::Http {
+      uri: "d-oauth-cli:initial-access-token".to_owned(),
+      reason: err.to_string(),
+    }),
+    _ => Err(DcrError::NoIat),
+  }
+}
+
+/// Write secret bytes with mode `0600` on Unix.
+fn write_secret_file(path: &PathBuf, bytes: &[u8]) -> Result<(), String> {
+  #[cfg(unix)]
+  {
+    use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
+    let mut options = std::fs::OpenOptions::new();
+    options.write(true).create(true).truncate(true).mode(0o600);
+    let mut file = options.open(path).map_err(|err| err.to_string())?;
+    std::io::Write::write_all(&mut file, bytes).map_err(|err| err.to_string())?;
+    let perms = std::fs::Permissions::from_mode(0o600);
+    std::fs::set_permissions(path, perms).map_err(|err| err.to_string())?;
+    Ok(())
+  }
+  #[cfg(not(unix))]
+  {
+    std::fs::write(path, bytes).map_err(|err| err.to_string())
+  }
+}

+ 214 - 0
crates/provision/tests/cli_driver.rs

@@ -0,0 +1,214 @@
+//! CLI-driver tests (todo 29) against the `fake-d-oauth.sh` fixture.
+//!
+//! The fixture is copied into a per-test tempdir and made executable with
+//! `std` permissions (no shell `chmod`); every driver run gets a fresh
+//! `FAKE_STATE_DIR`. The minted `IATTEST`-prefixed tokens are synthetic test
+//! values, never real credentials.
+
+use std::{fs, path::PathBuf};
+
+use provision::cli_driver::{
+  DEMO_POST_LOGOUT_URI, DEMO_REDIRECT_URI, DEMO_SCOPE, Driver, SetupInputs, load_env_file,
+  run_setup,
+};
+
+/// Process-env fixture state is global: serialize the CLI-driver tests so two
+/// tests never point `FAKE_STATE_DIR` at different tempdirs at once.
+static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+/// Install the fixture as an executable in a fresh tempdir; return the
+/// tempdir (kept alive by the caller), the CLI path, and the state dir.
+fn install_fake_cli() -> (tempfile::TempDir, PathBuf, PathBuf) {
+  let dir = tempfile::tempdir().expect("tempdir");
+  let cli = dir.path().join("fake-d-oauth.sh");
+  let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fake-d-oauth.sh");
+  let script = fs::read_to_string(&source).expect("read fixture");
+  fs::write(&cli, script).expect("write fake cli");
+  #[cfg(unix)]
+  {
+    use std::os::unix::fs::PermissionsExt as _;
+    fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).expect("chmod fake cli");
+  }
+  let state = dir.path().join("state");
+  fs::create_dir(&state).expect("state dir");
+  unsafe {
+    std::env::set_var("FAKE_STATE_DIR", &state);
+  }
+  (dir, cli, state)
+}
+
+/// Minimal `.env` file: comments, blanks, and quoted values included.
+fn write_env_file(dir: &tempfile::TempDir) -> PathBuf {
+  let path = dir.path().join("d-oauth.env");
+  fs::write(
+    &path,
+    "# fake operator env\nD_OAUTH_ISSUER=https://localhost:8080\nQUOTED=\"hello world\"\nEMPTY=\n",
+  )
+  .expect("write env file");
+  path
+}
+
+fn write_password_file(dir: &tempfile::TempDir) -> PathBuf {
+  let path = dir.path().join("demo-password.txt");
+  fs::write(&path, "correct-horse-battery-staple").expect("write password file");
+  path
+}
+
+fn run_driver(cli: &std::path::Path, env_file: &std::path::Path, pw: &std::path::Path) -> String {
+  let outcome = run_setup(&SetupInputs {
+    cli: cli.to_owned(),
+    env_file: env_file.to_owned(),
+    email: "demo@example.com".to_owned(),
+    password_file: pw.to_owned(),
+    post_logout_uri: DEMO_POST_LOGOUT_URI.to_owned(),
+  })
+  .expect("setup succeeds");
+  outcome.initial_access_token
+}
+
+#[test]
+fn cli_driver_runs_full_sequence_and_captures_iat() {
+  let _guard = SERIAL.lock().expect("serial guard");
+  let (dir, cli, state) = install_fake_cli();
+  let env_file = write_env_file(&dir);
+  let pw = write_password_file(&dir);
+
+  let iat = run_driver(&cli, &env_file, &pw);
+  assert_eq!(iat, format!("IATTEST{:037}", 1));
+
+  let log = fs::read_to_string(state.join("call.log")).expect("call log");
+  let mut calls = log.lines();
+  assert_eq!(
+    calls.next(),
+    Some(format!("scope create {DEMO_SCOPE}").as_str())
+  );
+  assert_eq!(calls.next(), Some("user scopes --email demo@example.com"));
+  let create = calls.next().expect("user create call");
+  assert!(create.starts_with("user create --email demo@example.com --password-file "));
+  assert_eq!(
+    calls.next(),
+    Some(format!("user grant-scope --email demo@example.com --scope {DEMO_SCOPE}").as_str())
+  );
+  assert_eq!(
+    calls.next(),
+    Some(format!("redirect-uri create {DEMO_REDIRECT_URI}").as_str())
+  );
+  assert_eq!(
+    calls.next(),
+    Some(format!("redirect-uri create {DEMO_POST_LOGOUT_URI} --kind post_logout").as_str())
+  );
+  assert_eq!(
+    calls.next(),
+    Some(format!("redirect-uri enable {DEMO_POST_LOGOUT_URI} --kind post_logout").as_str())
+  );
+  assert_eq!(
+    calls.next(),
+    Some("initial-access-token issue --uses 1 --ttl 600")
+  );
+  assert_eq!(calls.next(), None);
+
+  // The IAT travels on stdout into the DCR step only: it must not appear in
+  // the argv log or in any persisted fixture state.
+  assert!(!log.contains("IATTEST"));
+  let _ = dir;
+}
+
+#[test]
+fn cli_driver_second_run_skips_existing_user() {
+  let _guard = SERIAL.lock().expect("serial guard");
+  let (dir, cli, state) = install_fake_cli();
+  let env_file = write_env_file(&dir);
+  let pw = write_password_file(&dir);
+
+  assert_eq!(
+    run_driver(&cli, &env_file, &pw),
+    format!("IATTEST{:037}", 1)
+  );
+  // Second run: `user create` hits the already-exists path and must not fail.
+  assert_eq!(
+    run_driver(&cli, &env_file, &pw),
+    format!("IATTEST{:037}", 2)
+  );
+
+  let log = fs::read_to_string(state.join("call.log")).expect("call log");
+  assert_eq!(
+    log
+      .lines()
+      .filter(|line| line.starts_with("user create "))
+      .count(),
+    2
+  );
+  assert!(!log.contains("IATTEST"));
+  let _ = dir;
+}
+
+#[test]
+fn cli_driver_surfaces_missing_scope_as_typed_error() {
+  let _guard = SERIAL.lock().expect("serial guard");
+  let (dir, cli, _state) = install_fake_cli();
+  let env_file = write_env_file(&dir);
+  let pw = write_password_file(&dir);
+
+  unsafe {
+    std::env::set_var("FAKE_FAIL_STEP", "grant");
+  }
+  let result = run_setup(&SetupInputs {
+    cli: cli.to_owned(),
+    env_file: env_file.to_owned(),
+    email: "demo@example.com".to_owned(),
+    password_file: pw.to_owned(),
+    post_logout_uri: DEMO_POST_LOGOUT_URI.to_owned(),
+  });
+  unsafe {
+    std::env::remove_var("FAKE_FAIL_STEP");
+  }
+  match result {
+    Err(provision::cli_driver::CliDriverError::Step { step, stderr, .. }) => {
+      assert_eq!(step, "user grant-scope");
+      assert!(
+        stderr.contains("not found: scope demo.read"),
+        "stderr: {stderr}"
+      );
+    }
+    other => panic!("expected a typed grant-scope failure, got {other:?}"),
+  }
+  let _ = dir;
+}
+
+#[test]
+fn cli_driver_parses_env_file_without_touching_process_env() {
+  let _guard = SERIAL.lock().expect("serial guard");
+  let (dir, _cli, _state) = install_fake_cli();
+  let env_file = write_env_file(&dir);
+
+  let env = load_env_file(&env_file).expect("parse env file");
+  assert_eq!(
+    env.get("D_OAUTH_ISSUER").map(String::as_str),
+    Some("https://localhost:8080")
+  );
+  assert_eq!(env.get("QUOTED").map(String::as_str), Some("hello world"));
+  assert_eq!(env.get("EMPTY").map(String::as_str), Some(""));
+  // Parsing must not export into our own process environment.
+  assert!(std::env::var("D_OAUTH_ISSUER").is_err());
+  let _ = dir;
+}
+
+#[test]
+fn cli_driver_driver_struct_is_usable_directly() {
+  let _guard = SERIAL.lock().expect("serial guard");
+  let (dir, cli, state) = install_fake_cli();
+  let env_file = write_env_file(&dir);
+  let pw = write_password_file(&dir);
+  let env = load_env_file(&env_file).expect("parse env file");
+
+  let driver = Driver {
+    cli: cli.clone(),
+    env,
+  };
+  let outcome = driver
+    .run("demo@example.com", &pw, DEMO_POST_LOGOUT_URI)
+    .expect("driver run");
+  assert_eq!(outcome.initial_access_token, format!("IATTEST{:037}", 1));
+  assert!(state.join("scopes.txt").exists());
+  let _ = dir;
+}

+ 471 - 0
crates/provision/tests/dcr.rs

@@ -0,0 +1,471 @@
+//! DCR tests (todo 30) against a `wiremock` fake AS — never a live server.
+//!
+//! All tokens and secrets below are synthetic test values.
+
+use std::{fs, path::PathBuf};
+
+use provision::cli_driver::DEMO_POST_LOGOUT_URI;
+use provision::dcr::{
+  ClientRecord, POST_LOGOUT_NOT_ATTACHED_NOTE, PostLogoutAttach, PostLogoutOutcome,
+  ReconcileOutcome, RegisterInputs, Registration, load, persist, reconcile, register,
+};
+use wiremock::{
+  Mock, MockServer, ResponseTemplate,
+  matchers::{body_json, header, method, path},
+};
+
+const FIRST_IAT: &str = "iat-first-use-only";
+const FRESH_IAT: &str = "iat-fresh-second-use-only";
+
+fn inputs(issuer: &str) -> RegisterInputs {
+  RegisterInputs {
+    issuer: issuer.to_owned(),
+    ..RegisterInputs::default()
+  }
+}
+
+fn expected_body() -> serde_json::Value {
+  serde_json::json!({
+    "client_name": "oauth2-wasm-demo",
+    "token_endpoint_auth_method": "client_secret_basic",
+    "grant_types": ["authorization_code", "refresh_token"],
+    "response_types": ["code"],
+    "redirect_uris": ["http://127.0.0.1:3000/callback"],
+    "scope": "demo.read",
+    "dpop_bound_access_tokens": true,
+  })
+}
+
+fn registration_response(server: &MockServer) -> serde_json::Value {
+  serde_json::json!({
+    "client_id": "client-1",
+    "client_secret": "secret-1",
+    "registration_access_token": "rat-1",
+    "registration_client_uri": format!("{}/register/client-1", server.uri()),
+    "redirect_uris": ["http://127.0.0.1:3000/callback"],
+    "client_name": "oauth2-wasm-demo",
+    "token_endpoint_auth_method": "client_secret_basic",
+    "grant_types": ["authorization_code", "refresh_token"],
+    "response_types": ["code"],
+    "scope": "demo.read",
+    "dpop_bound_access_tokens": true,
+    "client_id_issued_at": 1_700_000_000,
+  })
+}
+
+fn test_client() -> reqwest::Client {
+  oauth_core::discovery::build_client().expect("test http client")
+}
+
+/// Install `fake-d-oauth.sh` executable in a fresh tempdir; return the tempdir,
+/// CLI path, and state dir.
+fn install_fake_cli() -> (tempfile::TempDir, PathBuf, PathBuf) {
+  let dir = tempfile::tempdir().expect("tempdir");
+  let cli = dir.path().join("fake-d-oauth.sh");
+  let source = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fake-d-oauth.sh");
+  let script = fs::read_to_string(&source).expect("read fixture");
+  fs::write(&cli, script).expect("write fake cli");
+  #[cfg(unix)]
+  {
+    use std::os::unix::fs::PermissionsExt as _;
+    fs::set_permissions(&cli, fs::Permissions::from_mode(0o755)).expect("chmod fake cli");
+  }
+  let state = dir.path().join("state");
+  fs::create_dir(&state).expect("state dir");
+  (dir, cli, state)
+}
+
+/// Write a `.env` file pointing the fake CLI at `state`.
+fn write_attach_env(dir: &tempfile::TempDir, state: &std::path::Path) -> PathBuf {
+  let path = dir.path().join("attach.env");
+  fs::write(&path, format!("FAKE_STATE_DIR={}\n", state.display())).expect("write env file");
+  path
+}
+
+/// Attach config wired to the fake CLI.
+fn attach_config(cli: PathBuf, env_file: PathBuf) -> PostLogoutAttach {
+  PostLogoutAttach {
+    cli: Some(cli),
+    env_file: Some(env_file),
+    uri: DEMO_POST_LOGOUT_URI.to_owned(),
+  }
+}
+
+/// The argv line the fake CLI records for the attach step.
+fn attach_line(client_id: &str) -> String {
+  format!(
+    "client attach-redirect-uri --client-id {client_id} --uri {DEMO_POST_LOGOUT_URI} --kind post_logout"
+  )
+}
+
+#[cfg(unix)]
+fn assert_0600(path: &std::path::Path) {
+  use std::os::unix::fs::PermissionsExt as _;
+  let mode = fs::metadata(path).expect("metadata").permissions().mode() & 0o777;
+  assert_eq!(mode, 0o600, "client record must be owner-only");
+}
+
+#[tokio::test]
+async fn dcr_registers_and_persists_0600() {
+  let server = MockServer::start().await;
+  Mock::given(method("POST"))
+    .and(path("/register"))
+    .and(header(
+      "authorization",
+      format!("Bearer {FIRST_IAT}").as_str(),
+    ))
+    .and(body_json(expected_body()))
+    .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
+    .mount(&server)
+    .await;
+
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("client.json");
+
+  let record = register(&test_client(), &inputs(&server.uri()), FIRST_IAT)
+    .await
+    .expect("register");
+  assert_eq!(record.client_id, "client-1");
+  assert_eq!(
+    record.registration_client_uri,
+    format!("{}/register/client-1", server.uri())
+  );
+  persist(&record, &out).expect("persist");
+  #[cfg(unix)]
+  assert_0600(&out);
+
+  let stored = load(&out).expect("reload");
+  assert_eq!(stored, record);
+  // The persisted file carries the minted credentials only — never the IAT.
+  let raw = fs::read_to_string(&out).expect("read record");
+  assert!(!raw.contains(FIRST_IAT));
+  // The redacted Debug never leaks the secret or the management token.
+  let debug = format!("{record:?}");
+  assert!(!debug.contains("secret-1"));
+  assert!(!debug.contains("rat-1"));
+}
+
+#[tokio::test]
+async fn dcr_rerun_with_stale_record_reregisters_with_fresh_iat() {
+  let server = MockServer::start().await;
+  let stale_uri = format!("{}/register/stale-client", server.uri());
+
+  Mock::given(method("GET"))
+    .and(path("/register/stale-client"))
+    .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
+      "error": "invalid_client",
+    })))
+    .mount(&server)
+    .await;
+  Mock::given(method("POST"))
+    .and(path("/register"))
+    .and(header(
+      "authorization",
+      format!("Bearer {FRESH_IAT}").as_str(),
+    ))
+    .and(body_json(expected_body()))
+    .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
+    .mount(&server)
+    .await;
+
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("client.json");
+  persist(
+    &ClientRecord {
+      client_id: "stale-client".to_owned(),
+      client_secret: "stale-secret".to_owned(),
+      registration_access_token: "stale-rat".to_owned(),
+      registration_client_uri: stale_uri,
+    },
+    &out,
+  )
+  .expect("persist stale record");
+
+  let outcome = reconcile(&test_client(), &inputs(&server.uri()), &out, |status| {
+    assert_eq!(status, 401);
+    Ok(FRESH_IAT.to_owned())
+  })
+  .await
+  .expect("reconcile");
+  assert_eq!(outcome, ReconcileOutcome::ReRegistered);
+
+  let stored = load(&out).expect("reload");
+  assert_eq!(stored.client_id, "client-1");
+  assert!(
+    !fs::read_to_string(&out)
+      .expect("read")
+      .contains("stale-secret")
+  );
+  #[cfg(unix)]
+  assert_0600(&out);
+}
+
+#[tokio::test]
+async fn dcr_live_record_is_kept_without_reregistering() {
+  let server = MockServer::start().await;
+  Mock::given(method("GET"))
+    .and(path("/register/client-1"))
+    .and(header("authorization", "Bearer rat-1"))
+    .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+      "client_id": "client-1",
+    })))
+    .mount(&server)
+    .await;
+
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("client.json");
+  let before = ClientRecord {
+    client_id: "client-1".to_owned(),
+    client_secret: "secret-1".to_owned(),
+    registration_access_token: "rat-1".to_owned(),
+    registration_client_uri: format!("{}/register/client-1", server.uri()),
+  };
+  persist(&before, &out).expect("persist");
+
+  let outcome = reconcile(&test_client(), &inputs(&server.uri()), &out, |_| {
+    panic!("a live record must not mint a fresh IAT");
+  })
+  .await
+  .expect("reconcile");
+  assert_eq!(outcome, ReconcileOutcome::Kept);
+  assert_eq!(load(&out).expect("reload"), before);
+}
+
+#[tokio::test]
+async fn dcr_invalid_redirect_uri_prints_remediation() {
+  let server = MockServer::start().await;
+  Mock::given(method("POST"))
+    .and(path("/register"))
+    .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
+      "error": "invalid_redirect_uri",
+      "error_description": "redirect URI is not in the catalog",
+    })))
+    .mount(&server)
+    .await;
+
+  match register(&test_client(), &inputs(&server.uri()), FIRST_IAT).await {
+    Err(provision::dcr::DcrError::Rejected {
+      message,
+      code,
+      status,
+    }) => {
+      assert_eq!(status, 400);
+      assert_eq!(code.as_deref(), Some("invalid_redirect_uri"));
+      assert!(
+        message.contains("dcr_redirect_policy=catalog"),
+        "remediation: {message}"
+      );
+      assert!(
+        message.contains("catalog_loopback"),
+        "remediation: {message}"
+      );
+    }
+    other => panic!("expected an invalid_redirect_uri rejection, got {other:?}"),
+  }
+}
+
+#[tokio::test]
+async fn dcr_stale_record_without_iat_source_fails_typed() {
+  let server = MockServer::start().await;
+  Mock::given(method("GET"))
+    .and(path("/register/stale-client"))
+    .respond_with(ResponseTemplate::new(404))
+    .mount(&server)
+    .await;
+
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("client.json");
+  persist(
+    &ClientRecord {
+      client_id: "stale-client".to_owned(),
+      client_secret: "stale-secret".to_owned(),
+      registration_access_token: "stale-rat".to_owned(),
+      registration_client_uri: format!("{}/register/stale-client", server.uri()),
+    },
+    &out,
+  )
+  .expect("persist stale record");
+
+  match reconcile(&test_client(), &inputs(&server.uri()), &out, |status| {
+    Err(provision::dcr::DcrError::FreshIatRequired { status })
+  })
+  .await
+  {
+    Err(provision::dcr::DcrError::FreshIatRequired { status }) => assert_eq!(status, 404),
+    other => panic!("expected FreshIatRequired, got {other:?}"),
+  }
+}
+
+#[tokio::test]
+async fn dcr_register_then_attaches_post_logout() {
+  let server = MockServer::start().await;
+  Mock::given(method("POST"))
+    .and(path("/register"))
+    .and(header(
+      "authorization",
+      format!("Bearer {FIRST_IAT}").as_str(),
+    ))
+    .and(body_json(expected_body()))
+    .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
+    .mount(&server)
+    .await;
+
+  let (dir, cli, state) = install_fake_cli();
+  let env_file = write_attach_env(&dir, &state);
+  let out = dir.path().join("client.json");
+  let reg_inputs = inputs(&server.uri());
+  let attach = attach_config(cli, env_file);
+  let registration = Registration {
+    inputs: &reg_inputs,
+    out: &out,
+    attach: &attach,
+  };
+
+  let (record, outcome) = registration
+    .register_and_attach(&test_client(), FIRST_IAT)
+    .await
+    .expect("register then attach");
+  assert_eq!(record.client_id, "client-1");
+  assert_eq!(outcome, PostLogoutOutcome::Attached);
+  assert!(out.exists(), "the record must be persisted before attach");
+
+  let log = fs::read_to_string(state.join("call.log")).expect("call log");
+  assert_eq!(log.lines().last(), Some(attach_line("client-1").as_str()));
+  let _ = dir;
+}
+
+#[tokio::test]
+async fn dcr_reconcile_reregister_then_attaches_post_logout() {
+  let server = MockServer::start().await;
+  let stale_uri = format!("{}/register/stale-client", server.uri());
+  Mock::given(method("GET"))
+    .and(path("/register/stale-client"))
+    .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
+      "error": "invalid_client",
+    })))
+    .mount(&server)
+    .await;
+  Mock::given(method("POST"))
+    .and(path("/register"))
+    .and(header(
+      "authorization",
+      format!("Bearer {FRESH_IAT}").as_str(),
+    ))
+    .and(body_json(expected_body()))
+    .respond_with(ResponseTemplate::new(201).set_body_json(registration_response(&server)))
+    .mount(&server)
+    .await;
+
+  let (dir, cli, state) = install_fake_cli();
+  let env_file = write_attach_env(&dir, &state);
+  let out = dir.path().join("client.json");
+  persist(
+    &ClientRecord {
+      client_id: "stale-client".to_owned(),
+      client_secret: "stale-secret".to_owned(),
+      registration_access_token: "stale-rat".to_owned(),
+      registration_client_uri: stale_uri,
+    },
+    &out,
+  )
+  .expect("persist stale record");
+
+  let reg_inputs = inputs(&server.uri());
+  let attach = attach_config(cli, env_file);
+  let registration = Registration {
+    inputs: &reg_inputs,
+    out: &out,
+    attach: &attach,
+  };
+
+  let (outcome, attached) = registration
+    .reconcile_and_attach(&test_client(), |status| {
+      assert_eq!(status, 401);
+      Ok(FRESH_IAT.to_owned())
+    })
+    .await
+    .expect("reconcile then attach");
+  assert_eq!(outcome, ReconcileOutcome::ReRegistered);
+  assert_eq!(attached, Some(PostLogoutOutcome::Attached));
+  assert_eq!(load(&out).expect("reload").client_id, "client-1");
+
+  let log = fs::read_to_string(state.join("call.log")).expect("call log");
+  assert!(
+    log.lines().any(|line| line == attach_line("client-1")),
+    "attach call missing from log: {log}"
+  );
+  let _ = dir;
+}
+
+#[tokio::test]
+async fn dcr_live_record_is_kept_without_attaching() {
+  let server = MockServer::start().await;
+  Mock::given(method("GET"))
+    .and(path("/register/client-1"))
+    .and(header("authorization", "Bearer rat-1"))
+    .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
+      "client_id": "client-1",
+    })))
+    .mount(&server)
+    .await;
+
+  let (dir, cli, state) = install_fake_cli();
+  let env_file = write_attach_env(&dir, &state);
+  let out = dir.path().join("client.json");
+  persist(
+    &ClientRecord {
+      client_id: "client-1".to_owned(),
+      client_secret: "secret-1".to_owned(),
+      registration_access_token: "rat-1".to_owned(),
+      registration_client_uri: format!("{}/register/client-1", server.uri()),
+    },
+    &out,
+  )
+  .expect("persist live record");
+
+  let reg_inputs = inputs(&server.uri());
+  let attach = attach_config(cli, env_file);
+  let registration = Registration {
+    inputs: &reg_inputs,
+    out: &out,
+    attach: &attach,
+  };
+
+  let (outcome, attached) = registration
+    .reconcile_and_attach(&test_client(), |_| {
+      panic!("a live record must not mint a fresh IAT")
+    })
+    .await
+    .expect("reconcile");
+  assert_eq!(outcome, ReconcileOutcome::Kept);
+  assert_eq!(attached, None);
+  assert!(
+    !state.join("call.log").exists(),
+    "a live record must not trigger the attach step"
+  );
+  let _ = dir;
+}
+
+#[test]
+fn dcr_attach_skipped_without_cli_is_typed() {
+  let skip = PostLogoutAttach {
+    cli: None,
+    env_file: None,
+    uri: DEMO_POST_LOGOUT_URI.to_owned(),
+  };
+  assert_eq!(
+    skip.apply("client-1").expect("skip is not an error"),
+    PostLogoutOutcome::SkippedNoCli
+  );
+  // Only one of the two CLI paths is not enough to attach.
+  let partial = PostLogoutAttach {
+    cli: Some(PathBuf::from("/bin/false")),
+    env_file: None,
+    uri: DEMO_POST_LOGOUT_URI.to_owned(),
+  };
+  assert_eq!(
+    partial.apply("client-1").expect("skip is not an error"),
+    PostLogoutOutcome::SkippedNoCli
+  );
+  assert!(!POST_LOGOUT_NOT_ATTACHED_NOTE.is_empty());
+}

+ 145 - 0
crates/provision/tests/e2e.rs

@@ -0,0 +1,145 @@
+//! E2E bootstrap fixture tests (todo 31).
+//!
+//! All passwords and TOTP secrets below are synthetic test values in temp
+//! dirs — never the real `config/` tree, never real credentials.
+
+use std::fs;
+
+use provision::e2e::{
+  CHANGE_CONFIRM_FIELD, CHANGE_NEW_PASSWORD_FIELD, CONSENT_APPROVE_VALUE, CONSENT_DECISION_FIELD,
+  CONSENT_REQUEST_ID_FIELD, CSRF_FIELD, E2eError, LOGIN_CODE_FIELD, LOGIN_EMAIL_FIELD,
+  LOGIN_PASSWORD_FIELD, MISSING_FIXTURE_HINT, WriteOutcome, load, write_initial,
+};
+
+#[cfg(unix)]
+fn assert_0600(path: &std::path::Path) {
+  use std::os::unix::fs::PermissionsExt as _;
+  let mode = fs::metadata(path)
+    .expect("fixture metadata")
+    .permissions()
+    .mode()
+    & 0o777;
+  assert_eq!(mode, 0o600, "fixture at `{}` is not 0600", path.display());
+}
+
+#[test]
+fn writes_email_and_password_0600_only_when_absent() {
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("e2e.json");
+
+  let outcome = write_initial(&out, "demo@example.com", "test-password-01").expect("first write");
+  assert_eq!(outcome, WriteOutcome::Created);
+
+  let raw = fs::read_to_string(&out).expect("fixture readable");
+  let value: serde_json::Value = serde_json::from_str(&raw).expect("fixture is JSON");
+  assert_eq!(
+    value.get("email").and_then(|email| email.as_str()),
+    Some("demo@example.com")
+  );
+  assert_eq!(
+    value.get("password").and_then(|password| password.as_str()),
+    Some("test-password-01")
+  );
+  assert!(
+    value.get("totp_secret").is_none(),
+    "provision must not write the spec-owned totp_secret field"
+  );
+  #[cfg(unix)]
+  assert_0600(&out);
+}
+
+#[test]
+fn leaves_an_existing_file_byte_for_byte_untouched() {
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("e2e.json");
+  // A spec-owned file: rotated password plus a captured TOTP secret.
+  let spec_owned =
+    r#"{"email":"demo@example.com","password":"rotated-by-spec","totp_secret":"JBSWY3DPEHPK3PXP"}"#;
+  fs::write(&out, spec_owned).expect("pre-existing fixture");
+
+  let outcome = write_initial(&out, "demo@example.com", "initial-password").expect("second write");
+  assert_eq!(outcome, WriteOutcome::Kept);
+
+  let raw = fs::read_to_string(&out).expect("fixture readable");
+  assert_eq!(raw, spec_owned, "existing fixture must be untouched");
+}
+
+#[test]
+fn written_fixture_round_trips_through_load() {
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("nested/e2e.json");
+
+  write_initial(&out, "demo@example.com", "test-password-01").expect("write");
+  let fixture = load(&out).expect("load");
+  assert_eq!(fixture.email, "demo@example.com");
+  assert_eq!(fixture.password, "test-password-01");
+  assert_eq!(fixture.totp_secret, None);
+}
+
+#[test]
+fn missing_fixture_load_fails_with_run_provision_first_hint() {
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("e2e.json");
+
+  let result = load(&out);
+  assert!(
+    matches!(result, Err(E2eError::Missing { .. })),
+    "expected Missing, got {result:?}"
+  );
+  let message = result.expect_err("missing fixture").to_string();
+  assert!(
+    message.contains("provision"),
+    "hint must name provision: {message}"
+  );
+  assert!(
+    message.contains(MISSING_FIXTURE_HINT),
+    "hint must match the documented contract: {message}"
+  );
+}
+
+#[test]
+fn malformed_fixture_is_a_typed_parse_error() {
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("e2e.json");
+  fs::write(&out, "{not json").expect("malformed fixture");
+
+  let result = load(&out);
+  assert!(matches!(result, Err(E2eError::Parse { .. })));
+}
+
+#[test]
+fn debug_redacts_password_and_totp_secret() {
+  let dir = tempfile::tempdir().expect("tempdir");
+  let out = dir.path().join("e2e.json");
+  write_initial(&out, "demo@example.com", "S3CRET-PW").expect("write");
+
+  let mut fixture = load(&out).expect("load");
+  fixture.totp_secret = Some("S3CRET-TOTP".to_owned());
+  let rendered = format!("{fixture:?}");
+  assert!(
+    !rendered.contains("S3CRET-PW"),
+    "password leaked: {rendered}"
+  );
+  assert!(
+    !rendered.contains("S3CRET-TOTP"),
+    "totp secret leaked: {rendered}"
+  );
+  assert!(rendered.contains("demo@example.com"));
+}
+
+#[test]
+fn documented_selectors_match_the_d_oauth_templates() {
+  // Login form (`src/ui/login.rs` `LoginTemplate`).
+  assert_eq!(LOGIN_EMAIL_FIELD, "email");
+  assert_eq!(LOGIN_PASSWORD_FIELD, "password");
+  assert_eq!(LOGIN_CODE_FIELD, "code");
+  // Hidden form token on every d-OAuth form.
+  assert_eq!(CSRF_FIELD, "csrf");
+  // Password-change form (`PasswordTemplate`).
+  assert_eq!(CHANGE_NEW_PASSWORD_FIELD, "new_password");
+  assert_eq!(CHANGE_CONFIRM_FIELD, "confirm");
+  // Consent form (`src/ui/consent.rs` `ConsentTemplate`/`ConsentForm`).
+  assert_eq!(CONSENT_REQUEST_ID_FIELD, "request_id");
+  assert_eq!(CONSENT_DECISION_FIELD, "decision");
+  assert_eq!(CONSENT_APPROVE_VALUE, "approve");
+}

+ 123 - 0
crates/provision/tests/fake-d-oauth.sh

@@ -0,0 +1,123 @@
+#!/bin/sh
+# Fake `d-oauth-server` for the provision CLI-driver tests.
+#
+# State lives under $FAKE_STATE_DIR (a per-test tempdir). Every invocation is
+# appended to $FAKE_STATE_DIR/call.log as one argv line. Secrets are never
+# echoed: the only stdout token is the minted synthetic token, and tests assert
+# it never appears in any log.
+#
+# The `issue` step mirrors the real CLI wire shape: a human label plus the
+# token on ONE stdout line (`"<message>: <token>"`), never the bare token.
+# The token is a deterministic 44-char base64url-alphabet value
+# (`IATTEST` + zero-padded counter), never a real credential.
+#
+# Supported surface (argv-exact, no shell interpolation by the caller):
+#   scope create <name>
+#   user scopes --email <email>
+#   user create --email <email> --password-file <path>
+#   user grant-scope --email <email> --scope <scope>
+#   redirect-uri create <uri> [--kind <kind>]
+#   redirect-uri enable <uri> [--kind <kind>]
+#   client attach-redirect-uri --client-id <id> --uri <uri> --kind <kind>
+#   initial-access-token issue --uses <n> --ttl <secs>
+#
+# Failure hooks (env): FAKE_FAIL_STEP=grant forces `grant-scope` to fail as if
+# the scope were missing from the catalog.
+
+set -u
+
+S="${FAKE_STATE_DIR:?FAKE_STATE_DIR must be set}"
+echo "$*" >> "$S/call.log"
+
+fail() {
+  echo "$1" >&2
+  exit 1
+}
+
+case "${1:-}" in
+  scope)
+    # scope create <name>
+    [ "${2:-}" = "create" ] || fail "usage error: scope $*"
+    echo "$3" >> "$S/scopes.txt"
+    echo "created scope $3"
+    ;;
+  user)
+    case "${2:-}" in
+      scopes)
+        # user scopes --email <email>
+        email="${4:-}"
+        if grep -qx "$email" "$S/users.txt" 2>/dev/null; then
+          echo "demo.read"
+        else
+          fail "not found: user $email"
+        fi
+        ;;
+      create)
+        # user create --email <email> --password-file <path>
+        email="${4:-}"
+        if grep -qx "$email" "$S/users.txt" 2>/dev/null; then
+          fail "already exists: user $email"
+        fi
+        echo "$email" >> "$S/users.txt"
+        echo "created user $email"
+        ;;
+      grant-scope)
+        # user grant-scope --email <email> --scope <scope>
+        email="${4:-}"
+        scope="${6:-}"
+        if [ "${FAKE_FAIL_STEP:-}" = "grant" ]; then
+          fail "not found: scope $scope"
+        fi
+        if ! grep -qx "$scope" "$S/scopes.txt" 2>/dev/null; then
+          fail "not found: scope $scope"
+        fi
+        echo "$email:$scope" >> "$S/grants.txt"
+        echo "granted scope $scope to $email"
+        ;;
+      *)
+        fail "usage error: user $*"
+        ;;
+    esac
+    ;;
+  redirect-uri)
+    kind="authorize"
+    [ "${4:-}" = "--kind" ] && kind="${5:-authorize}"
+    case "${2:-}" in
+      create)
+        # redirect-uri create <uri> [--kind <kind>]
+        echo "$3:$kind" >> "$S/redirects.txt"
+        echo "created redirect $3 ($kind)"
+        ;;
+      enable)
+        # redirect-uri enable <uri> [--kind <kind>]
+        echo "$3:$kind" >> "$S/redirects-enabled.txt"
+        echo "enabled redirect $3 ($kind)"
+        ;;
+      *)
+        fail "usage error: redirect-uri $*"
+        ;;
+    esac
+    ;;
+  client)
+    case "${2:-}" in
+      attach-redirect-uri)
+        # client attach-redirect-uri --client-id <id> --uri <uri> --kind <kind>
+        echo "${4:-}:${6:-}:${8:-authorize}" >> "$S/attachments.txt"
+        echo "attached ${6:-} (${8:-authorize}) to ${4:-}"
+        ;;
+      *)
+        fail "usage error: client $*"
+        ;;
+    esac
+    ;;
+  initial-access-token)
+    n="$(cat "$S/iat-counter.txt" 2>/dev/null || echo 0)"
+    n=$((n + 1))
+    echo "$n" > "$S/iat-counter.txt"
+    token="$(printf 'IATTEST%037d' "$n")"
+    echo "issued initial access token (1 use): $token"
+    ;;
+  *)
+    fail "usage error: $*"
+    ;;
+esac

+ 75 - 0
crates/provision/tests/keygen.rs

@@ -0,0 +1,75 @@
+//! `provision keygen` contract: fresh P-256 key, `0600` file, stable
+//! thumbprint, and no silent overwrite of an in-use key.
+
+use std::path::PathBuf;
+
+use provision::keygen::{DEFAULT_DPOP_KEY_PATH, generate_key};
+
+fn unique_dir(tag: &str) -> PathBuf {
+  let dir = std::env::temp_dir().join(format!("provision-keygen-{}-{tag}", std::process::id()));
+  std::fs::create_dir_all(&dir).expect("test tempdir must be creatable");
+  dir
+}
+
+#[test]
+fn keygen_writes_0600_reloads_with_stable_thumbprint() {
+  let dir = unique_dir("roundtrip");
+  let path = dir.join("dpop-key.json");
+
+  let first_jkt = generate_key(&path, false).expect("first keygen must succeed");
+  assert!(path.exists(), "key file must exist after keygen");
+
+  let loaded =
+    oauth_core::dpop::key::DpopKey::load_from_file(&path).expect("generated file must reload");
+  assert_eq!(loaded.thumbprint().expect("thumbprint"), first_jkt);
+
+  let raw = std::fs::read_to_string(&path).expect("key file must be readable");
+  let value: serde_json::Value = serde_json::from_str(&raw).expect("key file must be JSON");
+  assert_eq!(value.get("kty").and_then(|v| v.as_str()), Some("EC"));
+  assert!(value.get("d").is_some(), "persisted JWK carries the scalar");
+
+  #[cfg(unix)]
+  {
+    use std::os::unix::fs::PermissionsExt;
+    let mode = std::fs::metadata(&path)
+      .expect("metadata")
+      .permissions()
+      .mode()
+      & 0o777;
+    assert_eq!(mode, 0o600, "DPoP key file must be owner-only");
+  }
+
+  std::fs::remove_file(&path).expect("cleanup");
+  std::fs::remove_dir(&dir).expect("cleanup");
+}
+
+#[test]
+fn keygen_refuses_overwrite_without_force_and_rotates_with_force() {
+  let dir = unique_dir("overwrite");
+  let path = dir.join("dpop-key.json");
+
+  let first_jkt = generate_key(&path, false).expect("first keygen must succeed");
+  let before = std::fs::read(&path).expect("key file must be readable");
+
+  let err = generate_key(&path, false).expect_err("second keygen must fail");
+  assert!(
+    matches!(err, provision::keygen::KeygenError::Exists { .. }),
+    "rerun without --force must be a typed Exists error"
+  );
+  let kept = std::fs::read(&path).expect("key file must still be readable");
+  assert_eq!(kept, before, "refused rerun must leave the key untouched");
+
+  let second_jkt = generate_key(&path, true).expect("--force must rotate");
+  assert_ne!(first_jkt, second_jkt, "forced rerun must mint a fresh key");
+  let rotated =
+    oauth_core::dpop::key::DpopKey::load_from_file(&path).expect("rotated file must reload");
+  assert_eq!(rotated.thumbprint().expect("thumbprint"), second_jkt);
+
+  std::fs::remove_file(&path).expect("cleanup");
+  std::fs::remove_dir(&dir).expect("cleanup");
+}
+
+#[test]
+fn default_dpop_key_path_matches_server_convention() {
+  assert_eq!(DEFAULT_DPOP_KEY_PATH, "config/dpop-key.json");
+}

+ 44 - 0
crates/server/Cargo.toml

@@ -0,0 +1,44 @@
+[package]
+name = "server"
+version = "0.1.0"
+edition = "2024"
+
+[lib]
+name = "server"
+path = "src/lib.rs"
+
+[[bin]]
+name = "server"
+path = "src/main.rs"
+
+[dependencies]
+axum = "0.8"
+base64 = "0.22"
+oauth-core = { path = "../oauth-core" }
+rand = "0.9"
+reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
+secrecy = { version = "0.10.3", features = ["serde"] }
+serde = { version = "1.0", features = ["derive"] }
+serde_json = "1.0"
+subtle = "2.6"
+thiserror = "2.0"
+tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "signal", "net"] }
+tower = { version = "0.5", features = ["util"] }
+tower-http = { version = "0.7", features = ["fs", "trace", "set-header"] }
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+url = "2.5"
+
+[dev-dependencies]
+rcgen = "0.14"
+# `ring`-only provider: disabling the default features keeps `aws-lc-rs` (and
+# its `aws-lc-sys` C build) out of the dependency graph, matching the static
+# musl runtime build and the reqwest `rustls-tls` feature set.
+rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
+tempfile = "3.23"
+tokio = { version = "1.53", features = ["rt-multi-thread", "macros", "signal", "net"] }
+tokio-rustls = { version = "0.26", default-features = false, features = ["ring", "tls12", "logging"] }
+wiremock = "0.6"
+
+[lints.rust]
+unsafe_code = "forbid"

+ 148 - 0
crates/server/src/api.rs

@@ -0,0 +1,148 @@
+//! Public JSON API under `/api`.
+//!
+//! [`router`] mounts the unauthenticated display endpoints with API security
+//! headers. Todos 17-18 extend this router with the session/refresh/logout
+//! routes (session lookup, CSRF guard); todo 21 drives the whole suite.
+
+use axum::Router;
+use axum::extract::State;
+use axum::http::StatusCode;
+use axum::http::header::{self, HeaderName, HeaderValue};
+use axum::middleware;
+use axum::response::{IntoResponse, Response};
+use axum::routing::{get, post};
+use oauth_core::Capabilities;
+use serde::Serialize;
+
+use crate::session::{SessionStore, csrf_guard};
+use crate::state::AppState;
+
+/// API Content-Security-Policy: JSON responses are never executed, framed, or
+/// used as a base for relative resolution.
+pub const API_CSP: &str = "default-src 'none'; frame-ancestors 'none'; base-uri 'none'";
+
+/// Name of the CSP response header.
+pub const CSP_HEADER: HeaderName = HeaderName::from_static("content-security-policy");
+
+/// Build the `/api` sub-router with API security headers applied to every
+/// response.
+///
+/// `GET /api/session` serves the introspection-backed session view;
+/// `POST /api/refresh` and `POST /api/logout` additionally pass
+/// [`csrf_guard`]. The store is threaded through so the guard can validate
+/// the session cookie and the per-session CSRF token.
+pub fn router(sessions: SessionStore) -> Router<AppState> {
+  let guarded = Router::new()
+    .route("/refresh", post(crate::oauth::refresh))
+    .route("/logout", post(crate::oauth::logout))
+    .layer(middleware::from_fn_with_state(sessions, csrf_guard));
+  Router::new()
+    .route("/config", get(display_config))
+    .route("/session", get(crate::oauth::session_api))
+    .merge(guarded)
+    .layer(middleware::map_response(set_api_headers))
+}
+
+/// Insert the API security headers (`X-Content-Type-Options`,
+/// `Referrer-Policy`, strict CSP with `frame-ancestors 'none'`).
+pub async fn set_api_headers(mut response: Response) -> Response {
+  let headers = response.headers_mut();
+  headers.insert(
+    header::X_CONTENT_TYPE_OPTIONS,
+    HeaderValue::from_static("nosniff"),
+  );
+  headers.insert(
+    header::REFERRER_POLICY,
+    HeaderValue::from_static("same-origin"),
+  );
+  headers.insert(CSP_HEADER, HeaderValue::from_static(API_CSP));
+  response
+}
+
+/// Non-secret display configuration for the WASM UI boot.
+///
+/// Shape: `{issuer, client_id, scope, capabilities:{par,dpop,introspection,
+/// userinfo,dcr,refresh}}`. The client secret and DPoP key are never
+/// included; the capabilities come from the resolved profile (todo 15
+/// refreshes them from discovery).
+#[derive(Debug, Serialize)]
+pub struct DisplayConfig {
+  /// The authorization server issuer identifier.
+  pub issuer: String,
+  /// The public client identifier.
+  pub client_id: String,
+  /// The space-delimited scope string requested by the demo.
+  pub scope: String,
+  /// Capability flags the UI degrades on.
+  pub capabilities: Capabilities,
+  /// Non-secret sign-in target for the landing CTA. Relative `/oauth/start`
+  /// in prod/demo mode; an absolute BFF origin (e.g. `APP_START_URL`) in
+  /// Trunk dev mode so the CTA leaves the Trunk proxy.
+  pub start_url: String,
+  /// Non-secret provider (AS) RP-initiated logout endpoint, derived from the
+  /// configured issuer as `<issuer>/logout`. Empty when no issuer is
+  /// configured. The browser navigates here after the local session is
+  /// cleared so the authorization-server session ends too.
+  pub provider_logout_url: String,
+}
+
+/// `GET /api/config` -> `200` with the public display config. No auth.
+///
+/// Capabilities come from cached discovery for the configured issuer (the
+/// same cache `/oauth/start` uses), falling back to the static profile caps
+/// when discovery is unavailable so the landing never fails on this.
+pub async fn display_config(State(state): State<AppState>) -> impl IntoResponse {
+  let profile = state.config().profile().clone();
+  let start_url = state.config().start_url().to_owned();
+  let provider_logout_url = {
+    let issuer = profile.issuer.trim();
+    if issuer.is_empty() {
+      String::new()
+    } else {
+      format!("{}/logout", issuer.trim_end_matches('/'))
+    }
+  };
+  let capabilities =
+    crate::oauth::discovery_capabilities(&state, &profile.issuer, profile.capabilities).await;
+  let body = DisplayConfig {
+    issuer: profile.issuer,
+    client_id: profile.client.client_id,
+    scope: profile.scope,
+    capabilities,
+    start_url,
+    provider_logout_url,
+  };
+  (StatusCode::OK, axum::Json(body))
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn display_config_serializes_without_secrets() {
+    let body = DisplayConfig {
+      issuer: "https://as.example".to_owned(),
+      client_id: "demo-client".to_owned(),
+      scope: "demo.read".to_owned(),
+      capabilities: Capabilities {
+        par: true,
+        dpop: true,
+        introspection: true,
+        userinfo: false,
+        dcr: true,
+        refresh: true,
+      },
+      start_url: "/oauth/start".to_owned(),
+      provider_logout_url: "https://as.example/logout".to_owned(),
+    };
+    let raw = serde_json::to_string(&body).expect("DisplayConfig serializes");
+    assert!(raw.contains("https://as.example"));
+    assert!(raw.contains("https://as.example/logout"));
+    assert!(raw.contains("demo-client"));
+    assert!(raw.contains("capabilities"));
+    for forbidden in ["client_secret", "secret", "dpop_key", "private"] {
+      assert!(!raw.contains(forbidden), "leak of {forbidden} in {raw}");
+    }
+  }
+}

+ 33 - 0
crates/server/src/app.rs

@@ -0,0 +1,33 @@
+//! Router assembly and middleware wiring.
+//!
+//! [`build_app`] is the single entry point used by both `main.rs` and the
+//! integration tests. It mounts the health probe and the public `/api`
+//! display endpoints, then defers every unmatched path to the static SPA
+//! service, and wraps the whole stack in the HTTP tracing layer.
+//!
+//! Extension points for todos 15-18: extend `api::router()` with the OAuth
+//! and session routes (they receive [`AppState`] via `State<AppState>` once
+//! `with_state` runs). Stateful routes MUST be added before the
+//! `fallback_service` call.
+
+use axum::Router;
+use axum::routing::get;
+use tower_http::trace::TraceLayer;
+
+use crate::state::AppState;
+use crate::{api, health, static_files};
+
+/// Build the application router.
+///
+/// Returns a state-resolved `Router` ready for `axum::serve` or
+/// `tower::ServiceExt::oneshot`.
+pub fn build_app(state: AppState) -> Router {
+  let router: Router<AppState> = Router::new()
+    .route("/healthz", get(health::healthz))
+    .route("/oauth/start", get(crate::oauth::start))
+    .route("/callback", get(crate::oauth::callback))
+    .nest("/api", api::router(state.sessions().clone()))
+    .fallback_service(static_files::service(state.config().dist_dir()));
+
+  router.layer(TraceLayer::new_for_http()).with_state(state)
+}

+ 827 - 0
crates/server/src/config.rs

@@ -0,0 +1,827 @@
+//! Layered server configuration for the BFF.
+//!
+//! Precedence, highest wins:
+//!
+//! 1. Process environment (`APP_*`, with `OAUTH_*`/`SERVER_*` aliases).
+//! 2. `config/client.json` (client credentials; mode `0600`).
+//! 3. `config/config.json` (or the path from `--config` / `APP_CONFIG`).
+//! 4. Built-in dev defaults (`127.0.0.1:3001`, `crates/web/dist`).
+//!
+//! A missing default config file is not an error (dev defaults apply); a
+//! missing *explicit* `--config`/`APP_CONFIG` path is a typed [`ConfigError`].
+//! [`DevSettings::insecure_tls`] always defaults to `false` and is only ever
+//! enabled through an explicit file or environment value. Secrets are held in
+//! [`SecretString`] and never appear in [`fmt::Debug`] output.
+
+use std::fmt;
+use std::net::{IpAddr, Ipv4Addr, SocketAddr};
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use oauth_core::{Capabilities, ClientCredentials, DevSettings, OAuthProfile};
+use secrecy::SecretString;
+use serde::Deserialize;
+use thiserror::Error;
+
+/// Default bind address for development. Trunk owns port 3000; the BFF uses
+/// 3001 so the dev-mode proxy can forward to it.
+pub const DEFAULT_BIND_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3001);
+
+/// Default SPA build output, relative to the workspace root.
+pub const DEFAULT_DIST_DIR: &str = "crates/web/dist";
+
+/// Default config file, relative to the workspace root.
+pub const DEFAULT_CONFIG_PATH: &str = "config/config.json";
+
+/// Default client-credentials file, relative to the workspace root.
+pub const DEFAULT_CLIENT_PATH: &str = "config/client.json";
+
+/// Default DPoP private-key file, relative to the workspace root.
+pub const DEFAULT_DPOP_KEY_PATH: &str = "config/dpop-key.json";
+
+/// Default server-side session time-to-live.
+pub const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(3600);
+
+/// Environment override for the bind address (e.g. `0.0.0.0:3001`).
+pub const BIND_ADDR_ENV: &str = "SERVER_BIND_ADDR";
+/// `APP_*` alias for the bind address.
+pub const APP_BIND_ADDR_ENV: &str = "APP_BIND_ADDR";
+
+/// Environment override for the SPA build directory.
+pub const DIST_DIR_ENV: &str = "SERVER_DIST_DIR";
+/// `APP_*` alias for the SPA build directory.
+pub const APP_DIST_DIR_ENV: &str = "APP_DIST_DIR";
+
+/// Environment override for the config file path (`--config` wins over this).
+pub const CONFIG_PATH_ENV: &str = "APP_CONFIG";
+/// Fallback environment override for the config file path.
+pub const CONFIG_PATH_ALT_ENV: &str = "SERVER_CONFIG";
+
+/// Environment override for the client-credentials file path.
+pub const CLIENT_PATH_ENV: &str = "APP_CLIENT_FILE";
+
+/// `APP_*` aliases for the OAuth profile fields (the `OAUTH_*` keys are the
+/// [`OAuthProfile`] canonical names and are honored as fallbacks).
+pub const APP_ISSUER_ENV: &str = "APP_ISSUER";
+pub const APP_REDIRECT_URI_ENV: &str = "APP_REDIRECT_URI";
+pub const APP_SCOPE_ENV: &str = "APP_SCOPE";
+pub const APP_CLIENT_ID_ENV: &str = "APP_CLIENT_ID";
+pub const APP_CLIENT_SECRET_ENV: &str = "APP_CLIENT_SECRET";
+pub const APP_INSECURE_TLS_ENV: &str = "APP_INSECURE_TLS";
+
+/// Environment override for the `sid` cookie `Secure` attribute.
+pub const COOKIE_SECURE_ENV: &str = "APP_COOKIE_SECURE";
+/// Environment override for a custom CA bundle (private CA, PEM).
+pub const CA_CERT_ENV: &str = "APP_CA_CERT";
+/// Environment override for the DPoP private-key file.
+pub const DPOP_KEY_ENV: &str = "APP_DPOP_KEY";
+/// Environment override for the server-side session TTL in seconds.
+pub const SESSION_TTL_ENV: &str = "APP_SESSION_TTL_SECS";
+/// Environment override for the sign-in CTA target (non-secret BFF origin).
+/// e.g. `APP_START_URL=http://127.0.0.1:3001/oauth/start` in Trunk dev mode.
+pub const START_URL_ENV: &str = "APP_START_URL";
+/// Default sign-in target: relative path (prod/demo mode, same origin).
+pub const DEFAULT_START_URL: &str = "/oauth/start";
+
+/// Resolved server configuration.
+pub struct ServerConfig {
+  bind_addr: SocketAddr,
+  dist_dir: PathBuf,
+  cookie_secure: bool,
+  ca_cert_path: Option<PathBuf>,
+  dpop_key_path: Option<PathBuf>,
+  session_ttl: Duration,
+  profile: OAuthProfile,
+  start_url: String,
+}
+
+impl ServerConfig {
+  /// Build a configuration from explicit values (used by tests and by callers
+  /// that already resolved every layer).
+  pub fn new(bind_addr: SocketAddr, dist_dir: impl Into<PathBuf>) -> Self {
+    Self {
+      bind_addr,
+      dist_dir: dist_dir.into(),
+      cookie_secure: false,
+      ca_cert_path: None,
+      dpop_key_path: Some(PathBuf::from(DEFAULT_DPOP_KEY_PATH)),
+      session_ttl: DEFAULT_SESSION_TTL,
+      profile: OAuthProfile::default(),
+      start_url: DEFAULT_START_URL.to_owned(),
+    }
+  }
+
+  /// Load configuration from the default file locations with environment
+  /// overlays applied.
+  ///
+  /// # Errors
+  ///
+  /// Returns a typed [`ConfigError`] for unreadable explicit paths, malformed
+  /// JSON, or invalid values. Never panics and never logs secrets.
+  pub fn load() -> Result<Self, ConfigError> {
+    let config_path = config_path_from_env();
+    Self::load_from_paths(config_path.as_deref(), None)
+  }
+
+  /// Load configuration from an explicit config file path (the `--config`
+  /// entry point), deriving the client-credentials path as the sibling
+  /// `client.json` unless `APP_CLIENT_FILE` overrides it.
+  ///
+  /// # Errors
+  ///
+  /// Same as [`ServerConfig::load`]; a missing explicit file is an error.
+  pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
+    Self::load_from_paths(Some(path), None)
+  }
+
+  /// Load configuration from explicit file paths with the real process
+  /// environment overlaid. `None` selects the defaults
+  /// ([`DEFAULT_CONFIG_PATH`], [`DEFAULT_CLIENT_PATH`]).
+  ///
+  /// # Errors
+  ///
+  /// Same as [`ServerConfig::load`].
+  pub fn load_from_paths(
+    config_path: Option<&Path>,
+    client_path: Option<&Path>,
+  ) -> Result<Self, ConfigError> {
+    Self::load_from_paths_with(config_path, client_path, &env_lookup)
+  }
+
+  /// Load configuration from explicit file paths with an injectable
+  /// environment lookup (key -> value). This is the side-effect-free form
+  /// used by tests to avoid mutating the process environment.
+  ///
+  /// # Errors
+  ///
+  /// Same as [`ServerConfig::load`].
+  pub fn load_from_paths_with(
+    config_path: Option<&Path>,
+    client_path: Option<&Path>,
+    lookup: &dyn Fn(&str) -> Option<String>,
+  ) -> Result<Self, ConfigError> {
+    let resolved_config = resolve_config_path(config_path, lookup);
+    let file = match &resolved_config {
+      ResolvedPath::Explicit(path) => Some(read_json::<FileConfig>(path)?),
+      ResolvedPath::Default(path) => match read_json::<FileConfig>(path) {
+        Ok(file) => Some(file),
+        Err(ConfigError::Io { .. }) => None,
+        Err(other) => return Err(other),
+      },
+    };
+    let file = file.unwrap_or_default();
+
+    let resolved_client = resolve_client_path(client_path, resolved_config.as_path(), lookup);
+    let client_file = match &resolved_client {
+      Some(path) => match read_json::<ClientFile>(path) {
+        Ok(client) => Some(client),
+        Err(ConfigError::Io { .. }) => None,
+        Err(other) => return Err(other),
+      },
+      None => None,
+    };
+
+    let mut merged = PartialConfig::default();
+    merged.apply_file(file);
+    if let Some(client) = client_file {
+      merged.apply_client_file(client);
+    }
+    merged.apply_env(lookup)?;
+    merged.build()
+  }
+
+  /// Load configuration from the environment only, falling back to the dev
+  /// defaults ([`DEFAULT_BIND_ADDR`], [`DEFAULT_DIST_DIR`]).
+  ///
+  /// Kept for source compatibility with the Wave 1 skeleton; prefer
+  /// [`ServerConfig::load`] so file-based values are honored.
+  ///
+  /// # Errors
+  ///
+  /// Returns [`ConfigError::BindAddr`] or [`ConfigError::Invalid`] when an
+  /// environment value cannot be parsed.
+  pub fn from_env() -> Result<Self, ConfigError> {
+    let mut merged = PartialConfig::default();
+    merged.apply_env(&env_lookup)?;
+    merged.build()
+  }
+
+  /// Address the HTTP listener binds.
+  #[must_use]
+  pub fn bind_addr(&self) -> SocketAddr {
+    self.bind_addr
+  }
+
+  /// Directory containing the built SPA assets.
+  #[must_use]
+  pub fn dist_dir(&self) -> &Path {
+    &self.dist_dir
+  }
+
+  /// Whether the `sid` session cookie carries the `Secure` attribute.
+  #[must_use]
+  pub fn cookie_secure(&self) -> bool {
+    self.cookie_secure
+  }
+
+  /// Optional PEM bundle for a private CA trusted by the outbound HTTP client.
+  #[must_use]
+  pub fn ca_cert_path(&self) -> Option<&Path> {
+    self.ca_cert_path.as_deref()
+  }
+
+  /// DPoP private-key file (`config/dpop-key.json` by default). `None`
+  /// disables DPoP-bound flows with a typed error instead of a panic.
+  #[must_use]
+  pub fn dpop_key_path(&self) -> Option<&Path> {
+    self.dpop_key_path.as_deref()
+  }
+
+  /// Override the DPoP key path (tests and custom wiring).
+  pub fn set_dpop_key_path(&mut self, path: Option<PathBuf>) {
+    self.dpop_key_path = path;
+  }
+
+  /// Server-side session time-to-live.
+  #[must_use]
+  pub fn session_ttl(&self) -> Duration {
+    self.session_ttl
+  }
+
+  /// The resolved OAuth client profile (issuer, redirect, scope, credentials,
+  /// capabilities, dev flags).
+  #[must_use]
+  pub fn profile(&self) -> &OAuthProfile {
+    &self.profile
+  }
+
+  #[must_use]
+  pub fn start_url(&self) -> &str {
+    &self.start_url
+  }
+}
+
+impl Default for ServerConfig {
+  fn default() -> Self {
+    Self::new(DEFAULT_BIND_ADDR, DEFAULT_DIST_DIR)
+  }
+}
+
+impl fmt::Debug for ServerConfig {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    // `OAuthProfile` already redacts the client secret in its own `Debug`.
+    f.debug_struct("ServerConfig")
+      .field("bind_addr", &self.bind_addr)
+      .field("dist_dir", &self.dist_dir)
+      .field("cookie_secure", &self.cookie_secure)
+      .field("ca_cert_path", &self.ca_cert_path)
+      .field("dpop_key_path", &self.dpop_key_path)
+      .field("session_ttl", &self.session_ttl)
+      .field("start_url", &self.start_url)
+      .field("profile", &self.profile)
+      .finish()
+  }
+}
+
+/// Read the real process environment, treating empty/unset as absent.
+fn env_lookup(key: &str) -> Option<String> {
+  match std::env::var(key) {
+    Ok(value) if !value.trim().is_empty() => Some(value),
+    _ => None,
+  }
+}
+
+/// Config-file path selected by `--config`/env, or the default location.
+fn config_path_from_env() -> Option<PathBuf> {
+  env_lookup(CONFIG_PATH_ENV)
+    .or_else(|| env_lookup(CONFIG_PATH_ALT_ENV))
+    .map(PathBuf::from)
+}
+
+/// How the config file path was resolved.
+enum ResolvedPath {
+  Explicit(PathBuf),
+  Default(PathBuf),
+}
+
+impl ResolvedPath {
+  fn as_path(&self) -> &Path {
+    match self {
+      Self::Explicit(path) | Self::Default(path) => path,
+    }
+  }
+}
+
+fn resolve_config_path(
+  explicit: Option<&Path>,
+  lookup: &dyn Fn(&str) -> Option<String>,
+) -> ResolvedPath {
+  if let Some(path) = explicit {
+    return ResolvedPath::Explicit(path.to_owned());
+  }
+  if let Some(path) = lookup(CONFIG_PATH_ENV).or_else(|| lookup(CONFIG_PATH_ALT_ENV)) {
+    return ResolvedPath::Explicit(PathBuf::from(path));
+  }
+  ResolvedPath::Default(PathBuf::from(DEFAULT_CONFIG_PATH))
+}
+
+/// Client-credentials path: explicit > `APP_CLIENT_FILE` > sibling of the
+/// config file (`<config-dir>/client.json`).
+fn resolve_client_path(
+  explicit: Option<&Path>,
+  config_path: &Path,
+  lookup: &dyn Fn(&str) -> Option<String>,
+) -> Option<PathBuf> {
+  if let Some(path) = explicit {
+    return Some(path.to_owned());
+  }
+  if let Some(path) = lookup(CLIENT_PATH_ENV) {
+    return Some(PathBuf::from(path));
+  }
+  // Sibling `client.json` next to the config file.
+  Some(config_path.parent().map_or_else(
+    || PathBuf::from(DEFAULT_CLIENT_PATH),
+    |dir| dir.join("client.json"),
+  ))
+}
+
+/// Read and parse a JSON file.
+fn read_json<T>(path: &Path) -> Result<T, ConfigError>
+where
+  T: for<'de> Deserialize<'de>,
+{
+  let raw = std::fs::read_to_string(path).map_err(|source| ConfigError::Io {
+    path: path.display().to_string(),
+    source,
+  })?;
+  serde_json::from_str(&raw).map_err(|source| ConfigError::Parse {
+    path: path.display().to_string(),
+    reason: source.to_string(),
+  })
+}
+
+/// Mergeable intermediate: every layer fills in only what it provides.
+#[derive(Debug, Default)]
+struct PartialConfig {
+  bind_addr: Option<SocketAddr>,
+  dist_dir: Option<PathBuf>,
+  cookie_secure: Option<bool>,
+  ca_cert_path: Option<PathBuf>,
+  dpop_key_path: Option<PathBuf>,
+  session_ttl: Option<Duration>,
+  issuer: Option<String>,
+  redirect_uri: Option<String>,
+  scope: Option<String>,
+  client_id: Option<String>,
+  client_secret: Option<SecretString>,
+  capabilities: Option<Capabilities>,
+  insecure_tls: Option<bool>,
+  start_url: Option<String>,
+}
+
+impl PartialConfig {
+  fn apply_file(&mut self, file: FileConfig) {
+    if let Some(value) = file.server.bind_addr {
+      self.bind_addr = Some(value);
+    }
+    if let Some(value) = file.server.dist_dir {
+      self.dist_dir = Some(PathBuf::from(value));
+    }
+    if let Some(value) = file.server.cookie_secure {
+      self.cookie_secure = Some(value);
+    }
+    if let Some(value) = file.server.ca_cert_path {
+      self.ca_cert_path = Some(PathBuf::from(value));
+    }
+    if let Some(value) = file.server.dpop_key_path {
+      self.dpop_key_path = Some(PathBuf::from(value));
+    }
+    if let Some(secs) = file.server.session_ttl_secs {
+      self.session_ttl = Some(Duration::from_secs(secs));
+    }
+    if let Some(value) = file.oauth.issuer {
+      self.issuer = Some(value);
+    }
+    if let Some(value) = file.oauth.redirect_uri {
+      self.redirect_uri = Some(value);
+    }
+    if let Some(value) = file.oauth.scope {
+      self.scope = Some(value);
+    }
+    if let Some(value) = file.oauth.client_id {
+      self.client_id = Some(value);
+    }
+    if let Some(value) = file.oauth.client_secret {
+      self.client_secret = Some(SecretString::from(value));
+    }
+    if let Some(value) = file.oauth.capabilities {
+      self.capabilities = Some(value);
+    }
+    if let Some(dev) = file.oauth.dev
+      && let Some(value) = dev.insecure_tls
+    {
+      self.insecure_tls = Some(value);
+    }
+    if let Some(value) = file
+      .oauth
+      .start_url
+      .filter(|value| !value.trim().is_empty())
+    {
+      self.start_url = Some(value);
+    }
+  }
+
+  fn apply_client_file(&mut self, client: ClientFile) {
+    if let Some(value) = client.client_id {
+      self.client_id = Some(value);
+    }
+    if let Some(value) = client.client_secret {
+      self.client_secret = Some(SecretString::from(value));
+    }
+  }
+
+  fn apply_env(&mut self, lookup: &dyn Fn(&str) -> Option<String>) -> Result<(), ConfigError> {
+    if let Some(value) = lookup(APP_BIND_ADDR_ENV).or_else(|| lookup(BIND_ADDR_ENV)) {
+      self.bind_addr = Some(parse_bind_addr(&value)?);
+    }
+    if let Some(value) = lookup(APP_DIST_DIR_ENV).or_else(|| lookup(DIST_DIR_ENV)) {
+      self.dist_dir = Some(PathBuf::from(value));
+    }
+    if let Some(value) = lookup(APP_ISSUER_ENV)
+      .or_else(|| lookup(OAuthProfile::ENV_ISSUER))
+      .filter(|value| !value.trim().is_empty())
+    {
+      self.issuer = Some(value);
+    }
+    if let Some(value) = lookup(APP_REDIRECT_URI_ENV)
+      .or_else(|| lookup(OAuthProfile::ENV_REDIRECT_URI))
+      .filter(|value| !value.trim().is_empty())
+    {
+      self.redirect_uri = Some(value);
+    }
+    if let Some(value) = lookup(APP_SCOPE_ENV)
+      .or_else(|| lookup(OAuthProfile::ENV_SCOPE))
+      .filter(|value| !value.trim().is_empty())
+    {
+      self.scope = Some(value);
+    }
+    if let Some(value) = lookup(APP_CLIENT_ID_ENV)
+      .or_else(|| lookup(OAuthProfile::ENV_CLIENT_ID))
+      .filter(|value| !value.trim().is_empty())
+    {
+      self.client_id = Some(value);
+    }
+    if let Some(value) =
+      lookup(APP_CLIENT_SECRET_ENV).or_else(|| lookup(OAuthProfile::ENV_CLIENT_SECRET))
+    {
+      self.client_secret = Some(SecretString::from(value));
+    }
+    if let Some(value) =
+      lookup(APP_INSECURE_TLS_ENV).or_else(|| lookup(OAuthProfile::ENV_DEV_INSECURE_TLS))
+    {
+      self.insecure_tls = Some(parse_bool(APP_INSECURE_TLS_ENV, &value)?);
+    }
+    if let Some(value) = lookup(COOKIE_SECURE_ENV) {
+      self.cookie_secure = Some(parse_bool(COOKIE_SECURE_ENV, &value)?);
+    }
+    if let Some(value) = lookup(CA_CERT_ENV).filter(|value| !value.trim().is_empty()) {
+      self.ca_cert_path = Some(PathBuf::from(value));
+    }
+    if let Some(value) = lookup(DPOP_KEY_ENV) {
+      let trimmed = value.trim();
+      self.dpop_key_path = Some(PathBuf::from(trimmed));
+    }
+    if let Some(value) = lookup(SESSION_TTL_ENV) {
+      self.session_ttl = Some(parse_ttl(&value)?);
+    }
+    if let Some(value) = lookup(START_URL_ENV).filter(|value| !value.trim().is_empty()) {
+      self.start_url = Some(value);
+    }
+    Ok(())
+  }
+
+  fn build(self) -> Result<ServerConfig, ConfigError> {
+    let session_ttl = self.session_ttl.unwrap_or(DEFAULT_SESSION_TTL);
+    if session_ttl.is_zero() {
+      return Err(ConfigError::Invalid {
+        key: SESSION_TTL_ENV.to_owned(),
+        reason: "session TTL must be greater than zero".to_owned(),
+      });
+    }
+    Ok(ServerConfig {
+      bind_addr: self.bind_addr.unwrap_or(DEFAULT_BIND_ADDR),
+      dist_dir: self
+        .dist_dir
+        .unwrap_or_else(|| PathBuf::from(DEFAULT_DIST_DIR)),
+      cookie_secure: self.cookie_secure.unwrap_or(false),
+      ca_cert_path: self.ca_cert_path,
+      dpop_key_path: self
+        .dpop_key_path
+        .or_else(|| Some(PathBuf::from(DEFAULT_DPOP_KEY_PATH))),
+      session_ttl,
+      start_url: self
+        .start_url
+        .filter(|value| !value.trim().is_empty())
+        .unwrap_or_else(|| DEFAULT_START_URL.to_owned()),
+      profile: OAuthProfile {
+        issuer: self.issuer.unwrap_or_default(),
+        redirect_uri: self.redirect_uri.unwrap_or_default(),
+        scope: self.scope.unwrap_or_default(),
+        client: ClientCredentials {
+          client_id: self.client_id.unwrap_or_default(),
+          client_secret: self
+            .client_secret
+            .unwrap_or_else(|| SecretString::from(String::new())),
+        },
+        capabilities: self.capabilities.unwrap_or_default(),
+        dev: DevSettings {
+          // Fail closed: only an explicit `true` opts in.
+          insecure_tls: self.insecure_tls.unwrap_or(false),
+        },
+      },
+    })
+  }
+}
+
+/// Top-level `config.json` shape. Every field is optional; missing sections
+/// fall back to defaults, then environment.
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+struct FileConfig {
+  server: FileServer,
+  oauth: FileOAuth,
+}
+
+/// `server` section of `config.json`.
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+struct FileServer {
+  bind_addr: Option<SocketAddr>,
+  dist_dir: Option<String>,
+  cookie_secure: Option<bool>,
+  ca_cert_path: Option<String>,
+  dpop_key_path: Option<String>,
+  session_ttl_secs: Option<u64>,
+}
+
+/// `oauth` section of `config.json`.
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+struct FileOAuth {
+  issuer: Option<String>,
+  redirect_uri: Option<String>,
+  scope: Option<String>,
+  client_id: Option<String>,
+  client_secret: Option<String>,
+  capabilities: Option<Capabilities>,
+  start_url: Option<String>,
+  dev: Option<FileDev>,
+}
+
+/// `oauth.dev` section of `config.json`.
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+struct FileDev {
+  insecure_tls: Option<bool>,
+}
+
+/// `client.json` shape: confidential-client credentials, mode `0600`.
+#[derive(Debug, Default, Deserialize)]
+#[serde(default)]
+struct ClientFile {
+  client_id: Option<String>,
+  client_secret: Option<String>,
+}
+
+/// Parse a `host:port` bind address.
+///
+/// # Errors
+///
+/// Returns [`ConfigError::BindAddr`] when `value` is not a valid socket
+/// address.
+pub fn parse_bind_addr(value: &str) -> Result<SocketAddr, ConfigError> {
+  value.parse().map_err(|source| ConfigError::BindAddr {
+    value: value.to_owned(),
+    source,
+  })
+}
+
+/// Parse a permissive boolean (`true`/`false`/`1`/`0`, case-insensitive).
+fn parse_bool(key: &str, value: &str) -> Result<bool, ConfigError> {
+  match value.trim().to_ascii_lowercase().as_str() {
+    "true" | "1" => Ok(true),
+    "false" | "0" => Ok(false),
+    _ => Err(ConfigError::Invalid {
+      key: key.to_owned(),
+      reason: format!("expected a boolean, got `{value}`"),
+    }),
+  }
+}
+
+/// Parse a session TTL in seconds (must be positive).
+fn parse_ttl(value: &str) -> Result<Duration, ConfigError> {
+  let secs: u64 = value.trim().parse().map_err(|_| ConfigError::Invalid {
+    key: SESSION_TTL_ENV.to_owned(),
+    reason: format!("expected seconds as a positive integer, got `{value}`"),
+  })?;
+  if secs == 0 {
+    return Err(ConfigError::Invalid {
+      key: SESSION_TTL_ENV.to_owned(),
+      reason: "session TTL must be greater than zero".to_owned(),
+    });
+  }
+  Ok(Duration::from_secs(secs))
+}
+
+/// Configuration failures. Typed so the bootstrap can exit non-zero with a
+/// clean message and no panic. No variant carries secret material.
+#[derive(Debug, Error)]
+pub enum ConfigError {
+  /// The configured bind address could not be parsed.
+  #[error("invalid {BIND_ADDR_ENV} value {value:?}: {source}")]
+  BindAddr {
+    /// The rejected value.
+    value: String,
+    /// The underlying parse failure.
+    #[source]
+    source: std::net::AddrParseError,
+  },
+  /// A config file could not be read.
+  #[error("cannot read config file {path:?}: {source}")]
+  Io {
+    /// The file that could not be read.
+    path: String,
+    /// The underlying I/O failure.
+    #[source]
+    source: std::io::Error,
+  },
+  /// A config file is not valid JSON or has the wrong shape.
+  #[error("invalid config file {path:?}: {reason}")]
+  Parse {
+    /// The file that failed to parse.
+    path: String,
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+  /// A required configuration key was not provided.
+  #[error("missing required configuration: {key}")]
+  Missing {
+    /// The key (or environment variable) that is required.
+    key: String,
+  },
+  /// A provided value could not be parsed.
+  #[error("invalid value for `{key}`: {reason}")]
+  Invalid {
+    /// The key whose value was rejected.
+    key: String,
+    /// A non-secret explanation of why the value was rejected.
+    reason: String,
+  },
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+  use std::collections::HashMap;
+
+  fn lookup_from<'a>(map: &'a HashMap<&'a str, &'a str>) -> impl Fn(&str) -> Option<String> + 'a {
+    move |key: &str| map.get(key).map(|value| (*value).to_owned())
+  }
+
+  fn write_temp(path: &Path, contents: &str) {
+    let parent = path.parent().expect("test path has a parent");
+    std::fs::create_dir_all(parent).expect("test dir is creatable");
+    std::fs::write(path, contents).expect("test fixture is writable");
+  }
+
+  #[test]
+  fn default_bind_addr_is_dev_loopback_3001() {
+    assert_eq!(DEFAULT_BIND_ADDR, SocketAddr::from(([127, 0, 0, 1], 3001)));
+  }
+
+  #[test]
+  fn new_preserves_explicit_values() {
+    let config = ServerConfig::new(DEFAULT_BIND_ADDR, DEFAULT_DIST_DIR);
+    assert_eq!(config.bind_addr(), SocketAddr::from(([127, 0, 0, 1], 3001)));
+    assert_eq!(config.dist_dir(), Path::new(DEFAULT_DIST_DIR));
+    assert!(!config.cookie_secure());
+    assert_eq!(config.session_ttl(), DEFAULT_SESSION_TTL);
+  }
+
+  #[test]
+  fn parse_bind_addr_accepts_valid_and_rejects_garbage() -> Result<(), ConfigError> {
+    assert_eq!(
+      parse_bind_addr("0.0.0.0:8080")?,
+      SocketAddr::from(([0, 0, 0, 0], 8080))
+    );
+    assert!(parse_bind_addr("not-an-address").is_err());
+    Ok(())
+  }
+
+  #[test]
+  fn insecure_tls_defaults_false_with_no_inputs() {
+    let empty = HashMap::new();
+    let config =
+      ServerConfig::load_from_paths_with(None, None, &lookup_from(&empty)).expect("empty loads");
+    assert!(!config.profile().dev.insecure_tls);
+  }
+
+  #[test]
+  fn fixture_config_loads_with_env_overrides() {
+    let dir = tempfile::tempdir().expect("temp dir is creatable");
+    let config_path = dir.path().join("config.json");
+    let client_path = dir.path().join("client.json");
+    write_temp(
+      &config_path,
+      r#"{
+        "server": { "bind_addr": "127.0.0.1:3999", "cookie_secure": true, "session_ttl_secs": 60 },
+        "oauth": {
+          "issuer": "https://as.example",
+          "redirect_uri": "http://127.0.0.1:3000/callback",
+          "scope": "demo.read",
+          "capabilities": { "par": true, "dpop": true, "introspection": true },
+          "dev": { "insecure_tls": true }
+        }
+      }"#,
+    );
+    write_temp(
+      &client_path,
+      r#"{"client_id": "file-client", "client_secret": "FILE-S3CRET"}"#,
+    );
+
+    let env = HashMap::from([
+      (APP_BIND_ADDR_ENV, "127.0.0.1:4001"),
+      (APP_SCOPE_ENV, "env.read"),
+    ]);
+    let config = ServerConfig::load_from_paths_with(
+      Some(&config_path),
+      Some(&client_path),
+      &lookup_from(&env),
+    )
+    .expect("fixture loads");
+
+    // Env wins over the file.
+    assert_eq!(config.bind_addr(), SocketAddr::from(([127, 0, 0, 1], 4001)));
+    assert_eq!(config.profile().scope, "env.read");
+    // File values survive where env is silent.
+    assert_eq!(config.profile().issuer, "https://as.example");
+    assert_eq!(config.profile().client.client_id, "file-client");
+    assert!(config.cookie_secure());
+    assert_eq!(config.session_ttl(), Duration::from_secs(60));
+    assert!(config.profile().dev.insecure_tls);
+    assert!(config.profile().capabilities.par);
+  }
+
+  #[test]
+  fn env_client_secret_overrides_file_without_leaking_into_debug() {
+    use secrecy::ExposeSecret as _;
+    let dir = tempfile::tempdir().expect("temp dir is creatable");
+    let config_path = dir.path().join("config.json");
+    let client_path = dir.path().join("client.json");
+    write_temp(
+      &config_path,
+      r#"{"oauth": {"issuer": "https://as.example"}}"#,
+    );
+    write_temp(
+      &client_path,
+      r#"{"client_id": "file-client", "client_secret": "FILE-S3CRET"}"#,
+    );
+
+    let env = HashMap::from([(APP_CLIENT_SECRET_ENV, "ENV-S3CRET")]);
+    let config = ServerConfig::load_from_paths_with(
+      Some(&config_path),
+      Some(&client_path),
+      &lookup_from(&env),
+    )
+    .expect("fixture loads");
+
+    assert_eq!(
+      config.profile().client.client_secret.expose_secret(),
+      "ENV-S3CRET"
+    );
+    let rendered = format!("{config:?}");
+    assert!(!rendered.contains("ENV-S3CRET"));
+    assert!(!rendered.contains("FILE-S3CRET"));
+  }
+
+  #[test]
+  fn malformed_config_yields_typed_error_without_panic() {
+    let dir = tempfile::tempdir().expect("temp dir is creatable");
+    let config_path = dir.path().join("config.json");
+    write_temp(&config_path, r#"{"server": {"bind_addr": [1, 2, 3]}}"#);
+
+    let empty = HashMap::new();
+    let result = ServerConfig::load_from_paths_with(Some(&config_path), None, &lookup_from(&empty));
+    assert!(
+      matches!(result, Err(ConfigError::Parse { .. })),
+      "expected Parse, got {result:?}"
+    );
+  }
+
+  #[test]
+  fn invalid_env_bool_is_typed_not_panic() {
+    let env = HashMap::from([(APP_INSECURE_TLS_ENV, "yes")]);
+    let result = ServerConfig::load_from_paths_with(None, None, &lookup_from(&env));
+    assert!(matches!(result, Err(ConfigError::Invalid { .. })));
+  }
+}

+ 22 - 0
crates/server/src/health.rs

@@ -0,0 +1,22 @@
+//! Liveness probe: `GET /healthz`.
+//!
+//! Dependency-free so it can prove the process is up even when the OAuth
+//! backends are not configured yet. The readiness check that touches the
+//! session/http-client resources is added in Wave 3.
+
+use axum::Json;
+use axum::http::StatusCode;
+use axum::response::IntoResponse;
+use serde::Serialize;
+
+/// JSON body returned by [`healthz`].
+#[derive(Debug, Serialize)]
+pub struct HealthBody {
+  /// Liveness marker; always `"ok"` on a served response.
+  pub status: &'static str,
+}
+
+/// `GET /healthz` -> `200 OK` with `{"status":"ok"}`.
+pub async fn healthz() -> impl IntoResponse {
+  (StatusCode::OK, Json(HealthBody { status: "ok" }))
+}

+ 379 - 0
crates/server/src/http_client.rs

@@ -0,0 +1,379 @@
+//! Hardened outbound HTTP client and log redaction.
+//!
+//! [`build_client`] is the single construction point for every request the
+//! BFF sends to the authorization server (discovery, PAR, token,
+//! introspection, revocation, DCR). It uses `rustls-tls`, fails closed on
+//! untrusted certificates, and only trusts a self-signed development
+//! certificate when explicitly opted in via [`TlsConfig`] — either a custom
+//! private-CA bundle or dev-only `insecure_tls` (which itself defaults to
+//! `false` in [`ServerConfig`][crate::config::ServerConfig]).
+//!
+//! Discovery 503 retry is reused from
+//! [`oauth_core::discovery`][oauth_core::discovery#retry-rule]: pass the
+//! client built here to `discover_with_client` and the bounded
+//! retry/backoff applies unchanged.
+//!
+//! The redaction layer ([`loggable_request`], [`sanitize_url`], and the
+//! re-exported [`redact_secret`]) guarantees tokens, secrets, verifiers,
+//! proofs, and codes never reach logs: sensitive headers are replaced with
+//! the stable marker and URLs are stripped of query/fragment before logging.
+
+use std::path::{Path, PathBuf};
+use std::time::Duration;
+
+use thiserror::Error;
+
+pub use oauth_core::redact::{REDACTED, Redacted, redact_secret};
+
+use crate::config::ServerConfig;
+
+/// Outbound request timeout, matching `oauth-core`'s shared client.
+pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(10);
+
+/// TLS trust configuration for the outbound client.
+///
+/// Built from [`ServerConfig`]; `insecure_tls` is fail-closed (`false`
+/// unless explicitly enabled for a local dev server).
+#[derive(Debug, Clone, Default)]
+pub struct TlsConfig {
+  /// Trust any certificate (dev-only bypass for a self-signed AS cert).
+  pub insecure_tls: bool,
+  /// PEM bundle for a private CA trusted in addition to the platform roots.
+  pub ca_cert_path: Option<PathBuf>,
+}
+
+impl TlsConfig {
+  /// Derive the TLS config from the resolved server configuration.
+  #[must_use]
+  pub fn from_server_config(config: &ServerConfig) -> Self {
+    Self {
+      insecure_tls: config.profile().dev.insecure_tls,
+      ca_cert_path: config.ca_cert_path().map(Path::to_path_buf),
+    }
+  }
+}
+
+/// Build the hardened outbound client.
+///
+/// Plain `rustls-tls` with a bounded timeout, the platform roots plus an
+/// optional private-CA bundle, and `danger_accept_invalid_certs` set **only**
+/// from the explicit dev opt-in — never by default.
+///
+/// # Errors
+///
+/// Returns [`HttpClientError`] when the CA bundle cannot be read/parsed or
+/// the client cannot be constructed.
+pub fn build_client(tls: &TlsConfig) -> Result<reqwest::Client, HttpClientError> {
+  let mut builder = reqwest::Client::builder().timeout(REQUEST_TIMEOUT);
+  if let Some(path) = &tls.ca_cert_path {
+    for certificate in load_ca_bundle(path)? {
+      builder = builder.add_root_certificate(certificate);
+    }
+  }
+  if tls.insecure_tls {
+    builder = builder.danger_accept_invalid_certs(true);
+  }
+  builder.build().map_err(|err| HttpClientError::Build {
+    reason: err.to_string(),
+  })
+}
+
+/// Build the client directly from the resolved server configuration.
+///
+/// # Errors
+///
+/// Same as [`build_client`].
+pub fn build_client_for_config(config: &ServerConfig) -> Result<reqwest::Client, HttpClientError> {
+  build_client(&TlsConfig::from_server_config(config))
+}
+
+/// Default client for state construction before config is resolved.
+/// Equivalent to a fail-closed [`build_client`] with [`TlsConfig::default`].
+#[must_use]
+pub fn default_client() -> reqwest::Client {
+  reqwest::Client::new()
+}
+
+/// Read a PEM CA bundle into reqwest certificates.
+///
+/// # Errors
+///
+/// Returns [`HttpClientError::CaCert`] when the file is unreadable or holds
+/// no parseable certificate.
+fn load_ca_bundle(path: &Path) -> Result<Vec<reqwest::Certificate>, HttpClientError> {
+  let raw = std::fs::read(path).map_err(|source| HttpClientError::CaCert {
+    path: path.display().to_string(),
+    reason: format!("cannot read CA bundle: {source}"),
+  })?;
+  reqwest::Certificate::from_pem_bundle(&raw).map_err(|source| HttpClientError::CaCert {
+    path: path.display().to_string(),
+    reason: format!("invalid PEM in CA bundle: {source}"),
+  })
+}
+
+/// One-line, log-safe summary of an outbound AS request.
+///
+/// Sensitive headers are never rendered — their presence is recorded as the
+/// redaction marker — and the URL is passed through [`sanitize_url`], so
+/// codes or tokens in query/fragment cannot leak into logs.
+#[must_use]
+pub fn loggable_request(
+  method: &str,
+  url: &str,
+  has_authorization: bool,
+  has_dpop: bool,
+) -> String {
+  let mut line = format!("{method} {}", sanitize_url(url));
+  if has_authorization {
+    line.push_str(&format!(" authorization={REDACTED}"));
+  }
+  if has_dpop {
+    line.push_str(&format!(" dpop={REDACTED}"));
+  }
+  line
+}
+
+/// Strip query and fragment from a URL for logging.
+///
+/// Returns the input unchanged when it does not parse as a URL.
+#[must_use]
+pub fn sanitize_url(url: &str) -> String {
+  let Ok(mut parsed) = url::Url::parse(url) else {
+    return url.to_owned();
+  };
+  parsed.set_query(None);
+  parsed.set_fragment(None);
+  parsed.to_string()
+}
+
+/// HTTP client construction failures. No variant carries secrets.
+#[derive(Debug, Error)]
+pub enum HttpClientError {
+  /// The reqwest client could not be constructed.
+  #[error("failed to build http client: {reason}")]
+  Build {
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+  /// The custom CA bundle could not be loaded.
+  #[error("invalid CA bundle at {path:?}: {reason}")]
+  CaCert {
+    /// The bundle that failed to load.
+    path: String,
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+  use std::sync::Arc;
+
+  #[test]
+  fn redaction_never_emits_secrets_into_log_lines() {
+    let secret = "CLIENT-S3CRET";
+    let token = "ACCESS-T0KEN";
+    let verifier = "VERIFIER-V4LUE";
+    let proof = "DPOP-PR00F";
+    let code = "AUTH-C0DE";
+
+    let line = loggable_request(
+      "POST",
+      &format!("https://as.example/token?code={code}"),
+      true,
+      true,
+    );
+    let decorated = format!(
+      "{line} verifier={} proof={} token={} secret={}",
+      redact_secret(verifier),
+      redact_secret(proof),
+      redact_secret(token),
+      redact_secret(secret)
+    );
+
+    for sensitive in [secret, token, verifier, proof, code] {
+      assert!(
+        !decorated.contains(sensitive),
+        "secret leaked into log line: {decorated:?}"
+      );
+    }
+    assert!(decorated.contains(REDACTED));
+    assert!(decorated.starts_with("POST https://as.example/token"));
+  }
+
+  #[test]
+  fn sanitize_url_strips_query_and_fragment() {
+    assert_eq!(
+      sanitize_url("https://as.example/authorize?code=SECRET#fragment"),
+      "https://as.example/authorize"
+    );
+    assert_eq!(
+      sanitize_url("not a url [[["),
+      "not a url [[[",
+      "unparseable input passes through unchanged"
+    );
+  }
+
+  #[test]
+  fn insecure_tls_is_fail_closed_by_default() {
+    assert!(!TlsConfig::default().insecure_tls);
+    assert!(
+      TlsConfig::from_server_config(&ServerConfig::default())
+        .ca_cert_path
+        .is_none()
+    );
+  }
+
+  #[test]
+  fn missing_ca_bundle_is_a_typed_error() {
+    let tls = TlsConfig {
+      insecure_tls: false,
+      ca_cert_path: Some(PathBuf::from("/nonexistent/ca.pem")),
+    };
+    assert!(matches!(
+      build_client(&tls),
+      Err(HttpClientError::CaCert { .. })
+    ));
+  }
+
+  /// Minimal TLS stub: handshakes with the generated self-signed cert, reads
+  /// one request, and answers a fixed `200 ok`.
+  async fn run_tls_stub(
+    cert_pem: String,
+    key_pem: String,
+  ) -> Result<(u16, tokio::task::JoinHandle<()>), Box<dyn std::error::Error>> {
+    use rustls::pki_types::pem::PemObject as _;
+    use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
+
+    // The test stub needs a process-wide crypto provider; reqwest drives the
+    // client side with its own, so any already-installed default is fine.
+    let _ = rustls::crypto::ring::default_provider().install_default();
+
+    let certs = rustls::pki_types::CertificateDer::pem_slice_iter(cert_pem.as_bytes())
+      .collect::<Result<Vec<_>, _>>()
+      .map_err(|_| "invalid cert PEM")?;
+    let key = rustls::pki_types::PrivateKeyDer::from_pem_slice(key_pem.as_bytes())
+      .map_err(|_| "invalid key PEM")?;
+    let mut server_config = rustls::ServerConfig::builder()
+      .with_no_client_auth()
+      .with_single_cert(certs, key)
+      .map_err(|_| "invalid cert/key pair")?;
+    server_config.alpn_protocols = vec![b"http/1.1".to_vec()];
+    let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_config));
+
+    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
+    let port = listener.local_addr()?.port();
+    let handle = tokio::spawn(async move {
+      let Ok((stream, _)) = listener.accept().await else {
+        return;
+      };
+      let Ok(mut tls) = acceptor.accept(stream).await else {
+        return;
+      };
+      let mut buffer = vec![0_u8; 4096];
+      let Ok(_) = tls.read(&mut buffer).await else {
+        return;
+      };
+      let _ = tls
+        .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok")
+        .await;
+    });
+    Ok((port, handle))
+  }
+
+  fn self_signed_pair() -> (String, String) {
+    let certified =
+      rcgen::generate_simple_self_signed(vec!["localhost".to_owned()]).expect("cert generates");
+    (certified.cert.pem(), certified.signing_key.serialize_pem())
+  }
+
+  #[tokio::test]
+  async fn self_signed_cert_trusted_only_with_opt_in() -> Result<(), Box<dyn std::error::Error>> {
+    let (cert_pem, key_pem) = self_signed_pair();
+
+    // Fail closed: the default hardened client rejects the self-signed cert.
+    let (port, stub) = run_tls_stub(cert_pem.clone(), key_pem.clone()).await?;
+    let closed = build_client(&TlsConfig::default())?;
+    let denied = closed
+      .get(format!("https://localhost:{port}/"))
+      .send()
+      .await;
+    assert!(
+      denied.is_err(),
+      "untrusted self-signed cert must fail closed"
+    );
+    stub.abort();
+
+    // Explicit custom root: the same cert verifies.
+    let dir = tempfile::tempdir()?;
+    let ca_path = dir.path().join("ca.pem");
+    std::fs::write(&ca_path, &cert_pem)?;
+    let (port, stub) = run_tls_stub(cert_pem.clone(), key_pem.clone()).await?;
+    let trusted = build_client(&TlsConfig {
+      insecure_tls: false,
+      ca_cert_path: Some(ca_path),
+    })?;
+    let body = trusted
+      .get(format!("https://localhost:{port}/"))
+      .send()
+      .await?
+      .text()
+      .await?;
+    assert_eq!(body, "ok");
+    stub.abort();
+
+    // Explicit dev bypass: the same cert verifies without a custom root.
+    let (port, stub) = run_tls_stub(cert_pem, key_pem).await?;
+    let bypass = build_client(&TlsConfig {
+      insecure_tls: true,
+      ca_cert_path: None,
+    })?;
+    let body = bypass
+      .get(format!("https://localhost:{port}/"))
+      .send()
+      .await?
+      .text()
+      .await?;
+    assert_eq!(body, "ok");
+    stub.abort();
+    Ok(())
+  }
+
+  #[tokio::test]
+  async fn discovery_503_retry_applies_through_the_hardened_client()
+  -> Result<(), Box<dyn std::error::Error>> {
+    use wiremock::{
+      Mock, MockServer, ResponseTemplate,
+      matchers::{method, path},
+    };
+
+    let server = MockServer::start().await;
+    let issuer = server.uri();
+    let doc = serde_json::json!({
+      "issuer": issuer,
+      "authorization_endpoint": format!("{issuer}/authorize"),
+      "token_endpoint": format!("{issuer}/token"),
+      "introspection_endpoint": format!("{issuer}/introspect"),
+      "pushed_authorization_request_endpoint": format!("{issuer}/par"),
+      "dpop_signing_alg_values_supported": ["ES256"],
+    });
+    Mock::given(method("GET"))
+      .and(path("/.well-known/oauth-authorization-server"))
+      .respond_with(ResponseTemplate::new(503))
+      .up_to_n_times(1)
+      .mount(&server)
+      .await;
+    Mock::given(method("GET"))
+      .and(path("/.well-known/oauth-authorization-server"))
+      .respond_with(ResponseTemplate::new(200).set_body_json(&doc))
+      .mount(&server)
+      .await;
+
+    let client = build_client(&TlsConfig::default())?;
+    let metadata = oauth_core::discover_with_client(&client, &issuer).await?;
+    assert_eq!(metadata.token_endpoint, Some(format!("{issuer}/token")));
+    assert!(metadata.capabilities().par);
+    Ok(())
+  }
+}

+ 19 - 0
crates/server/src/lib.rs

@@ -0,0 +1,19 @@
+//! BFF HTTP server for the OAuth 2.1 demo client.
+//!
+//! Wave 1 skeleton: configuration, shared state, the health probe, the static
+//! SPA fallback, and the router assembly with clear extension points for the
+//! Wave 3 OAuth routes (`/oauth/start`, `/callback`, `/api/*`).
+//!
+//! The crate is a library so integration tests under `tests/` can drive the
+//! router with `tower::ServiceExt::oneshot` without binding a port; `main.rs`
+//! is a thin bootstrap over [`app::build_app`].
+
+pub mod api;
+pub mod app;
+pub mod config;
+pub mod health;
+pub mod http_client;
+pub mod oauth;
+pub mod session;
+pub mod state;
+pub mod static_files;

+ 147 - 0
crates/server/src/main.rs

@@ -0,0 +1,147 @@
+//! BFF bootstrap: tracing, layered config, listener, graceful shutdown.
+
+use std::error::Error;
+use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
+use std::path::PathBuf;
+use std::time::Duration;
+
+use server::{app, config::ServerConfig, state::AppState};
+use tokio::net::TcpListener;
+use tracing_subscriber::EnvFilter;
+
+/// Upper bound on the shell-less health probe.
+/// Upper bound on the shell-less health probe.
+const HEALTHCHECK_TIMEOUT: Duration = Duration::from_secs(3);
+
+#[tokio::main]
+async fn main() -> Result<(), Box<dyn Error>> {
+  if is_healthcheck() {
+    // Container `HEALTHCHECK` mode: probe `/healthz` and exit without ever
+    // binding the HTTP listener. Exit 0 on a 2xx, 1 otherwise.
+    let code = if run_healthcheck().await { 0 } else { 1 };
+    std::process::exit(code);
+  }
+
+  init_tracing();
+
+  let config = match config_path_arg() {
+    Some(path) => ServerConfig::load_from_file(&path)?,
+    None => ServerConfig::load()?,
+  };
+  let bind_addr = config.bind_addr();
+  let state = AppState::try_new(config)?;
+  let router = app::build_app(state);
+
+  let listener = TcpListener::bind(bind_addr).await?;
+  tracing::info!(%bind_addr, "server listening");
+
+  axum::serve(listener, router)
+    .with_graceful_shutdown(shutdown_signal())
+    .await?;
+
+  tracing::info!("server shut down cleanly");
+  Ok(())
+}
+
+/// Read `--config <path>` / `--config=<path>` from the process arguments.
+/// Returns `None` when absent (the layered loader falls back to
+/// `APP_CONFIG` and then `config/config.json`).
+fn config_path_arg() -> Option<PathBuf> {
+  let mut args = std::env::args().skip(1);
+  while let Some(arg) = args.next() {
+    if let Some(path) = arg.strip_prefix("--config=") {
+      return Some(PathBuf::from(path));
+    }
+    if arg == "--config" {
+      return args.next().map(PathBuf::from);
+    }
+  }
+  None
+}
+
+/// Install the tracing subscriber, falling back to `info` when
+/// `RUST_LOG` is unset or invalid. Never panics: an already-installed
+/// subscriber is ignored.
+fn init_tracing() {
+  let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
+  let _ = tracing_subscriber::fmt().with_env_filter(filter).try_init();
+}
+
+/// Resolve on Ctrl-C (all platforms) or SIGTERM (unix).
+async fn shutdown_signal() {
+  let ctrl_c = async {
+    if let Err(error) = tokio::signal::ctrl_c().await {
+      tracing::error!(%error, "failed to install Ctrl-C handler");
+      std::future::pending::<()>().await;
+    }
+  };
+
+  #[cfg(unix)]
+  let terminate = async {
+    match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
+      Ok(mut stream) => {
+        stream.recv().await;
+      }
+      Err(error) => {
+        tracing::error!(%error, "failed to install SIGTERM handler");
+        std::future::pending::<()>().await;
+      }
+    }
+  };
+
+  #[cfg(not(unix))]
+  let terminate = std::future::pending::<()>();
+
+  tokio::select! {
+    () = ctrl_c => {}
+    () = terminate => {}
+  }
+}
+
+/// Whether the first CLI argument selects the `healthcheck` mode.
+fn is_healthcheck() -> bool {
+  std::env::args().nth(1).as_deref() == Some("healthcheck")
+}
+
+/// Probe `GET http://<loopback>:<port>/healthz`, returning whether the
+/// response succeeded. Used by the distroless `HEALTHCHECK` (no shell, no
+/// curl); never returns an error, only a boolean.
+async fn run_healthcheck() -> bool {
+  let url = format!("http://{}/healthz", loopback_bind_addr());
+  let client = match reqwest::Client::builder()
+    .timeout(HEALTHCHECK_TIMEOUT)
+    .build()
+  {
+    Ok(client) => client,
+    Err(error) => {
+      eprintln!("healthcheck: failed to build HTTP client: {error}");
+      return false;
+    }
+  };
+
+  match client.get(url).send().await {
+    Ok(response) => response.status().is_success(),
+    Err(error) => {
+      eprintln!("healthcheck: request failed: {error}");
+      false
+    }
+  }
+}
+
+/// Resolve the server's bind address the same way `main` does (environment >
+/// config file > defaults) and map an unspecified host (`0.0.0.0` / `::`) to
+/// the matching loopback so the probe reaches the in-container listener.
+fn loopback_bind_addr() -> SocketAddr {
+  let addr = ServerConfig::load()
+    .map(|config| config.bind_addr())
+    .unwrap_or_else(|_| ServerConfig::default().bind_addr());
+  match addr.ip() {
+    IpAddr::V4(ip) if ip.is_unspecified() => {
+      SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), addr.port())
+    }
+    IpAddr::V6(ip) if ip.is_unspecified() => {
+      SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), addr.port())
+    }
+    _ => addr,
+  }
+}

+ 1250 - 0
crates/server/src/oauth.rs

@@ -0,0 +1,1250 @@
+//! BFF OAuth flow: `/oauth/start`, `/callback`, `/api/session`,
+//! `/api/refresh`, `/api/logout`.
+//!
+//! The browser never sees the client secret, the DPoP private key, raw
+//! tokens, the PKCE verifier, or the authorization code. Pending
+//! authorizations live in [`PendingStore`] keyed by `state` (single-use,
+//! bound to the session that started the flow); discovery answers are held
+//! in [`DiscoveryCache`]; introspection answers in [`IntrospectCache`].
+//!
+//! Route map for the web lane:
+//!
+//! - `GET /oauth/start` -> `302` to the AS `authorization_endpoint` (PAR
+//!   `request_uri` when the AS advertises PAR, plain query params otherwise).
+//! - `GET /callback?code=..&state=..[&iss=..]` -> `302` to `/dashboard` with a
+//!   rotated `sid` cookie; AS `error` redirects become a typed HTML page.
+//! - `GET /api/session` -> `200` [`SessionView`] JSON (see the struct docs for
+//!   the exact shape, including the `csrf_token` field the web client sends
+//!   back on POSTs). When the access token is inactive or within the renewal
+//!   skew the session is refreshed transparently (rotation + a fresh DPoP
+//!   proof); only a missing refresh token or an `invalid_grant` reuse/revocation
+//!   yields `401` and clears the session.
+//! - `POST /api/refresh` (CSRF-guarded) -> `200` rotation JSON or `401` when
+//!   the refresh family was revoked (`invalid_grant`).
+//! - `POST /api/logout` (CSRF-guarded) -> `200` after best-effort revocation
+//!   and local session destruction. The authorization-server session is ended
+//!   by the browser navigating to the issuer-derived provider logout endpoint
+//!   (`/api/config.provider_logout_url`, i.e. `<issuer>/logout`).
+
+use std::collections::HashMap;
+use std::collections::hash_map::DefaultHasher;
+use std::fmt;
+use std::hash::{Hash, Hasher};
+use std::path::Path;
+use std::sync::{Arc, RwLock};
+use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
+
+use axum::body::Body;
+use axum::extract::{Query, State};
+use axum::http::{HeaderMap, StatusCode, header};
+use axum::response::{IntoResponse, Response};
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use oauth_core::dpop::key::DpopKey;
+use oauth_core::{IntrospectionResponse, OAuthProfile, ServerMetadata, TokenResponse};
+use rand::TryRngCore as _;
+use secrecy::{ExposeSecret as _, SecretString};
+use serde::{Deserialize, Serialize};
+
+use crate::session::{
+  SessionData, SessionStore, clear_cookie_value, session_id_from_cookie_header, set_cookie_value,
+};
+use crate::state::AppState;
+
+/// Pending authorizations live this long before `/callback` must consume them.
+pub const PENDING_TTL: Duration = Duration::from_secs(600);
+/// Cached discovery documents live this long before a fresh fetch.
+pub const DISCOVERY_TTL: Duration = Duration::from_secs(300);
+/// Cached introspection answers live this long (short cache only).
+pub const INTROSPECT_TTL: Duration = Duration::from_secs(30);
+/// Access tokens at or within this many seconds of expiry are renewed on a
+/// `GET /api/session` probe instead of being served as-is. A small skew keeps
+/// the UI from ever rendering a token that expires mid-request and bounds how
+/// often a comfortably valid token triggers a refresh.
+pub const REFRESH_SKEW_SECS: i64 = 30;
+
+/// Shared handle to the DPoP signing key.
+pub type DpopKeyHandle = Arc<DpopKey>;
+
+/// Load the DPoP key from `path` when present. A missing or unreadable file
+/// yields `None` so handlers can report a typed error instead of panicking.
+#[must_use]
+pub fn load_dpop_key(path: Option<&Path>) -> Option<DpopKeyHandle> {
+  let path = path?;
+  DpopKey::load_from_file(path).ok().map(Arc::new)
+}
+
+/// One pending authorization: the PKCE verifier plus the session that started
+/// the flow. The verifier never leaves the server.
+pub struct PendingRecord {
+  verifier: SecretString,
+  session_id: String,
+  issuer: String,
+  created_at: Instant,
+}
+
+impl PendingRecord {
+  #[cfg(test)]
+  pub fn session_id(&self) -> &str {
+    &self.session_id
+  }
+
+  #[cfg(test)]
+  pub fn issuer(&self) -> &str {
+    &self.issuer
+  }
+}
+
+impl fmt::Debug for PendingRecord {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    f.debug_struct("PendingRecord")
+      .field("verifier", &oauth_core::redact::REDACTED)
+      .field("session_id", &self.session_id)
+      .field("issuer", &self.issuer)
+      .field("created_at", &self.created_at)
+      .finish()
+  }
+}
+
+/// Single-use pending-authorization map keyed by `state`.
+#[derive(Debug, Clone)]
+pub struct PendingStore {
+  inner: Arc<RwLock<HashMap<String, PendingRecord>>>,
+  ttl: Duration,
+}
+
+impl PendingStore {
+  /// Create an empty store with the default [`PENDING_TTL`].
+  #[must_use]
+  pub fn new() -> Self {
+    Self {
+      inner: Arc::new(RwLock::new(HashMap::new())),
+      ttl: PENDING_TTL,
+    }
+  }
+
+  /// Remember a pending authorization bound to the session that started it.
+  pub fn insert(&self, state: String, verifier: SecretString, session_id: String, issuer: String) {
+    if let Ok(mut guard) = self.inner.write() {
+      guard.insert(
+        state,
+        PendingRecord {
+          verifier,
+          session_id,
+          issuer,
+          created_at: Instant::now(),
+        },
+      );
+    }
+  }
+
+  /// Consume a pending record exactly once. Unknown or expired states yield
+  /// `None` (expired entries are evicted); a consumed state never resolves
+  /// again, which is the replay defense.
+  pub fn take(&self, state: &str) -> Option<PendingRecord> {
+    let mut guard = self.inner.write().ok()?;
+    let record = guard.remove(state)?;
+    if record.created_at.elapsed() >= self.ttl {
+      return None;
+    }
+    Some(record)
+  }
+
+  /// The `state` bound to `session_id`, if any. Diagnostic support for
+  /// driving `/callback` after `/oauth/start` in tests without ever reading
+  /// the verifier.
+  #[must_use]
+  pub fn pending_state_for_session(&self, session_id: &str) -> Option<String> {
+    self
+      .inner
+      .read()
+      .ok()?
+      .iter()
+      .find_map(|(state, record)| (record.session_id == session_id).then(|| state.clone()))
+  }
+
+  /// Number of live entries; tests only.
+  #[must_use]
+  pub fn len(&self) -> usize {
+    self.inner.read().map_or(0, |guard| guard.len())
+  }
+
+  /// Whether the store holds no entries; tests only.
+  #[must_use]
+  pub fn is_empty(&self) -> bool {
+    self.len() == 0
+  }
+}
+
+impl Default for PendingStore {
+  fn default() -> Self {
+    Self::new()
+  }
+}
+
+/// Issuer-keyed discovery cache with [`DISCOVERY_TTL`] expiry.
+#[derive(Debug, Clone)]
+pub struct DiscoveryCache {
+  inner: Arc<RwLock<HashMap<String, (ServerMetadata, Instant)>>>,
+  ttl: Duration,
+}
+
+impl DiscoveryCache {
+  /// Create an empty cache with the default [`DISCOVERY_TTL`].
+  #[must_use]
+  pub fn new() -> Self {
+    Self {
+      inner: Arc::new(RwLock::new(HashMap::new())),
+      ttl: DISCOVERY_TTL,
+    }
+  }
+
+  /// A live cached document for `issuer`, if any (expired entries evicted).
+  pub fn get(&self, issuer: &str) -> Option<ServerMetadata> {
+    let mut guard = self.inner.write().ok()?;
+    let (metadata, cached_at) = guard.get(issuer)?;
+    if cached_at.elapsed() >= self.ttl {
+      guard.remove(issuer);
+      return None;
+    }
+    Some(metadata.clone())
+  }
+
+  /// Cache a freshly discovered document.
+  pub fn put(&self, issuer: &str, metadata: ServerMetadata) {
+    if let Ok(mut guard) = self.inner.write() {
+      guard.insert(issuer.to_owned(), (metadata, Instant::now()));
+    }
+  }
+}
+
+impl Default for DiscoveryCache {
+  fn default() -> Self {
+    Self::new()
+  }
+}
+
+/// Short-lived introspection cache keyed by a non-reversible hash of the
+/// access token (the raw token is never used as a map key).
+#[derive(Debug, Clone)]
+pub struct IntrospectCache {
+  inner: Arc<RwLock<HashMap<u64, (IntrospectionResponse, Instant)>>>,
+  ttl: Duration,
+}
+
+impl IntrospectCache {
+  /// Create an empty cache with the default [`INTROSPECT_TTL`].
+  #[must_use]
+  pub fn new() -> Self {
+    Self {
+      inner: Arc::new(RwLock::new(HashMap::new())),
+      ttl: INTROSPECT_TTL,
+    }
+  }
+
+  /// A live cached answer for `token`, if any.
+  pub fn get(&self, token: &SecretString) -> Option<IntrospectionResponse> {
+    let mut guard = self.inner.write().ok()?;
+    let entry = guard.get(&hash_token(token))?;
+    if entry.1.elapsed() >= self.ttl {
+      guard.remove(&hash_token(token));
+      return None;
+    }
+    Some(entry.0.clone())
+  }
+
+  /// Cache an introspection answer.
+  pub fn put(&self, token: &SecretString, answer: IntrospectionResponse) {
+    if let Ok(mut guard) = self.inner.write() {
+      guard.insert(hash_token(token), (answer, Instant::now()));
+    }
+  }
+
+  /// Drop the cached answer for `token` (called when it reports inactive).
+  pub fn invalidate(&self, token: &SecretString) {
+    if let Ok(mut guard) = self.inner.write() {
+      guard.remove(&hash_token(token));
+    }
+  }
+}
+
+impl Default for IntrospectCache {
+  fn default() -> Self {
+    Self::new()
+  }
+}
+
+/// Non-reversible cache key for an access token.
+fn hash_token(token: &SecretString) -> u64 {
+  let mut hasher = DefaultHasher::new();
+  token.expose_secret().hash(&mut hasher);
+  hasher.finish()
+}
+
+/// Current Unix time in seconds; `0` when the clock is unavailable.
+fn now_secs() -> i64 {
+  SystemTime::now()
+    .duration_since(UNIX_EPOCH)
+    .map_or(0, |elapsed| elapsed.as_secs() as i64)
+}
+
+/// Generate a 256-bit base64url (no-pad) `state` value.
+fn new_state() -> Option<String> {
+  let mut raw = [0_u8; 32];
+  rand::rngs::OsRng.try_fill_bytes(&mut raw).ok()?;
+  Some(URL_SAFE_NO_PAD.encode(raw))
+}
+
+/// Escape user/AS-controlled text for the HTML error pages.
+fn escape_html(raw: &str) -> String {
+  let mut escaped = String::with_capacity(raw.len());
+  for ch in raw.chars() {
+    match ch {
+      '&' => escaped.push_str("&amp;"),
+      '<' => escaped.push_str("&lt;"),
+      '>' => escaped.push_str("&gt;"),
+      '"' => escaped.push_str("&quot;"),
+      '\'' => escaped.push_str("&#39;"),
+      _ => escaped.push(ch),
+    }
+  }
+  escaped
+}
+
+/// Mark an OAuth-flow response as uncacheable.
+///
+/// The `/oauth/start` and `/callback` redirects carry single-use PAR
+/// `request_uri` handles: a cached or history-replayed `302` would present an
+/// already-consumed handle and fail at the AS with `invalid_request`. The
+/// same holds for the flow's error pages, which embed per-attempt state.
+fn no_store(mut response: Response) -> Response {
+  let headers = response.headers_mut();
+  headers.insert(
+    header::CACHE_CONTROL,
+    header::HeaderValue::from_static("no-store"),
+  );
+  headers.insert(header::PRAGMA, header::HeaderValue::from_static("no-cache"));
+  headers.insert(header::EXPIRES, header::HeaderValue::from_static("0"));
+  response
+}
+
+/// Typed user-facing HTML error page (never carries secrets).
+fn error_page(status: StatusCode, title: &str, detail: &str) -> Response {
+  let body = format!(
+    "<!doctype html><html><head><meta charset=\"utf-8\"><title>{}</title></head>\
+     <body><h1>{}</h1><p>{}</p><p><a href=\"/\">Back to the demo</a></p></body></html>",
+    escape_html(title),
+    escape_html(title),
+    escape_html(detail),
+  );
+  match Response::builder()
+    .status(status)
+    .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+    .body(Body::from(body))
+  {
+    Ok(response) => no_store(response),
+    Err(_) => (status, "authentication unavailable").into_response(),
+  }
+}
+
+/// JSON error body for the `/api` routes (never carries secrets).
+fn json_error(status: StatusCode, code: &str, message: &str) -> Response {
+  let body = serde_json::json!({ "error": code, "message": message }).to_string();
+  match Response::builder()
+    .status(status)
+    .header(header::CONTENT_TYPE, "application/json")
+    .body(Body::from(body))
+  {
+    Ok(response) => response,
+    Err(_) => (status, "request failed").into_response(),
+  }
+}
+
+/// `302` redirect, optionally setting the session cookie.
+fn redirect(location: &str, set_cookie: Option<String>) -> Response {
+  let mut builder = Response::builder().status(StatusCode::FOUND);
+  if let Ok(value) = header::HeaderValue::from_str(location) {
+    builder = builder.header(header::LOCATION, value);
+  }
+  if let Some(cookie) = set_cookie
+    && let Ok(value) = header::HeaderValue::from_str(&cookie)
+  {
+    builder = builder.header(header::SET_COOKIE, value);
+  }
+  match builder.body(Body::empty()) {
+    Ok(response) => no_store(response),
+    Err(_) => error_page(
+      StatusCode::FOUND,
+      "Redirect",
+      "The sign-in redirect could not be built.",
+    ),
+  }
+}
+
+/// The session id from the request cookies, when it resolves to a live
+/// session.
+fn live_session_id(store: &SessionStore, headers: &HeaderMap) -> Option<String> {
+  let cookie = headers
+    .get(header::COOKIE)
+    .and_then(|value| value.to_str().ok())?;
+  let id = session_id_from_cookie_header(cookie)?;
+  store.get(id).map(|_| id.to_owned())
+}
+
+/// Ensure a session exists for the flow: reuse the live one, or create a
+/// fresh session and report the `Set-Cookie` value the caller must send.
+fn ensure_session(
+  store: &SessionStore,
+  headers: &HeaderMap,
+  secure: bool,
+  ttl: Duration,
+) -> Result<(String, Option<String>), Box<Response>> {
+  if let Some(id) = live_session_id(store, headers) {
+    return Ok((id, None));
+  }
+  match store.create() {
+    Ok(created) => {
+      let cookie = set_cookie_value(&created.id, secure, ttl);
+      Ok((created.id, Some(cookie)))
+    }
+    Err(_) => Err(Box::new(error_page(
+      StatusCode::INTERNAL_SERVER_ERROR,
+      "Session unavailable",
+      "The sign-in session could not be created. Please retry.",
+    ))),
+  }
+}
+
+/// Capabilities for the display config: discovery-derived, never failing.
+///
+/// Returns the cached discovery document's [`oauth_core::Capabilities`] when
+/// available, fetching and caching it on a miss. Any failure (empty issuer,
+/// unreachable AS, invalid document) yields `fallback` so `/api/config` stays
+/// `200`. No panic, no response type: the landing must never fail on this.
+pub async fn discovery_capabilities(
+  state: &AppState,
+  issuer: &str,
+  fallback: oauth_core::Capabilities,
+) -> oauth_core::Capabilities {
+  if issuer.trim().is_empty() {
+    return fallback;
+  }
+  if let Some(metadata) = state.discovery().get(issuer) {
+    return metadata.capabilities();
+  }
+  match oauth_core::discover_with_client(state.client(), issuer).await {
+    Ok(metadata) => {
+      let capabilities = metadata.capabilities();
+      state.discovery().put(issuer, metadata);
+      capabilities
+    }
+    Err(_) => fallback,
+  }
+}
+
+/// Cached discovery for the configured issuer. Failures become a typed
+/// user-facing error page, never a panic.
+async fn cached_metadata(state: &AppState, issuer: &str) -> Result<ServerMetadata, Box<Response>> {
+  if let Some(metadata) = state.discovery().get(issuer) {
+    return Ok(metadata);
+  }
+  match oauth_core::discover_with_client(state.client(), issuer).await {
+    Ok(metadata) => {
+      state.discovery().put(issuer, metadata.clone());
+      Ok(metadata)
+    }
+    Err(err) => {
+      let detail = match &err {
+        oauth_core::OAuthError::DiscoveryUnavailable { .. } => {
+          "The authorization server directory is temporarily unavailable. Please retry."
+        }
+        _ => "The authorization server could not be reached. Please retry.",
+      };
+      Err(Box::new(error_page(
+        StatusCode::BAD_GATEWAY,
+        "Authorization server unavailable",
+        detail,
+      )))
+    }
+  }
+}
+
+/// The DPoP key, or a typed server-misconfiguration page when absent.
+fn require_dpop(state: &AppState) -> Result<DpopKeyHandle, Box<Response>> {
+  state.dpop_key().ok_or_else(|| {
+    Box::new(error_page(
+      StatusCode::INTERNAL_SERVER_ERROR,
+      "Sign-in unavailable",
+      "The server DPoP key is not configured. Contact the demo operator.",
+    ))
+  })
+}
+
+/// `GET /oauth/start`: run discovery (cached), mint PKCE + `state`, persist
+/// the pending record bound to the session, then `302` to the authorization
+/// endpoint (PAR `request_uri` when advertised, plain query params otherwise).
+pub async fn start(State(state): State<AppState>, headers: HeaderMap) -> Response {
+  let profile = state.config().profile().clone();
+  if profile.issuer.trim().is_empty() {
+    return error_page(
+      StatusCode::INTERNAL_SERVER_ERROR,
+      "Sign-in unavailable",
+      "No authorization-server issuer is configured.",
+    );
+  }
+  let ttl = state.config().session_ttl();
+  let (session_id, set_cookie) = match ensure_session(
+    state.sessions(),
+    &headers,
+    state.config().cookie_secure(),
+    ttl,
+  ) {
+    Ok(found) => found,
+    Err(response) => return *response,
+  };
+
+  let metadata = match cached_metadata(&state, &profile.issuer).await {
+    Ok(metadata) => metadata,
+    Err(response) => return *response,
+  };
+  let Some(authorization_endpoint) = metadata.authorization_endpoint.clone() else {
+    return error_page(
+      StatusCode::BAD_GATEWAY,
+      "Authorization server unavailable",
+      "The authorization server did not advertise an authorization endpoint.",
+    );
+  };
+  let Ok(auth_url) = url::Url::parse(&authorization_endpoint) else {
+    return error_page(
+      StatusCode::BAD_GATEWAY,
+      "Authorization server unavailable",
+      "The advertised authorization endpoint is not a valid URL.",
+    );
+  };
+
+  let verifier = match oauth_core::pkce::generate_verifier() {
+    Ok(verifier) => verifier,
+    Err(_) => {
+      return error_page(
+        StatusCode::INTERNAL_SERVER_ERROR,
+        "Sign-in unavailable",
+        "The sign-in request could not be prepared. Please retry.",
+      );
+    }
+  };
+  let challenge = match oauth_core::pkce::challenge_from_verifier(&verifier) {
+    Ok(challenge) => challenge,
+    Err(_) => {
+      return error_page(
+        StatusCode::INTERNAL_SERVER_ERROR,
+        "Sign-in unavailable",
+        "The sign-in request could not be prepared. Please retry.",
+      );
+    }
+  };
+  let Some(state_value) = new_state() else {
+    return error_page(
+      StatusCode::INTERNAL_SERVER_ERROR,
+      "Sign-in unavailable",
+      "The sign-in request could not be prepared. Please retry.",
+    );
+  };
+
+  let par_request = match oauth_core::ParRequest::new(
+    &profile.client.client_id,
+    &profile.redirect_uri,
+    &profile.scope,
+    &state_value,
+    &challenge,
+    Some("S256"),
+  ) {
+    Ok(request) => request,
+    Err(_) => {
+      return error_page(
+        StatusCode::INTERNAL_SERVER_ERROR,
+        "Sign-in unavailable",
+        "The sign-in request could not be prepared. Please retry.",
+      );
+    }
+  };
+
+  let location = if metadata.pushed_authorization_request_endpoint.is_some() {
+    match oauth_core::push_authorization_request(
+      state.client(),
+      &metadata,
+      &profile.client,
+      &par_request,
+    )
+    .await
+    {
+      Ok(par) => {
+        let mut target = auth_url.clone();
+        target
+          .query_pairs_mut()
+          .append_pair("client_id", &profile.client.client_id)
+          .append_pair("request_uri", &par.request_uri);
+        target.to_string()
+      }
+      Err(_) => {
+        return error_page(
+          StatusCode::BAD_GATEWAY,
+          "Authorization server unavailable",
+          "The authorization request could not be pushed. Please retry.",
+        );
+      }
+    }
+  } else {
+    let mut target = auth_url.clone();
+    target
+      .query_pairs_mut()
+      .append_pair("response_type", "code")
+      .append_pair("client_id", &profile.client.client_id)
+      .append_pair("redirect_uri", &profile.redirect_uri)
+      .append_pair("scope", &profile.scope)
+      .append_pair("state", &state_value)
+      .append_pair("code_challenge", &challenge)
+      .append_pair("code_challenge_method", "S256");
+    target.to_string()
+  };
+
+  state.pending().insert(
+    state_value,
+    SecretString::from(verifier),
+    session_id,
+    profile.issuer,
+  );
+  redirect(&location, set_cookie)
+}
+
+/// Authorization-redirect query: the code/state pair plus the optional
+/// RFC 9207 `iss` and the RFC 6749 `error` mapping.
+#[derive(Debug, Deserialize)]
+pub struct CallbackQuery {
+  code: Option<String>,
+  state: Option<String>,
+  iss: Option<String>,
+  error: Option<String>,
+  error_description: Option<String>,
+}
+
+/// `GET /callback`: validate `state` (single-use) and `iss` when present,
+/// map AS `error` redirects to a typed page, exchange the code with a fresh
+/// DPoP proof, store the tokens, rotate the session id, then `302` to
+/// `/dashboard`.
+pub async fn callback(
+  State(state): State<AppState>,
+  Query(query): Query<CallbackQuery>,
+) -> Response {
+  if let Some(code) = query.error {
+    let mut detail = format!("The authorization server refused the request ({code}).");
+    if let Some(description) = query.error_description
+      && !description.trim().is_empty()
+    {
+      detail.push_str(&format!(" {}", description.trim()));
+    }
+    if code == "invalid_scope" {
+      let required = state.config().profile().scope.trim();
+      let requirement = if required.is_empty() {
+        "this deployment has no required scope configured".to_owned()
+      } else {
+        format!(
+          "this client requires the `{required}` scope, which the authorization server refused"
+        )
+      };
+      detail.push_str(&format!(
+        " Note: {requirement}; the account may not be entitled to it. Ask an operator to \
+         grant it, then start over."
+      ));
+      return error_page(StatusCode::BAD_REQUEST, "Scope not granted", &detail);
+    }
+    if code == "access_denied" {
+      detail.push_str(" You denied the consent request; no session was created.");
+    }
+    return error_page(StatusCode::BAD_REQUEST, "Authorization denied", &detail);
+  }
+  let Some(state_value) = query.state.filter(|value| !value.trim().is_empty()) else {
+    return error_page(
+      StatusCode::BAD_REQUEST,
+      "Invalid callback",
+      "The callback is missing its state. Please start over from /oauth/start.",
+    );
+  };
+  let Some(code) = query.code.filter(|value| !value.trim().is_empty()) else {
+    return error_page(
+      StatusCode::BAD_REQUEST,
+      "Invalid callback",
+      "The callback is missing its authorization code. Please start over.",
+    );
+  };
+
+  let Some(pending) = state.pending().take(&state_value) else {
+    return error_page(
+      StatusCode::BAD_REQUEST,
+      "Invalid callback",
+      "The sign-in state is unknown, expired, or already used. Please start over.",
+    );
+  };
+  let profile = state.config().profile().clone();
+  if let Some(iss) = query.iss
+    && iss != profile.issuer
+  {
+    return error_page(
+      StatusCode::BAD_REQUEST,
+      "Invalid callback",
+      "The issuer in the callback does not match the configured server.",
+    );
+  }
+
+  let dpop = match require_dpop(&state) {
+    Ok(key) => key,
+    Err(response) => return *response,
+  };
+  let metadata = match cached_metadata(&state, &profile.issuer).await {
+    Ok(metadata) => metadata,
+    Err(response) => return *response,
+  };
+  let exchanged = oauth_core::exchange_code(
+    state.client(),
+    &metadata,
+    &profile.client,
+    &dpop,
+    &code,
+    &profile.redirect_uri,
+    pending.verifier.expose_secret(),
+  )
+  .await;
+  let tokens = match exchanged {
+    Ok(tokens) => tokens,
+    Err(err) => {
+      let detail = match &err {
+        oauth_core::OAuthError::Token(oauth_core::TokenError::InvalidGrant { description }) => {
+          format!("The authorization code was rejected ({description}). Start over.")
+        }
+        _ => "The code exchange failed. Please start over from /oauth/start.".to_owned(),
+      };
+      return error_page(StatusCode::BAD_GATEWAY, "Sign-in failed", &detail);
+    }
+  };
+
+  let ttl = state.config().session_ttl();
+  let secure = state.config().cookie_secure();
+  let session_id = if state.sessions().store_tokens(
+    &pending.session_id,
+    Some(tokens.access_token.clone()),
+    tokens.refresh_token.clone(),
+    Some(tokens.token_type.clone()),
+  ) {
+    match state.sessions().rotate(&pending.session_id) {
+      Ok(Some(next)) => next,
+      Ok(None) | Err(_) => pending.session_id.clone(),
+    }
+  } else {
+    match state.sessions().create() {
+      Ok(created) => {
+        state.sessions().store_tokens(
+          &created.id,
+          Some(tokens.access_token.clone()),
+          tokens.refresh_token.clone(),
+          Some(tokens.token_type.clone()),
+        );
+        created.id
+      }
+      Err(_) => {
+        return error_page(
+          StatusCode::INTERNAL_SERVER_ERROR,
+          "Sign-in failed",
+          "The session could not be established. Please retry.",
+        );
+      }
+    }
+  };
+  redirect(
+    "/dashboard",
+    Some(set_cookie_value(&session_id, secure, ttl)),
+  )
+}
+
+/// Sender-constraint confirmation as served to the web lane: the RFC 7638
+/// thumbprint of the DPoP public JWK the token is bound to.
+#[derive(Debug, Serialize)]
+pub struct CnfView {
+  /// JWK SHA-256 thumbprint of the bound DPoP key.
+  pub jkt: String,
+}
+/// The `GET /api/session` JSON shape for the web lane.
+///
+/// - `subject` is omitted (not `null`) when introspection carries no `sub`
+///   (e.g. client-credentials tokens).
+/// - `token_type` is the DISPLAYED type: `"DPoP"` when `cnf.jkt` is present,
+///   else the stored exchange-time type, else the introspection value.
+/// - `expires_in` is computed as `max(exp - now, 0)`; introspection returns
+///   `exp`/`iat`, never `expires_in`.
+/// - `csrf_token` is the session's per-session token the web client sends as
+///   `x-csrf-token` on `POST /api/refresh` and `POST /api/logout`.
+#[derive(Debug, Serialize)]
+pub struct SessionView {
+  /// The issuer the session was established against.
+  pub issuer: String,
+  /// The client the token was issued to.
+  pub client_id: String,
+  /// The subject (email on the auth-code path); absent when the AS omits it.
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub subject: Option<String>,
+  /// Granted scopes, split on whitespace.
+  pub scope: Vec<String>,
+  /// Scopes this deployment requires that the granted token does not carry.
+  ///
+  /// Order-preserving and de-duplicated; always serialized (empty when the
+  /// grant covers the configured scope). A missing scope is reported, never
+  /// fabricated or widened.
+  pub missing_scopes: Vec<String>,
+  /// Displayed token type (`"DPoP"` when sender-constrained).
+  pub token_type: String,
+  /// Remaining access-token lifetime in seconds.
+  pub expires_in: i64,
+  /// Token issuance as Unix seconds, when reported.
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub iat: Option<i64>,
+  /// Token expiry as Unix seconds, when reported.
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub exp: Option<i64>,
+  /// Sender-constraint confirmation, only for bound tokens.
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub cnf: Option<CnfView>,
+  /// Actor claim, only for token-exchange tokens.
+  #[serde(skip_serializing_if = "Option::is_none")]
+  pub act: Option<serde_json::Value>,
+  /// Per-session CSRF token for the state-changing POST routes.
+  pub csrf_token: String,
+}
+
+/// Required scopes the granted token does not carry: order-preserving and
+/// de-duplicated. The configured scope is the only source of truth, so a
+/// missing scope is reported, never fabricated or widened.
+fn missing_scopes(required: &str, granted: &[String]) -> Vec<String> {
+  let mut missing: Vec<String> = Vec::new();
+  for required in required.split_whitespace() {
+    let already_granted = granted.iter().any(|granted| granted.as_str() == required);
+    let already_reported = missing.iter().any(|seen| seen.as_str() == required);
+    if !already_granted && !already_reported {
+      missing.push(required.to_owned());
+    }
+  }
+  missing
+}
+
+/// Introspect an access token through the short-lived cache. A miss fetches
+/// and caches the answer; any upstream failure is a ready-to-send `502`.
+async fn introspect_cached(
+  state: &AppState,
+  profile: &OAuthProfile,
+  access: &SecretString,
+) -> Result<IntrospectionResponse, Box<Response>> {
+  if let Some(cached) = state.introspected().get(access) {
+    return Ok(cached);
+  }
+  let metadata = match cached_metadata(state, &profile.issuer).await {
+    Ok(metadata) => metadata,
+    Err(_) => {
+      return Err(Box::new(json_error(
+        StatusCode::BAD_GATEWAY,
+        "upstream_unavailable",
+        "The authorization server could not be reached.",
+      )));
+    }
+  };
+  match oauth_core::introspect(
+    state.client(),
+    &metadata,
+    &profile.client,
+    access,
+    Some("access_token"),
+  )
+  .await
+  {
+    Ok(answer) => {
+      state.introspected().put(access, answer.clone());
+      Ok(answer)
+    }
+    Err(_) => Err(Box::new(json_error(
+      StatusCode::BAD_GATEWAY,
+      "upstream_unavailable",
+      "Token introspection failed.",
+    ))),
+  }
+}
+
+/// Assemble the [`SessionView`] from one authoritative introspection answer.
+fn build_session_view(
+  profile: &OAuthProfile,
+  session: &SessionData,
+  answer: &IntrospectionResponse,
+) -> SessionView {
+  let token_type = if answer.cnf.is_some() {
+    "DPoP".to_owned()
+  } else if let Some(stored) = session.token_type() {
+    stored.to_owned()
+  } else if let Some(reported) = answer.token_type.clone() {
+    reported
+  } else {
+    "Bearer".to_owned()
+  };
+  let granted: Vec<String> = answer
+    .scope
+    .as_deref()
+    .map(|scope| {
+      scope
+        .split_whitespace()
+        .map(str::to_owned)
+        .collect::<Vec<String>>()
+    })
+    .unwrap_or_default();
+  let missing = missing_scopes(&profile.scope, &granted);
+  SessionView {
+    issuer: profile.issuer.clone(),
+    client_id: answer.client_id.clone().unwrap_or_default(),
+    subject: answer.sub.clone().filter(|sub| !sub.is_empty()),
+    scope: granted,
+    missing_scopes: missing,
+    token_type,
+    expires_in: answer.exp.map(|exp| (exp - now_secs()).max(0)).unwrap_or(0),
+    iat: answer.iat,
+    exp: answer.exp,
+    cnf: answer.cnf.clone().map(|cnf| CnfView { jkt: cnf.jkt }),
+    act: answer.act.clone(),
+    csrf_token: session.csrf_token().expose_secret().to_owned(),
+  }
+}
+
+/// `200` with the view for a still-active (but not comfortably valid) token.
+fn active_view(
+  profile: &OAuthProfile,
+  session: &SessionData,
+  answer: &IntrospectionResponse,
+) -> Response {
+  (
+    StatusCode::OK,
+    axum::Json(build_session_view(profile, session, answer)),
+  )
+    .into_response()
+}
+
+/// Unrecoverable session end: drop the staled introspection entry, destroy the
+/// session, and return `401 session_expired` with the clearing cookie.
+fn session_expired(state: &AppState, session_id: &str, access: &SecretString) -> Response {
+  state.introspected().invalidate(access);
+  state.sessions().destroy(session_id);
+  let mut response = json_error(
+    StatusCode::UNAUTHORIZED,
+    "session_expired",
+    "The session is no longer active.",
+  );
+  response.headers_mut().insert(
+    header::SET_COOKIE,
+    header::HeaderValue::from_static("sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
+  );
+  response
+}
+
+/// Rebuild an introspection-shaped answer from a successful refresh response
+/// plus the prior identity/binding.
+///
+/// Used only when the rotated access token cannot be re-introspected: the
+/// refresh response is authoritative for lifetime and granted scope, while the
+/// subject and DPoP `cnf.jkt` are unchanged by a rotation.
+fn answer_from_refresh(
+  previous: &IntrospectionResponse,
+  tokens: &TokenResponse,
+) -> IntrospectionResponse {
+  let now = now_secs();
+  IntrospectionResponse {
+    active: true,
+    scope: tokens.scope.clone().or_else(|| previous.scope.clone()),
+    client_id: previous.client_id.clone(),
+    token_type: Some(tokens.token_type.clone()),
+    exp: Some(now.saturating_add(i64::try_from(tokens.expires_in).unwrap_or(i64::MAX))),
+    iat: Some(now),
+    sub: previous.sub.clone(),
+    cnf: previous.cnf.clone(),
+    act: previous.act.clone(),
+  }
+}
+
+/// `GET /api/session`: `401` when unauthenticated; otherwise introspect the
+/// access token and return the [`SessionView`].
+///
+/// When the token is inactive, or still active but within
+/// [`REFRESH_SKEW_SECS`] of expiry, the session is renewed transparently with
+/// its refresh token (rotation + a fresh DPoP proof) before answering. Only a
+/// missing refresh token or an `invalid_grant` reuse/revocation clears the
+/// session; a transient failure leaves a still-active token usable. Raw tokens
+/// and secrets never enter the response.
+pub async fn session_api(State(state): State<AppState>, headers: HeaderMap) -> Response {
+  let Some(session_id) = live_session_id(state.sessions(), &headers) else {
+    return json_error(
+      StatusCode::UNAUTHORIZED,
+      "unauthenticated",
+      "No active session.",
+    );
+  };
+  let Some(session) = state.sessions().get(&session_id) else {
+    return json_error(
+      StatusCode::UNAUTHORIZED,
+      "unauthenticated",
+      "No active session.",
+    );
+  };
+  let Some(access) = session.access_token().cloned() else {
+    return json_error(
+      StatusCode::UNAUTHORIZED,
+      "unauthenticated",
+      "No active session.",
+    );
+  };
+
+  let profile = state.config().profile().clone();
+  let answer = match introspect_cached(&state, &profile, &access).await {
+    Ok(answer) => answer,
+    Err(response) => return *response,
+  };
+
+  let comfortably_valid = answer.active
+    && answer
+      .exp
+      .is_none_or(|exp| exp - now_secs() > REFRESH_SKEW_SECS);
+  if comfortably_valid {
+    return active_view(&profile, &session, &answer);
+  }
+
+  let Some(refresh_token) = session.refresh_token().cloned() else {
+    return session_expired(&state, &session_id, &access);
+  };
+
+  let metadata = match cached_metadata(&state, &profile.issuer).await {
+    Ok(metadata) => metadata,
+    Err(_) => {
+      return if answer.active {
+        active_view(&profile, &session, &answer)
+      } else {
+        json_error(
+          StatusCode::BAD_GATEWAY,
+          "upstream_unavailable",
+          "The authorization server could not be reached.",
+        )
+      };
+    }
+  };
+  let Some(dpop) = state.dpop_key() else {
+    return if answer.active {
+      active_view(&profile, &session, &answer)
+    } else {
+      json_error(
+        StatusCode::INTERNAL_SERVER_ERROR,
+        "server_misconfigured",
+        "The server DPoP key is not configured.",
+      )
+    };
+  };
+
+  match oauth_core::refresh(
+    state.client(),
+    &metadata,
+    &profile.client,
+    &dpop,
+    &refresh_token,
+    None,
+  )
+  .await
+  {
+    Ok(tokens) => {
+      let kept_refresh = tokens.refresh_token.clone().or(Some(refresh_token));
+      state.sessions().store_tokens(
+        &session_id,
+        Some(tokens.access_token.clone()),
+        kept_refresh,
+        Some(tokens.token_type.clone()),
+      );
+      state.introspected().invalidate(&access);
+      let Some(refreshed) = state.sessions().get(&session_id) else {
+        return json_error(
+          StatusCode::UNAUTHORIZED,
+          "unauthenticated",
+          "No active session.",
+        );
+      };
+      match introspect_cached(&state, &profile, &tokens.access_token).await {
+        Ok(new_answer) => active_view(&profile, &refreshed, &new_answer),
+        Err(_) => {
+          let fallback = answer_from_refresh(&answer, &tokens);
+          active_view(&profile, &refreshed, &fallback)
+        }
+      }
+    }
+    Err(oauth_core::OAuthError::Token(oauth_core::TokenError::InvalidGrant { .. })) => {
+      session_expired(&state, &session_id, &access)
+    }
+    Err(_) => {
+      if answer.active {
+        active_view(&profile, &session, &answer)
+      } else {
+        json_error(
+          StatusCode::BAD_GATEWAY,
+          "refresh_failed",
+          "Token refresh failed.",
+        )
+      }
+    }
+  }
+}
+
+/// Successful rotation answer for `POST /api/refresh` (no tokens inside).
+#[derive(Debug, Serialize)]
+struct RefreshView {
+  refreshed: bool,
+  expires_in: u64,
+  #[serde(skip_serializing_if = "Option::is_none")]
+  scope: Option<String>,
+}
+
+/// `POST /api/refresh`: rotate with a fresh DPoP proof. `invalid_grant`
+/// (reuse detected / family revoked) clears the session and yields `401`.
+/// Mounted behind [`crate::session::csrf_guard`]; never retries.
+pub async fn refresh(State(state): State<AppState>, headers: HeaderMap) -> Response {
+  let Some(session_id) = live_session_id(state.sessions(), &headers) else {
+    return json_error(
+      StatusCode::UNAUTHORIZED,
+      "unauthenticated",
+      "No active session.",
+    );
+  };
+  let Some(session) = state.sessions().get(&session_id) else {
+    return json_error(
+      StatusCode::UNAUTHORIZED,
+      "unauthenticated",
+      "No active session.",
+    );
+  };
+  let Some(refresh_token) = session.refresh_token().cloned() else {
+    return json_error(
+      StatusCode::UNAUTHORIZED,
+      "no_refresh_token",
+      "The session holds no refresh token.",
+    );
+  };
+
+  let profile = state.config().profile().clone();
+  let metadata = match cached_metadata(&state, &profile.issuer).await {
+    Ok(metadata) => metadata,
+    Err(_) => {
+      return json_error(
+        StatusCode::BAD_GATEWAY,
+        "upstream_unavailable",
+        "The authorization server could not be reached.",
+      );
+    }
+  };
+  let Some(dpop) = state.dpop_key() else {
+    return json_error(
+      StatusCode::INTERNAL_SERVER_ERROR,
+      "server_misconfigured",
+      "The server DPoP key is not configured.",
+    );
+  };
+  match oauth_core::refresh(
+    state.client(),
+    &metadata,
+    &profile.client,
+    &dpop,
+    &refresh_token,
+    None,
+  )
+  .await
+  {
+    Ok(tokens) => {
+      let kept_refresh = tokens.refresh_token.clone().or(Some(refresh_token));
+      state.sessions().store_tokens(
+        &session_id,
+        Some(tokens.access_token.clone()),
+        kept_refresh,
+        Some(tokens.token_type.clone()),
+      );
+      let view = RefreshView {
+        refreshed: true,
+        expires_in: tokens.expires_in,
+        scope: tokens.scope.clone(),
+      };
+      (StatusCode::OK, axum::Json(view)).into_response()
+    }
+    Err(oauth_core::OAuthError::Token(oauth_core::TokenError::InvalidGrant { .. })) => {
+      state.sessions().destroy(&session_id);
+      let mut response = json_error(
+        StatusCode::UNAUTHORIZED,
+        "invalid_grant",
+        "The refresh token was rejected; the session was cleared.",
+      );
+      response.headers_mut().insert(
+        header::SET_COOKIE,
+        header::HeaderValue::from_static("sid=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
+      );
+      response
+    }
+    Err(_) => json_error(
+      StatusCode::BAD_GATEWAY,
+      "refresh_failed",
+      "Token refresh failed.",
+    ),
+  }
+}
+
+/// `POST /api/logout`: best-effort `POST /revoke` for refresh + access, then
+/// clear the local session. This route does not redirect; the caller keeps
+/// control of the top-level navigation and should send the browser to the
+/// issuer-derived provider logout endpoint (`/api/config.provider_logout_url`)
+/// so the authorization-server session ends too.
+pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
+  if let Some(session_id) = live_session_id(state.sessions(), &headers)
+    && let Some(session) = state.sessions().get(&session_id)
+  {
+    let profile = state.config().profile().clone();
+    if let Ok(metadata) = cached_metadata(&state, &profile.issuer).await {
+      if let Some(refresh) = session.refresh_token() {
+        let _ = oauth_core::revoke(
+          state.client(),
+          &metadata,
+          &profile.client,
+          refresh,
+          Some("refresh_token"),
+        )
+        .await;
+      }
+      if let Some(access) = session.access_token() {
+        let _ = oauth_core::revoke(
+          state.client(),
+          &metadata,
+          &profile.client,
+          access,
+          Some("access_token"),
+        )
+        .await;
+      }
+    }
+    state.sessions().destroy(&session_id);
+  }
+  let body = serde_json::json!({
+    "ok": true,
+    "note": "Local session cleared. Navigate to the provider logout endpoint to end the authorization-server session.",
+  })
+  .to_string();
+  match Response::builder()
+    .status(StatusCode::OK)
+    .header(header::CONTENT_TYPE, "application/json")
+    .header(header::SET_COOKIE, clear_cookie_value())
+    .body(Body::from(body))
+  {
+    Ok(response) => response,
+    Err(_) => (
+      StatusCode::OK,
+      axum::Json(serde_json::json!({ "ok": true })),
+    )
+      .into_response(),
+  }
+}

+ 703 - 0
crates/server/src/session.rs

@@ -0,0 +1,703 @@
+//! Server-side sessions, the `sid` cookie, and origin/CSRF protection.
+//!
+//! Sessions live in a [`SessionStore`] (in-memory map with TTL); the browser
+//! holds only an opaque 256-bit `sid` cookie. Tokens are stored server-side in
+//! [`SessionData`] and never enter the cookie, and the session id is rotated
+//! on login/privilege change ([`SessionStore::rotate`]) as a session-fixation
+//! defense.
+//!
+//! State-changing routes additionally pass [`csrf_guard`], which rejects
+//! cross-site requests by `Origin`/`Sec-Fetch-Site` and requires the
+//! per-session CSRF token on every request. The token is issued at session
+//! creation alongside the id; todos 17-18 surface it (via `GET /api/session`)
+//! and enforce it on `POST /api/refresh` and `POST /api/logout`.
+
+use std::collections::HashMap;
+use std::fmt;
+use std::sync::{Arc, RwLock};
+use std::time::{Duration, Instant};
+
+use axum::http::{HeaderMap, Request, StatusCode, header};
+use axum::middleware::Next;
+use axum::response::{IntoResponse, Response};
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use rand::TryRngCore as _;
+use secrecy::SecretString;
+use subtle::ConstantTimeEq as _;
+use thiserror::Error;
+
+/// Name of the session cookie. It carries only the opaque session id.
+pub const SESSION_COOKIE: &str = "sid";
+
+/// Request header carrying the per-session CSRF token on state-changing calls.
+pub const CSRF_HEADER: &str = "x-csrf-token";
+
+/// Length of session ids and CSRF tokens in bytes (256 bits).
+pub const TOKEN_BYTES: usize = 32;
+
+/// Data held server-side for one session.
+///
+/// Tokens stay here; the cookie holds only the [`SESSION_COOKIE`] id. The CSRF
+/// token is handed to the browser through the JSON API (todo 17), never
+/// through the cookie.
+pub struct SessionData {
+  created_at: Instant,
+  csrf_token: SecretString,
+  access_token: Option<SecretString>,
+  refresh_token: Option<SecretString>,
+  token_type: Option<String>,
+}
+
+impl SessionData {
+  /// Create fresh session data with a new CSRF token and no tokens yet.
+  ///
+  /// # Errors
+  ///
+  /// Returns [`SessionError::Entropy`] when the CSPRNG is unavailable.
+  pub fn fresh() -> Result<Self, SessionError> {
+    Ok(Self {
+      created_at: Instant::now(),
+      csrf_token: SecretString::from(new_token()?),
+      access_token: None,
+      refresh_token: None,
+      token_type: None,
+    })
+  }
+
+  /// The per-session CSRF token, exposed so handlers can hand it to the
+  /// browser through the JSON API (never through the cookie).
+  #[must_use]
+  pub fn csrf_token(&self) -> &SecretString {
+    &self.csrf_token
+  }
+
+  /// Store rotated tokens after login/refresh (todos 16/18 consume this).
+  pub fn set_tokens(
+    &mut self,
+    access_token: Option<SecretString>,
+    refresh_token: Option<SecretString>,
+  ) {
+    self.access_token = access_token;
+    self.refresh_token = refresh_token;
+  }
+
+  /// The stored access token, if the session is authenticated.
+  #[must_use]
+  pub fn access_token(&self) -> Option<&SecretString> {
+    self.access_token.as_ref()
+  }
+
+  /// The stored refresh token, if the session holds one.
+  #[must_use]
+  pub fn refresh_token(&self) -> Option<&SecretString> {
+    self.refresh_token.as_ref()
+  }
+
+  /// The exchange-time token type (e.g. `"DPoP"`), used as the display
+  /// fallback when introspection reports `"Bearer"` (todos 17-18 set this).
+  #[must_use]
+  pub fn token_type(&self) -> Option<&str> {
+    self.token_type.as_deref()
+  }
+
+  /// Record the exchange-time token type alongside a token rotation.
+  pub fn set_token_type(&mut self, token_type: Option<String>) {
+    self.token_type = token_type;
+  }
+}
+
+impl Clone for SessionData {
+  fn clone(&self) -> Self {
+    use secrecy::ExposeSecret as _;
+    Self {
+      created_at: self.created_at,
+      csrf_token: SecretString::from(self.csrf_token.expose_secret().to_owned()),
+      access_token: self
+        .access_token
+        .as_ref()
+        .map(|token| SecretString::from(token.expose_secret().to_owned())),
+      refresh_token: self
+        .refresh_token
+        .as_ref()
+        .map(|token| SecretString::from(token.expose_secret().to_owned())),
+      token_type: self.token_type.clone(),
+    }
+  }
+}
+
+impl fmt::Debug for SessionData {
+  fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+    use oauth_core::redact::REDACTED;
+    f.debug_struct("SessionData")
+      .field("created_at", &self.created_at)
+      .field("csrf_token", &REDACTED)
+      .field(
+        "access_token",
+        &self.access_token.as_ref().map(|_| REDACTED),
+      )
+      .field(
+        "refresh_token",
+        &self.refresh_token.as_ref().map(|_| REDACTED),
+      )
+      .field("token_type", &self.token_type)
+      .finish()
+  }
+}
+
+/// A newly created session: the id for the cookie and the CSRF token for the
+/// JSON API.
+pub struct NewSession {
+  /// Opaque 256-bit session id; the only value placed in the cookie.
+  pub id: String,
+  /// Per-session CSRF token, returned through the JSON API.
+  pub csrf_token: SecretString,
+}
+
+/// Cloneable server-side session store with TTL expiry.
+#[derive(Debug, Clone)]
+pub struct SessionStore {
+  inner: Arc<RwLock<HashMap<String, SessionEntry>>>,
+  ttl: Duration,
+}
+
+#[derive(Debug, Clone)]
+struct SessionEntry {
+  data: SessionData,
+  created_at: Instant,
+}
+
+impl SessionStore {
+  /// Create an empty store; entries expire `ttl` after creation.
+  #[must_use]
+  pub fn new(ttl: Duration) -> Self {
+    Self {
+      inner: Arc::new(RwLock::new(HashMap::new())),
+      ttl,
+    }
+  }
+
+  /// Create a session, returning the cookie id and the CSRF token.
+  ///
+  /// # Errors
+  ///
+  /// Returns [`SessionError::Entropy`] when the CSPRNG is unavailable, or
+  /// [`SessionError::Lock`] when the store lock is poisoned.
+  pub fn create(&self) -> Result<NewSession, SessionError> {
+    let id = new_token()?;
+    let data = SessionData::fresh()?;
+    use secrecy::ExposeSecret as _;
+    let csrf_token = SecretString::from(data.csrf_token.expose_secret().to_owned());
+    self.inner.write().map_err(|_| SessionError::Lock)?.insert(
+      id.clone(),
+      SessionEntry {
+        data,
+        created_at: Instant::now(),
+      },
+    );
+    Ok(NewSession { id, csrf_token })
+  }
+
+  /// Look up a session; expired or unknown ids return `None` (expired entries
+  /// are evicted).
+  pub fn get(&self, id: &str) -> Option<SessionData> {
+    let mut guard = self.inner.write().ok()?;
+    let entry = guard.get(id)?;
+    if entry.created_at.elapsed() >= self.ttl {
+      guard.remove(id);
+      return None;
+    }
+    Some(entry.data.clone())
+  }
+
+  /// Rotate the session id, keeping the data (fixation defense: call on
+  /// login/privilege change). The old id stops working. Unknown or expired
+  /// ids yield `Ok(None)`.
+  ///
+  /// # Errors
+  ///
+  /// Returns [`SessionError::Entropy`] when the CSPRNG is unavailable, or
+  /// [`SessionError::Lock`] when the store lock is poisoned.
+  pub fn rotate(&self, old_id: &str) -> Result<Option<String>, SessionError> {
+    let mut guard = self.inner.write().map_err(|_| SessionError::Lock)?;
+    let Some(entry) = guard.remove(old_id) else {
+      return Ok(None);
+    };
+    if entry.created_at.elapsed() >= self.ttl {
+      return Ok(None);
+    }
+    let next_id = new_token()?;
+    guard.insert(next_id.clone(), entry);
+    Ok(Some(next_id))
+  }
+
+  /// Destroy a session (logout); unknown ids are a no-op.
+  pub fn destroy(&self, id: &str) {
+    if let Ok(mut guard) = self.inner.write() {
+      guard.remove(id);
+    }
+  }
+
+  /// Replace the tokens held by a live session (login/refresh rotation).
+  /// Returns `false` when the session is unknown or expired.
+  pub fn store_tokens(
+    &self,
+    id: &str,
+    access_token: Option<SecretString>,
+    refresh_token: Option<SecretString>,
+    token_type: Option<String>,
+  ) -> bool {
+    let Ok(mut guard) = self.inner.write() else {
+      return false;
+    };
+    let Some(entry) = guard.get_mut(id) else {
+      return false;
+    };
+    if entry.created_at.elapsed() >= self.ttl {
+      guard.remove(id);
+      return false;
+    }
+    entry.data.set_tokens(access_token, refresh_token);
+    entry.data.set_token_type(token_type);
+    true
+  }
+
+  /// Validate the presented CSRF token against the session's, in constant
+  /// time. Unknown/expired sessions never validate.
+  #[must_use]
+  pub fn validate_csrf(&self, id: &str, presented: &str) -> bool {
+    use secrecy::ExposeSecret as _;
+    let Some(data) = self.get(id) else {
+      return false;
+    };
+    let expected = data.csrf_token.expose_secret().as_bytes();
+    let given = presented.as_bytes();
+    expected.len() == given.len() && expected.ct_eq(given).unwrap_u8() == 1
+  }
+
+  /// Number of live (non-evicted) entries; tests and health probes only.
+  #[must_use]
+  pub fn len(&self) -> usize {
+    self.inner.read().map_or(0, |guard| guard.len())
+  }
+
+  /// Whether the store holds no entries.
+  #[must_use]
+  pub fn is_empty(&self) -> bool {
+    self.len() == 0
+  }
+}
+
+/// Generate a 256-bit base64url (no-pad) token.
+///
+/// # Errors
+///
+/// Returns [`SessionError::Entropy`] when the OS CSPRNG is unavailable.
+fn new_token() -> Result<String, SessionError> {
+  let mut raw = [0_u8; TOKEN_BYTES];
+  rand::rngs::OsRng
+    .try_fill_bytes(&mut raw)
+    .map_err(|_| SessionError::Entropy)?;
+  Ok(URL_SAFE_NO_PAD.encode(raw))
+}
+
+/// Build a `Set-Cookie` value for the session id.
+///
+/// Attributes: `Path=/`, `HttpOnly`, `SameSite=Lax`, `Max-Age=<ttl>`, and
+/// `Secure` when `secure` is set. The cookie carries only the opaque id —
+/// never tokens.
+#[must_use]
+pub fn set_cookie_value(id: &str, secure: bool, ttl: Duration) -> String {
+  let mut cookie = format!(
+    "{SESSION_COOKIE}={id}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
+    ttl.as_secs()
+  );
+  if secure {
+    cookie.push_str("; Secure");
+  }
+  cookie
+}
+
+/// Build a `Set-Cookie` value that clears the session cookie.
+#[must_use]
+pub fn clear_cookie_value() -> String {
+  format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")
+}
+
+/// Extract the session id from a `Cookie` header value.
+#[must_use]
+pub fn session_id_from_cookie_header(value: &str) -> Option<&str> {
+  value.split(';').find_map(|pair| {
+    let (name, cookie_value) = pair.split_once('=')?;
+    (name.trim() == SESSION_COOKIE).then(|| cookie_value.trim())
+  })
+}
+
+/// Origin/CSRF guard for state-changing routes (todos 17-18 mount it on
+/// `POST /api/refresh` and `POST /api/logout`).
+///
+/// Rejects with `403` when a present `Origin` does not match the request
+/// `Host`, or when `Sec-Fetch-Site` is `cross-site`. Rejects with `401` for a
+/// missing/unknown/expired session cookie, and with `403` for a missing or
+/// mismatched [`CSRF_HEADER`] token. Absent `Origin`/`Sec-Fetch-Site` headers
+/// (same-origin navigations, curl, tests) are allowed through to the session
+/// checks.
+pub async fn csrf_guard(
+  axum::extract::State(store): axum::extract::State<SessionStore>,
+  request: Request<axum::body::Body>,
+  next: Next,
+) -> Response {
+  if !origin_allows(request.headers(), request.uri()) {
+    return (StatusCode::FORBIDDEN, "cross-site request rejected").into_response();
+  }
+  let session_id = request
+    .headers()
+    .get(header::COOKIE)
+    .and_then(|value| value.to_str().ok())
+    .and_then(session_id_from_cookie_header)
+    .map(str::to_owned);
+  let Some(session_id) = session_id else {
+    return (StatusCode::UNAUTHORIZED, "no session").into_response();
+  };
+  if store.get(&session_id).is_none() {
+    return (StatusCode::UNAUTHORIZED, "no session").into_response();
+  }
+  let presented = request
+    .headers()
+    .get(CSRF_HEADER)
+    .and_then(|value| value.to_str().ok())
+    .unwrap_or_default();
+  if !store.validate_csrf(&session_id, presented) {
+    return (StatusCode::FORBIDDEN, "invalid csrf token").into_response();
+  }
+  next.run(request).await
+}
+
+/// Whether the request's origin headers permit a state-changing call.
+///
+/// A present `Origin` must match the `Host` authority (host and port); a
+/// `Sec-Fetch-Site: cross-site` is rejected. Everything else is allowed
+/// through to the session/CSRF checks.
+#[must_use]
+pub fn origin_allows(headers: &HeaderMap, uri: &axum::http::Uri) -> bool {
+  if let Some(origin) = headers
+    .get(header::ORIGIN)
+    .and_then(|value| value.to_str().ok())
+  {
+    return origin_matches_host(origin, headers, uri);
+  }
+  if let Some(site) = headers
+    .get("sec-fetch-site")
+    .and_then(|value| value.to_str().ok())
+  {
+    return !site.trim().eq_ignore_ascii_case("cross-site");
+  }
+  true
+}
+
+/// Whether an `Origin` value matches the request host authority.
+///
+/// Hosts must match case-insensitively. When the request authority carries an
+/// explicit port, the origin's effective port must equal it. When the request
+/// authority omits the port (e.g. Caddy forwards `Host: app.example`), it is
+/// accepted only if the origin's port is the scheme's default (http→80,
+/// https→443); a non-default origin port (`https://app.example:8443`) still
+/// fails, and a foreign origin host is always rejected.
+fn origin_matches_host(origin: &str, headers: &HeaderMap, uri: &axum::http::Uri) -> bool {
+  let (Some(origin_host), origin_port, origin_default_port) = split_origin(origin) else {
+    return false;
+  };
+  let (Some(request_host), request_port) = request_authority(headers, uri) else {
+    return false;
+  };
+  if !origin_host.eq_ignore_ascii_case(&request_host) {
+    return false;
+  }
+  match request_port {
+    Some(port) => origin_port == Some(port),
+    None => origin_default_port,
+  }
+}
+
+/// Split an `Origin` (`scheme://host[:port]`) into host, effective port, and
+/// whether that port is the scheme's default.
+fn split_origin(origin: &str) -> (Option<String>, Option<u16>, bool) {
+  let parsed: Option<(String, u16, bool)> = (|| {
+    let (scheme, rest) = origin.split_once("://")?;
+    let host_part = rest.split('/').next().unwrap_or(rest);
+    let default_port = match scheme {
+      "http" => 80,
+      "https" => 443,
+      _ => return None,
+    };
+    let (host, port, is_default) = match host_part.split_once(':') {
+      Some((host, port)) => {
+        let port = port.parse::<u16>().ok()?;
+        (host, port, port == default_port)
+      }
+      None => (host_part, default_port, true),
+    };
+    if host.is_empty() {
+      return None;
+    }
+    Some((host.to_owned(), port, is_default))
+  })();
+  match parsed {
+    Some((host, port, is_default)) => (Some(host), Some(port), is_default),
+    None => (None, None, false),
+  }
+}
+
+/// Request authority from the `Host` header (falling back to the URI).
+fn request_authority(headers: &HeaderMap, uri: &axum::http::Uri) -> (Option<String>, Option<u16>) {
+  let host_header = headers
+    .get(header::HOST)
+    .and_then(|value| value.to_str().ok());
+  let authority = host_header.or_else(|| uri.authority().map(|authority| authority.as_str()));
+  let Some(authority) = authority else {
+    return (None, None);
+  };
+  match authority.split_once(':') {
+    Some((host, port)) => (Some(host.to_owned()), port.parse::<u16>().ok()),
+    None => (Some(authority.to_owned()), None),
+  }
+}
+
+/// Session failures. No variant carries tokens or secrets.
+#[derive(Debug, Error)]
+pub enum SessionError {
+  /// The OS CSPRNG was unavailable for id generation.
+  #[error("session entropy unavailable")]
+  Entropy,
+  /// The session store lock was poisoned.
+  #[error("session store unavailable")]
+  Lock,
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+  use axum::body::Body;
+  use axum::http::Request;
+  use axum::routing::post;
+  use tower::ServiceExt as _;
+
+  fn test_store() -> SessionStore {
+    SessionStore::new(Duration::from_secs(3600))
+  }
+
+  fn request_headers(pairs: &[(&str, &str)]) -> HeaderMap {
+    let mut headers = HeaderMap::new();
+    for (name, value) in pairs {
+      headers.insert(
+        axum::http::HeaderName::from_bytes(name.as_bytes()).expect("valid header name"),
+        axum::http::HeaderValue::from_str(value).expect("valid header value"),
+      );
+    }
+    headers
+  }
+
+  fn origin_allows_headers(pairs: &[(&str, &str)]) -> bool {
+    origin_allows(
+      &request_headers(pairs),
+      &axum::http::Uri::from_static("/api/refresh"),
+    )
+  }
+
+  #[test]
+  fn origin_allows_default_port_when_request_host_omits_port() {
+    assert!(
+      origin_allows_headers(&[("host", "app.example"), ("origin", "https://app.example")]),
+      "Caddy forwards Host without a port; the implicit https default must match"
+    );
+  }
+
+  #[test]
+  fn origin_allows_matching_explicit_port() {
+    assert!(origin_allows_headers(&[
+      ("host", "127.0.0.1:3001"),
+      ("origin", "http://127.0.0.1:3001"),
+    ]));
+  }
+
+  #[test]
+  fn origin_rejects_foreign_host_even_without_request_port() {
+    assert!(!origin_allows_headers(&[
+      ("host", "app.example"),
+      ("origin", "https://evil.example"),
+    ]));
+  }
+
+  #[test]
+  fn origin_rejects_non_default_origin_port_when_request_host_omits_port() {
+    assert!(!origin_allows_headers(&[
+      ("host", "app.example"),
+      ("origin", "https://app.example:8443"),
+    ]));
+  }
+
+  #[test]
+  fn origin_allows_rejects_cross_site_sec_fetch() {
+    assert!(!origin_allows_headers(&[("sec-fetch-site", "cross-site")]));
+  }
+
+  #[test]
+  fn login_time_rotation_changes_the_id_and_kills_the_old() {
+    let store = test_store();
+    let before = store.create().expect("entropy available");
+    let after = store
+      .rotate(&before.id)
+      .expect("entropy available")
+      .expect("known session rotates");
+
+    assert_ne!(before.id, after, "rotation must issue a fresh id");
+    assert!(store.get(&before.id).is_none(), "old id must stop working");
+    assert!(store.get(&after).is_some(), "new id must resolve");
+  }
+
+  #[test]
+  fn cookie_carries_only_the_id_with_hardened_attributes() {
+    let id = "opaque-session-id";
+    let cookie = set_cookie_value(id, false, Duration::from_secs(3600));
+
+    assert!(cookie.starts_with("sid=opaque-session-id;"));
+    assert!(cookie.contains("HttpOnly"));
+    assert!(cookie.contains("SameSite=Lax"));
+    assert!(cookie.contains("Path=/"));
+    assert!(!cookie.contains("Secure"), "dev config leaves Secure off");
+    assert!(set_cookie_value(id, true, Duration::from_secs(60)).contains("Secure"));
+  }
+
+  #[test]
+  fn session_debug_and_cookie_never_carry_tokens() {
+    use secrecy::ExposeSecret as _;
+    let store = test_store();
+    let created = store.create().expect("entropy available");
+    let mut data = store.get(&created.id).expect("session exists");
+    data.set_tokens(
+      Some(SecretString::from("ACCESS-S3CRET")),
+      Some(SecretString::from("REFRESH-S3CRET")),
+    );
+
+    let rendered = format!("{data:?}");
+    assert!(!rendered.contains("ACCESS-S3CRET"));
+    assert!(!rendered.contains("REFRESH-S3CRET"));
+    assert!(!rendered.contains(data.csrf_token.expose_secret()));
+
+    let cookie = set_cookie_value(&created.id, false, Duration::from_secs(60));
+    assert!(!cookie.contains("ACCESS-S3CRET"));
+    assert_eq!(
+      session_id_from_cookie_header(&cookie),
+      Some(created.id.as_str())
+    );
+  }
+
+  #[test]
+  fn csrf_token_validates_for_its_own_session_only() {
+    use secrecy::ExposeSecret as _;
+    let store = test_store();
+    let first = store.create().expect("entropy available");
+    let second = store.create().expect("entropy available");
+
+    let token = first.csrf_token.expose_secret().to_owned();
+    assert!(store.validate_csrf(&first.id, &token));
+    assert!(
+      !store.validate_csrf(&second.id, &token),
+      "tokens are per-session"
+    );
+    assert!(!store.validate_csrf(&first.id, "forged"), "forgeries fail");
+    assert!(
+      !store.validate_csrf("unknown", &token),
+      "unknown sessions fail"
+    );
+  }
+
+  #[tokio::test]
+  async fn foreign_origin_post_is_rejected_with_403() {
+    use secrecy::ExposeSecret as _;
+    let store = test_store();
+    let created = store.create().expect("entropy available");
+    let app = axum::Router::new()
+      .route("/api/refresh", post(|| async { "ok" }))
+      .route_layer(axum::middleware::from_fn_with_state(
+        store.clone(),
+        csrf_guard,
+      ))
+      .with_state(store);
+
+    let response = app
+      .oneshot(
+        Request::builder()
+          .uri("/api/refresh")
+          .method("POST")
+          .header("host", "127.0.0.1:3001")
+          .header("origin", "https://evil.example")
+          .header("cookie", format!("{SESSION_COOKIE}={}", created.id))
+          .header(CSRF_HEADER, created.csrf_token.expose_secret())
+          .body(Body::empty())
+          .expect("request builds"),
+      )
+      .await
+      .expect("oneshot responds");
+
+    assert_eq!(response.status(), StatusCode::FORBIDDEN);
+  }
+
+  #[tokio::test]
+  async fn same_origin_post_with_csrf_token_passes() {
+    use secrecy::ExposeSecret as _;
+    let store = test_store();
+    let created = store.create().expect("entropy available");
+    let app = axum::Router::new()
+      .route("/api/refresh", post(|| async { "ok" }))
+      .route_layer(axum::middleware::from_fn_with_state(
+        store.clone(),
+        csrf_guard,
+      ))
+      .with_state(store);
+
+    let response = app
+      .oneshot(
+        Request::builder()
+          .uri("/api/refresh")
+          .method("POST")
+          .header("host", "127.0.0.1:3001")
+          .header("origin", "http://127.0.0.1:3001")
+          .header("cookie", format!("{SESSION_COOKIE}={}", created.id))
+          .header(CSRF_HEADER, created.csrf_token.expose_secret())
+          .body(Body::empty())
+          .expect("request builds"),
+      )
+      .await
+      .expect("oneshot responds");
+
+    assert_eq!(response.status(), StatusCode::OK);
+  }
+
+  #[tokio::test]
+  async fn missing_csrf_token_is_rejected_with_403() {
+    let store = test_store();
+    let created = store.create().expect("entropy available");
+    let app = axum::Router::new()
+      .route("/api/logout", post(|| async { "ok" }))
+      .route_layer(axum::middleware::from_fn_with_state(
+        store.clone(),
+        csrf_guard,
+      ))
+      .with_state(store);
+
+    let response = app
+      .oneshot(
+        Request::builder()
+          .uri("/api/logout")
+          .method("POST")
+          .header("host", "127.0.0.1:3001")
+          .header("cookie", format!("{SESSION_COOKIE}={}", created.id))
+          .body(Body::empty())
+          .expect("request builds"),
+      )
+      .await
+      .expect("oneshot responds");
+
+    assert_eq!(response.status(), StatusCode::FORBIDDEN);
+  }
+}

+ 138 - 0
crates/server/src/state.rs

@@ -0,0 +1,138 @@
+//! Shared application state handed to every handler.
+//!
+//! [`AppState`] is cheap to clone (all shared data sits behind [`Arc`]s) and
+//! carries the resolved [`ServerConfig`], the hardened outbound
+//! [`reqwest::Client`][crate::http_client::build_client], and the
+//! server-side [`SessionStore`]. Adding fields here keeps handler signatures
+//! stable for todos 15-18.
+
+use std::sync::Arc;
+
+use crate::config::ServerConfig;
+use crate::http_client::{self, HttpClientError};
+use crate::oauth::{DiscoveryCache, IntrospectCache, PendingStore};
+use crate::session::SessionStore;
+
+/// Cloneable handle to the server's shared configuration and resources.
+#[derive(Debug, Clone)]
+pub struct AppState {
+  config: Arc<ServerConfig>,
+  client: reqwest::Client,
+  sessions: SessionStore,
+  pending: PendingStore,
+  discovery: DiscoveryCache,
+  introspected: IntrospectCache,
+  dpop_key: Option<std::sync::Arc<oauth_core::dpop::key::DpopKey>>,
+}
+
+impl AppState {
+  /// Create state from a resolved configuration with a default fail-closed
+  /// HTTP client. Prefer [`AppState::try_new`] so the configured TLS trust
+  /// (custom CA / dev bypass) applies to the shared client.
+  #[must_use]
+  pub fn new(config: ServerConfig) -> Self {
+    let sessions = SessionStore::new(config.session_ttl());
+    Self {
+      config: Arc::new(config),
+      client: http_client::default_client(),
+      sessions,
+      pending: PendingStore::new(),
+      discovery: DiscoveryCache::new(),
+      introspected: IntrospectCache::new(),
+      dpop_key: None,
+    }
+  }
+
+  /// Create state from a resolved configuration, building the shared client
+  /// with the configured TLS trust.
+  ///
+  /// # Errors
+  ///
+  /// Returns [`HttpClientError`] when the CA bundle cannot be loaded or the
+  /// client cannot be constructed.
+  pub fn try_new(config: ServerConfig) -> Result<Self, HttpClientError> {
+    let client = http_client::build_client_for_config(&config)?;
+    let dpop_key = crate::oauth::load_dpop_key(config.dpop_key_path());
+    let sessions = SessionStore::new(config.session_ttl());
+    Ok(Self {
+      config: Arc::new(config),
+      client,
+      sessions,
+      pending: PendingStore::new(),
+      discovery: DiscoveryCache::new(),
+      introspected: IntrospectCache::new(),
+      dpop_key,
+    })
+  }
+
+  /// Create state with an explicit client (tests and custom wiring).
+  #[must_use]
+  pub fn with_client(config: ServerConfig, client: reqwest::Client) -> Self {
+    let sessions = SessionStore::new(config.session_ttl());
+    Self {
+      config: Arc::new(config),
+      client,
+      sessions,
+      pending: PendingStore::new(),
+      discovery: DiscoveryCache::new(),
+      introspected: IntrospectCache::new(),
+      dpop_key: None,
+    }
+  }
+
+  /// Attach a DPoP signing key (tests and custom wiring).
+  #[must_use]
+  pub fn with_dpop_key(mut self, key: oauth_core::dpop::key::DpopKey) -> Self {
+    self.dpop_key = Some(Arc::new(key));
+    self
+  }
+
+  /// The resolved server configuration.
+  #[must_use]
+  pub fn config(&self) -> &ServerConfig {
+    self.config.as_ref()
+  }
+
+  /// The shared hardened outbound HTTP client for AS calls.
+  #[must_use]
+  pub fn client(&self) -> &reqwest::Client {
+    &self.client
+  }
+
+  /// The server-side session store.
+  #[must_use]
+  pub fn sessions(&self) -> &SessionStore {
+    &self.sessions
+  }
+
+  /// Pending authorization records keyed by `state` (single-use).
+  #[must_use]
+  pub fn pending(&self) -> &PendingStore {
+    &self.pending
+  }
+
+  /// Cached authorization-server metadata keyed by issuer.
+  #[must_use]
+  pub fn discovery(&self) -> &DiscoveryCache {
+    &self.discovery
+  }
+
+  /// Short-lived introspection answer cache.
+  #[must_use]
+  pub fn introspected(&self) -> &IntrospectCache {
+    &self.introspected
+  }
+
+  /// The DPoP signing key, when `config/dpop-key.json` (or the configured
+  /// path) was present at startup. Handlers report a typed error when absent.
+  #[must_use]
+  pub fn dpop_key(&self) -> Option<std::sync::Arc<oauth_core::dpop::key::DpopKey>> {
+    self.dpop_key.clone()
+  }
+}
+
+impl From<ServerConfig> for AppState {
+  fn from(config: ServerConfig) -> Self {
+    Self::new(config)
+  }
+}

+ 104 - 0
crates/server/src/static_files.rs

@@ -0,0 +1,104 @@
+//! Static SPA serving with an `index.html` fallback and security headers.
+//!
+//! Wraps [`tower_http::services::ServeDir`] over the built `crates/web/dist`
+//! directory. When `index.html` exists, extensionless unknown paths fall back
+//! to it so client-side routes survive a hard refresh, while missing assets
+//! (paths whose last segment contains a `.`, e.g. `/assets/missing.js`) stay
+//! `404`. When the directory has not been built yet the fallback is a plain
+//! `404`, and the service never panics on the missing directory.
+//!
+//! The SPA fallback MUST use [`ServeDir::fallback`], not
+//! [`ServeDir::not_found_service`]. In tower-http 0.7.x `not_found_service`
+//! is implemented as `self.fallback(SetStatus::new(fallback, NOT_FOUND))`
+//! (see `serve_dir/mod.rs`), so it forcibly rewrites the served
+//! `index.html` to `404 Not Found`. `fallback` preserves the fallback
+//! service's own status, which is what a SPA needs: an unknown client route
+//! returns `200` with the app shell so the router can take over.
+//!
+//! Every static response carries `X-Content-Type-Options`, `Referrer-Policy`,
+//! and a strict WASM-appropriate CSP (external `.js`/`.wasm` only, no inline
+//! scripts). `ServeDir` neutralizes `..` segments, so traversal attempts
+//! cannot escape the dist root.
+
+use std::path::{Path, PathBuf};
+
+use axum::Router;
+use axum::http::Uri;
+use axum::http::header::{self, HeaderName, HeaderValue};
+use axum::middleware;
+use axum::response::{IntoResponse, Response};
+use axum::routing::get;
+use tower::ServiceExt as _;
+use tower_http::services::{ServeDir, ServeFile};
+
+/// The SPA entry point looked up inside the dist directory.
+pub const INDEX_FILE: &str = "index.html";
+
+/// Static CSP: scripts, styles, images, and connections from same origin
+/// only; `script-src` carries `'wasm-unsafe-eval'` so `WebAssembly.compile` /
+/// `instantiate` works without `'unsafe-eval'` or inline scripts; no plugins,
+/// no foreign frames as a base.
+pub const STATIC_CSP: &str = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; \
+  img-src 'self' data:; font-src 'self' data:; connect-src 'self'; \
+  object-src 'none'; base-uri 'self'";
+
+/// Name of the CSP response header.
+pub const CSP_HEADER: HeaderName = HeaderName::from_static("content-security-policy");
+
+/// Build the static service for `dist_dir`.
+///
+/// The returned router's fallback always tries the dist directory first. If
+/// `dist_dir/index.html` is present, extensionless unknown paths are served
+/// it (SPA fallback); otherwise unknown paths are `404`. Neither branch reads
+/// the filesystem at build time beyond the `is_file` probe, so a missing dist
+/// directory is safe.
+pub fn service(dist_dir: &Path) -> Router {
+  let serve_dir = ServeDir::new(dist_dir).append_index_html_on_directories(true);
+  let index = dist_dir.join(INDEX_FILE);
+  let router = if index.is_file() {
+    let fallback = Router::new().fallback(get({
+      let index = index.clone();
+      move |uri: Uri| serve_spa_fallback(index.clone(), uri)
+    }));
+    Router::new().fallback_service(serve_dir.fallback(fallback))
+  } else {
+    Router::new().fallback_service(serve_dir)
+  };
+  router.layer(middleware::map_response(set_static_headers))
+}
+
+/// Serve `index.html` for client-side routes; `404` for missing assets.
+async fn serve_spa_fallback(index: PathBuf, uri: Uri) -> Response {
+  let last_segment = uri.path().rsplit('/').next().unwrap_or_default();
+  if last_segment.contains('.') {
+    return axum::http::StatusCode::NOT_FOUND.into_response();
+  }
+  let request = axum::http::Request::builder()
+    .uri(uri)
+    .body(axum::body::Body::empty());
+  let Ok(request) = request else {
+    return axum::http::StatusCode::BAD_REQUEST.into_response();
+  };
+  ServeFile::new(index)
+    .oneshot(request)
+    .await
+    .map(IntoResponse::into_response)
+    .unwrap_or_else(|never| match never {})
+}
+
+/// Insert the static security headers, preserving any CSP already set.
+pub async fn set_static_headers(mut response: Response) -> Response {
+  let headers = response.headers_mut();
+  headers.insert(
+    header::X_CONTENT_TYPE_OPTIONS,
+    HeaderValue::from_static("nosniff"),
+  );
+  headers.insert(
+    header::REFERRER_POLICY,
+    HeaderValue::from_static("same-origin"),
+  );
+  if !headers.contains_key(CSP_HEADER.clone()) {
+    headers.insert(CSP_HEADER.clone(), HeaderValue::from_static(STATIC_CSP));
+  }
+  response
+}

+ 66 - 0
crates/server/tests/health.rs

@@ -0,0 +1,66 @@
+//! Integration tests for the BFF shell.
+//!
+//! These drive the [`Router`] through `tower::ServiceExt::oneshot`, which
+//! exercises the real handler stack without binding a port or starting a
+//! server.
+
+use std::net::SocketAddr;
+use std::path::{Path, PathBuf};
+
+use axum::body::Body;
+use axum::http::{Request, StatusCode};
+use server::app::build_app;
+use server::config::ServerConfig;
+use server::state::AppState;
+use tower::ServiceExt as _;
+
+/// Path to the SPA build output owned by Wave 4, resolved independently of
+/// the test's working directory.
+fn dist_dir() -> PathBuf {
+  Path::new(env!("CARGO_MANIFEST_DIR"))
+    .join("..")
+    .join("web")
+    .join("dist")
+}
+
+/// A router built from an explicit config (no environment dependence).
+fn test_app() -> axum::Router {
+  let config = ServerConfig::new(SocketAddr::from(([127, 0, 0, 1], 3001)), dist_dir());
+  build_app(AppState::new(config))
+}
+
+#[tokio::test]
+async fn healthz_returns_200_without_binding_a_port() -> Result<(), Box<dyn std::error::Error>> {
+  let response = test_app()
+    .oneshot(Request::builder().uri("/healthz").body(Body::empty())?)
+    .await?;
+
+  assert_eq!(response.status(), StatusCode::OK);
+  let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
+  let body = std::str::from_utf8(&bytes)?;
+  assert!(body.contains("ok"), "unexpected health body: {body:?}");
+  Ok(())
+}
+
+#[tokio::test]
+async fn unknown_path_is_404_or_spa_fallback_when_dist_exists()
+-> Result<(), Box<dyn std::error::Error>> {
+  let response = test_app()
+    .oneshot(Request::builder().uri("/nope").body(Body::empty())?)
+    .await?;
+
+  if dist_dir().join("index.html").is_file() {
+    assert_eq!(
+      response.status(),
+      StatusCode::OK,
+      "SPA fallback should serve index.html for an unknown path"
+    );
+  } else {
+    assert_eq!(
+      response.status(),
+      StatusCode::NOT_FOUND,
+      "without a built dist, an unknown path must be 404"
+    );
+  }
+  Ok(())
+}

+ 766 - 0
crates/server/tests/integration.rs

@@ -0,0 +1,766 @@
+//! Full-flow integration suite against a `wiremock` fake AS (todo 21).
+//!
+//! The fake AS implements the d-OAuth shape: RFC 8414 discovery (PAR +
+//! introspection + revocation endpoints, DPoP ES256, no userinfo), `/par`,
+//! `/token`, `/introspect`, `/revoke`. Every test drives the real router with
+//! `tower::ServiceExt::oneshot`: no ports are bound, no external servers
+//! start, no real network is touched.
+
+use axum::body::Body;
+use axum::http::{Request, StatusCode};
+use secrecy::{ExposeSecret as _, SecretString};
+use server::app::build_app;
+use server::config::ServerConfig;
+use server::session::SESSION_COOKIE;
+use server::state::AppState;
+use tower::ServiceExt as _;
+use wiremock::{
+  Mock, MockServer, ResponseTemplate,
+  matchers::{method, path},
+};
+
+type TestResult = Result<(), Box<dyn std::error::Error>>;
+
+struct Fixture {
+  app: axum::Router,
+  state: AppState,
+  issuer: String,
+  public_jwk: serde_json::Value,
+  jkt: String,
+}
+
+fn empty_lookup(_: &str) -> Option<String> {
+  None
+}
+
+/// Build an app whose profile points at the fake AS, with an in-memory DPoP
+/// key. The key's public JWK + thumbprint are kept for proof verification.
+fn fixture_app(fake: &MockServer) -> (Fixture, tempfile::TempDir) {
+  let dir = tempfile::tempdir().expect("temp dir is creatable");
+  let dist = dir.path().join("dist");
+  std::fs::create_dir_all(&dist).expect("fixture dist is creatable");
+  std::fs::write(
+    dist.join("index.html"),
+    "<html><body>spa-shell</body></html>",
+  )
+  .expect("fixture index is writable");
+
+  let issuer = fake.uri();
+  let config_json = format!(
+    r#"{{
+      "server": {{ "dist_dir": {} }},
+      "oauth": {{
+        "issuer": "{issuer}",
+        "redirect_uri": "http://127.0.0.1:3000/callback",
+        "scope": "demo.read"
+      }}
+    }}"#,
+    serde_json::to_string(&dist).expect("path serializes")
+  );
+  std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable");
+  std::fs::write(
+    dir.path().join("client.json"),
+    r#"{"client_id": "demo-client", "client_secret": "demo-secret"}"#,
+  )
+  .expect("client fixture is writable");
+
+  let config = ServerConfig::load_from_paths_with(
+    Some(&dir.path().join("config.json")),
+    Some(&dir.path().join("client.json")),
+    &empty_lookup,
+  )
+  .expect("fixture config loads");
+  let key = oauth_core::dpop::key::DpopKey::generate().expect("dpop key generates");
+  let public_jwk = key.public_jwk();
+  let jkt = key.thumbprint().expect("thumbprint computes");
+  let state = AppState::with_client(config, reqwest::Client::new()).with_dpop_key(key);
+  let app = build_app(state.clone());
+  (
+    Fixture {
+      app,
+      state,
+      issuer,
+      public_jwk,
+      jkt,
+    },
+    dir,
+  )
+}
+
+/// d-OAuth-shaped discovery: PAR + introspection + revocation + DPoP ES256,
+/// no userinfo endpoint.
+async fn mount_discovery(fake: &MockServer, issuer: &str) {
+  let doc = serde_json::json!({
+    "issuer": issuer,
+    "authorization_endpoint": format!("{issuer}/authorize"),
+    "token_endpoint": format!("{issuer}/token"),
+    "pushed_authorization_request_endpoint": format!("{issuer}/par"),
+    "introspection_endpoint": format!("{issuer}/introspect"),
+    "revocation_endpoint": format!("{issuer}/revoke"),
+    "dpop_signing_alg_values_supported": ["ES256"],
+  });
+  Mock::given(method("GET"))
+    .and(path("/.well-known/oauth-authorization-server"))
+    .respond_with(ResponseTemplate::new(200).set_body_json(&doc))
+    .mount(fake)
+    .await;
+}
+
+async fn mount_par(fake: &MockServer) {
+  Mock::given(method("POST"))
+    .and(path("/par"))
+    .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
+      "request_uri": "urn:ietf:params:oauth:request_uri:par-handle-1",
+      "expires_in": 90
+    })))
+    .mount(fake)
+    .await;
+}
+
+fn token_ok(access: &str, refresh: &str) -> ResponseTemplate {
+  ResponseTemplate::new(200).set_body_json(serde_json::json!({
+    "access_token": access,
+    "token_type": "DPoP",
+    "expires_in": 3600,
+    "refresh_token": refresh,
+    "scope": "demo.read",
+  }))
+}
+
+async fn mount_token(fake: &MockServer, template: ResponseTemplate) {
+  Mock::given(method("POST"))
+    .and(path("/token"))
+    .respond_with(template)
+    .mount(fake)
+    .await;
+}
+
+async fn mount_introspect(fake: &MockServer, body: serde_json::Value) {
+  Mock::given(method("POST"))
+    .and(path("/introspect"))
+    .respond_with(ResponseTemplate::new(200).set_body_json(&body))
+    .mount(fake)
+    .await;
+}
+
+async fn mount_revoke(fake: &MockServer) {
+  Mock::given(method("POST"))
+    .and(path("/revoke"))
+    .respond_with(ResponseTemplate::new(200))
+    .mount(fake)
+    .await;
+}
+
+fn active_introspection(exp: i64, iat: i64, jkt: &str) -> serde_json::Value {
+  serde_json::json!({
+    "active": true,
+    "scope": "demo.read",
+    "client_id": "demo-client",
+    "token_type": "Bearer",
+    "exp": exp,
+    "iat": iat,
+    "sub": "demo@example.com",
+    "cnf": { "jkt": jkt },
+  })
+}
+
+fn now_secs() -> i64 {
+  std::time::SystemTime::now()
+    .duration_since(std::time::UNIX_EPOCH)
+    .expect("clock available")
+    .as_secs() as i64
+}
+
+async fn send(
+  app: axum::Router,
+  request: Request<Body>,
+) -> Result<axum::http::Response<Body>, Box<dyn std::error::Error>> {
+  Ok(app.oneshot(request).await?)
+}
+
+fn get_request(uri: &str, cookie: Option<&str>) -> Request<Body> {
+  let mut builder = Request::builder().uri(uri);
+  if let Some(cookie) = cookie {
+    builder = builder.header("cookie", cookie);
+  }
+  builder.body(Body::empty()).expect("request builds")
+}
+
+fn post_request(uri: &str, cookie: &str, csrf: &str, origin: Option<&str>) -> Request<Body> {
+  let mut builder = Request::builder()
+    .uri(uri)
+    .method("POST")
+    .header("cookie", cookie)
+    .header("x-csrf-token", csrf);
+  if let Some(origin) = origin {
+    builder = builder.header("origin", origin);
+  }
+  builder.body(Body::empty()).expect("request builds")
+}
+
+async fn body_text(
+  response: axum::http::Response<Body>,
+) -> Result<String, Box<dyn std::error::Error>> {
+  let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
+  Ok(std::str::from_utf8(&bytes)?.to_owned())
+}
+
+fn header_value(response: &axum::http::Response<Body>, name: &str) -> String {
+  response
+    .headers()
+    .get(name)
+    .and_then(|value| value.to_str().ok())
+    .unwrap_or_default()
+    .to_owned()
+}
+
+fn sid_from_set_cookie(set_cookie: &str) -> Option<String> {
+  set_cookie.split(';').next().and_then(|pair| {
+    let (name, value) = pair.split_once('=')?;
+    (name.trim() == SESSION_COOKIE).then(|| value.trim().to_owned())
+  })
+}
+
+fn cookie_for(sid: &str) -> String {
+  format!("{SESSION_COOKIE}={sid}")
+}
+
+/// `/oauth/start` -> bound `(state, sid)`.
+async fn start_flow(fixture: &Fixture) -> Result<(String, String), Box<dyn std::error::Error>> {
+  let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
+  assert_eq!(response.status(), StatusCode::FOUND);
+  let location = header_value(&response, "location");
+  assert!(
+    location.contains("/authorize"),
+    "unexpected Location: {location}"
+  );
+  assert!(
+    location.contains("request_uri"),
+    "PAR handle missing: {location}"
+  );
+  let sid = sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("sid cookie");
+  let state = fixture
+    .state
+    .pending()
+    .pending_state_for_session(&sid)
+    .expect("state bound to the session");
+  Ok((state, sid))
+}
+
+/// `/callback` happy path -> rotated sid.
+async fn callback_flow(
+  fixture: &Fixture,
+  state: &str,
+  sid: &str,
+) -> Result<String, Box<dyn std::error::Error>> {
+  let response = send(
+    fixture.app.clone(),
+    get_request(
+      &format!("/callback?code=authcode-1&state={state}"),
+      Some(&cookie_for(sid)),
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::FOUND);
+  assert_eq!(header_value(&response, "location"), "/dashboard");
+  let next = sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("rotated cookie");
+  assert_ne!(sid, next, "session id must rotate on login");
+  Ok(next)
+}
+
+async fn session_json(
+  fixture: &Fixture,
+  sid: &str,
+) -> Result<(StatusCode, serde_json::Value), Box<dyn std::error::Error>> {
+  let response = send(
+    fixture.app.clone(),
+    get_request("/api/session", Some(&cookie_for(sid))),
+  )
+  .await?;
+  let status = response.status();
+  let body = body_text(response).await?;
+  let json: serde_json::Value =
+    serde_json::from_str(&body).expect("session body is JSON on every status");
+  Ok((status, json))
+}
+
+// ---- happy path: the whole flow end to end ----
+
+#[tokio::test]
+async fn full_flow_start_to_logout() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+  let now = now_secs();
+  mount_introspect(
+    &fake,
+    active_introspection(now + 300, now - 10, &fixture.jkt),
+  )
+  .await;
+  mount_revoke(&fake).await;
+
+  // `/oauth/start` -> extract `state`.
+  let (state, sid) = start_flow(&fixture).await?;
+
+  // `/callback` -> session + rotation.
+  let sid = callback_flow(&fixture, &state, &sid).await?;
+
+  // `/api/session` -> subject + scopes + DPoP display type.
+  let (status, json) = session_json(&fixture, &sid).await?;
+  assert_eq!(status, StatusCode::OK);
+  assert_eq!(json["subject"], "demo@example.com");
+  assert_eq!(json["scope"], serde_json::json!(["demo.read"]));
+  assert_eq!(json["token_type"], "DPoP");
+  assert_eq!(
+    json["cnf"]["jkt"],
+    serde_json::Value::String(fixture.jkt.clone())
+  );
+  let csrf = json["csrf_token"]
+    .as_str()
+    .expect("csrf_token served")
+    .to_owned();
+
+  // `/api/refresh` -> rotation receipt, no tokens in the body.
+  let response = send(
+    fixture.app.clone(),
+    post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  assert!(
+    body.contains("refreshed"),
+    "unexpected refresh body: {body}"
+  );
+  for forbidden in ["access-1", "refresh-1", "demo-secret"] {
+    assert!(
+      !body.contains(forbidden),
+      "secret leaked into refresh: {body}"
+    );
+  }
+
+  // `/api/logout` -> cleared session + AS note.
+  let response = send(
+    fixture.app.clone(),
+    post_request("/api/logout", &cookie_for(&sid), &csrf, None),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  assert!(
+    body.contains("provider logout endpoint"),
+    "logout note missing: {body}"
+  );
+  assert!(
+    fixture.state.sessions().get(&sid).is_none(),
+    "logout clears the session"
+  );
+
+  // Post-logout session probe is 401.
+  let (status, _) = session_json(&fixture, &sid).await?;
+  assert_eq!(status, StatusCode::UNAUTHORIZED);
+
+  // Both tokens were revoked best-effort.
+  let sent = fake.received_requests().await.unwrap_or_default();
+  assert_eq!(
+    sent
+      .iter()
+      .filter(|request| request.url.path() == "/revoke")
+      .count(),
+    2,
+    "refresh + access must both be revoked"
+  );
+  Ok(())
+}
+
+// ---- DPoP proofs verify against the JWK the client sent ----
+
+#[tokio::test]
+async fn dpop_proofs_verify_against_the_sent_jwk() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+  let now = now_secs();
+  mount_introspect(
+    &fake,
+    active_introspection(now + 300, now - 10, &fixture.jkt),
+  )
+  .await;
+
+  let (state, sid) = start_flow(&fixture).await?;
+  let sid = callback_flow(&fixture, &state, &sid).await?;
+  let (_, json) = session_json(&fixture, &sid).await?;
+  let csrf = json["csrf_token"]
+    .as_str()
+    .expect("csrf_token served")
+    .to_owned();
+
+  // Refresh so the fake AS sees two proofs (exchange + rotation).
+  let response = send(
+    fixture.app.clone(),
+    post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::OK);
+
+  let sent = fake.received_requests().await.unwrap_or_default();
+  let proofs: Vec<String> = sent
+    .iter()
+    .filter(|request| request.url.path() == "/token")
+    .filter_map(|request| {
+      request
+        .headers
+        .get("dpop")
+        .and_then(|value| value.to_str().ok())
+        .map(str::to_owned)
+    })
+    .collect();
+  assert_eq!(proofs.len(), 2, "exchange + refresh each send a proof");
+
+  let mut jtis = Vec::new();
+  for proof in &proofs {
+    let verified = oauth_core::dpop::proof::verify_proof(proof).expect("proof verifies");
+    assert_eq!(verified.htm, "POST");
+    assert_eq!(verified.htu, format!("{}/token", fixture.issuer));
+    let sent_jwk = verified.header.get("jwk").expect("proof carries its JWK");
+    assert_eq!(sent_jwk, &fixture.public_jwk, "proof key is the client key");
+    let jkt = oauth_core::dpop::key::thumbprint_of(sent_jwk).expect("thumbprint computes");
+    assert_eq!(
+      jkt, fixture.jkt,
+      "bound key matches the introspected cnf.jkt"
+    );
+    jtis.push(verified.jti.clone());
+  }
+  assert_ne!(jtis[0], jtis[1], "each proof carries a fresh jti");
+  Ok(())
+}
+
+// ---- failure paths ----
+
+#[tokio::test]
+async fn state_mismatch_never_exchanges() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+
+  let (_state, sid) = start_flow(&fixture).await?;
+  let response = send(
+    fixture.app.clone(),
+    get_request(
+      "/callback?code=authcode-1&state=forged-state",
+      Some(&cookie_for(&sid)),
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+  let sent = fake.received_requests().await.unwrap_or_default();
+  assert!(
+    sent.iter().all(|request| request.url.path() != "/token"),
+    "state mismatch must never trigger an exchange"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn discovery_503_yields_a_typed_error_page() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  Mock::given(method("GET"))
+    .and(path("/.well-known/oauth-authorization-server"))
+    .respond_with(ResponseTemplate::new(503))
+    .mount(&fake)
+    .await;
+  Mock::given(method("GET"))
+    .and(path("/.well-known/openid-configuration"))
+    .respond_with(ResponseTemplate::new(503))
+    .mount(&fake)
+    .await;
+
+  let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
+  assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
+  let body = body_text(response).await?;
+  assert!(
+    body.contains("unavailable"),
+    "expected a typed error page, got: {body}"
+  );
+  for forbidden in ["demo-secret", "code_verifier", "verifier"] {
+    assert!(
+      !body.contains(forbidden),
+      "secret leaked into error page: {body}"
+    );
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn invalid_client_exchange_fails_closed_without_session() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer).await;
+  mount_par(&fake).await;
+  mount_token(
+    &fake,
+    ResponseTemplate::new(401).set_body_json(serde_json::json!({
+      "error": "invalid_client",
+      "error_description": "unknown client",
+    })),
+  )
+  .await;
+
+  let (state, sid) = start_flow(&fixture).await?;
+  let response = send(
+    fixture.app.clone(),
+    get_request(
+      &format!("/callback?code=authcode-1&state={state}"),
+      Some(&cookie_for(&sid)),
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
+  let body = body_text(response).await?;
+  assert!(
+    !body.contains("demo-secret"),
+    "secret leaked into error page: {body}"
+  );
+  let (status, _) = session_json(&fixture, &sid).await?;
+  assert_eq!(
+    status,
+    StatusCode::UNAUTHORIZED,
+    "failed exchange creates no session"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn refresh_reuse_invalid_grant_clears_the_session() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer).await;
+  mount_par(&fake).await;
+  // First `/token` call (the exchange) succeeds; the refresh reuses a revoked
+  // family member and is rejected.
+  Mock::given(method("POST"))
+    .and(path("/token"))
+    .respond_with(token_ok("access-1", "refresh-1"))
+    .up_to_n_times(1)
+    .mount(&fake)
+    .await;
+  Mock::given(method("POST"))
+    .and(path("/token"))
+    .respond_with(ResponseTemplate::new(400).set_body_json(serde_json::json!({
+      "error": "invalid_grant",
+      "error_description": "refresh token reused",
+    })))
+    .mount(&fake)
+    .await;
+  let now = now_secs();
+  mount_introspect(
+    &fake,
+    active_introspection(now + 300, now - 10, &fixture.jkt),
+  )
+  .await;
+
+  let (state, sid) = start_flow(&fixture).await?;
+  let sid = callback_flow(&fixture, &state, &sid).await?;
+  let (_, json) = session_json(&fixture, &sid).await?;
+  let csrf = json["csrf_token"]
+    .as_str()
+    .expect("csrf_token served")
+    .to_owned();
+
+  let response = send(
+    fixture.app.clone(),
+    post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+  let body = body_text(response).await?;
+  assert!(
+    body.contains("invalid_grant"),
+    "expected the reuse code, got: {body}"
+  );
+  assert!(
+    fixture.state.sessions().get(&sid).is_none(),
+    "reuse must clear the session"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn csrf_and_origin_rejections_leave_the_session_intact() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+  let now = now_secs();
+  mount_introspect(
+    &fake,
+    active_introspection(now + 300, now - 10, &fixture.jkt),
+  )
+  .await;
+
+  let (state, sid) = start_flow(&fixture).await?;
+  let sid = callback_flow(&fixture, &state, &sid).await?;
+  let (_, json) = session_json(&fixture, &sid).await?;
+  let csrf = json["csrf_token"]
+    .as_str()
+    .expect("csrf_token served")
+    .to_owned();
+
+  // Foreign `Origin` is rejected.
+  let response = send(
+    fixture.app.clone(),
+    post_request(
+      "/api/refresh",
+      &cookie_for(&sid),
+      &csrf,
+      Some("https://evil.example"),
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::FORBIDDEN);
+
+  // Missing CSRF token is rejected.
+  let response = send(
+    fixture.app.clone(),
+    Request::builder()
+      .uri("/api/refresh")
+      .method("POST")
+      .header("cookie", cookie_for(&sid))
+      .body(Body::empty())
+      .expect("request builds"),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::FORBIDDEN);
+
+  // Cross-site fetch metadata is rejected.
+  let response = send(
+    fixture.app.clone(),
+    Request::builder()
+      .uri("/api/logout")
+      .method("POST")
+      .header("cookie", cookie_for(&sid))
+      .header("x-csrf-token", csrf.clone())
+      .header("sec-fetch-site", "cross-site")
+      .body(Body::empty())
+      .expect("request builds"),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::FORBIDDEN);
+
+  assert!(
+    fixture.state.sessions().get(&sid).is_some(),
+    "rejected POSTs must not touch the session"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn cookie_flags_are_hardened_through_the_flow() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+  let now = now_secs();
+  mount_introspect(
+    &fake,
+    active_introspection(now + 300, now - 10, &fixture.jkt),
+  )
+  .await;
+  mount_revoke(&fake).await;
+
+  // Start cookie: opaque id only, hardened attributes.
+  let start = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
+  let start_cookie = header_value(&start, "set-cookie");
+  for expected in ["sid=", "Path=/", "HttpOnly", "SameSite=Lax"] {
+    assert!(
+      start_cookie.contains(expected),
+      "start cookie misses {expected}: {start_cookie}"
+    );
+  }
+  assert!(
+    !start_cookie.contains("access-1"),
+    "token in cookie: {start_cookie}"
+  );
+  let sid = sid_from_set_cookie(&start_cookie).expect("sid cookie");
+  let state = fixture
+    .state
+    .pending()
+    .pending_state_for_session(&sid)
+    .expect("state bound to the session");
+
+  // Login rotates the id with the same flags.
+  let sid = callback_flow(&fixture, &state, &sid).await?;
+
+  let (_, json) = session_json(&fixture, &sid).await?;
+  let csrf = json["csrf_token"]
+    .as_str()
+    .expect("csrf_token served")
+    .to_owned();
+  let response = send(
+    fixture.app.clone(),
+    post_request("/api/logout", &cookie_for(&sid), &csrf, None),
+  )
+  .await?;
+  let cleared = header_value(&response, "set-cookie");
+  assert!(
+    cleared.contains("Max-Age=0"),
+    "logout must clear the cookie: {cleared}"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn no_secret_reaches_logs_or_debug_output() {
+  let line = server::http_client::loggable_request(
+    "POST",
+    "https://as.example/token?code=AUTH-C0DE",
+    true,
+    true,
+  );
+  let decorated = format!(
+    "{line} secret={} token={}",
+    server::http_client::redact_secret("CLIENT-S3CRET"),
+    server::http_client::redact_secret("ACCESS-T0KEN"),
+  );
+  for sensitive in ["CLIENT-S3CRET", "ACCESS-T0KEN", "AUTH-C0DE"] {
+    assert!(
+      !decorated.contains(sensitive),
+      "secret leaked into log line: {decorated:?}"
+    );
+  }
+
+  let store = fixture_store();
+  let created = store.create().expect("session creates");
+  store.store_tokens(
+    &created.id,
+    Some(SecretString::from("ACCESS-S3CRET")),
+    Some(SecretString::from("REFRESH-S3CRET")),
+    Some("DPoP".to_owned()),
+  );
+  let rendered = format!("{:?}", store.get(&created.id).expect("session lives"));
+  for sensitive in ["ACCESS-S3CRET", "REFRESH-S3CRET"] {
+    assert!(
+      !rendered.contains(sensitive),
+      "secret leaked into session Debug: {rendered:?}"
+    );
+  }
+  let cookie =
+    server::session::set_cookie_value(&created.id, false, std::time::Duration::from_secs(60));
+  assert!(!cookie.contains("ACCESS-S3CRET"));
+
+  let csrf = created.csrf_token.expose_secret().to_owned();
+  assert!(store.validate_csrf(&created.id, &csrf));
+}
+
+fn fixture_store() -> server::session::SessionStore {
+  server::session::SessionStore::new(std::time::Duration::from_secs(3600))
+}

+ 1115 - 0
crates/server/tests/oauth.rs

@@ -0,0 +1,1115 @@
+//! BFF OAuth routes against a `wiremock` fake AS (todos 15-18).
+//!
+//! Drives the router with `tower::ServiceExt::oneshot`: no ports are bound,
+//! no servers start. Each test mounts its own fake AS (discovery, PAR,
+//! token, introspection, revocation) and its own [`AppState`] so discovery
+//! and introspection caches never leak across tests.
+
+use std::collections::HashMap;
+
+use axum::body::Body;
+use axum::http::{Request, StatusCode};
+use secrecy::{ExposeSecret as _, SecretString};
+use server::app::build_app;
+use server::config::ServerConfig;
+use server::session::SESSION_COOKIE;
+use server::state::AppState;
+use tower::ServiceExt as _;
+use wiremock::{
+  Mock, MockServer, ResponseTemplate,
+  matchers::{body_string_contains, method, path},
+};
+
+type TestResult = Result<(), Box<dyn std::error::Error>>;
+
+struct Fixture {
+  app: axum::Router,
+  state: AppState,
+  issuer: String,
+}
+
+fn empty_lookup(_: &str) -> Option<String> {
+  None
+}
+
+/// Build an app whose profile points at the fake AS. The DPoP key is
+/// generated in memory and injected (no key file is read).
+fn fixture_app(fake: &MockServer) -> (Fixture, tempfile::TempDir) {
+  let dir = tempfile::tempdir().expect("temp dir is creatable");
+  let dist = dir.path().join("dist");
+  std::fs::create_dir_all(&dist).expect("fixture dist is creatable");
+  std::fs::write(
+    dist.join("index.html"),
+    "<html><body>spa-shell</body></html>",
+  )
+  .expect("fixture index is writable");
+
+  let issuer = fake.uri();
+  let config_json = format!(
+    r#"{{
+      "server": {{ "dist_dir": {} }},
+      "oauth": {{
+        "issuer": "{issuer}",
+        "redirect_uri": "http://127.0.0.1:3000/callback",
+        "scope": "demo.read"
+      }}
+    }}"#,
+    serde_json::to_string(&dist).expect("path serializes")
+  );
+  std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable");
+  std::fs::write(
+    dir.path().join("client.json"),
+    r#"{"client_id": "demo-client", "client_secret": "demo-secret"}"#,
+  )
+  .expect("client fixture is writable");
+
+  let config = ServerConfig::load_from_paths_with(
+    Some(&dir.path().join("config.json")),
+    Some(&dir.path().join("client.json")),
+    &empty_lookup,
+  )
+  .expect("fixture config loads");
+  let key = oauth_core::dpop::key::DpopKey::generate().expect("dpop key generates");
+  let state = AppState::with_client(config, reqwest::Client::new()).with_dpop_key(key);
+  let app = build_app(state.clone());
+  (Fixture { app, state, issuer }, dir)
+}
+
+/// Mount the discovery document on the fake AS. `with_par` controls whether
+/// the PAR endpoint is advertised (plain-authorize fallback when absent).
+async fn mount_discovery(fake: &MockServer, issuer: &str, with_par: bool) {
+  let mut doc = serde_json::json!({
+    "issuer": issuer,
+    "authorization_endpoint": format!("{issuer}/authorize"),
+    "token_endpoint": format!("{issuer}/token"),
+    "introspection_endpoint": format!("{issuer}/introspect"),
+    "revocation_endpoint": format!("{issuer}/revoke"),
+    "dpop_signing_alg_values_supported": ["ES256"],
+  });
+  if with_par {
+    doc["pushed_authorization_request_endpoint"] = serde_json::json!(format!("{issuer}/par"));
+  }
+  Mock::given(method("GET"))
+    .and(path("/.well-known/oauth-authorization-server"))
+    .respond_with(ResponseTemplate::new(200).set_body_json(&doc))
+    .mount(fake)
+    .await;
+}
+
+async fn mount_par(fake: &MockServer) {
+  Mock::given(method("POST"))
+    .and(path("/par"))
+    .respond_with(ResponseTemplate::new(201).set_body_json(serde_json::json!({
+      "request_uri": "urn:ietf:params:oauth:request_uri:par-handle-1",
+      "expires_in": 90
+    })))
+    .mount(fake)
+    .await;
+}
+
+fn token_ok(access: &str, refresh: &str) -> ResponseTemplate {
+  ResponseTemplate::new(200).set_body_json(serde_json::json!({
+    "access_token": access,
+    "token_type": "DPoP",
+    "expires_in": 3600,
+    "refresh_token": refresh,
+    "scope": "demo.read",
+  }))
+}
+
+async fn mount_token(fake: &MockServer, template: ResponseTemplate) {
+  Mock::given(method("POST"))
+    .and(path("/token"))
+    .respond_with(template)
+    .mount(fake)
+    .await;
+}
+
+async fn mount_introspect(fake: &MockServer, body: serde_json::Value) {
+  Mock::given(method("POST"))
+    .and(path("/introspect"))
+    .respond_with(ResponseTemplate::new(200).set_body_json(&body))
+    .mount(fake)
+    .await;
+}
+
+/// Introspection answer for one specific token (the form body carries
+/// `token=<value>`), so a renewal test can answer the old and rotated access
+/// tokens differently.
+async fn mount_introspect_for(fake: &MockServer, token: &str, body: serde_json::Value) {
+  Mock::given(method("POST"))
+    .and(path("/introspect"))
+    .and(body_string_contains(format!("token={token}")))
+    .respond_with(ResponseTemplate::new(200).set_body_json(&body))
+    .mount(fake)
+    .await;
+}
+
+async fn mount_revoke(fake: &MockServer) {
+  Mock::given(method("POST"))
+    .and(path("/revoke"))
+    .respond_with(ResponseTemplate::new(200))
+    .mount(fake)
+    .await;
+}
+
+fn active_introspection(exp: i64, iat: i64, with_cnf: bool) -> serde_json::Value {
+  let mut body = serde_json::json!({
+    "active": true,
+    "scope": "demo.read",
+    "client_id": "demo-client",
+    "token_type": "Bearer",
+    "exp": exp,
+    "iat": iat,
+    "sub": "demo@example.com",
+  });
+  if with_cnf {
+    body["cnf"] = serde_json::json!({ "jkt": "thumbprint-of-dpop-key" });
+  }
+  body
+}
+
+async fn send(
+  app: axum::Router,
+  request: Request<Body>,
+) -> Result<axum::http::Response<Body>, Box<dyn std::error::Error>> {
+  Ok(app.oneshot(request).await?)
+}
+
+fn get_request(uri: &str, cookie: Option<&str>) -> Request<Body> {
+  let mut builder = Request::builder().uri(uri);
+  if let Some(cookie) = cookie {
+    builder = builder.header("cookie", cookie);
+  }
+  builder.body(Body::empty()).expect("request builds")
+}
+
+fn post_request(uri: &str, cookie: &str, csrf: &str, origin: Option<&str>) -> Request<Body> {
+  let mut builder = Request::builder()
+    .uri(uri)
+    .method("POST")
+    .header("cookie", cookie)
+    .header("x-csrf-token", csrf);
+  if let Some(origin) = origin {
+    builder = builder.header("origin", origin);
+  }
+  builder.body(Body::empty()).expect("request builds")
+}
+
+async fn body_text(
+  response: axum::http::Response<Body>,
+) -> Result<String, Box<dyn std::error::Error>> {
+  let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
+  Ok(std::str::from_utf8(&bytes)?.to_owned())
+}
+
+/// Decode an `application/x-www-form-urlencoded` body (no extra dep).
+fn decode_form(raw: &str) -> HashMap<String, String> {
+  raw
+    .split('&')
+    .filter_map(|pair| {
+      let (name, value) = pair.split_once('=')?;
+      Some((percent_decode(name), percent_decode(value)))
+    })
+    .collect()
+}
+
+/// Minimal percent-decoder for ASCII form bodies.
+fn percent_decode(raw: &str) -> String {
+  let mut decoded = String::with_capacity(raw.len());
+  let mut bytes = raw.as_bytes().iter();
+  while let Some(byte) = bytes.next() {
+    match byte {
+      b'%' => {
+        let high = bytes.next().copied().unwrap_or(b'0');
+        let low = bytes.next().copied().unwrap_or(b'0');
+        let pair = [high, low];
+        match std::str::from_utf8(&pair) {
+          Ok(hex) => match u8::from_str_radix(hex, 16) {
+            Ok(value) => decoded.push(value as char),
+            Err(_) => decoded.push_str(&format!("%{hex}")),
+          },
+          Err(_) => decoded.push('%'),
+        }
+      }
+      b'+' => decoded.push(' '),
+      _ => decoded.push(*byte as char),
+    }
+  }
+  decoded
+}
+
+fn header_value(response: &axum::http::Response<Body>, name: &str) -> String {
+  response
+    .headers()
+    .get(name)
+    .and_then(|value| value.to_str().ok())
+    .unwrap_or_default()
+    .to_owned()
+}
+
+/// `sid` from a `Set-Cookie` response header.
+fn sid_from_set_cookie(set_cookie: &str) -> Option<String> {
+  set_cookie.split(';').next().and_then(|pair| {
+    let (name, value) = pair.split_once('=')?;
+    (name.trim() == SESSION_COOKIE).then(|| value.trim().to_owned())
+  })
+}
+
+fn cookie_for(sid: &str) -> String {
+  format!("{SESSION_COOKIE}={sid}")
+}
+
+// ---- todo 15: GET /oauth/start ----
+
+#[tokio::test]
+async fn start_redirects_with_par_request_uri_and_binds_pending() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_par(&fake).await;
+
+  let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
+  assert_eq!(response.status(), StatusCode::FOUND);
+  let location = header_value(&response, "location");
+  assert!(
+    location.contains("/authorize"),
+    "unexpected Location: {location}"
+  );
+  assert!(
+    location.contains("request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Apar-handle-1")
+      || location.contains("request_uri=urn:ietf:params:oauth:request_uri:par-handle-1"),
+    "PAR handle missing from Location: {location}"
+  );
+  assert!(
+    location.contains("client_id=demo-client"),
+    "client_id missing: {location}"
+  );
+  for forbidden in ["code_verifier", "verifier", "demo-secret", "S3CRET"] {
+    assert!(
+      !location.contains(forbidden),
+      "secret in redirect: {location}"
+    );
+  }
+
+  let set_cookie = header_value(&response, "set-cookie");
+  let sid = sid_from_set_cookie(&set_cookie).expect("start sets the sid cookie");
+  assert_eq!(
+    fixture.state.pending().len(),
+    1,
+    "pending map holds the request"
+  );
+  let bound = fixture
+    .state
+    .pending()
+    .pending_state_for_session(&sid)
+    .expect("pending record is bound to the session");
+  assert!(!bound.is_empty());
+
+  let sent = fake.received_requests().await.unwrap_or_default();
+  assert!(
+    sent.iter().any(|request| request.url.path() == "/par"),
+    "PAR was not pushed"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn start_without_par_falls_back_to_plain_authorize_redirect() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, false).await;
+
+  let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
+  assert_eq!(response.status(), StatusCode::FOUND);
+  let location = header_value(&response, "location");
+  assert!(
+    location.contains("/authorize"),
+    "unexpected Location: {location}"
+  );
+  assert!(location.contains("response_type%3Dcode") || location.contains("response_type=code"));
+  assert!(
+    location.contains("code_challenge_method=S256"),
+    "PKCE S256 missing: {location}"
+  );
+  assert!(
+    !location.contains("request_uri"),
+    "no PAR handle expected: {location}"
+  );
+  assert_eq!(fixture.state.pending().len(), 1);
+  Ok(())
+}
+
+#[tokio::test]
+async fn start_with_discovery_unavailable_yields_error_page() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  Mock::given(method("GET"))
+    .and(path("/.well-known/oauth-authorization-server"))
+    .respond_with(ResponseTemplate::new(503))
+    .mount(&fake)
+    .await;
+  Mock::given(method("GET"))
+    .and(path("/.well-known/openid-configuration"))
+    .respond_with(ResponseTemplate::new(503))
+    .mount(&fake)
+    .await;
+
+  let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
+  assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
+  let body = body_text(response).await?;
+  assert!(
+    body.contains("unavailable"),
+    "expected a typed error page, got: {body}"
+  );
+  assert!(
+    fixture.state.pending().is_empty(),
+    "nothing pending after a failed start"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn oauth_redirects_are_no_store() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+
+  let start = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
+  assert_eq!(start.status(), StatusCode::FOUND);
+  assert!(header_value(&start, "location").contains("/authorize"));
+  assert_no_store(&start, "oauth/start");
+
+  let sid = sid_from_set_cookie(&header_value(&start, "set-cookie")).expect("sid cookie");
+  let state = fixture
+    .state
+    .pending()
+    .pending_state_for_session(&sid)
+    .expect("state bound to the session");
+  let done = send(
+    fixture.app.clone(),
+    get_request(
+      &format!("/callback?code=authcode-1&state={state}"),
+      Some(&cookie_for(&sid)),
+    ),
+  )
+  .await?;
+  assert_eq!(done.status(), StatusCode::FOUND);
+  assert_no_store(&done, "callback");
+  Ok(())
+}
+
+fn assert_no_store(response: &axum::http::Response<Body>, route: &str) {
+  assert_eq!(
+    header_value(response, "cache-control"),
+    "no-store",
+    "{route} must not be cacheable"
+  );
+  assert_eq!(
+    header_value(response, "pragma"),
+    "no-cache",
+    "{route} pragma"
+  );
+  assert_eq!(header_value(response, "expires"), "0", "{route} expiry");
+}
+
+// ---- todo 16: GET /callback ----
+
+/// Drive `/oauth/start` then `/callback` with the bound state (happy path).
+async fn start_state(fixture: &Fixture) -> Result<(String, String), Box<dyn std::error::Error>> {
+  let response = send(fixture.app.clone(), get_request("/oauth/start", None)).await?;
+  assert_eq!(response.status(), StatusCode::FOUND);
+  let sid = sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("sid cookie");
+  let state = fixture
+    .state
+    .pending()
+    .pending_state_for_session(&sid)
+    .expect("state bound to the session");
+  Ok((state, sid))
+}
+
+#[tokio::test]
+async fn callback_happy_path_creates_session_rotates_and_redirects_to_dashboard() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+
+  let (state, sid) = start_state(&fixture).await?;
+  let response = send(
+    fixture.app.clone(),
+    get_request(
+      &format!("/callback?code=authcode-1&state={state}"),
+      Some(&cookie_for(&sid)),
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::FOUND);
+  assert_eq!(header_value(&response, "location"), "/dashboard");
+
+  let next_sid =
+    sid_from_set_cookie(&header_value(&response, "set-cookie")).expect("rotated cookie");
+  assert_ne!(sid, next_sid, "session id must rotate on login");
+  assert!(
+    fixture.state.sessions().get(&sid).is_none(),
+    "old id must die"
+  );
+
+  let session = fixture
+    .state
+    .sessions()
+    .get(&next_sid)
+    .expect("new session lives");
+  assert_eq!(
+    session
+      .access_token()
+      .expect("access stored")
+      .expose_secret(),
+    "access-1"
+  );
+  assert_eq!(
+    session
+      .refresh_token()
+      .expect("refresh stored")
+      .expose_secret(),
+    "refresh-1"
+  );
+  assert_eq!(session.token_type(), Some("DPoP"));
+
+  let sent = fake.received_requests().await.unwrap_or_default();
+  let token_call = sent
+    .iter()
+    .find(|request| request.url.path() == "/token")
+    .expect("code exchanged");
+  assert!(
+    token_call.headers.get("dpop").is_some(),
+    "exchange needs a DPoP proof"
+  );
+  let body = decode_form(&String::from_utf8_lossy(&token_call.body));
+  assert_eq!(
+    body.get("redirect_uri").map(String::as_str),
+    Some("http://127.0.0.1:3000/callback")
+  );
+  assert!(
+    body
+      .get("code_verifier")
+      .is_some_and(|value| !value.is_empty())
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn callback_unknown_state_is_400_without_exchange() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+
+  let response = send(
+    fixture.app.clone(),
+    get_request("/callback?code=authcode-1&state=no-such-state", None),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+  let sent = fake.received_requests().await.unwrap_or_default();
+  assert!(
+    sent.iter().all(|request| request.url.path() != "/token"),
+    "unknown state must never trigger an exchange"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn callback_replayed_state_is_400() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+
+  let (state, sid) = start_state(&fixture).await?;
+  let first = send(
+    fixture.app.clone(),
+    get_request(
+      &format!("/callback?code=authcode-1&state={state}"),
+      Some(&cookie_for(&sid)),
+    ),
+  )
+  .await?;
+  assert_eq!(first.status(), StatusCode::FOUND);
+
+  let replay = send(
+    fixture.app.clone(),
+    get_request(
+      &format!("/callback?code=authcode-1&state={state}"),
+      Some(&cookie_for(&sid)),
+    ),
+  )
+  .await?;
+  assert_eq!(replay.status(), StatusCode::BAD_REQUEST);
+  Ok(())
+}
+
+#[tokio::test]
+async fn callback_access_denied_maps_to_user_facing_denial() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+
+  let response = send(
+    fixture.app.clone(),
+    get_request(
+      "/callback?error=access_denied&error_description=the+user+denied+consent",
+      None,
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+  let body = body_text(response).await?;
+  assert!(
+    body.contains("denied"),
+    "expected a denial page, got: {body}"
+  );
+  let sent = fake.received_requests().await.unwrap_or_default();
+  assert!(
+    sent.iter().all(|request| request.url.path() != "/token"),
+    "denial must not exchange"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn callback_invalid_scope_names_the_required_scope() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+
+  let response = send(
+    fixture.app.clone(),
+    get_request(
+      "/callback?error=invalid_scope&error_description=the+requested+scope+is+unknown",
+      None,
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+  let body = body_text(response).await?;
+  assert!(
+    body.contains("Scope not granted"),
+    "expected the scope-not-granted page, got: {body}"
+  );
+  assert!(
+    body.contains("demo.read"),
+    "the page must name the configured scope, got: {body}"
+  );
+  let sent = fake.received_requests().await.unwrap_or_default();
+  assert!(
+    sent.iter().all(|request| request.url.path() != "/token"),
+    "invalid_scope must not exchange"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn callback_iss_mismatch_is_rejected() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+
+  let (state, sid) = start_state(&fixture).await?;
+  let response = send(
+    fixture.app.clone(),
+    get_request(
+      &format!("/callback?code=authcode-1&state={state}&iss=https%3A%2F%2Fevil.example"),
+      Some(&cookie_for(&sid)),
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::BAD_REQUEST);
+  let sent = fake.received_requests().await.unwrap_or_default();
+  assert!(
+    sent.iter().all(|request| request.url.path() != "/token"),
+    "iss mismatch must not exchange"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn callback_matching_iss_is_accepted() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_par(&fake).await;
+  mount_token(&fake, token_ok("access-1", "refresh-1")).await;
+
+  let (state, sid) = start_state(&fixture).await?;
+  let iss = urlencoding_like(&fixture.issuer);
+  let response = send(
+    fixture.app.clone(),
+    get_request(
+      &format!("/callback?code=authcode-1&state={state}&iss={iss}"),
+      Some(&cookie_for(&sid)),
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::FOUND);
+  assert_eq!(header_value(&response, "location"), "/dashboard");
+  Ok(())
+}
+
+/// Percent-encode an issuer for the `iss` query parameter without a new dep.
+fn urlencoding_like(raw: &str) -> String {
+  let mut encoded = String::with_capacity(raw.len());
+  for byte in raw.bytes() {
+    match byte {
+      b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+        encoded.push(byte as char)
+      }
+      _ => encoded.push_str(&format!("%{byte:02X}")),
+    }
+  }
+  encoded
+}
+
+// ---- todo 17: GET /api/session ----
+
+/// Authenticated session holding `access-1`/`refresh-1` plus its CSRF token.
+fn authenticated_session(fixture: &Fixture) -> (String, String) {
+  let created = fixture.state.sessions().create().expect("session creates");
+  assert!(fixture.state.sessions().store_tokens(
+    &created.id,
+    Some(SecretString::from("access-1".to_owned())),
+    Some(SecretString::from("refresh-1".to_owned())),
+    Some("DPoP".to_owned()),
+  ));
+  let csrf = fixture
+    .state
+    .sessions()
+    .get(&created.id)
+    .expect("session lives")
+    .csrf_token()
+    .expose_secret()
+    .to_owned();
+  (created.id, csrf)
+}
+
+#[tokio::test]
+async fn session_api_returns_subject_scopes_cnf_and_csrf_token() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  let now = std::time::SystemTime::now()
+    .duration_since(std::time::UNIX_EPOCH)
+    .expect("clock available")
+    .as_secs() as i64;
+  mount_introspect(&fake, active_introspection(now + 300, now - 10, true)).await;
+
+  let (sid, csrf) = authenticated_session(&fixture);
+  let response = send(
+    fixture.app.clone(),
+    get_request("/api/session", Some(&cookie_for(&sid))),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  let json: serde_json::Value = serde_json::from_str(&body)?;
+  assert_eq!(json["issuer"], fixture.issuer);
+  assert_eq!(json["client_id"], "demo-client");
+  assert_eq!(json["subject"], "demo@example.com");
+  assert_eq!(json["scope"], serde_json::json!(["demo.read"]));
+  assert_eq!(
+    json["missing_scopes"],
+    serde_json::json!([]),
+    "a grant covering the required scope reports no missing scope, got: {body}"
+  );
+  assert_eq!(
+    json["token_type"], "DPoP",
+    "cnf.jkt must surface as DPoP, got: {body}"
+  );
+  assert_eq!(json["cnf"]["jkt"], "thumbprint-of-dpop-key");
+  assert_eq!(json["iat"], now - 10);
+  assert_eq!(json["exp"], now + 300);
+  let expires_in = json["expires_in"].as_i64().expect("expires_in is a number");
+  assert!(
+    (290..=300).contains(&expires_in),
+    "unexpected expires_in {expires_in}"
+  );
+  assert_eq!(json["csrf_token"], serde_json::Value::String(csrf));
+  for forbidden in ["access-1", "refresh-1", "demo-secret", "S3CRET"] {
+    assert!(
+      !body.contains(forbidden),
+      "secret leaked into /api/session: {body}"
+    );
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn session_api_without_cnf_falls_back_to_stored_token_type() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  let now = std::time::SystemTime::now()
+    .duration_since(std::time::UNIX_EPOCH)
+    .expect("clock available")
+    .as_secs() as i64;
+  mount_introspect(&fake, active_introspection(now + 60, now - 5, false)).await;
+
+  let (sid, _) = authenticated_session(&fixture);
+  let response = send(
+    fixture.app.clone(),
+    get_request("/api/session", Some(&cookie_for(&sid))),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  let json: serde_json::Value = serde_json::from_str(&body)?;
+  assert_eq!(
+    json["token_type"], "DPoP",
+    "stored exchange type wins, got: {body}"
+  );
+  assert!(json.get("cnf").is_none_or(|value| value.is_null()));
+  Ok(())
+}
+
+#[tokio::test]
+async fn session_api_inactive_with_valid_refresh_renews() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  let now = std::time::SystemTime::now()
+    .duration_since(std::time::UNIX_EPOCH)
+    .expect("clock available")
+    .as_secs() as i64;
+  mount_introspect_for(&fake, "access-1", serde_json::json!({ "active": false })).await;
+  mount_introspect_for(
+    &fake,
+    "access-2",
+    active_introspection(now + 300, now - 1, true),
+  )
+  .await;
+  mount_token(&fake, token_ok("access-2", "refresh-2")).await;
+
+  let (sid, _) = authenticated_session(&fixture);
+  let response = send(
+    fixture.app.clone(),
+    get_request("/api/session", Some(&cookie_for(&sid))),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  let json: serde_json::Value = serde_json::from_str(&body)?;
+  assert_eq!(
+    json["exp"],
+    now + 300,
+    "renewed view must carry the new expiry, got: {body}"
+  );
+  assert_eq!(json["cnf"]["jkt"], "thumbprint-of-dpop-key");
+  assert_eq!(json["subject"], "demo@example.com");
+
+  let session = fixture
+    .state
+    .sessions()
+    .get(&sid)
+    .expect("a valid refresh token must keep the session alive");
+  assert_eq!(
+    session.access_token().expect("new access").expose_secret(),
+    "access-2"
+  );
+  assert_eq!(
+    session
+      .refresh_token()
+      .expect("rotated refresh")
+      .expose_secret(),
+    "refresh-2"
+  );
+  for forbidden in ["access-2", "refresh-2", "demo-secret"] {
+    assert!(!body.contains(forbidden), "token leaked into {body}");
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn session_api_active_within_skew_renews() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  let now = std::time::SystemTime::now()
+    .duration_since(std::time::UNIX_EPOCH)
+    .expect("clock available")
+    .as_secs() as i64;
+  mount_introspect_for(
+    &fake,
+    "access-1",
+    active_introspection(now + 10, now - 300, true),
+  )
+  .await;
+  mount_introspect_for(
+    &fake,
+    "access-2",
+    active_introspection(now + 300, now - 1, true),
+  )
+  .await;
+  mount_token(&fake, token_ok("access-2", "refresh-2")).await;
+
+  let (sid, _) = authenticated_session(&fixture);
+  let response = send(
+    fixture.app.clone(),
+    get_request("/api/session", Some(&cookie_for(&sid))),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  let json: serde_json::Value = serde_json::from_str(&body)?;
+  assert_eq!(
+    json["exp"],
+    now + 300,
+    "a token inside the skew window must be renewed, got: {body}"
+  );
+  let session = fixture
+    .state
+    .sessions()
+    .get(&sid)
+    .expect("session survives the renewal");
+  assert_eq!(
+    session.access_token().expect("new access").expose_secret(),
+    "access-2"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn session_api_inactive_with_invalid_grant_clears_the_session() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_introspect(&fake, serde_json::json!({ "active": false })).await;
+  mount_token(
+    &fake,
+    ResponseTemplate::new(400).set_body_json(serde_json::json!({
+      "error": "invalid_grant",
+      "error_description": "refresh token reused",
+    })),
+  )
+  .await;
+
+  let (sid, _) = authenticated_session(&fixture);
+  let response = send(
+    fixture.app.clone(),
+    get_request("/api/session", Some(&cookie_for(&sid))),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+  assert!(
+    header_value(&response, "set-cookie").contains("Max-Age=0"),
+    "cookie must clear"
+  );
+  assert!(
+    fixture.state.sessions().get(&sid).is_none(),
+    "reuse detection must still end the session"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn session_api_inactive_without_refresh_token_clears_the_session() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_introspect(&fake, serde_json::json!({ "active": false })).await;
+
+  let created = fixture.state.sessions().create().expect("session creates");
+  assert!(fixture.state.sessions().store_tokens(
+    &created.id,
+    Some(SecretString::from("access-1".to_owned())),
+    None,
+    Some("DPoP".to_owned()),
+  ));
+  let response = send(
+    fixture.app.clone(),
+    get_request("/api/session", Some(&cookie_for(&created.id))),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+  assert!(
+    header_value(&response, "set-cookie").contains("Max-Age=0"),
+    "cookie must clear"
+  );
+  assert!(
+    fixture.state.sessions().get(&created.id).is_none(),
+    "a session with no refresh token cannot be renewed"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn session_api_unauthenticated_is_401() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+
+  let response = send(fixture.app.clone(), get_request("/api/session", None)).await?;
+  assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+  Ok(())
+}
+
+// ---- todo 18: POST /api/refresh + POST /api/logout ----
+
+#[tokio::test]
+async fn refresh_logout_rotation_replaces_the_refresh_token() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_token(&fake, token_ok("access-2", "refresh-2")).await;
+
+  let (sid, csrf) = authenticated_session(&fixture);
+  let response = send(
+    fixture.app.clone(),
+    post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  assert!(
+    body.contains("refreshed"),
+    "unexpected refresh body: {body}"
+  );
+  for forbidden in ["access-2", "refresh-2", "demo-secret"] {
+    assert!(
+      !body.contains(forbidden),
+      "token leaked into refresh answer: {body}"
+    );
+  }
+
+  let session = fixture
+    .state
+    .sessions()
+    .get(&sid)
+    .expect("session survives rotation");
+  assert_eq!(
+    session.access_token().expect("new access").expose_secret(),
+    "access-2"
+  );
+  assert_eq!(
+    session
+      .refresh_token()
+      .expect("new refresh")
+      .expose_secret(),
+    "refresh-2"
+  );
+
+  let sent = fake.received_requests().await.unwrap_or_default();
+  let refresh_call = sent
+    .iter()
+    .find(|request| request.url.path() == "/token")
+    .expect("refresh posted");
+  assert!(
+    refresh_call.headers.get("dpop").is_some(),
+    "refresh needs a fresh DPoP proof"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn refresh_logout_invalid_grant_clears_the_session() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_token(
+    &fake,
+    ResponseTemplate::new(400).set_body_json(serde_json::json!({
+      "error": "invalid_grant",
+      "error_description": "refresh token reused",
+    })),
+  )
+  .await;
+
+  let (sid, csrf) = authenticated_session(&fixture);
+  let response = send(
+    fixture.app.clone(),
+    post_request("/api/refresh", &cookie_for(&sid), &csrf, None),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+  assert!(
+    fixture.state.sessions().get(&sid).is_none(),
+    "reuse must clear the session"
+  );
+  assert!(
+    fixture.state.sessions().is_empty(),
+    "no session may survive invalid_grant"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn refresh_logout_revokes_best_effort_and_clears() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+  mount_revoke(&fake).await;
+
+  let (sid, csrf) = authenticated_session(&fixture);
+  let response = send(
+    fixture.app.clone(),
+    post_request("/api/logout", &cookie_for(&sid), &csrf, None),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  let json: serde_json::Value = serde_json::from_str(&body)?;
+  assert_eq!(json["ok"], true);
+  assert!(
+    json["note"]
+      .as_str()
+      .unwrap_or_default()
+      .contains("provider logout endpoint"),
+    "logout must document the AS-session limit, got: {body}"
+  );
+  assert!(
+    fixture.state.sessions().get(&sid).is_none(),
+    "logout clears the session"
+  );
+
+  let sent = fake.received_requests().await.unwrap_or_default();
+  let revocations = sent
+    .iter()
+    .filter(|request| request.url.path() == "/revoke")
+    .count();
+  assert_eq!(
+    revocations, 2,
+    "refresh + access must both be revoked best-effort"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn refresh_logout_foreign_origin_post_is_403() -> TestResult {
+  let fake = MockServer::start().await;
+  let (fixture, _dir) = fixture_app(&fake);
+  mount_discovery(&fake, &fixture.issuer, true).await;
+
+  let (sid, csrf) = authenticated_session(&fixture);
+  let response = send(
+    fixture.app.clone(),
+    post_request(
+      "/api/refresh",
+      &cookie_for(&sid),
+      &csrf,
+      Some("https://evil.example"),
+    ),
+  )
+  .await?;
+  assert_eq!(response.status(), StatusCode::FORBIDDEN);
+  assert!(
+    fixture.state.sessions().get(&sid).is_some(),
+    "rejected POST must not touch the session"
+  );
+  Ok(())
+}

+ 321 - 0
crates/server/tests/static_config.rs

@@ -0,0 +1,321 @@
+//! Integration tests for static SPA serving and `GET /api/config`.
+//!
+//! These drive the [`Router`] through `tower::ServiceExt::oneshot`: no ports
+//! are bound and no servers start. The SPA fixture and the config files live
+//! in a `tempfile` directory, never in the real `config/` or `dist/`.
+
+use std::collections::HashMap;
+
+use axum::body::Body;
+use axum::http::{Request, StatusCode};
+use server::app::build_app;
+use server::config::ServerConfig;
+use server::state::AppState;
+use tower::ServiceExt as _;
+
+/// Build an app backed by a temp dist dir (with `index.html` + one asset)
+/// and temp config files loaded through the real layered loader.
+fn fixture_app() -> (axum::Router, tempfile::TempDir) {
+  let dir = tempfile::tempdir().expect("temp dir is creatable");
+  let dist = dir.path().join("dist");
+  std::fs::create_dir_all(dist.join("assets")).expect("fixture dirs are creatable");
+  std::fs::write(
+    dist.join("index.html"),
+    "<html><body>spa-shell</body></html>",
+  )
+  .expect("fixture index is writable");
+  std::fs::write(dist.join("assets/app.js"), "console.log('app');")
+    .expect("fixture asset is writable");
+
+  let config_json = format!(
+    r#"{{
+      "server": {{ "dist_dir": {} }},
+      "oauth": {{
+        "issuer": "https://as.example",
+        "redirect_uri": "http://127.0.0.1:3000/callback",
+        "scope": "demo.read",
+        "capabilities": {{ "par": true, "dpop": true, "introspection": true }}
+      }}
+    }}"#,
+    serde_json::to_string(&dist).expect("path serializes")
+  );
+  std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable");
+  std::fs::write(
+    dir.path().join("client.json"),
+    r#"{"client_id": "demo-client", "client_secret": "FILE-S3CRET"}"#,
+  )
+  .expect("client fixture is writable");
+
+  let empty: HashMap<&str, &str> = HashMap::new();
+  let config = ServerConfig::load_from_paths_with(
+    Some(&dir.path().join("config.json")),
+    Some(&dir.path().join("client.json")),
+    &|key| empty.get(key).map(|value| (*value).to_owned()),
+  )
+  .expect("fixture config loads");
+  (build_app(AppState::new(config)), dir)
+}
+
+async fn get(
+  app: axum::Router,
+  uri: &str,
+) -> Result<axum::http::Response<Body>, Box<dyn std::error::Error>> {
+  Ok(
+    app
+      .oneshot(Request::builder().uri(uri).body(Body::empty())?)
+      .await?,
+  )
+}
+
+async fn body_text(
+  response: axum::http::Response<Body>,
+) -> Result<String, Box<dyn std::error::Error>> {
+  let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?;
+  Ok(std::str::from_utf8(&bytes)?.to_owned())
+}
+
+/// Build an app whose profile points at `issuer` with no static capabilities
+/// (all false), so any `true` flag in `/api/config` must come from discovery.
+fn discovery_fixture_app(
+  issuer: &str,
+  lookup: &dyn Fn(&str) -> Option<String>,
+) -> (axum::Router, tempfile::TempDir) {
+  let dir = tempfile::tempdir().expect("temp dir is creatable");
+  let dist = dir.path().join("dist");
+  std::fs::create_dir_all(&dist).expect("fixture dist is creatable");
+  std::fs::write(
+    dist.join("index.html"),
+    "<html><body>spa-shell</body></html>",
+  )
+  .expect("fixture index is writable");
+  let config_json = format!(
+    r#"{{
+      "server": {{ "dist_dir": {} }},
+      "oauth": {{
+        "issuer": "{issuer}",
+        "redirect_uri": "http://127.0.0.1:3000/callback",
+        "scope": "demo.read"
+      }}
+    }}"#,
+    serde_json::to_string(&dist).expect("path serializes")
+  );
+  std::fs::write(dir.path().join("config.json"), config_json).expect("config is writable");
+  std::fs::write(
+    dir.path().join("client.json"),
+    r#"{"client_id": "demo-client", "client_secret": "FILE-S3CRET"}"#,
+  )
+  .expect("client fixture is writable");
+  let config = ServerConfig::load_from_paths_with(
+    Some(&dir.path().join("config.json")),
+    Some(&dir.path().join("client.json")),
+    lookup,
+  )
+  .expect("fixture config loads");
+  (
+    build_app(AppState::with_client(config, reqwest::Client::new())),
+    dir,
+  )
+}
+
+#[tokio::test]
+async fn root_serves_index_html_with_static_csp() -> Result<(), Box<dyn std::error::Error>> {
+  let (app, _dir) = fixture_app();
+  let response = get(app, "/").await?;
+
+  assert_eq!(response.status(), StatusCode::OK);
+  let csp = response
+    .headers()
+    .get("content-security-policy")
+    .and_then(|value| value.to_str().ok())
+    .unwrap_or_default()
+    .to_owned();
+  assert!(csp.contains("script-src 'self'"), "unexpected CSP: {csp}");
+  assert!(
+    csp.contains("'wasm-unsafe-eval'"),
+    "WASM must be allowed for the Leptos app to boot: {csp}"
+  );
+  assert!(
+    !csp.contains("unsafe-inline"),
+    "inline scripts forbidden: {csp}"
+  );
+  assert!(
+    !csp.contains("'unsafe-eval'"),
+    "full eval forbidden (wasm-unsafe-eval only): {csp}"
+  );
+  assert_eq!(
+    response
+      .headers()
+      .get("x-content-type-options")
+      .and_then(|value| value.to_str().ok()),
+    Some("nosniff")
+  );
+  let body = body_text(response).await?;
+  assert!(body.contains("spa-shell"), "unexpected body: {body:?}");
+  Ok(())
+}
+
+#[tokio::test]
+async fn missing_asset_is_404_not_fallback() -> Result<(), Box<dyn std::error::Error>> {
+  let (app, _dir) = fixture_app();
+  let response = get(app, "/assets/missing.js").await?;
+  assert_eq!(response.status(), StatusCode::NOT_FOUND);
+  Ok(())
+}
+
+#[tokio::test]
+async fn existing_asset_is_served() -> Result<(), Box<dyn std::error::Error>> {
+  let (app, _dir) = fixture_app();
+  let response = get(app, "/assets/app.js").await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let content_type = response
+    .headers()
+    .get("content-type")
+    .and_then(|value| value.to_str().ok())
+    .unwrap_or_default()
+    .to_owned();
+  assert!(
+    content_type.contains("javascript"),
+    "unexpected MIME: {content_type}"
+  );
+  Ok(())
+}
+
+#[tokio::test]
+async fn client_route_spa_falls_back_to_index() -> Result<(), Box<dyn std::error::Error>> {
+  let (app, _dir) = fixture_app();
+  let response = get(app, "/dashboard").await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  assert!(body.contains("spa-shell"), "unexpected body: {body:?}");
+  Ok(())
+}
+
+#[tokio::test]
+async fn api_config_returns_public_display_config_only() -> Result<(), Box<dyn std::error::Error>> {
+  let (app, _dir) = fixture_app();
+  let response = get(app, "/api/config").await?;
+
+  assert_eq!(response.status(), StatusCode::OK);
+  let csp = response
+    .headers()
+    .get("content-security-policy")
+    .and_then(|value| value.to_str().ok())
+    .unwrap_or_default()
+    .to_owned();
+  assert!(
+    csp.contains("frame-ancestors 'none'"),
+    "unexpected API CSP: {csp}"
+  );
+  assert_eq!(
+    response
+      .headers()
+      .get("x-content-type-options")
+      .and_then(|value| value.to_str().ok()),
+    Some("nosniff")
+  );
+  assert_eq!(
+    response
+      .headers()
+      .get("referrer-policy")
+      .and_then(|value| value.to_str().ok()),
+    Some("same-origin")
+  );
+
+  let body = body_text(response).await?;
+  let json: serde_json::Value = serde_json::from_str(&body)?;
+  assert_eq!(json["issuer"], "https://as.example");
+  assert_eq!(json["client_id"], "demo-client");
+  assert_eq!(json["scope"], "demo.read");
+  assert_eq!(json["provider_logout_url"], "https://as.example/logout");
+  assert_eq!(json["capabilities"]["par"], true);
+  assert_eq!(json["capabilities"]["userinfo"], false);
+  for forbidden in ["S3CRET", "client_secret", "dpop_key", "private_key"] {
+    assert!(!body.contains(forbidden), "leak of {forbidden} in {body}");
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn api_config_derives_capabilities_from_discovery() -> Result<(), Box<dyn std::error::Error>>
+{
+  use wiremock::{
+    Mock, MockServer, ResponseTemplate,
+    matchers::{method, path},
+  };
+  let fake = MockServer::start().await;
+  let issuer = fake.uri();
+  let doc = serde_json::json!({
+    "issuer": issuer,
+    "authorization_endpoint": format!("{issuer}/authorize"),
+    "token_endpoint": format!("{issuer}/token"),
+    "pushed_authorization_request_endpoint": format!("{issuer}/par"),
+    "introspection_endpoint": format!("{issuer}/introspect"),
+    "registration_endpoint": format!("{issuer}/register"),
+    "grant_types_supported": ["authorization_code", "refresh_token"],
+    "dpop_signing_alg_values_supported": ["ES256"],
+  });
+  Mock::given(method("GET"))
+    .and(path("/.well-known/oauth-authorization-server"))
+    .respond_with(ResponseTemplate::new(200).set_body_json(&doc))
+    .mount(&fake)
+    .await;
+
+  let (app, _dir) = discovery_fixture_app(&issuer, &|_| None);
+  let response = get(app, "/api/config").await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  let json: serde_json::Value = serde_json::from_str(&body)?;
+  assert_eq!(json["capabilities"]["par"], true);
+  assert_eq!(json["capabilities"]["dpop"], true);
+  assert_eq!(json["capabilities"]["introspection"], true);
+  assert_eq!(json["capabilities"]["userinfo"], false);
+  assert_eq!(json["start_url"], "/oauth/start");
+  assert_eq!(json["provider_logout_url"], format!("{issuer}/logout"));
+  for forbidden in ["S3CRET", "client_secret", "dpop_key", "private_key"] {
+    assert!(!body.contains(forbidden), "leak of {forbidden} in {body}");
+  }
+  Ok(())
+}
+
+#[tokio::test]
+async fn api_config_falls_back_to_profile_caps_when_discovery_is_down()
+-> Result<(), Box<dyn std::error::Error>> {
+  let lookup = |key: &str| {
+    (key == server::config::START_URL_ENV).then(|| "http://127.0.0.1:3001/oauth/start".to_owned())
+  };
+  let (app, _dir) = discovery_fixture_app("http://127.0.0.1:9", &lookup);
+  let response = get(app, "/api/config").await?;
+  assert_eq!(response.status(), StatusCode::OK);
+  let body = body_text(response).await?;
+  let json: serde_json::Value = serde_json::from_str(&body)?;
+  assert_eq!(json["capabilities"]["par"], false);
+  assert_eq!(json["capabilities"]["dpop"], false);
+  assert_eq!(json["capabilities"]["introspection"], false);
+  assert_eq!(json["start_url"], "http://127.0.0.1:3001/oauth/start");
+  Ok(())
+}
+
+#[tokio::test]
+async fn path_traversal_does_not_escape_dist_root() -> Result<(), Box<dyn std::error::Error>> {
+  let (app, _dir) = fixture_app();
+  let response = get(app, "/../etc/passwd").await?;
+
+  // The stack normalizes `/../etc/passwd` to the extensionless `/etc/passwd`,
+  // which hits the SPA fallback: safe as long as the body is the app shell
+  // and never the target file.
+  let body = body_text(response).await?;
+  assert!(!body.contains("root:"), "traversal escaped dist root");
+  assert!(body.contains("spa-shell"), "unexpected body: {body:?}");
+
+  let (app, _dir) = fixture_app();
+  let encoded = get(app, "/assets/%2e%2e/%2e%2e/etc/passwd").await?;
+  // The fallback only ever serves the app shell, so an encoded traversal
+  // cannot escape either; assert the body, not the status.
+  let encoded_body = body_text(encoded).await?;
+  assert!(!encoded_body.contains("root:"), "encoded traversal escaped");
+  assert!(
+    encoded_body.contains("spa-shell"),
+    "unexpected body: {encoded_body:?}"
+  );
+  Ok(())
+}

+ 31 - 0
crates/web/Cargo.toml

@@ -0,0 +1,31 @@
+[package]
+name = "web"
+version = "0.1.0"
+edition = "2024"
+
+# The lib target stays host-testable (`cargo test -p web --lib`) and keeps the
+# package name as its crate name. The binary gets a distinct target name so
+# Trunk can select it unambiguously: `cargo --bin web` still emits the cdylib
+# library artifact, which is also named "web" and trips Trunk's
+# "more than one target artifact" guard.
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[[bin]]
+name = "web-app"
+path = "src/main.rs"
+
+[dependencies]
+console_error_panic_hook = "0.1.7"
+# `gloo-net` is already in the lock graph (a leptos dependency); pinning it here
+# as a direct dep keeps the same 0.6.0 resolution. No Node/npm involved.
+gloo-net = { version = "0.6", default-features = false, features = ["http", "json"] }
+# `js-sys` provides the JavaScript `Date` used for the local-time offset; it is
+# already in the lock graph via leptos/wasm-bindgen.
+js-sys = "0.3"
+leptos = { version = "0.8.21", features = ["csr"] }
+leptos_router = "0.8.16"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+wasm-bindgen = "=0.2.129"
+web-sys = { version = "0.3", features = ["Clipboard", "Document", "Element", "HtmlElement", "Location", "Navigator", "Storage", "Window"] }

+ 53 - 0
crates/web/Trunk.toml

@@ -0,0 +1,53 @@
+# Trunk configuration for the CSR/WASM UI.
+#
+# Run modes (never change the redirect URI off http://127.0.0.1:3000/callback):
+#   dev  (E2E target): `cd crates/web && trunk serve --port 3000`
+#         plus the BFF on :3001; this file proxies /oauth/, /callback, /api/.
+#         Trunk's proxy follows the BFF's /oauth/start 302 to the AS and swallows
+#         it (200 empty body), so sign-in MUST bypass the proxy: run the BFF with
+#         `APP_START_URL=http://127.0.0.1:3001/oauth/start` and the landing CTA
+#         navigates straight at the BFF origin (top-level assign, cookies are
+#         host-scoped so SameSite=Lax still holds across :3000 -> :3001).
+#   demo/prod: `SERVER_BIND_ADDR=127.0.0.1:3000 cargo run -p server`
+#         serves crates/web/dist directly; no Trunk process, no proxies, no
+#         APP_START_URL override (relative /oauth/start is same-origin).
+#
+# Build (verification): `trunk build`. The executor never runs `trunk serve`.
+
+[build]
+target = "index.html"
+
+# Strict-CSP boot (no inline scripts). Trunk's default autoloader is an inline
+# `<script type="module">`, which the BFF's `STATIC_CSP` (`script-src 'self'
+# 'wasm-unsafe-eval'`, no `'unsafe-inline'`) refuses to execute. So:
+#   - inject_scripts = false: Trunk injects no inline loader and no preloads;
+#   - filehash = false: stable `web-app.js` / `web-app_bg.wasm` names that the
+#     external `bootstrap.js` module can import statically (a content hash is
+#     unknowable to a static loader).
+# `bootstrap.js` is copied to the dist root (see index.html) and referenced
+# there as a plain same-origin `<script type="module" src="/bootstrap.js">`.
+inject_scripts = false
+filehash = false
+
+[serve]
+addresses = ["127.0.0.1"]
+port = 3000
+
+# Forward every BFF-owned route to the axum server on :3001 so the browser
+# sees a single origin (no CORS, cookies stay same-site). With no `rewrite`
+# the backend path is the match prefix: /oauth/start -> :3001/oauth/start.
+[[proxy]]
+backend = "http://127.0.0.1:3001/oauth/"
+
+[[proxy]]
+backend = "http://127.0.0.1:3001/callback"
+
+[[proxy]]
+backend = "http://127.0.0.1:3001/api/"
+
+# Pin CLI tools so the wasm-bindgen CLI matches the crate dependency exactly
+# (`wasm-bindgen = "=0.2.129"` in Cargo.toml) and Tailwind runs v4.
+# The Tailwind CLI is a standalone binary; no Node/npm is required.
+[tools]
+wasm_bindgen = "0.2.129"
+tailwindcss = "4.3.3"

+ 33 - 0
crates/web/bootstrap.js

@@ -0,0 +1,33 @@
+// External Trunk autoloader for the CSR/WASM app.
+//
+// Trunk's stock autoloader is an inline `<script type="module">`; the BFF's
+// strict `STATIC_CSP` (`script-src 'self' 'wasm-unsafe-eval'`, no
+// `'unsafe-inline'`) blocks it, so the production SPA shell never booted.
+// `Trunk.toml` sets `inject_scripts = false` and `filehash = false`; this
+// same-origin module replaces the inline loader. It imports the stable
+// wasm-bindgen artifact, initializes it, and keeps the contract the inline
+// loader provided (`window.wasmBindings` + `TrunkApplicationStarted`).
+
+// Theme init: dark is the default; `theme` is the only localStorage key this
+// app writes. Static imports are hoisted, but this runs before `init()` (the
+// mount gate), so the first painted frame already has the right `data-theme`.
+const THEME_KEY = 'theme';
+let theme = 'dark';
+try {
+  theme = localStorage.getItem(THEME_KEY) === 'light' ? 'light' : 'dark';
+} catch {
+  theme = 'dark';
+}
+document.documentElement.dataset.theme = theme;
+
+const themeColor = document.querySelector('meta[name="theme-color"]');
+if (themeColor) {
+  themeColor.setAttribute('content', theme === 'light' ? '#f9fafb' : '#141821');
+}
+
+import init, * as bindings from './web-app.js';
+
+const wasm = await init({ module_or_path: new URL('./web-app_bg.wasm', import.meta.url) });
+
+window.wasmBindings = bindings;
+dispatchEvent(new CustomEvent('TrunkApplicationStarted', { detail: { wasm } }));

+ 32 - 0
crates/web/index.html

@@ -0,0 +1,32 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+    <meta name="color-scheme" content="dark light" />
+    <meta
+      name="description"
+      content="A confidential BFF runs the OAuth 2.1 flow - PKCE, PAR, DPoP - while this WASM page renders the identity, scopes, and token metadata that come back."
+    />
+    <meta name="theme-color" content="#141821" />
+    <!-- The crate has both a lib (host-testable) and a bin target, so pin the
+         Trunk artifact explicitly; `web-app` names the bin in Cargo.toml. -->
+    <link data-trunk rel="rust" data-bin="web-app" />
+    <!-- External CSP-safe autoloader: `inject_scripts = false` (Trunk.toml)
+         leaves no inline `<script type="module">` for the BFF's strict
+         `script-src 'self' 'wasm-unsafe-eval'` to block; this same-origin
+         module imports the stable `web-app.js`/`web-app_bg.wasm` artifacts
+         and calls `init()`. -->
+    <link data-trunk rel="copy-file" href="bootstrap.js" />
+    <script type="module" src="/bootstrap.js"></script>
+    <link data-trunk rel="tailwind-css" href="style/input.css" />
+    <title>OAuth 2.1 Client - sender-constrained sign-in</title>
+  </head>
+  <body>
+    <noscript>
+      <p class="p-6 font-sans text-sm">
+        This demo is a WebAssembly application and needs JavaScript enabled.
+      </p>
+    </noscript>
+  </body>
+</html>

+ 171 - 0
crates/web/src/api.rs

@@ -0,0 +1,171 @@
+//! Same-origin BFF transport (wasm32 only).
+//!
+//! Every call targets the BFF under the page's own origin (`/api/...`); the
+//! browser never talks to the authorization server directly. Failures are
+//! typed as [`ApiError`] so the UI can render a specific card; no response
+//! path uses `unwrap`/`expect`.
+
+use gloo_net::http::{Request, RequestBuilder, Response as NetResponse};
+use serde::de::DeserializeOwned;
+use serde_json::Value;
+use web::model::{
+  ApiError, DisplayConfig, ProbeStatus, RefreshView, SessionProbe, SessionView,
+  classify_probe_status, probe_with_snapshot,
+};
+use web::viewmodel::SIGN_IN_PATH;
+
+/// Load the public display configuration from `GET /api/config`.
+pub async fn fetch_config() -> Result<DisplayConfig, ApiError> {
+  let response = Request::get("/api/config")
+    .send()
+    .await
+    .map_err(|error| ApiError::network(error.to_string()))?;
+  parse_json(response).await
+}
+
+/// Probe `GET /api/session`: parse the display-safe [`SessionView`] on `2xx`,
+/// classify `401` as the unauthenticated state, and surface everything else as
+/// a typed failure (never a panic).
+pub async fn probe_session() -> SessionProbe {
+  match Request::get("/api/session").send().await {
+    Err(error) => SessionProbe::Unavailable(ApiError::network(error.to_string())),
+    Ok(response) => {
+      let status = response.status();
+      match classify_probe_status(status) {
+        ProbeStatus::Ok => match response.json::<SessionView>().await {
+          Ok(session) => probe_with_snapshot(status, session),
+          Err(error) => SessionProbe::Unavailable(ApiError::decode(error.to_string())),
+        },
+        ProbeStatus::Unauthenticated => SessionProbe::Unauthenticated,
+        ProbeStatus::EndpointMissing => SessionProbe::EndpointMissing,
+        ProbeStatus::Unavailable(_) => {
+          SessionProbe::Unavailable(read_http_error(response, status).await)
+        }
+      }
+    }
+  }
+}
+
+/// Ask the BFF to rotate the token (`POST /api/refresh`).
+///
+/// Sends the CSRF token from the session view as `x-csrf-token`. A `401` comes
+/// back as a typed [`ApiError::Http`] with status 401 - the caller re-probes
+/// and lands on the unauthenticated state.
+pub async fn refresh_session(csrf_token: Option<&str>) -> Result<RefreshView, ApiError> {
+  let request = csrf_header(
+    Request::post("/api/refresh").header("accept", "application/json"),
+    csrf_token,
+  );
+  let response = request
+    .send()
+    .await
+    .map_err(|error| ApiError::network(error.to_string()))?;
+  parse_json(response).await
+}
+
+/// Ask the BFF to end the session and best-effort revoke the tokens
+/// (`POST /api/logout`).
+///
+/// Sends the CSRF token from the session view as `x-csrf-token`; a `401` is a
+/// success for logout purposes (there was nothing left to end).
+pub async fn logout(csrf_token: Option<&str>) -> Result<(), ApiError> {
+  let request = csrf_header(
+    Request::post("/api/logout").header("accept", "application/json"),
+    csrf_token,
+  );
+  let response = request
+    .send()
+    .await
+    .map_err(|error| ApiError::network(error.to_string()))?;
+  match response.status() {
+    200..=399 | 401 => Ok(()),
+    other => Err(read_http_error(response, other).await),
+  }
+}
+
+/// Local timezone offset in minutes as JavaScript reports it
+/// (`getTimezoneOffset()`: UTC minus local), used to render `iat`/`exp`
+/// locally via the host-testable formatter in `viewmodel`.
+pub fn local_offset_minutes() -> i32 {
+  js_sys::Date::new_0().get_timezone_offset().round() as i32
+}
+
+/// Top-level navigation to a BFF-advertised sign-in target.
+///
+/// The AS forbids framing, so this is a full-page assign, never an iframe or
+/// a fetch. When `APP_START_URL` points the config at the absolute BFF origin
+/// (Trunk dev mode), the assign leaves the Trunk proxy so the BFF `302`
+/// reaches the browser; blank targets fall back to [`SIGN_IN_PATH`].
+pub fn sign_in_to(target: &str) {
+  let trimmed = target.trim();
+  let destination = if trimmed.is_empty() {
+    SIGN_IN_PATH
+  } else {
+    trimmed
+  };
+  if let Some(window) = web_sys::window() {
+    let _ = window.location().assign(destination);
+  }
+}
+
+/// Top-level navigation back to the landing page.
+pub fn go_home() {
+  if let Some(window) = web_sys::window() {
+    let _ = window.location().assign("/");
+  }
+}
+
+/// Top-level navigation to the provider (AS) RP-initiated logout endpoint.
+///
+/// The AS destroys its session, clears its cookie, and `302`s back to the
+/// post-logout redirect URI registered with the client. A blank target falls
+/// back to [`go_home`], so a BFF without an issuer still leaves the app.
+pub fn logout_to(target: &str) {
+  let trimmed = target.trim();
+  if trimmed.is_empty() {
+    go_home();
+    return;
+  }
+  if let Some(window) = web_sys::window() {
+    let _ = window.location().assign(trimmed);
+  }
+}
+
+fn csrf_header(request: RequestBuilder, csrf_token: Option<&str>) -> RequestBuilder {
+  match csrf_token {
+    Some(token) => request.header("x-csrf-token", token),
+    None => request,
+  }
+}
+
+async fn parse_json<T: DeserializeOwned>(response: NetResponse) -> Result<T, ApiError> {
+  let status = response.status();
+  if !(200..=299).contains(&status) {
+    return Err(read_http_error(response, status).await);
+  }
+  response
+    .json::<T>()
+    .await
+    .map_err(|error| ApiError::decode(error.to_string()))
+}
+
+async fn read_http_error(response: NetResponse, status: u16) -> ApiError {
+  let body = response.text().await.unwrap_or_default();
+  let (code, detail) = parse_error_body(&body);
+  ApiError::http(status, code, detail)
+}
+
+fn parse_error_body(body: &str) -> (Option<String>, Option<String>) {
+  let parsed = serde_json::from_str::<Value>(body).ok();
+  let code = parsed
+    .as_ref()
+    .and_then(|value| value.get("error"))
+    .and_then(Value::as_str)
+    .map(str::to_owned);
+  let detail = parsed
+    .as_ref()
+    .and_then(|value| value.get("error_description"))
+    .and_then(Value::as_str)
+    .map(str::to_owned);
+  (code, detail)
+}

+ 162 - 0
crates/web/src/app.rs

@@ -0,0 +1,162 @@
+//! Application shell: skip link, header, router, footer.
+
+use leptos::prelude::*;
+use leptos_router::components::{A, Redirect, Route, Router, Routes};
+use leptos_router::path;
+use web::model::SessionProbe;
+use web::viewmodel::{NAV_ITEMS, PRODUCT_NAME, dashboard_nav_visible};
+
+use super::components::dashboard::Dashboard;
+use super::components::deployment::ConfigResource;
+use super::components::feedback::SessionSkeleton;
+use super::components::icons::{Icon, IconKind};
+use super::components::landing::Landing;
+use super::components::theme::ThemeToggle;
+
+/// Root component, mounted to `<body>` by `main.rs`.
+#[component]
+pub fn App() -> impl IntoView {
+  // One app-level session probe, shared with the header and the `/dashboard`
+  // guard so navigation gating and route guarding read a single source of
+  // truth. `LocalResource` is `Copy`, so context provision is cheap.
+  let session = LocalResource::new(|| async { crate::api::probe_session().await });
+  provide_context(session);
+
+  // One app-level config resource, shared with the landing CTA and the
+  // dashboard deployment card so both read a single `/api/config` fetch.
+  let config: ConfigResource = LocalResource::new(|| async { crate::api::fetch_config().await });
+  provide_context(config);
+
+  view! {
+    <Router>
+      <div class="flex min-h-dvh flex-col bg-canvas text-ink">
+        <a
+          href="#main"
+          class="sr-only focus:not-sr-only focus:absolute focus:top-4 focus:left-4 focus:z-50 focus:rounded-lg focus:bg-raised focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:shadow-lg"
+        >
+          "Skip to content"
+        </a>
+
+        <header class="sticky top-0 z-20 border-b border-line/70 bg-canvas/85 backdrop-blur-md">
+          <div class="mx-auto flex h-16 w-full max-w-6xl items-center justify-between gap-4 px-4 sm:px-6">
+            <A href="/" attr:class="group flex items-center gap-2.5 rounded-lg">
+              <span
+                aria-hidden="true"
+                class="grid size-8 place-items-center rounded-lg bg-btn text-btn-ink shadow-sm transition-colors group-hover:bg-btn-hover"
+              >
+                <Icon kind=IconKind::Shield class="size-4" />
+              </span>
+              <span class="text-sm font-semibold tracking-tight">{PRODUCT_NAME}</span>
+            </A>
+            <div class="flex items-center gap-2">
+              <PrimaryNav />
+              <ThemeToggle />
+            </div>
+          </div>
+        </header>
+
+        <main
+          id="main"
+          tabindex="-1"
+          class="mx-auto w-full max-w-6xl flex-1 px-4 py-10 sm:px-6 sm:py-14"
+        >
+          <Routes fallback=|| view! { <NotFound /> }>
+            <Route path=path!("/") view=Landing />
+            <Route path=path!("/dashboard") view=DashboardRoute />
+          </Routes>
+        </main>
+
+        <footer class="border-t border-line">
+          <div class="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-between gap-2 px-4 py-5 text-xs text-muted sm:px-6">
+            <span>"OAuth 2.1 · PKCE S256 · DPoP · PAR · opaque tokens"</span>
+            <span>"Secrets stay on the server"</span>
+          </div>
+        </footer>
+      </div>
+    </Router>
+  }
+}
+
+/// Header navigation: `Landing` is always rendered; `Dashboard` appears only
+/// once the shared probe reports [`SessionProbe::Active`].
+#[component]
+fn PrimaryNav() -> impl IntoView {
+  let session = use_context::<LocalResource<SessionProbe>>();
+  let dashboard_visible = Signal::derive(move || {
+    session
+      .and_then(|resource| resource.get())
+      .is_some_and(|probe| dashboard_nav_visible(&probe))
+  });
+
+  view! {
+    <nav aria-label="Primary" class="flex items-center gap-1 text-sm">
+      {NAV_ITEMS
+        .iter()
+        .map(|(label, href)| {
+          let visible = if *href == "/dashboard" {
+            dashboard_visible
+          } else {
+            Signal::derive(|| true)
+          };
+          view! {
+            <Show when=move || visible.get()>
+              <A
+                href=*href
+                attr:class="rounded-md px-3 py-1.5 text-muted transition-colors hover:bg-sunken hover:text-ink"
+              >
+                {*label}
+              </A>
+            </Show>
+          }
+        })
+        .collect_view()}
+    </nav>
+  }
+}
+
+/// `/dashboard` guard: an active session renders the dashboard, a definitive
+/// [`SessionProbe::Unauthenticated`] redirects to the landing page, and
+/// loading/error states keep rendering the dashboard's own skeleton and typed
+/// error cards so a transient BFF failure is never misread as logged-out.
+#[component]
+fn DashboardRoute() -> impl IntoView {
+  let session = use_context::<LocalResource<SessionProbe>>();
+  // Coarse memo: `Active(_)` -> `Active(_)` refetches must not remount the
+  // dashboard, or its refresh/log-out state would reset underneath the user.
+  let redirect_home = Memo::new(move |_| {
+    session.is_some_and(|resource| matches!(resource.get(), Some(SessionProbe::Unauthenticated)))
+  });
+
+  view! {
+    {move || {
+      let Some(resource) = session else {
+        return view! { <SessionSkeleton /> }.into_any();
+      };
+      if redirect_home.get() {
+        view! { <Redirect path="/" /> }.into_any()
+      } else {
+        view! { <Dashboard probe=resource /> }.into_any()
+      }
+    }}
+  }
+}
+
+/// Fallback for unmatched routes.
+#[component]
+fn NotFound() -> impl IntoView {
+  view! {
+    <section class="mx-auto max-w-md py-24 text-center">
+      <p class=super::components::styles::EYEBROW>"404"</p>
+      <h1 class="mt-2 text-title font-semibold text-ink">"Page not found"</h1>
+      <p class="mt-3 text-sm leading-6 text-muted">
+        "That route does not exist in this client."
+      </p>
+      <A
+        href="/"
+        attr:class="mt-7 inline-flex rounded-lg bg-btn px-4 py-2 text-sm font-semibold text-btn-ink transition-colors hover:bg-btn-hover"
+      >
+        "Back to landing"
+      </A>
+    </section>
+  }
+}

+ 547 - 0
crates/web/src/components/dashboard.rs

@@ -0,0 +1,547 @@
+//! Authenticated dashboard: user card, token panel, flow timeline, and the
+//! display-safe introspection viewer.
+//!
+//! Every string and decision a test asserts lives in `web::viewmodel`; these
+//! components only compose the pure models into markup.
+
+use std::time::Duration;
+
+use leptos::prelude::*;
+use web::model::{ApiError, ErrorCardKind, SessionProbe, SessionView, TokenView};
+use web::viewmodel::{
+  AUTO_REFRESH_AT_SECS, SERVICE_PRINCIPAL_HINT, StepState, TIMELINE_LEN, TimelineStep,
+  UserCardModel, countdown_label, insufficient_scope_notice, introspection_json, scope_narrowed,
+  scope_narrowed_notice, should_auto_refresh, timeline_for_session, token_panel, user_card,
+};
+
+use super::deployment::DeploymentSection;
+use super::feedback::{ActionButton, EmptyState, ErrorCard, SessionSkeleton, SignInLink};
+use super::icons::{Icon, IconKind};
+use super::styles::{BTN_SECONDARY, EYEBROW, PANEL};
+
+/// Dashboard route (`/dashboard`).
+///
+/// The session probe is owned by `App` and shared through context, so the
+/// header, the route guard, and this component all read a single source of
+/// truth; the guard never runs a second probe that could disagree.
+#[component]
+pub fn Dashboard(
+  /// Shared app-level `GET /api/session` probe (provided by `App`).
+  probe: LocalResource<SessionProbe>,
+) -> impl IntoView {
+  let csrf = RwSignal::new(None::<String>);
+  let expires_in = RwSignal::new(0_i64);
+  let auto_refreshed = RwSignal::new(false);
+  let logout_error = RwSignal::new(None::<ApiError>);
+  let refresh_error = RwSignal::new(None::<ApiError>);
+  let scope_notice = RwSignal::new(false);
+  let busy = RwSignal::new(false);
+
+  Effect::new(move |_| {
+    if let Some(SessionProbe::Active(session)) = probe.get() {
+      csrf.set(Some(session.csrf_token.clone()));
+      let remaining = session.expires_in.max(0);
+      expires_in.set(remaining);
+      if remaining > AUTO_REFRESH_AT_SECS {
+        // A renewed (comfortably valid) session re-arms the guard; a still
+        // near-expiry reply does not, so the ticker cannot spin.
+        auto_refreshed.set(false);
+      }
+    }
+  });
+
+  let ticker = set_interval_with_handle(
+    move || {
+      expires_in.update(|remaining| {
+        if *remaining > 0 {
+          *remaining -= 1;
+        }
+      });
+      if !auto_refreshed.get_untracked() && should_auto_refresh(expires_in.get_untracked()) {
+        auto_refreshed.set(true);
+        probe.refetch();
+      }
+    },
+    Duration::from_secs(1),
+  );
+  on_cleanup(move || {
+    if let Ok(handle) = ticker {
+      handle.clear();
+    }
+  });
+
+  let refresh = Callback::new(move |_: web_sys::MouseEvent| {
+    if busy.get_untracked() {
+      return;
+    }
+    busy.set(true);
+    refresh_error.set(None);
+    scope_notice.set(false);
+    let previous_scope = probe
+      .get_untracked()
+      .map_or_else(Vec::new, |probe| probe.active_scope());
+    leptos::task::spawn_local(async move {
+      let token = csrf.get_untracked();
+      match crate::api::refresh_session(token.as_deref()).await {
+        Ok(view) => {
+          scope_notice.set(scope_narrowed(&previous_scope, view.scope.as_deref()));
+        }
+        Err(error) => {
+          if error.status() != Some(401) {
+            refresh_error.set(Some(error));
+          }
+        }
+      }
+      probe.refetch();
+      busy.set(false);
+    });
+  });
+  let logout = Callback::new(move |_: web_sys::MouseEvent| {
+    if busy.get_untracked() {
+      return;
+    }
+    busy.set(true);
+    logout_error.set(None);
+    leptos::task::spawn_local(async move {
+      let token = csrf.get_untracked();
+      match crate::api::logout(token.as_deref()).await {
+        Ok(()) => {
+          let target = crate::api::fetch_config()
+            .await
+            .map(|config| config.provider_logout_url)
+            .unwrap_or_default();
+          crate::api::logout_to(&target);
+        }
+        Err(error) => {
+          logout_error.set(Some(error));
+          busy.set(false);
+        }
+      }
+    });
+  });
+
+  view! {
+    <section class="space-y-8">
+      <header class="flex flex-col gap-5 sm:flex-row sm:items-end sm:justify-between">
+        <div>
+          <p class=EYEBROW>"Session"</p>
+          <h1 class="mt-2 text-title font-semibold text-ink">"Dashboard"</h1>
+          <p class="mt-2 max-w-2xl text-sm leading-6 text-muted">
+            "Live state of the BFF-held session. Secrets and raw tokens never reach this page."
+          </p>
+        </div>
+        <div
+          class="flex flex-wrap items-center gap-2"
+          aria-busy=move || if busy.get() { "true" } else { "false" }
+        >
+          <ActionButton
+            label="Refresh"
+            icon=IconKind::Refresh
+            disabled=Signal::derive(move || busy.get())
+            on_click=refresh
+          />
+          <ActionButton
+            label="Log out"
+            icon=IconKind::LogOut
+            disabled=Signal::derive(move || busy.get())
+            on_click=logout
+          />
+          {move || {
+            busy.get()
+              .then(|| {
+                view! {
+                  <span class="text-xs text-muted" role="status">
+                    "Working…"
+                  </span>
+                }
+              })
+          }}
+        </div>
+      </header>
+
+      {move || {
+        logout_error
+          .get()
+          .map(|error| {
+            let kind = error.card_kind();
+            let diagnostic = error.diagnostic();
+            view! { <ErrorCard kind=kind diagnostic=diagnostic /> }
+          })
+      }}
+
+      {move || {
+        refresh_error
+          .get()
+          .map(|error| {
+            let kind = error.card_kind();
+            let diagnostic = error.diagnostic();
+            view! { <ErrorCard kind=kind diagnostic=diagnostic /> }
+          })
+      }}
+
+      {move || match probe.get() {
+        None => view! { <SessionSkeleton /> }.into_any(),
+        Some(SessionProbe::Active(session)) => {
+          view! {
+            <SignedIn
+              session=*session
+              expires_in=Signal::derive(move || expires_in.get())
+              scope_notice=Signal::derive(move || scope_notice.get())
+            />
+          }
+            .into_any()
+        }
+        Some(SessionProbe::Unauthenticated) => {
+          view! {
+            <ErrorCard kind=ErrorCardKind::SessionExpired>
+              <SignInLink label="Sign in again" />
+            </ErrorCard>
+          }
+            .into_any()
+        }
+        Some(SessionProbe::EndpointMissing) => {
+          let copy = web::viewmodel::session_endpoint_missing_copy();
+          view! {
+            <EmptyState copy=copy>
+              <ActionButton label="Check again" icon=IconKind::Refresh on_click=refresh />
+            </EmptyState>
+          }
+            .into_any()
+        }
+        Some(SessionProbe::Unavailable(error)) => {
+          let kind = error.card_kind();
+          let diagnostic = error.diagnostic();
+          view! {
+            <ErrorCard kind=kind diagnostic=diagnostic>
+              <ActionButton label="Retry" icon=IconKind::Refresh on_click=refresh />
+              <SignInLink label="Sign in again" class=BTN_SECONDARY />
+            </ErrorCard>
+          }
+            .into_any()
+        }
+      }}
+    </section>
+  }
+}
+
+/// Signed-in confirmation plus the identity, token, and flow panels.
+#[component]
+fn SignedIn(
+  session: SessionView,
+  #[prop(into)] expires_in: Signal<i64>,
+  #[prop(into)] scope_notice: Signal<bool>,
+) -> impl IntoView {
+  let token = TokenView::from_session(&session);
+  let user = user_card(&session);
+  let steps = timeline_for_session(&session);
+  let introspection = introspection_json(&session);
+  let missing_scope = insufficient_scope_notice(&session.missing_scopes, &session.scope);
+
+  view! {
+    <div class="space-y-6">
+      {missing_scope
+        .map(|copy| {
+          view! {
+            <div
+              role="status"
+              class="flex items-start gap-3 rounded-card border border-danger-line bg-danger-surface px-4 py-3"
+            >
+              <span class="mt-0.5 shrink-0 text-danger">
+                <Icon kind=IconKind::Alert class="size-4" />
+              </span>
+              <div class="min-w-0">
+                <p class="text-xs font-semibold uppercase tracking-[0.18em] text-danger">
+                  {copy.eyebrow}
+                </p>
+                <p class="mt-1 text-sm font-semibold text-ink">{copy.title}</p>
+                <p class="mt-1 text-sm leading-6 text-ink/90">{copy.body}</p>
+              </div>
+            </div>
+          }
+        })}
+
+      <div class="flex items-center gap-3 rounded-card border border-success-line bg-success-surface px-4 py-3">
+        <span class="shrink-0 text-success">
+          <Icon kind=IconKind::Check class="size-4" />
+        </span>
+        <p class="text-sm font-medium text-success" role="status">
+          "Session active - the BFF holds the tokens."
+        </p>
+      </div>
+
+      {move || {
+        scope_notice
+          .get()
+          .then(|| {
+            let copy = scope_narrowed_notice();
+            view! {
+              <div
+                role="status"
+                class="flex items-start gap-3 rounded-card border border-accent-soft-ink/40 bg-accent-soft px-4 py-3"
+              >
+                <span class="mt-0.5 shrink-0 text-accent-soft-ink">
+                  <Icon kind=IconKind::Info class="size-4" />
+                </span>
+                <div class="min-w-0">
+                  <p class="text-sm font-semibold text-accent-soft-ink">{copy.title}</p>
+                  <p class="mt-1 text-sm leading-6 text-ink/90">{copy.body}</p>
+                </div>
+              </div>
+            }
+          })
+      }}
+
+      <div class="grid gap-4 lg:grid-cols-2">
+        <UserCard model=user />
+        <TokenPanel token=token expires_in=expires_in />
+      </div>
+
+      <DeploymentSection />
+
+      <FlowTimeline steps=steps />
+      <RawIntrospectionViewer json=introspection />
+    </div>
+  }
+}
+
+/// Avatar/initials, subject, `client_id`, issuer, and scope chips.
+#[component]
+fn UserCard(model: UserCardModel) -> impl IntoView {
+  let chips = model
+    .chips
+    .iter()
+    .map(|chip| {
+      view! {
+        <li class="rounded-full border border-line-strong bg-sunken px-2.5 py-1 font-mono text-xs text-ink">
+          {chip.clone()}
+        </li>
+      }
+    })
+    .collect_view();
+  let service_hint = (!model.subject_is_person)
+    .then(|| view! { <p class="mt-3 text-xs leading-5 text-muted">{SERVICE_PRINCIPAL_HINT}</p> });
+
+  view! {
+    <article class=format!("{PANEL} p-5")>
+      <div class="flex items-center gap-3">
+        <span
+          aria-hidden="true"
+          class="grid size-11 shrink-0 place-items-center rounded-full bg-accent-soft text-sm font-semibold text-accent-soft-ink"
+        >
+          {model.initials}
+        </span>
+        <div class="min-w-0">
+          <p class=EYEBROW>"Signed in"</p>
+          <h2 class="mt-1 truncate text-base font-semibold text-ink">{model.subject}</h2>
+        </div>
+      </div>
+      {service_hint}
+      <dl class="mt-5 grid gap-x-6 gap-y-4 sm:grid-cols-2">
+        <div class="min-w-0">
+          <dt class="text-xs font-medium tracking-wider text-muted uppercase">
+            "Client ID"
+          </dt>
+          <dd class="mt-1.5 font-mono text-sm break-all text-ink">{model.client_id}</dd>
+        </div>
+        <div class="min-w-0">
+          <dt class="text-xs font-medium tracking-wider text-muted uppercase">"Issuer"</dt>
+          <dd class="mt-1.5 font-mono text-sm break-all text-ink">{model.issuer}</dd>
+        </div>
+        <div class="min-w-0 sm:col-span-2">
+          <dt class="text-xs font-medium tracking-wider text-muted uppercase">
+            "Granted scope"
+          </dt>
+          <dd class="mt-2">
+            <ul class="flex flex-wrap gap-2" role="list">
+              {chips}
+            </ul>
+          </dd>
+        </div>
+      </dl>
+    </article>
+  }
+}
+
+/// Displayed token type, live countdown, `iat`/`exp` local times, and a
+/// truncated `cnf.jkt` with a copy button. The raw access token is never
+/// available to this component.
+#[component]
+fn TokenPanel(token: TokenView, #[prop(into)] expires_in: Signal<i64>) -> impl IntoView {
+  let offset = crate::api::local_offset_minutes();
+  let panel = token_panel(&token, offset);
+  let jkt_full = panel.jkt_full.clone();
+  let copied = RwSignal::new(false);
+  let copy_thumbprint = Callback::new(move |_: web_sys::MouseEvent| {
+    let Some(jkt) = jkt_full.as_deref() else {
+      return;
+    };
+    if let Some(window) = web_sys::window() {
+      let _ = window.navigator().clipboard().write_text(jkt);
+      copied.set(true);
+      set_timeout(move || copied.set(false), Duration::from_millis(1600));
+    }
+  });
+
+  let constraint_style = if panel.sender_constrained {
+    "border-success-line bg-success-surface text-success"
+  } else {
+    "border-line bg-sunken text-muted"
+  };
+  let thumbprint = match panel.jkt_short.clone() {
+    Some(short) => {
+      view! {
+        <span class="flex flex-wrap items-center gap-2">
+          <code
+            class="rounded-md border border-line bg-sunken px-2 py-1 font-mono text-xs text-ink"
+            title=panel.jkt_full.clone().unwrap_or_default()
+          >
+            {short}
+          </code>
+          <button
+            type="button"
+            class="inline-flex items-center gap-1.5 rounded-md border border-line-strong bg-raised px-2.5 py-1 text-xs font-medium text-ink transition-colors hover:bg-sunken"
+            aria-label="Copy the cnf.jkt thumbprint"
+            on:click=move |event| copy_thumbprint.run(event)
+          >
+            <Icon kind=IconKind::Hash class="size-3.5" />
+            <span>{move || if copied.get() { "Copied" } else { "Copy" }}</span>
+          </button>
+        </span>
+      }
+        .into_any()
+    }
+    None => {
+      view! { <span class="text-xs text-muted">"Not present (bearer token)"</span> }
+        .into_any()
+    }
+  };
+
+  view! {
+    <article class=format!("{PANEL} p-5")>
+      <div class="flex flex-wrap items-center justify-between gap-2">
+        <h2 class="text-sm font-semibold text-ink">"Access token"</h2>
+        <span class="rounded-full border border-line-strong bg-sunken px-2.5 py-1 font-mono text-xs text-ink">
+          {panel.token_type.clone()}
+        </span>
+      </div>
+      <p class="mt-3">
+        <span class=format!(
+          "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium {constraint_style}",
+        )>
+          <Icon
+            kind=if panel.sender_constrained { IconKind::Check } else { IconKind::Info }
+            class="size-3.5"
+          />
+          <span>{panel.constraint_label}</span>
+        </span>
+      </p>
+      <dl class="mt-5 grid grid-cols-2 gap-x-6 gap-y-4">
+        <div class="min-w-0">
+          <dt class="text-xs font-medium tracking-wider text-muted uppercase">
+            "Expires in"
+          </dt>
+          <dd
+            role="timer"
+            class="mt-1.5 font-mono text-sm font-semibold text-ink tabular-nums"
+          >
+            {move || countdown_label(expires_in.get())}
+          </dd>
+        </div>
+        <div class="min-w-0">
+          <dt class="text-xs font-medium tracking-wider text-muted uppercase">"Issued"</dt>
+          <dd class="mt-1.5 font-mono text-xs break-all text-ink">{panel.iat_label.clone()}</dd>
+        </div>
+        <div class="min-w-0">
+          <dt class="text-xs font-medium tracking-wider text-muted uppercase">"Expires"</dt>
+          <dd class="mt-1.5 font-mono text-xs break-all text-ink">{panel.exp_label.clone()}</dd>
+        </div>
+        <div class="min-w-0">
+          <dt class="text-xs font-medium tracking-wider text-muted uppercase">"cnf.jkt"</dt>
+          <dd class="mt-1.5">{thumbprint}</dd>
+        </div>
+      </dl>
+      <p class="mt-4 text-xs leading-5 text-muted">
+        "The raw access token stays on the BFF; only display-safe metadata is rendered here."
+      </p>
+    </article>
+  }
+}
+
+/// The six completed steps of the authorization flow.
+#[component]
+fn FlowTimeline(steps: [TimelineStep; TIMELINE_LEN]) -> impl IntoView {
+  let items = steps
+    .iter()
+    .map(|step| {
+      let complete = step.state == StepState::Complete;
+      let marker = if complete {
+        "border-success-line bg-success-surface text-success"
+      } else {
+        "border-line-strong bg-sunken text-muted"
+      };
+      view! {
+        <li class="flex gap-3.5">
+          <span
+            aria-hidden="true"
+            class=format!(
+              "grid size-7 shrink-0 place-items-center rounded-full border text-xs font-semibold {marker}",
+            )
+          >
+            {if complete {
+              view! { <Icon kind=IconKind::Check class="size-3.5" /> }.into_any()
+            } else {
+              view! { <Icon kind=IconKind::Info class="size-3.5" /> }.into_any()
+            }}
+          </span>
+          <div class="min-w-0 flex-1 pb-1">
+            <h3 class="text-sm font-semibold text-ink">{step.title}</h3>
+            <p class="mt-1 text-sm leading-6 text-muted">{step.body}</p>
+            <span class="sr-only">
+              {if complete { "completed" } else { "not sender-constrained" }}
+            </span>
+          </div>
+        </li>
+      }
+    })
+    .collect_view();
+
+  view! {
+    <section aria-labelledby="flow-heading" class=format!("{PANEL} p-5 sm:p-6")>
+      <div class="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
+        <h2 id="flow-heading" class="text-sm font-semibold text-ink">
+          "Authorization flow"
+        </h2>
+        <p class="text-xs text-muted">"Completed server-side"</p>
+      </div>
+      <ol class="mt-5 space-y-4" role="list">
+        {items}
+      </ol>
+    </section>
+  }
+}
+
+/// Collapsible, read-only viewer for the pretty-printed introspection JSON.
+#[component]
+fn RawIntrospectionViewer(json: String) -> impl IntoView {
+  view! {
+    <details class=format!("{PANEL} group p-5 sm:p-6")>
+      <summary class="flex cursor-pointer items-start justify-between gap-3">
+        <span class="min-w-0">
+          <span class="block text-sm font-semibold text-ink">"Raw introspection"</span>
+          <span class="mt-1 block text-xs leading-5 text-muted">
+            "Display-safe JSON as read by the BFF. Tokens, secrets, and headers are never included."
+          </span>
+        </span>
+        <span class="shrink-0 text-xs font-medium text-accent-ink group-open:hidden">
+          "Show JSON"
+        </span>
+        <span class="hidden shrink-0 text-xs font-medium text-accent-ink group-open:inline">
+          "Hide JSON"
+        </span>
+      </summary>
+      <pre class="mt-4 max-h-96 overflow-auto rounded-lg border border-line bg-sunken p-4 font-mono text-xs leading-5 text-ink">
+        {json}
+      </pre>
+    </details>
+  }
+}

+ 126 - 0
crates/web/src/components/deployment.rs

@@ -0,0 +1,126 @@
+//! Shared "This deployment" card: live `/api/config` values.
+//!
+//! Rendered on the authenticated dashboard; the landing page consumes the same
+//! resource only for the sign-in CTA's `start_url`.
+
+use leptos::prelude::*;
+use web::model::{ApiError, DisplayConfig, config_card_kind};
+use web::viewmodel::{issuer_label, scope_chips};
+
+use super::feedback::{CapabilityBadge, ConfigSkeleton, ErrorCard, SignInLink};
+use super::icons::{Icon, IconKind};
+use super::styles::{BTN_SECONDARY, PANEL};
+
+/// Shared app-level `GET /api/config` resource, provided by `App` so the
+/// landing CTA and the dashboard card read a single fetch.
+pub type ConfigResource = LocalResource<Result<DisplayConfig, ApiError>>;
+
+/// Read the shared config resource, falling back to a component-local fetch
+/// when rendered outside the app shell.
+pub fn config_resource() -> ConfigResource {
+  use_context::<ConfigResource>()
+    .unwrap_or_else(|| LocalResource::new(|| async { crate::api::fetch_config().await }))
+}
+
+/// "This deployment" card: issuer, `client_id`, requested scope chips, and the
+/// advertised capability badges; degrades to a skeleton while `/api/config`
+/// loads and a typed error card when it fails.
+#[component]
+pub fn DeploymentSection() -> impl IntoView {
+  let config = config_resource();
+
+  view! {
+    <section aria-labelledby="deployment-heading" class=format!("{PANEL} p-6 sm:p-7")>
+      <div class="flex flex-wrap items-baseline justify-between gap-x-6 gap-y-1">
+        <h2 id="deployment-heading" class="text-sm font-semibold text-ink">
+          "This deployment"
+        </h2>
+        <p class="font-mono text-xs text-muted">"GET /api/config"</p>
+      </div>
+      <div class="mt-5">
+        {move || match config.get() {
+          None => view! { <ConfigSkeleton /> }.into_any(),
+          Some(Ok(panel)) => view! { <ConfigPanel config=panel /> }.into_any(),
+          Some(Err(error)) => {
+            let kind = config_card_kind(&error);
+            let diagnostic = error.diagnostic();
+            view! {
+              <ErrorCard kind=kind diagnostic=diagnostic>
+                <button
+                  type="button"
+                  class=BTN_SECONDARY
+                  on:click=move |_| {
+                    config.refetch();
+                  }
+                >
+                  <Icon kind=IconKind::Refresh class="size-4" />
+                  "Retry"
+                </button>
+                <SignInLink label="Sign in anyway" class=BTN_SECONDARY />
+              </ErrorCard>
+            }
+              .into_any()
+          }
+        }}
+      </div>
+    </section>
+  }
+}
+
+/// Live `/api/config` values: issuer, client, scope chips, capabilities.
+#[component]
+fn ConfigPanel(config: DisplayConfig) -> impl IntoView {
+  let chips = scope_chips(&config.scope)
+    .into_iter()
+    .map(|chip| {
+      view! {
+        <li class="rounded-full border border-line-strong bg-sunken px-2.5 py-1 font-mono text-xs text-ink">
+          {chip.to_owned()}
+        </li>
+      }
+    })
+    .collect_view();
+  let capabilities = config
+    .capabilities
+    .labelled()
+    .into_iter()
+    .map(|(label, enabled)| {
+      view! { <CapabilityBadge label=label enabled=enabled /> }
+    })
+    .collect_view();
+
+  view! {
+    <dl class="grid gap-x-10 gap-y-6 sm:grid-cols-2">
+      <div class="min-w-0">
+        <dt class="text-xs font-medium tracking-wider text-muted uppercase">"Issuer"</dt>
+        <dd class="mt-1.5 font-mono text-sm break-all text-ink">
+          {issuer_label(&config.issuer).to_owned()}
+        </dd>
+      </div>
+      <div class="min-w-0">
+        <dt class="text-xs font-medium tracking-wider text-muted uppercase">"Client ID"</dt>
+        <dd class="mt-1.5 font-mono text-sm break-all text-ink">{config.client_id}</dd>
+      </div>
+      <div class="min-w-0 sm:col-span-2">
+        <dt class="text-xs font-medium tracking-wider text-muted uppercase">
+          "Requested scope"
+        </dt>
+        <dd class="mt-2">
+          <ul class="flex flex-wrap gap-2" role="list">
+            {chips}
+          </ul>
+        </dd>
+      </div>
+      <div class="min-w-0 sm:col-span-2">
+        <dt class="text-xs font-medium tracking-wider text-muted uppercase">
+          "Advertised capabilities"
+        </dt>
+        <dd class="mt-2">
+          <ul class="flex flex-wrap gap-2" role="list">
+            {capabilities}
+          </ul>
+        </dd>
+      </div>
+    </dl>
+  }
+}

+ 235 - 0
crates/web/src/components/feedback.rs

@@ -0,0 +1,235 @@
+//! Loading, empty, and error-state building blocks with a11y semantics.
+
+use leptos::prelude::*;
+use web::model::ErrorCardKind;
+use web::viewmodel::{EmptyCopy, error_copy, sign_in_target, sign_in_target_for};
+
+use super::icons::{Icon, IconKind};
+use super::styles::{BTN_PRIMARY, BTN_SECONDARY};
+
+/// Decorative shimmer block. The live-region text lives in the caller.
+#[component]
+pub fn Skeleton(#[prop(optional, into)] class: String) -> impl IntoView {
+  let class = if class.trim().is_empty() {
+    "h-4 w-full".to_owned()
+  } else {
+    class
+  };
+  view! {
+    <div
+      aria-hidden="true"
+      class=format!("motion-safe:animate-pulse rounded-md bg-sunken {class}")
+    ></div>
+  }
+}
+
+/// Loading placeholder for the deployment card.
+#[component]
+pub fn ConfigSkeleton() -> impl IntoView {
+  view! {
+    <div role="status" aria-live="polite">
+      <span class="sr-only">"Loading authorization server configuration"</span>
+      <div class="flex flex-wrap items-center gap-3">
+        <Skeleton class="h-4 w-28" />
+        <Skeleton class="h-4 w-52" />
+      </div>
+      <div class="mt-5 grid gap-4 sm:grid-cols-2">
+        <Skeleton class="h-5 w-3/4" />
+        <Skeleton class="h-5 w-2/3" />
+      </div>
+      <div class="mt-5 flex flex-wrap gap-2">
+        <Skeleton class="h-7 w-16 rounded-full" />
+        <Skeleton class="h-7 w-20 rounded-full" />
+        <Skeleton class="h-7 w-24 rounded-full" />
+      </div>
+    </div>
+  }
+}
+
+/// Loading placeholder for the dashboard session card.
+#[component]
+pub fn SessionSkeleton() -> impl IntoView {
+  view! {
+    <div role="status" aria-live="polite" class="space-y-4">
+      <span class="sr-only">"Loading session state"</span>
+      <div class="flex items-center gap-4">
+        <Skeleton class="size-11 rounded-full" />
+        <div class="flex-1 space-y-2">
+          <Skeleton class="h-4 w-44" />
+          <Skeleton class="h-3 w-64" />
+        </div>
+      </div>
+      <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
+        <Skeleton class="h-28 rounded-card" />
+        <Skeleton class="h-28 rounded-card" />
+        <Skeleton class="h-28 rounded-card" />
+      </div>
+    </div>
+  }
+}
+
+/// Typed error card. `role="alert"` announces the failure; actions are
+/// passed as children so the caller owns the recovery path.
+#[component]
+pub fn ErrorCard(
+  kind: ErrorCardKind,
+  #[prop(optional, into)] diagnostic: Option<String>,
+  #[prop(optional)] children: Option<ChildrenFn>,
+) -> impl IntoView {
+  let copy = error_copy(kind);
+  view! {
+    <section
+      role="alert"
+      class="rounded-card border border-danger-line bg-danger-surface p-5 shadow-card"
+    >
+      <div class="flex items-start gap-3">
+        <span class="mt-0.5 shrink-0 text-danger">
+          <Icon kind=IconKind::Alert />
+        </span>
+        <div class="min-w-0 flex-1">
+          <p class="text-xs font-semibold uppercase tracking-[0.18em] text-danger">
+            {copy.eyebrow}
+          </p>
+          <h2 class="mt-1.5 text-base font-semibold text-ink">{copy.title}</h2>
+          <p class="mt-2 text-sm leading-6 text-ink/90">{copy.body}</p>
+          {diagnostic
+            .map(|detail| {
+              view! {
+                <details class="mt-3 rounded-md border border-danger-line/70 bg-canvas/60 px-3 py-2">
+                  <summary class="cursor-pointer text-xs font-medium text-danger">
+                    "Technical detail"
+                  </summary>
+                  <p class="mt-2 font-mono text-xs break-all text-muted">{detail}</p>
+                </details>
+              }
+            })}
+          {children
+            .map(|children| {
+              view! { <div class="mt-4 flex flex-wrap gap-2">{children()}</div> }
+            })}
+        </div>
+      </div>
+    </section>
+  }
+}
+
+/// Empty state for a valid but not-yet-populated resource.
+#[component]
+pub fn EmptyState(
+  copy: EmptyCopy,
+  #[prop(optional)] children: Option<ChildrenFn>,
+) -> impl IntoView {
+  view! {
+    <section class="rounded-card border border-dashed border-line-strong bg-raised p-6 text-center">
+      <span class="mx-auto grid size-10 place-items-center rounded-full bg-sunken text-muted">
+        <Icon kind=IconKind::Info />
+      </span>
+      <p class="mt-4 text-xs font-semibold uppercase tracking-[0.18em] text-muted">
+        {copy.eyebrow}
+      </p>
+      <h2 class="mt-1.5 text-base font-semibold text-ink">{copy.title}</h2>
+      <p class="mx-auto mt-2 max-w-xl text-sm leading-6 text-muted">{copy.body}</p>
+      {children
+        .map(|children| {
+          view! { <div class="mt-5 flex flex-wrap justify-center gap-2">{children()}</div> }
+        })}
+    </section>
+  }
+}
+
+/// One capability flag: glyph plus label, never color alone.
+#[component]
+pub fn CapabilityBadge(label: &'static str, enabled: bool) -> impl IntoView {
+  let (chip, icon) = if enabled {
+    (
+      "border-success-line bg-success-surface text-success",
+      IconKind::Check,
+    )
+  } else {
+    ("border-line bg-sunken text-muted", IconKind::X)
+  };
+  view! {
+    <li class=format!(
+      "inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium {chip}",
+    )>
+      <Icon kind=icon class="size-3.5" />
+      <span>{label}</span>
+      <span class="sr-only">
+        {if enabled { " supported" } else { " not supported" }}
+      </span>
+    </li>
+  }
+}
+
+/// Primary sign-in call to action.
+///
+/// The `href` keeps keyboard/progressive-enhancement behavior; the click
+/// handler routes through [`crate::api::sign_in_to`] for the explicit
+/// `window.location.assign` contract (BFF `start_url`, else `/oauth/start`).
+#[component]
+pub fn SignInLink(
+  #[prop(optional, into)] label: String,
+  #[prop(optional, into)] class: String,
+  #[prop(optional, into)] start_url: String,
+) -> impl IntoView {
+  let label = if label.is_empty() {
+    "Sign in".to_owned()
+  } else {
+    label
+  };
+  let class = if class.is_empty() {
+    BTN_PRIMARY.to_owned()
+  } else {
+    class
+  };
+  let href = if start_url.trim().is_empty() {
+    sign_in_target().to_owned()
+  } else {
+    sign_in_target_for(&start_url).to_owned()
+  };
+  view! {
+    <a
+      href=href.clone()
+      class=class
+      on:click=move |event: web_sys::MouseEvent| {
+        event.prevent_default();
+        crate::api::sign_in_to(&href);
+      }
+    >
+      <span>{label}</span>
+      <Icon kind=IconKind::ArrowRight class="size-4" />
+    </a>
+  }
+}
+
+/// Secondary button used by the dashboard session controls.
+#[component]
+pub fn ActionButton(
+  #[prop(into)] label: String,
+  #[prop(optional, into)] class: String,
+  #[prop(optional)] icon: Option<IconKind>,
+  #[prop(optional)] disabled: Option<Signal<bool>>,
+  #[prop(optional)] on_click: Option<Callback<web_sys::MouseEvent>>,
+) -> impl IntoView {
+  let class = if class.is_empty() {
+    BTN_SECONDARY.to_owned()
+  } else {
+    class
+  };
+  let disabled = disabled.unwrap_or_else(|| Signal::derive(|| false));
+  view! {
+    <button
+      type="button"
+      class=class
+      disabled=move || disabled.get()
+      on:click=move |event| {
+        if let Some(action) = on_click {
+          action.run(event);
+        }
+      }
+    >
+      {icon.map(|kind| view! { <Icon kind=kind class="size-4" /> })}
+      <span>{label}</span>
+    </button>
+  }
+}

+ 212 - 0
crates/web/src/components/icons.rs

@@ -0,0 +1,212 @@
+//! Inline SVG icon set (no emoji, no icon font, CSP-friendly).
+
+use leptos::prelude::*;
+use web::viewmodel::FeatureGlyph;
+
+/// Every glyph the UI can render.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum IconKind {
+  Shield,
+  Key,
+  Link,
+  Route,
+  Hash,
+  Search,
+  Register,
+  Alert,
+  Info,
+  Check,
+  X,
+  Refresh,
+  LogOut,
+  ArrowRight,
+  Sun,
+  Moon,
+}
+
+impl From<FeatureGlyph> for IconKind {
+  fn from(glyph: FeatureGlyph) -> Self {
+    match glyph {
+      FeatureGlyph::Shield => Self::Shield,
+      FeatureGlyph::Key => Self::Key,
+      FeatureGlyph::Link => Self::Link,
+      FeatureGlyph::Route => Self::Route,
+      FeatureGlyph::Search => Self::Search,
+      FeatureGlyph::Register => Self::Register,
+      FeatureGlyph::Refresh => Self::Refresh,
+      FeatureGlyph::Hash => Self::Hash,
+    }
+  }
+}
+
+/// Decorative inline icon; the label always lives in adjacent text.
+#[component]
+pub fn Icon(kind: IconKind, #[prop(optional, into)] class: String) -> impl IntoView {
+  let class = if class.trim().is_empty() {
+    "size-5".to_owned()
+  } else {
+    class
+  };
+  view! {
+    <svg
+      class=class
+      viewBox="0 0 24 24"
+      fill="none"
+      stroke="currentColor"
+      stroke-width="1.8"
+      stroke-linecap="round"
+      stroke-linejoin="round"
+      aria-hidden="true"
+      focusable="false"
+    >
+      {match kind {
+        IconKind::Shield => {
+          view! {
+            <>
+              <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
+              <path d="m9 12 2 2 4-4" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Key => {
+          view! {
+            <>
+              <path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" />
+              <circle cx="16.5" cy="7.5" r=".5" fill="currentColor" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Link => {
+          view! {
+            <>
+              <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
+              <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Route => {
+          view! {
+            <>
+              <circle cx="6" cy="19" r="3" />
+              <path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15" />
+              <circle cx="18" cy="5" r="3" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Hash => {
+          view! {
+            <>
+              <line x1="4" x2="20" y1="9" y2="9" />
+              <line x1="4" x2="20" y1="15" y2="15" />
+              <line x1="10" x2="8" y1="3" y2="21" />
+              <line x1="16" x2="14" y1="3" y2="21" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Search => {
+          view! {
+            <>
+              <circle cx="11" cy="11" r="8" />
+              <path d="m21 21-4.3-4.3" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Register => {
+          view! {
+            <>
+              <path d="M3.85 8.62a4 4 0 0 1 4.78-4.77 4 4 0 0 1 6.74 0 4 4 0 0 1 4.78 4.78 4 4 0 0 1 0 6.74 4 4 0 0 1-4.77 4.78 4 4 0 0 1-6.75 0 4 4 0 0 1-4.78-4.77 4 4 0 0 1 0-6.76Z" />
+              <path d="M12 8v8" />
+              <path d="M8 12h8" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Alert => {
+          view! {
+            <>
+              <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
+              <path d="M12 9v4" />
+              <path d="M12 17h.01" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Info => {
+          view! {
+            <>
+              <circle cx="12" cy="12" r="10" />
+              <path d="M12 16v-4" />
+              <path d="M12 8h.01" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Check => view! { <path d="M20 6 9 17l-5-5" /> }.into_any(),
+        IconKind::X => {
+          view! {
+            <>
+              <path d="M18 6 6 18" />
+              <path d="m6 6 12 12" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Refresh => {
+          view! {
+            <>
+              <path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
+              <path d="M21 3v5h-5" />
+              <path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
+              <path d="M8 16H3v5" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::LogOut => {
+          view! {
+            <>
+              <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
+              <polyline points="16 17 21 12 16 7" />
+              <line x1="21" x2="9" y1="12" y2="12" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::ArrowRight => {
+          view! {
+            <>
+              <path d="M5 12h14" />
+              <path d="m12 5 7 7-7 7" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Sun => {
+          view! {
+            <>
+              <circle cx="12" cy="12" r="4" />
+              <path d="M12 2v2" />
+              <path d="M12 20v2" />
+              <path d="m4.93 4.93 1.41 1.41" />
+              <path d="m17.66 17.66 1.41 1.41" />
+              <path d="M2 12h2" />
+              <path d="M20 12h2" />
+              <path d="m6.34 17.66-1.41 1.41" />
+              <path d="m19.07 4.93-1.41 1.41" />
+            </>
+          }
+            .into_any()
+        }
+        IconKind::Moon => {
+          view! { <path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" /> }.into_any()
+        }
+      }}
+    </svg>
+  }
+}

+ 186 - 0
crates/web/src/components/landing.rs

@@ -0,0 +1,186 @@
+//! Unauthenticated landing page: branding, guarantees, sign-in CTA.
+
+use leptos::prelude::*;
+use web::viewmodel::{
+  BUILDER_BREAKDOWN_LABEL, BUILDER_CTA_LABEL, BUILDER_EXPERTISE, BUILDER_EXPERTISE_LABEL,
+  BUILDER_EYEBROW, BUILDER_HEADING, BUILDER_INDUSTRIES, BUILDER_INDUSTRIES_LABEL,
+  BUILDER_LANGUAGES, BUILDER_NAME, BUILDER_ROLE, BUILDER_SECTION_ID, BUILDER_SUMMARY,
+  BUILDER_TOTAL_LABEL, VALUE_PROP, experience_areas, experience_years_label, features,
+  total_experience_label,
+};
+
+use super::deployment::config_resource;
+use super::feedback::SignInLink;
+use super::icons::{Icon, IconKind};
+use super::styles::{BTN_SECONDARY, EYEBROW, PANEL};
+
+/// Landing route (`/`): value proposition, guarantees, and sign-in CTA.
+#[component]
+pub fn Landing() -> impl IntoView {
+  let config = config_resource();
+
+  view! {
+    <div class="space-y-16 sm:space-y-20">
+      <section aria-labelledby="hero-heading" class="mx-auto max-w-3xl text-center">
+        <p class=EYEBROW>"OAuth 2.1 reference client"</p>
+        <h1
+          id="hero-heading"
+          class="mt-4 text-4xl font-semibold text-balance text-ink sm:text-display"
+        >
+          "See exactly what your OAuth 2.1 server issues."
+        </h1>
+        <p class="mx-auto mt-5 max-w-2xl text-base leading-7 text-muted sm:text-lg">
+          {VALUE_PROP}
+        </p>
+        <div class="mt-9 flex flex-col items-center justify-center gap-3 sm:flex-row">
+          {move || {
+            let target = config
+              .get()
+              .and_then(|result| result.ok())
+              .map(|panel| panel.start_url)
+              .unwrap_or_default();
+            view! { <SignInLink start_url=target /> }
+          }}
+          <a href=format!("#{BUILDER_SECTION_ID}") class=BTN_SECONDARY>
+            {BUILDER_CTA_LABEL}
+          </a>
+          <a href="#features" class=BTN_SECONDARY>
+            "See the guarantees"
+          </a>
+        </div>
+        <p class="mt-4 text-xs text-muted">
+          "Secrets, PKCE verifiers, and DPoP keys stay on the BFF - never in this page, never in storage."
+        </p>
+      </section>
+
+      <BuilderSection />
+
+      <section id="features" aria-labelledby="features-heading" class="scroll-mt-24">
+        <div class="max-w-2xl">
+          <p class=EYEBROW>"Guarantees"</p>
+          <h2 id="features-heading" class="mt-2 text-title font-semibold text-ink">
+            "What the flow actually enforces"
+          </h2>
+          <p class="mt-3 text-sm leading-6 text-muted">
+            "Eight protocol-level properties the BFF relies on - the capabilities this deployment advertises, explained in plain language."
+          </p>
+        </div>
+        <ul class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3" role="list">
+          {features()
+            .iter()
+            .map(|feature| {
+              let icon = IconKind::from(feature.glyph);
+              view! {
+                <li class="rounded-card border border-line bg-raised p-5 shadow-card transition-colors hover:border-line-strong">
+                  <span class="grid size-9 place-items-center rounded-lg bg-accent-soft text-accent-soft-ink">
+                    <Icon kind=icon class="size-5" />
+                  </span>
+                  <h3 class="mt-4 text-sm font-semibold text-ink">{feature.title}</h3>
+                  <p class="mt-2 text-sm leading-6 text-muted">{feature.body}</p>
+                </li>
+              }
+            })
+            .collect_view()}
+        </ul>
+      </section>
+    </div>
+  }
+}
+
+/// "Who built this?" section: builder identity, live experience stats, and
+/// the industries and expertise areas behind the client.
+#[component]
+fn BuilderSection() -> impl IntoView {
+  let current_year = js_sys::Date::new_0().get_full_year() as i32;
+  let total_label = total_experience_label(current_year);
+  let breakdown = experience_areas(current_year)
+    .into_iter()
+    .map(|(label, years)| {
+      view! {
+        <li class="flex items-baseline justify-between gap-4 py-1.5">
+          <span class="text-sm text-muted">{label}</span>
+          <span class="font-mono text-xs text-ink">{experience_years_label(years)}</span>
+        </li>
+      }
+    })
+    .collect_view();
+  let industries = BUILDER_INDUSTRIES
+    .iter()
+    .map(|industry| {
+      view! {
+        <li class="rounded-full border border-line-strong bg-sunken px-2.5 py-1 text-xs text-ink">
+          {*industry}
+        </li>
+      }
+    })
+    .collect_view();
+  let expertise = BUILDER_EXPERTISE
+    .iter()
+    .map(|area| {
+      view! {
+        <li class="flex gap-2.5">
+          <span
+            class="mt-2.5 size-1.5 shrink-0 rounded-full bg-accent-ink"
+            aria-hidden="true"
+          ></span>
+          <span class="text-sm leading-6 text-muted">{*area}</span>
+        </li>
+      }
+    })
+    .collect_view();
+
+  view! {
+    <section id=BUILDER_SECTION_ID aria-labelledby="builder-heading" class="scroll-mt-24">
+      <div class="max-w-2xl">
+        <p class=EYEBROW>{BUILDER_EYEBROW}</p>
+        <h2 id="builder-heading" class="mt-2 text-title font-semibold text-ink">
+          {BUILDER_HEADING}
+        </h2>
+      </div>
+      <div class=format!("{PANEL} mt-8 grid gap-10 p-6 sm:p-8 lg:grid-cols-[1.15fr_1fr]")>
+        <div>
+          <p class="text-xl font-semibold text-ink">{BUILDER_NAME}</p>
+          <p class="mt-1 text-sm font-medium text-accent-ink">{BUILDER_ROLE}</p>
+          <p class="mt-5 text-sm leading-6 text-muted">{BUILDER_SUMMARY}</p>
+          <dl class="mt-8 grid gap-6 sm:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
+            <div class="rounded-lg border border-line bg-sunken p-5">
+              <dt class="text-xs font-medium tracking-wider text-muted uppercase">
+                {BUILDER_TOTAL_LABEL}
+              </dt>
+              <dd class="mt-2 text-title font-semibold text-ink">{total_label}</dd>
+            </div>
+            <div class="min-w-0">
+              <dt class="text-xs font-medium tracking-wider text-muted uppercase">
+                {BUILDER_BREAKDOWN_LABEL}
+              </dt>
+              <dd class="mt-2">
+                <ul class="divide-y divide-line" role="list">
+                  {breakdown}
+                </ul>
+              </dd>
+            </div>
+          </dl>
+        </div>
+        <div class="space-y-8">
+          <div>
+            <h3 class="text-xs font-medium tracking-wider text-muted uppercase">
+              {BUILDER_INDUSTRIES_LABEL}
+            </h3>
+            <ul class="mt-3 flex flex-wrap gap-2" role="list">
+              {industries}
+            </ul>
+          </div>
+          <div>
+            <h3 class="text-xs font-medium tracking-wider text-muted uppercase">
+              {BUILDER_EXPERTISE_LABEL}
+            </h3>
+            <ul class="mt-3 space-y-2.5" role="list">
+              {expertise}
+            </ul>
+          </div>
+          <p class="text-xs text-muted">{BUILDER_LANGUAGES}</p>
+        </div>
+      </div>
+    </section>
+  }
+}

+ 9 - 0
crates/web/src/components/mod.rs

@@ -0,0 +1,9 @@
+//! Browser component modules (wasm32 only, wired in `main.rs`).
+
+pub mod dashboard;
+pub mod deployment;
+pub mod feedback;
+pub mod icons;
+pub mod landing;
+pub mod styles;
+pub mod theme;

+ 16 - 0
crates/web/src/components/styles.rs

@@ -0,0 +1,16 @@
+//! Shared class strings for the component layer.
+
+/// Primary action (sign-in style, accent-filled, dark-aware pair).
+pub const BTN_PRIMARY: &str = "inline-flex items-center justify-center gap-2 rounded-lg bg-btn \
+  px-5 py-2.5 text-sm font-semibold text-btn-ink shadow-sm transition-colors hover:bg-btn-hover";
+
+/// Secondary action (bordered, raised surface).
+pub const BTN_SECONDARY: &str = "inline-flex items-center justify-center gap-2 rounded-lg \
+  border border-line-strong bg-raised px-4 py-2 text-sm font-medium text-ink transition-colors \
+  hover:bg-sunken";
+
+/// Small uppercase section eyebrow.
+pub const EYEBROW: &str = "text-xs font-semibold uppercase tracking-[0.22em] text-accent-ink";
+
+/// Raised card surface.
+pub const PANEL: &str = "rounded-card border border-line bg-raised shadow-card";

+ 84 - 0
crates/web/src/components/theme.rs

@@ -0,0 +1,84 @@
+//! Header theme toggle: dark is the default, light is an explicit choice.
+//!
+//! `bootstrap.js` applies `data-theme` from `localStorage` before the app
+//! mounts; this component reads that state back, flips
+//! `document.documentElement`'s `data-theme`, and owns the single
+//! `localStorage` key (`theme`). Nothing else is ever stored here.
+
+use leptos::prelude::*;
+
+use super::icons::{Icon, IconKind};
+
+/// The only `localStorage` key this app writes.
+const THEME_KEY: &str = "theme";
+const THEME_DARK: &str = "dark";
+const THEME_LIGHT: &str = "light";
+
+fn current_theme() -> &'static str {
+  let theme = web_sys::window()
+    .and_then(|window| window.document())
+    .and_then(|document| document.document_element())
+    .and_then(|root| root.get_attribute("data-theme"))
+    .unwrap_or_default();
+  if theme == THEME_LIGHT {
+    THEME_LIGHT
+  } else {
+    THEME_DARK
+  }
+}
+
+fn theme_color(theme: &str) -> &'static str {
+  if theme == THEME_LIGHT {
+    "#f9fafb"
+  } else {
+    "#141821"
+  }
+}
+
+fn apply_theme(theme: &'static str) {
+  let Some(window) = web_sys::window() else {
+    return;
+  };
+  if let Some(root) = window
+    .document()
+    .and_then(|document| document.document_element())
+  {
+    let _ = root.set_attribute("data-theme", theme);
+  }
+  if let Ok(Some(storage)) = window.local_storage() {
+    let _ = storage.set_item(THEME_KEY, theme);
+  }
+  if let Some(document) = window.document()
+    && let Ok(Some(meta)) = document.query_selector("meta[name=\"theme-color\"]")
+  {
+    let _ = meta.set_attribute("content", theme_color(theme));
+  }
+}
+
+/// Toggle between the dark (default) and light palettes.
+#[component]
+pub fn ThemeToggle() -> impl IntoView {
+  let dark = RwSignal::new(current_theme() == THEME_DARK);
+
+  view! {
+    <button
+      type="button"
+      on:click=move |_| {
+        let next = !dark.get_untracked();
+        apply_theme(if next { THEME_DARK } else { THEME_LIGHT });
+        dark.set(next);
+      }
+      aria-label="Dark theme"
+      aria-pressed=move || if dark.get() { "true" } else { "false" }
+      class="inline-flex size-8 shrink-0 items-center justify-center rounded-lg border border-line-strong bg-raised text-muted transition-colors hover:bg-sunken hover:text-ink"
+    >
+      {move || {
+        if dark.get() {
+          view! { <Icon kind=IconKind::Moon class="size-4" /> }.into_any()
+        } else {
+          view! { <Icon kind=IconKind::Sun class="size-4" /> }.into_any()
+        }
+      }}
+    </button>
+  }
+}

+ 22 - 0
crates/web/src/lib.rs

@@ -0,0 +1,22 @@
+//! Host-compilable library surface for the `web` crate.
+//!
+//! The browser UI itself is a WASM-only binary (`src/main.rs` + the
+//! `components`, `app`, and `api` modules, built with `trunk build`). This
+//! library target exists so the view types (`model`) and presentation
+//! decisions (`viewmodel`) can be unit-tested with `cargo test -p web --lib`
+//! on the host.
+
+pub mod model;
+pub mod viewmodel;
+
+pub use viewmodel::PRODUCT_NAME;
+
+#[cfg(test)]
+mod tests {
+  use super::PRODUCT_NAME;
+
+  #[test]
+  fn product_name_is_not_empty() {
+    assert!(!PRODUCT_NAME.trim().is_empty());
+  }
+}

+ 21 - 0
crates/web/src/main.rs

@@ -0,0 +1,21 @@
+//! Trunk entry point for the CSR/WASM UI.
+
+#[cfg(target_arch = "wasm32")]
+mod api;
+#[cfg(target_arch = "wasm32")]
+mod app;
+#[cfg(target_arch = "wasm32")]
+mod components;
+
+#[cfg(target_arch = "wasm32")]
+fn main() {
+  console_error_panic_hook::set_once();
+  leptos::mount::mount_to_body(app::App);
+}
+
+/// Host builds are a no-op: the UI is a WASM-only artifact. This keeps
+/// workspace-wide `cargo check`/`cargo test` green on the host target.
+#[cfg(not(target_arch = "wasm32"))]
+fn main() {
+  eprintln!("The `web` crate is a WASM-only binary; build it with `trunk build`.");
+}

+ 600 - 0
crates/web/src/model.rs

@@ -0,0 +1,600 @@
+//! Platform-independent view types for the BFF JSON endpoints.
+//!
+//! This module is compiled for the host as well as for wasm32, so every shape
+//! the UI depends on can be unit-tested with `cargo test -p web --lib`. Only
+//! request/response transport lives in the wasm-gated `api` module.
+
+use serde::{Deserialize, Serialize};
+
+/// Public, non-secret display configuration from `GET /api/config`.
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+pub struct DisplayConfig {
+  pub issuer: String,
+  pub client_id: String,
+  pub scope: String,
+  #[serde(default)]
+  pub capabilities: Capabilities,
+  #[serde(default = "default_start_url")]
+  pub start_url: String,
+  /// Provider (AS) RP-initiated logout endpoint, issuer-derived. Defaults to
+  /// empty when the BFF omits it (older payload) or has no issuer configured.
+  #[serde(default)]
+  pub provider_logout_url: String,
+}
+
+/// Default sign-in target when the BFF omits `start_url` (older shape).
+fn default_start_url() -> String {
+  crate::viewmodel::SIGN_IN_PATH.to_owned()
+}
+
+/// Capability flags the UI degrades on. Mirrors the BFF field names.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
+#[serde(default)]
+pub struct Capabilities {
+  pub par: bool,
+  pub dpop: bool,
+  pub introspection: bool,
+  pub userinfo: bool,
+  pub dcr: bool,
+  pub refresh: bool,
+}
+
+impl Capabilities {
+  /// Labelled flags in a stable order for the landing capability strip.
+  pub fn labelled(self) -> [(&'static str, bool); 5] {
+    [
+      ("PAR", self.par),
+      ("DPoP", self.dpop),
+      ("Introspection", self.introspection),
+      ("DCR", self.dcr),
+      ("Refresh", self.refresh),
+    ]
+  }
+}
+
+/// A typed failure from a same-origin BFF request.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ApiError {
+  /// The request never reached the BFF (offline, proxy down, DNS).
+  Network { detail: String },
+  /// The BFF answered with a non-success status.
+  Http {
+    status: u16,
+    code: Option<String>,
+    detail: Option<String>,
+  },
+  /// The BFF answered 2xx but the body did not match the documented shape.
+  Decode { detail: String },
+}
+
+impl ApiError {
+  pub fn network(detail: impl Into<String>) -> Self {
+    Self::Network {
+      detail: detail.into(),
+    }
+  }
+
+  pub fn decode(detail: impl Into<String>) -> Self {
+    Self::Decode {
+      detail: detail.into(),
+    }
+  }
+
+  pub fn http(status: u16, code: Option<String>, detail: Option<String>) -> Self {
+    Self::Http {
+      status,
+      code,
+      detail,
+    }
+  }
+
+  pub fn status(&self) -> Option<u16> {
+    match self {
+      Self::Http { status, .. } => Some(*status),
+      Self::Network { .. } | Self::Decode { .. } => None,
+    }
+  }
+
+  pub fn code(&self) -> Option<&str> {
+    match self {
+      Self::Http { code, .. } => code.as_deref(),
+      Self::Network { .. } | Self::Decode { .. } => None,
+    }
+  }
+
+  /// Short, non-secret diagnostic line for the collapsible error details.
+  pub fn diagnostic(&self) -> String {
+    match self {
+      Self::Network { detail } => format!("network error: {detail}"),
+      Self::Decode { detail } => format!("unexpected body: {detail}"),
+      Self::Http {
+        status,
+        code,
+        detail,
+      } => match (code, detail) {
+        (Some(code), Some(detail)) => format!("HTTP {status} ({code}): {detail}"),
+        (Some(code), None) => format!("HTTP {status} ({code})"),
+        (None, Some(detail)) => format!("HTTP {status}: {detail}"),
+        (None, None) => format!("HTTP {status}"),
+      },
+    }
+  }
+}
+
+/// The card variant an API failure should render as.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ErrorCardKind {
+  DiscoveryUnavailable,
+  ClientRejected,
+  SessionExpired,
+  NetworkUnavailable,
+  ConfigUnavailable,
+  ServerError,
+}
+
+impl ApiError {
+  /// Classify an API failure into the card the user sees.
+  pub fn card_kind(&self) -> ErrorCardKind {
+    match self {
+      Self::Network { .. } => ErrorCardKind::NetworkUnavailable,
+      Self::Decode { .. } => ErrorCardKind::ServerError,
+      Self::Http { status, code, .. } => classify_http_status(*status, code.as_deref()),
+    }
+  }
+}
+
+/// Map an HTTP status (plus an optional OAuth error code) to a card kind.
+///
+/// Discovery outages surface as `502/503/504` from the BFF proxy; a rejected
+/// client arrives as `invalid_client`/`unauthorized_client`; `401` is the
+/// expired-session case the dashboard must show.
+pub fn classify_http_status(status: u16, code: Option<&str>) -> ErrorCardKind {
+  match code {
+    Some("discovery_unavailable") => return ErrorCardKind::DiscoveryUnavailable,
+    Some("invalid_client" | "unauthorized_client") => return ErrorCardKind::ClientRejected,
+    _ => {}
+  }
+  match status {
+    401 => ErrorCardKind::SessionExpired,
+    502..=504 => ErrorCardKind::DiscoveryUnavailable,
+    _ => ErrorCardKind::ServerError,
+  }
+}
+
+/// Config-specific classification: a missing `/api/config` is a distinct card
+/// from a generic server failure.
+pub fn config_card_kind(error: &ApiError) -> ErrorCardKind {
+  match error {
+    ApiError::Http { status: 404, .. } => ErrorCardKind::ConfigUnavailable,
+    other => other.card_kind(),
+  }
+}
+
+/// Sender-constraint confirmation from the introspection response.
+#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
+pub struct CnfView {
+  /// RFC 7638 JWK thumbprint of the DPoP key the token is bound to.
+  pub jkt: String,
+}
+
+/// Display-safe view of `GET /api/session` (todo 17's `SessionView`).
+///
+/// Field names mirror the BFF JSON exactly. There is no token or secret field
+/// by construction: the BFF never sends one, and the browser never asks d-OAuth
+/// directly. `subject`, `iat`, `exp`, `cnf`, and `act` are optional because the
+/// introspection response omits them (client-credentials has no `sub`; a
+/// bearer token has no `cnf`).
+#[derive(Debug, Clone, PartialEq, Default, Deserialize, Serialize)]
+pub struct SessionView {
+  pub issuer: String,
+  pub client_id: String,
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub subject: Option<String>,
+  #[serde(default)]
+  pub scope: Vec<String>,
+  /// Required scopes the BFF asked for that the token does not carry.
+  #[serde(default)]
+  pub missing_scopes: Vec<String>,
+  #[serde(default)]
+  pub token_type: String,
+  #[serde(default)]
+  pub expires_in: i64,
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub iat: Option<i64>,
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub exp: Option<i64>,
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub cnf: Option<CnfView>,
+  /// Actor claim (`act`), only present on token-exchange tokens.
+  #[serde(default, skip_serializing_if = "Option::is_none")]
+  pub act: Option<serde_json::Value>,
+  /// Per-session CSRF token sent as `x-csrf-token` on the state-changing POSTs.
+  #[serde(default)]
+  pub csrf_token: String,
+}
+
+/// Successful rotation answer for `POST /api/refresh` (no tokens inside).
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
+pub struct RefreshView {
+  pub refreshed: bool,
+  pub expires_in: u64,
+  /// The grant after rotation; `None` when the AS did not report a scope.
+  #[serde(default)]
+  pub scope: Option<String>,
+}
+
+/// Display-safe projection of the token metadata in a [`SessionView`].
+///
+/// The displayed `token_type` is rendered verbatim from the BFF, which already
+/// derives `"DPoP"` when `cnf.jkt` is present; `jkt` is the sender-constraint
+/// thumbprint, never a token.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct TokenView {
+  pub token_type: String,
+  pub expires_in: i64,
+  pub iat: Option<i64>,
+  pub exp: Option<i64>,
+  pub jkt: Option<String>,
+}
+
+impl TokenView {
+  /// Project the token fields out of a session view.
+  pub fn from_session(session: &SessionView) -> Self {
+    Self {
+      token_type: session.token_type.clone(),
+      expires_in: session.expires_in,
+      iat: session.iat,
+      exp: session.exp,
+      jkt: session
+        .cnf
+        .as_ref()
+        .map(|cnf| cnf.jkt.clone())
+        .filter(|jkt| !jkt.is_empty()),
+    }
+  }
+
+  /// True when the token is bound to a DPoP key (`cnf.jkt` present).
+  pub fn sender_constrained(&self) -> bool {
+    self.jkt.is_some()
+  }
+}
+
+/// One granted-scope chip for the user card.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct ScopeChip {
+  pub label: String,
+}
+
+impl ScopeChip {
+  /// Split one scope entry (an entry may itself carry several space-delimited
+  /// scopes) into trimmed chips, dropping empties.
+  pub fn from_entry(entry: &str) -> Vec<Self> {
+    entry
+      .split_whitespace()
+      .map(|label| Self {
+        label: label.to_owned(),
+      })
+      .collect()
+  }
+
+  /// Flatten and deduplicate the `scope[]` array in first-seen order.
+  pub fn list(scope: &[String]) -> Vec<Self> {
+    let mut chips: Vec<Self> = Vec::new();
+    for entry in scope {
+      for chip in Self::from_entry(entry) {
+        if !chips.contains(&chip) {
+          chips.push(chip);
+        }
+      }
+    }
+    chips
+  }
+}
+
+/// Outcome of probing `GET /api/session`.
+#[derive(Debug, Clone, PartialEq)]
+pub enum SessionProbe {
+  /// `2xx` with a session body.
+  Active(Box<SessionView>),
+  /// `401`: the BFF has no live session for this browser.
+  Unauthenticated,
+  /// `404`/`405`: the server lane has not exposed the endpoint yet.
+  EndpointMissing,
+  /// Anything else: typed failure with a diagnostic.
+  Unavailable(ApiError),
+}
+
+impl SessionProbe {
+  /// The session view when the probe is active.
+  pub fn active(&self) -> Option<&SessionView> {
+    match self {
+      Self::Active(session) => Some(session),
+      Self::Unauthenticated | Self::EndpointMissing | Self::Unavailable(_) => None,
+    }
+  }
+
+  /// The granted scope of an active session (empty for other states).
+  pub fn active_scope(&self) -> Vec<String> {
+    self
+      .active()
+      .map(|session| session.scope.clone())
+      .unwrap_or_default()
+  }
+}
+
+/// Status-only classification of a session probe, before the body is parsed.
+#[derive(Debug, Clone, PartialEq)]
+pub enum ProbeStatus {
+  /// `2xx`: parse the [`SessionView`] body next.
+  Ok,
+  /// `401`: the BFF has no live session for this browser.
+  Unauthenticated,
+  /// `404`/`405`: the endpoint is not deployed.
+  EndpointMissing,
+  /// Anything else: failure with a status-only diagnostic.
+  Unavailable(ApiError),
+}
+
+/// Classify a session-probe status before the body is parsed.
+pub fn classify_probe_status(status: u16) -> ProbeStatus {
+  match status {
+    200..=299 => ProbeStatus::Ok,
+    401 => ProbeStatus::Unauthenticated,
+    404 | 405 => ProbeStatus::EndpointMissing,
+    other => ProbeStatus::Unavailable(ApiError::http(other, None, None)),
+  }
+}
+
+/// Combine a probe status with the parsed body.
+///
+/// A `401` maps to [`SessionProbe::Unauthenticated`] (the landing state); a
+/// `2xx` carries the display-safe [`SessionView`]. No path panics on a body
+/// shape the caller could not parse - the wasm layer maps decode failures to
+/// [`SessionProbe::Unavailable`] before calling this.
+pub fn probe_with_snapshot(status: u16, session: SessionView) -> SessionProbe {
+  match classify_probe_status(status) {
+    ProbeStatus::Ok => SessionProbe::Active(Box::new(session)),
+    ProbeStatus::Unauthenticated => SessionProbe::Unauthenticated,
+    ProbeStatus::EndpointMissing => SessionProbe::EndpointMissing,
+    ProbeStatus::Unavailable(error) => SessionProbe::Unavailable(error),
+  }
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn display_config_deserializes_the_documented_shape() {
+    let raw = r#"{
+      "issuer": "https://localhost:8080",
+      "client_id": "demo-client",
+      "scope": "demo.read",
+      "capabilities": {
+        "par": true, "dpop": true, "introspection": true,
+        "userinfo": false, "dcr": true, "refresh": true
+      }
+    }"#;
+    let config: DisplayConfig = serde_json::from_str(raw).expect("fixture parses");
+    assert_eq!(config.issuer, "https://localhost:8080");
+    assert_eq!(config.client_id, "demo-client");
+    assert_eq!(config.scope, "demo.read");
+    assert!(config.capabilities.par);
+    assert!(config.capabilities.dpop);
+    assert!(!config.capabilities.userinfo);
+  }
+
+  #[test]
+  fn display_config_defaults_start_url_to_the_relative_start_route() {
+    let raw = r#"{"issuer":"https://as.example","client_id":"c","scope":"s"}"#;
+    let config: DisplayConfig = serde_json::from_str(raw).expect("fixture parses");
+    assert_eq!(config.start_url, "/oauth/start");
+    assert_eq!(config.provider_logout_url, "");
+  }
+
+  #[test]
+  fn display_config_carries_the_provider_logout_url_when_present() {
+    let raw = r#"{
+      "issuer": "https://as.example",
+      "client_id": "c",
+      "scope": "s",
+      "provider_logout_url": "https://as.example/logout"
+    }"#;
+    let config: DisplayConfig = serde_json::from_str(raw).expect("fixture parses");
+    assert_eq!(config.provider_logout_url, "https://as.example/logout");
+  }
+
+  #[test]
+  fn capabilities_default_to_all_false_when_absent() {
+    let raw = r#"{"issuer":"https://as.example","client_id":"c","scope":"s"}"#;
+    let config: DisplayConfig = serde_json::from_str(raw).expect("fixture parses");
+    assert_eq!(config.capabilities, Capabilities::default());
+    assert!(config.capabilities.labelled().iter().all(|(_, on)| !on));
+  }
+
+  #[test]
+  fn classify_401_is_the_expired_session_card() {
+    assert_eq!(
+      classify_http_status(401, None),
+      ErrorCardKind::SessionExpired
+    );
+    assert_eq!(
+      ApiError::http(401, None, None).card_kind(),
+      ErrorCardKind::SessionExpired
+    );
+  }
+
+  #[test]
+  fn classify_discovery_outage_and_rejected_client() {
+    assert_eq!(
+      classify_http_status(503, Some("discovery_unavailable")),
+      ErrorCardKind::DiscoveryUnavailable
+    );
+    assert_eq!(
+      classify_http_status(502, None),
+      ErrorCardKind::DiscoveryUnavailable
+    );
+    assert_eq!(
+      classify_http_status(400, Some("invalid_client")),
+      ErrorCardKind::ClientRejected
+    );
+    assert_eq!(
+      classify_http_status(400, Some("unauthorized_client")),
+      ErrorCardKind::ClientRejected
+    );
+  }
+
+  #[test]
+  fn network_and_decode_failures_have_distinct_cards() {
+    assert_eq!(
+      ApiError::network("connection refused").card_kind(),
+      ErrorCardKind::NetworkUnavailable
+    );
+    assert_eq!(
+      ApiError::decode("missing field `issuer`").card_kind(),
+      ErrorCardKind::ServerError
+    );
+    assert_eq!(
+      config_card_kind(&ApiError::http(404, None, None)),
+      ErrorCardKind::ConfigUnavailable
+    );
+  }
+
+  #[test]
+  fn session_view_deserializes_the_documented_shape() {
+    let raw = r#"{
+      "issuer": "https://localhost:8080",
+      "client_id": "demo-client",
+      "subject": "efren@example.com",
+      "scope": ["demo.read", "profile"],
+      "token_type": "DPoP",
+      "expires_in": 297,
+      "iat": 1700000000,
+      "exp": 1700000297,
+      "cnf": { "jkt": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" },
+      "csrf_token": "csrf-123"
+    }"#;
+    let session: SessionView = serde_json::from_str(raw).expect("fixture parses");
+    assert_eq!(session.issuer, "https://localhost:8080");
+    assert_eq!(session.client_id, "demo-client");
+    assert_eq!(session.subject.as_deref(), Some("efren@example.com"));
+    assert_eq!(session.scope, ["demo.read", "profile"]);
+    assert_eq!(session.token_type, "DPoP");
+    assert_eq!(session.expires_in, 297);
+    assert_eq!(session.iat, Some(1_700_000_000));
+    assert_eq!(session.exp, Some(1_700_000_297));
+    assert_eq!(
+      session.cnf.as_ref().map(|cnf| cnf.jkt.as_str()),
+      Some("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
+    );
+    assert_eq!(session.csrf_token, "csrf-123");
+    assert!(session.act.is_none());
+  }
+
+  #[test]
+  fn session_view_accepts_a_subjectless_service_token() {
+    let raw = r#"{
+      "issuer": "https://localhost:8080",
+      "client_id": "service-client",
+      "scope": [],
+      "token_type": "Bearer",
+      "expires_in": 0,
+      "csrf_token": "csrf-9"
+    }"#;
+    let session: SessionView = serde_json::from_str(raw).expect("fixture parses");
+    assert_eq!(session.subject, None);
+    assert!(session.cnf.is_none());
+    assert!(session.scope.is_empty());
+    let token = TokenView::from_session(&session);
+    assert!(!token.sender_constrained());
+    assert_eq!(token.token_type, "Bearer");
+    assert_eq!(token.expires_in, 0);
+  }
+
+  #[test]
+  fn refresh_view_deserializes_with_and_without_scope() {
+    let narrowed: RefreshView =
+      serde_json::from_str(r#"{"refreshed":true,"expires_in":300,"scope":"demo.read"}"#)
+        .expect("fixture parses");
+    assert!(narrowed.refreshed);
+    assert_eq!(narrowed.expires_in, 300);
+    assert_eq!(narrowed.scope.as_deref(), Some("demo.read"));
+
+    let quiet: RefreshView =
+      serde_json::from_str(r#"{"refreshed":true,"expires_in":42}"#).expect("fixture parses");
+    assert_eq!(quiet.scope, None);
+  }
+
+  #[test]
+  fn token_view_derives_sender_constraint_from_cnf() {
+    let mut session = SessionView {
+      token_type: "Bearer".to_owned(),
+      ..SessionView::default()
+    };
+    session.cnf = Some(CnfView {
+      jkt: "thumbprint".to_owned(),
+    });
+    let token = TokenView::from_session(&session);
+    assert!(token.sender_constrained());
+    assert_eq!(token.jkt.as_deref(), Some("thumbprint"));
+
+    let empty_jkt = CnfView { jkt: String::new() };
+    session.cnf = Some(empty_jkt);
+    assert!(!TokenView::from_session(&session).sender_constrained());
+  }
+
+  #[test]
+  fn scope_chips_flatten_and_deduplicate() {
+    let scope = vec![
+      "demo.read".to_owned(),
+      "profile openid".to_owned(),
+      "demo.read".to_owned(),
+    ];
+    let labels: Vec<String> = ScopeChip::list(&scope)
+      .into_iter()
+      .map(|chip| chip.label)
+      .collect();
+    assert_eq!(labels, ["demo.read", "profile", "openid"]);
+    assert!(ScopeChip::list(&[]).is_empty());
+    assert!(ScopeChip::list(&["  ".to_owned()]).is_empty());
+  }
+
+  #[test]
+  fn probe_status_classification_covers_the_state_machine() {
+    assert_eq!(classify_probe_status(401), ProbeStatus::Unauthenticated);
+    assert_eq!(classify_probe_status(404), ProbeStatus::EndpointMissing);
+    assert_eq!(classify_probe_status(405), ProbeStatus::EndpointMissing);
+    assert_eq!(classify_probe_status(200), ProbeStatus::Ok);
+    assert!(matches!(
+      classify_probe_status(500),
+      ProbeStatus::Unavailable(_)
+    ));
+  }
+
+  #[test]
+  fn a_401_maps_to_the_unauthenticated_state_even_with_a_body() {
+    let fixture = SessionView {
+      csrf_token: "csrf-1".to_owned(),
+      ..SessionView::default()
+    };
+    let probe = probe_with_snapshot(401, fixture);
+    assert_eq!(probe, SessionProbe::Unauthenticated);
+    assert!(probe.active().is_none());
+    assert!(probe.active_scope().is_empty());
+  }
+
+  #[test]
+  fn probe_with_snapshot_carries_the_active_session() {
+    let fixture = SessionView {
+      issuer: "https://localhost:8080".to_owned(),
+      client_id: "demo-client".to_owned(),
+      scope: vec!["demo.read".to_owned()],
+      csrf_token: "csrf-7".to_owned(),
+      ..SessionView::default()
+    };
+    let probe = probe_with_snapshot(200, fixture);
+    let active = probe.active().expect("active session");
+    assert_eq!(active.client_id, "demo-client");
+    assert_eq!(probe.active_scope(), ["demo.read"]);
+  }
+}

+ 1211 - 0
crates/web/src/viewmodel.rs

@@ -0,0 +1,1211 @@
+//! Pure presentation decisions shared by the browser components.
+//!
+//! Every user-visible string that a test cares about lives here so the
+//! branded landing and the typed error cards can be asserted on the host.
+
+use crate::model::{ErrorCardKind, ScopeChip, SessionProbe, SessionView, TokenView};
+
+/// Product display name shown in the header, footer, and document title.
+pub const PRODUCT_NAME: &str = "OAuth 2.1 Client";
+
+/// One-line value proposition under the hero heading.
+pub const VALUE_PROP: &str = "A confidential BFF runs the OAuth 2.1 dance - PKCE, PAR, \
+  DPoP - while the browser renders only the identity, scopes, and token metadata that \
+  come back. No secret ever reaches this page.";
+
+/// Top-level navigation entry: label and route.
+pub const NAV_ITEMS: [(&str, &str); 2] = [("Landing", "/"), ("Dashboard", "/dashboard")];
+
+/// Server-side redirect that starts the authorization flow.
+///
+/// Top-level navigation only: the AS forbids framing, so this must never be
+/// loaded into an iframe.
+pub const SIGN_IN_PATH: &str = "/oauth/start";
+
+/// The route the sign-in CTA navigates to.
+pub fn sign_in_target() -> &'static str {
+  SIGN_IN_PATH
+}
+
+/// The sign-in target for a loaded display config: the BFF-advertised
+/// `start_url` when non-empty (absolute BFF origin in Trunk dev mode so the
+/// CTA leaves the Trunk proxy), else the relative [`SIGN_IN_PATH`].
+pub fn sign_in_target_for(start_url: &str) -> &str {
+  let trimmed = start_url.trim();
+  if trimmed.is_empty() {
+    SIGN_IN_PATH
+  } else {
+    trimmed
+  }
+}
+
+/// A feature bullet on the landing page.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct Feature {
+  pub title: &'static str,
+  pub body: &'static str,
+  pub glyph: FeatureGlyph,
+}
+
+/// Icon selector for feature bullets (resolved to inline SVG by the UI).
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FeatureGlyph {
+  Shield,
+  Key,
+  Link,
+  Route,
+  Search,
+  Register,
+  Refresh,
+  Hash,
+}
+
+/// Every protocol-level guarantee the landing page advertises.
+pub fn features() -> &'static [Feature] {
+  const FEATURES: [Feature; 8] = [
+    Feature {
+      title: "OAuth 2.1",
+      body: "Authorization-code flow only - no implicit grants, no password grants, \
+        no token in the address bar.",
+      glyph: FeatureGlyph::Shield,
+    },
+    Feature {
+      title: "PKCE S256",
+      body: "Every request carries a one-time verifier, so an intercepted \
+        authorization code cannot be redeemed.",
+      glyph: FeatureGlyph::Key,
+    },
+    Feature {
+      title: "DPoP sender-constraint",
+      body: "The access token is bound to a per-client key; a stolen bearer string \
+        is useless on its own.",
+      glyph: FeatureGlyph::Link,
+    },
+    Feature {
+      title: "PAR",
+      body: "Pushed Authorization Requests move the request parameters off the \
+        browser redirect and onto an authenticated back channel.",
+      glyph: FeatureGlyph::Route,
+    },
+    Feature {
+      title: "Introspection",
+      body: "The BFF asks the authorization server whether the token is still \
+        active (RFC 7662) and reads the authoritative subject and scope; the UI \
+        never trusts a client-side claim.",
+      glyph: FeatureGlyph::Search,
+    },
+    Feature {
+      title: "Dynamic Client Registration",
+      body: "The confidential client registers itself (RFC 7591) and reconciles \
+        drift on re-run (RFC 7592), so there is no hand-copied client secret.",
+      glyph: FeatureGlyph::Register,
+    },
+    Feature {
+      title: "Refresh-token rotation",
+      body: "Each refresh returns a new refresh token; replaying an older one is \
+        detected and clears the session (RFC 9700).",
+      glyph: FeatureGlyph::Refresh,
+    },
+    Feature {
+      title: "Opaque, hashed tokens",
+      body: "The server issues reference tokens, stores only hashes, and \
+        introspects them to answer who and what was granted.",
+      glyph: FeatureGlyph::Hash,
+    },
+  ];
+  &FEATURES
+}
+
+/// Landing CTA that jumps to the builder section.
+pub const BUILDER_CTA_LABEL: &str = "Who built this?";
+
+/// Anchor id of the builder section.
+pub const BUILDER_SECTION_ID: &str = "builder";
+
+/// Eyebrow above the builder heading.
+pub const BUILDER_EYEBROW: &str = "The builder";
+
+/// Builder section heading.
+pub const BUILDER_HEADING: &str = "Who built this?";
+
+/// Builder name.
+pub const BUILDER_NAME: &str = "Efren Yevale Varela";
+
+/// Builder role line.
+pub const BUILDER_ROLE: &str = "System Architect · DevSecOps Engineer";
+
+/// Professional summary shown beside the experience stat.
+pub const BUILDER_SUMMARY: &str = "Information technology has been a deliberate choice since 1998, \
+  when I started as a computer programmer. Over the years I've followed a self-taught, \
+  security-first path - adopting new technology when it can be used deliberately and safely to \
+  design better systems. I've built and operated software across a wide range of industries, and \
+  I still get the most out of both learning and teaching.";
+
+/// Small label above the total-experience stat.
+pub const BUILDER_TOTAL_LABEL: &str = "Total experience";
+
+/// Small label above the per-area breakdown.
+pub const BUILDER_BREAKDOWN_LABEL: &str = "By area";
+
+/// Label above the industries list.
+pub const BUILDER_INDUSTRIES_LABEL: &str = "Industries";
+
+/// Label above the expertise list.
+pub const BUILDER_EXPERTISE_LABEL: &str = "Areas of expertise";
+
+/// One-line languages note.
+pub const BUILDER_LANGUAGES: &str = "Bilingual in Spanish and English.";
+
+/// Industries the builder has worked in (sector labels only, never companies).
+pub const BUILDER_INDUSTRIES: [&str; 7] = [
+  "Banking & financial services",
+  "Insurance (title & real estate)",
+  "Telecommunications",
+  "Government & public sector",
+  "Advertising & marketing technology",
+  "Logistics & supply chain",
+  "Security services",
+];
+
+/// Areas of expertise (labels only).
+pub const BUILDER_EXPERTISE: [&str; 6] = [
+  "Cloud infrastructure & CI/CD",
+  "Container orchestration",
+  "Distributed systems & messaging",
+  "Information security & incident response",
+  "Data engineering & analytics",
+  "Artificial intelligence / private LLM deployments",
+];
+
+/// Year the builder's career started; the experience baseline.
+pub const BUILDER_START_YEAR: i32 = 1998;
+
+/// One experience area from the resume, with its 2022 figure.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ExperienceArea {
+  pub label: &'static str,
+  pub resume_years: i32,
+}
+
+/// Resume areas with their 2022 figures; the first entry is the anchor.
+pub const EXPERIENCE_AREAS: [ExperienceArea; 5] = [
+  ExperienceArea {
+    label: "Computer programming",
+    resume_years: 23,
+  },
+  ExperienceArea {
+    label: "Process & information-systems analysis",
+    resume_years: 20,
+  },
+  ExperienceArea {
+    label: "Enterprise application integration",
+    resume_years: 18,
+  },
+  ExperienceArea {
+    label: "Data analysis",
+    resume_years: 16,
+  },
+  ExperienceArea {
+    label: "Information security",
+    resume_years: 16,
+  },
+];
+
+/// The anchored resume figure: the highest area count in 2022.
+const ANCHOR_RESUME_YEARS: i32 = EXPERIENCE_AREAS[0].resume_years;
+
+/// Total years since [`BUILDER_START_YEAR`], clamped so pre-start years are zero.
+pub fn total_experience_years(current_year: i32) -> i32 {
+  (current_year - BUILDER_START_YEAR).max(0)
+}
+
+/// The resume areas shifted so the highest figure matches the running total.
+///
+/// Every figure moves by the same delta as the anchor; pre-start years clamp
+/// each entry at zero instead of going negative.
+pub fn experience_areas(current_year: i32) -> Vec<(&'static str, i32)> {
+  let total = total_experience_years(current_year);
+  EXPERIENCE_AREAS
+    .iter()
+    .map(|area| {
+      let shifted = total - (ANCHOR_RESUME_YEARS - area.resume_years);
+      (area.label, shifted.max(0))
+    })
+    .collect()
+}
+
+/// "28 years of experience" for the prominent stat.
+pub fn total_experience_label(current_year: i32) -> String {
+  format!(
+    "{} years of experience",
+    total_experience_years(current_year)
+  )
+}
+
+/// "23 yrs" for one breakdown row.
+pub fn experience_years_label(years: i32) -> String {
+  format!("{} yrs", years.max(0))
+}
+
+/// Split a space-delimited scope string into a deduplicated chip list.
+pub fn scope_chips(scope: &str) -> Vec<&str> {
+  let mut chips: Vec<&str> = Vec::new();
+  for chip in scope.split_whitespace() {
+    if !chips.contains(&chip) {
+      chips.push(chip);
+    }
+  }
+  chips
+}
+
+/// Display form of an issuer identifier: trimmed, never an empty chip.
+pub fn issuer_label(issuer: &str) -> &str {
+  let trimmed = issuer.trim().trim_end_matches('/');
+  if trimmed.is_empty() {
+    "(issuer not configured)"
+  } else {
+    trimmed
+  }
+}
+
+/// Copy for a typed error card.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ErrorCopy {
+  pub eyebrow: &'static str,
+  pub title: &'static str,
+  pub body: &'static str,
+}
+
+/// Human copy for each error-card variant.
+pub fn error_copy(kind: ErrorCardKind) -> ErrorCopy {
+  match kind {
+    ErrorCardKind::DiscoveryUnavailable => ErrorCopy {
+      eyebrow: "Authorization server unreachable",
+      title: "Discovery is unavailable",
+      body: "The BFF could not read the authorization server metadata. Check that \
+        d-OAuth is running and that its issuer URL matches this deployment, then retry.",
+    },
+    ErrorCardKind::ClientRejected => ErrorCopy {
+      eyebrow: "Sign-in refused",
+      title: "The authorization server rejected this client",
+      body: "The registered client_id or its credentials were not accepted. Re-run \
+        provisioning to reconcile the DCR registration, then retry.",
+    },
+    ErrorCardKind::SessionExpired => ErrorCopy {
+      eyebrow: "Session expired",
+      title: "Your session is no longer valid",
+      body: "The BFF no longer holds an active token for this browser. Sign in again \
+        to start a fresh authorization flow.",
+    },
+    ErrorCardKind::NetworkUnavailable => ErrorCopy {
+      eyebrow: "BFF unreachable",
+      title: "Cannot reach the local server",
+      body: "The page could not talk to the same-origin BFF. Confirm the server is \
+        listening on port 3001 (dev) or 3000 (demo), then retry.",
+    },
+    ErrorCardKind::ConfigUnavailable => ErrorCopy {
+      eyebrow: "Configuration",
+      title: "Display configuration is unavailable",
+      body: "The BFF did not return /api/config yet. The page keeps working with \
+        reduced information; start the server and retry.",
+    },
+    ErrorCardKind::ServerError => ErrorCopy {
+      eyebrow: "Unexpected response",
+      title: "The BFF returned an error",
+      body: "The request reached the server but it could not complete. The \
+        non-secret diagnostic below can be shared when reporting the issue.",
+    },
+  }
+}
+
+/// Whether the primary navigation should reveal the Dashboard entry.
+///
+/// Only a definitively [`SessionProbe::Active`] session shows the dashboard
+/// link; loading, unauthenticated, and unavailable states keep it hidden so a
+/// transient BFF failure never exposes a dead route.
+pub fn dashboard_nav_visible(probe: &SessionProbe) -> bool {
+  matches!(probe, SessionProbe::Active(_))
+}
+
+/// Copy for the empty state shown while `/api/session` does not exist yet.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct EmptyCopy {
+  pub eyebrow: &'static str,
+  pub title: &'static str,
+  pub body: &'static str,
+}
+
+/// Empty-state copy for a not-yet-provisioned session endpoint.
+pub fn session_endpoint_missing_copy() -> EmptyCopy {
+  EmptyCopy {
+    eyebrow: "Not provisioned",
+    title: "The session endpoint is not available yet",
+    body: "This build is wired for the BFF session API, but the server lane has not \
+      exposed /api/session yet. The landing page and sign-in flow keep working; the \
+      dashboard fills in once that endpoint lands.",
+  }
+}
+
+/// Which card (if any) a session probe should render.
+///
+/// `None` means the dashboard renders an empty state or the signed-in shell
+/// instead of an error card.
+pub fn probe_card_kind(probe: &SessionProbe) -> Option<ErrorCardKind> {
+  match probe {
+    SessionProbe::Unauthenticated => Some(ErrorCardKind::SessionExpired),
+    SessionProbe::Unavailable(error) => Some(error.card_kind()),
+    SessionProbe::Active(_) | SessionProbe::EndpointMissing => None,
+  }
+}
+
+// ---------------------------------------------------------------------------
+// User card (todo 24)
+// ---------------------------------------------------------------------------
+
+/// Neutral label for a token that carries no subject claim.
+pub const SERVICE_PRINCIPAL_LABEL: &str = "Service principal";
+
+/// Explains why there is no person behind the session.
+pub const SERVICE_PRINCIPAL_HINT: &str =
+  "No subject was asserted for this token - the client is acting on its own behalf.";
+
+/// True when the session carries a non-empty subject claim.
+pub fn subject_is_person(subject: Option<&str>) -> bool {
+  subject.is_some_and(|value| !value.trim().is_empty())
+}
+
+/// Display form of the subject: the email when present, a neutral
+/// service-principal label otherwise (client-credentials omit `sub`).
+pub fn subject_label(subject: Option<&str>) -> &str {
+  match subject {
+    Some(value) if !value.trim().is_empty() => value.trim(),
+    _ => SERVICE_PRINCIPAL_LABEL,
+  }
+}
+
+/// Avatar initials derived from the subject's local part, or `SP` for a
+/// service principal. At most two characters, always non-empty.
+pub fn avatar_initials(subject: Option<&str>) -> String {
+  let trimmed = subject.unwrap_or("").trim();
+  if trimmed.is_empty() {
+    return "SP".to_owned();
+  }
+  let local = trimmed.split('@').next().unwrap_or(trimmed);
+  let mut initials = String::new();
+  for part in local.split(|character: char| !character.is_alphanumeric()) {
+    if let Some(character) = part.chars().next() {
+      initials.extend(character.to_uppercase());
+      if initials.chars().count() >= 2 {
+        break;
+      }
+    }
+  }
+  if initials.is_empty() {
+    return local
+      .chars()
+      .next()
+      .map(|character| character.to_uppercase().collect())
+      .unwrap_or_else(|| "?".to_owned());
+  }
+  initials
+}
+
+/// Everything the user card renders, derived from one session view.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct UserCardModel {
+  pub subject: String,
+  pub subject_is_person: bool,
+  pub initials: String,
+  pub client_id: String,
+  pub issuer: String,
+  pub chips: Vec<String>,
+}
+
+/// Build the user-card model: subject/email, client_id, issuer, scope chips.
+pub fn user_card(session: &SessionView) -> UserCardModel {
+  UserCardModel {
+    subject: subject_label(session.subject.as_deref()).to_owned(),
+    subject_is_person: subject_is_person(session.subject.as_deref()),
+    initials: avatar_initials(session.subject.as_deref()),
+    client_id: session.client_id.clone(),
+    issuer: issuer_label(&session.issuer).to_owned(),
+    chips: ScopeChip::list(&session.scope)
+      .into_iter()
+      .map(|chip| chip.label)
+      .collect(),
+  }
+}
+
+// ---------------------------------------------------------------------------
+// Token panel (todo 25)
+// ---------------------------------------------------------------------------
+
+/// Fallback shown only when the BFF sends an empty `token_type`; normally the
+/// BFF's own value (`"DPoP"` when `cnf.jkt` is present) is rendered verbatim.
+pub const TOKEN_TYPE_FALLBACK: &str = "Bearer";
+
+/// Label for a sender-constrained (DPoP-bound) token.
+pub const SENDER_CONSTRAINED_LABEL: &str = "Sender-constrained (DPoP)";
+
+/// Label for a token with no `cnf.jkt` binding.
+pub const BEARER_LABEL: &str = "Not sender-constrained";
+
+/// Placeholder for an `iat`/`exp` the AS did not report.
+pub const TIME_UNKNOWN: &str = "not reported";
+
+/// Render the BFF's displayed token type without ever defaulting a DPoP token
+/// to `Bearer`.
+pub fn token_type_label(token_type: &str) -> &str {
+  let trimmed = token_type.trim();
+  if trimmed.is_empty() {
+    TOKEN_TYPE_FALLBACK
+  } else {
+    trimmed
+  }
+}
+
+/// Human sender-constraint state, driven by `cnf.jkt` presence.
+pub fn sender_constraint_label(sender_constrained: bool) -> &'static str {
+  if sender_constrained {
+    SENDER_CONSTRAINED_LABEL
+  } else {
+    BEARER_LABEL
+  }
+}
+
+/// Countdown text for the remaining access-token lifetime.
+pub fn countdown_label(seconds: i64) -> String {
+  if seconds <= 0 {
+    return "expired".to_owned();
+  }
+  let hours = seconds / 3600;
+  let minutes = (seconds % 3600) / 60;
+  let secs = seconds % 60;
+  if hours > 0 {
+    format!("{hours}:{minutes:02}:{secs:02}")
+  } else {
+    format!("{minutes}:{secs:02}")
+  }
+}
+
+/// Remaining access-token lifetime at or below which the dashboard asks the
+/// BFF to renew the session by re-probing `GET /api/session`. Kept in step
+/// with the BFF's `REFRESH_SKEW_SECS`.
+pub const AUTO_REFRESH_AT_SECS: i64 = 30;
+
+/// Whether the countdown has crossed the auto-refresh threshold.
+pub fn should_auto_refresh(remaining_secs: i64) -> bool {
+  remaining_secs <= AUTO_REFRESH_AT_SECS
+}
+
+/// Format a Unix timestamp as a local `YYYY-MM-DD HH:MM:SS` string.
+///
+/// `offset_minutes` is the JavaScript `getTimezoneOffset()` value (UTC minus
+/// local), so the conversion is pure and unit-testable on the host.
+pub fn format_unix_local(unix_seconds: i64, offset_minutes: i32) -> String {
+  let local = unix_seconds.saturating_sub(i64::from(offset_minutes) * 60);
+  let days = local.div_euclid(86_400);
+  let seconds_of_day = local.rem_euclid(86_400);
+  let (year, month, day) = civil_from_days(days);
+  let hour = seconds_of_day / 3600;
+  let minute = (seconds_of_day % 3600) / 60;
+  let second = seconds_of_day % 60;
+  format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}")
+}
+
+/// Format an optional timestamp, rendering [`TIME_UNKNOWN`] when absent.
+pub fn format_unix_opt(unix_seconds: Option<i64>, offset_minutes: i32) -> String {
+  unix_seconds.map_or_else(
+    || TIME_UNKNOWN.to_owned(),
+    |seconds| format_unix_local(seconds, offset_minutes),
+  )
+}
+
+/// Shorten a long identifier in the middle, keeping both ends recognizable.
+pub fn truncate_middle(value: &str, head: usize, tail: usize) -> String {
+  let characters: Vec<char> = value.chars().collect();
+  if characters.len() <= head + tail + 1 {
+    return value.to_owned();
+  }
+  let head_text: String = characters[..head].iter().collect();
+  let tail_text: String = characters[characters.len() - tail..].iter().collect();
+  format!("{head_text}…{tail_text}")
+}
+
+/// Everything the token card renders, derived from one token view.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct TokenPanelModel {
+  pub token_type: String,
+  pub sender_constrained: bool,
+  pub constraint_label: &'static str,
+  pub iat_label: String,
+  pub exp_label: String,
+  pub jkt_full: Option<String>,
+  pub jkt_short: Option<String>,
+}
+
+/// Build the token-panel model: type, iat/exp local times, truncated `cnf.jkt`.
+///
+/// The raw access token is never available here - the BFF does not send it.
+pub fn token_panel(token: &TokenView, offset_minutes: i32) -> TokenPanelModel {
+  TokenPanelModel {
+    token_type: token_type_label(&token.token_type).to_owned(),
+    sender_constrained: token.sender_constrained(),
+    constraint_label: sender_constraint_label(token.sender_constrained()),
+    iat_label: format_unix_opt(token.iat, offset_minutes),
+    exp_label: format_unix_opt(token.exp, offset_minutes),
+    jkt_full: token.jkt.clone(),
+    jkt_short: token.jkt.as_deref().map(|jkt| truncate_middle(jkt, 8, 6)),
+  }
+}
+
+/// True when a refresh response granted a strict subset of the previous scope.
+///
+/// `None` (the AS did not report a scope) is treated as "no change" so the
+/// notice never appears without evidence.
+pub fn scope_narrowed(previous: &[String], refreshed: Option<&str>) -> bool {
+  let Some(refreshed) = refreshed else {
+    return false;
+  };
+  let refreshed_chips = scope_chips(refreshed);
+  let previous_chips: Vec<String> = ScopeChip::list(previous)
+    .into_iter()
+    .map(|chip| chip.label)
+    .collect();
+  let all_refreshed_were_granted = refreshed_chips
+    .iter()
+    .all(|chip| previous_chips.iter().any(|previous| previous == chip));
+  let lost_a_scope = previous_chips
+    .iter()
+    .any(|previous| !refreshed_chips.contains(&previous.as_str()));
+  all_refreshed_were_granted && lost_a_scope
+}
+
+/// Copy for the scope-narrowing notice.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct ScopeNarrowedNotice {
+  pub eyebrow: &'static str,
+  pub title: &'static str,
+  pub body: &'static str,
+}
+
+/// Notice shown when a refresh narrowed the granted scope.
+pub fn scope_narrowed_notice() -> ScopeNarrowedNotice {
+  ScopeNarrowedNotice {
+    eyebrow: "Scope changed",
+    title: "The refresh narrowed the granted scope",
+    body: "The refresh response granted less than the session previously held. The BFF \
+      stores the narrowed grant; sign in again to re-request the configured scope.",
+  }
+}
+
+/// Copy for the missing-scope notice.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct MissingScopeNotice {
+  pub eyebrow: &'static str,
+  pub title: &'static str,
+  pub body: String,
+}
+
+/// Notice shown when the server authenticated the session but the token does
+/// not carry every scope this deployment requires. `None` when nothing is
+/// missing, so an authorized session renders no notice.
+pub fn insufficient_scope_notice(
+  missing: &[String],
+  granted: &[String],
+) -> Option<MissingScopeNotice> {
+  if missing.is_empty() {
+    return None;
+  }
+  let missing_list = scope_list(missing);
+  let granted_list = if granted.is_empty() {
+    "none".to_owned()
+  } else {
+    scope_list(granted)
+  };
+  Some(MissingScopeNotice {
+    eyebrow: "Missing scope",
+    title: "Signed in without the required scope",
+    body: format!(
+      "The server authenticated this account but did not grant the required scope {missing_list}. \
+       Granted scope: {granted_list}. Ask an operator to grant it, then Refresh."
+    ),
+  })
+}
+
+/// Render scope names as inline code, comma-separated.
+fn scope_list(scopes: &[String]) -> String {
+  scopes
+    .iter()
+    .map(|scope| format!("`{scope}`"))
+    .collect::<Vec<String>>()
+    .join(", ")
+}
+
+// ---------------------------------------------------------------------------
+// Flow timeline and introspection viewer (todo 26)
+// ---------------------------------------------------------------------------
+
+/// Number of steps in the authorization-flow timeline.
+pub const TIMELINE_LEN: usize = 6;
+
+/// Title of the token-exchange step for a DPoP-bound token.
+pub const DPOP_STEP_TITLE: &str = "Token exchange with DPoP";
+
+/// Title of the token-exchange step when no `cnf.jkt` was introspected.
+pub const BEARER_STEP_TITLE: &str = "Not sender-constrained";
+
+/// Completion state of one timeline step.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum StepState {
+  Complete,
+  Degraded,
+}
+
+/// One completed step of the authorization flow.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct TimelineStep {
+  pub title: &'static str,
+  pub body: &'static str,
+  pub state: StepState,
+}
+
+/// The six steps of the demo's authorization flow.
+///
+/// The token-exchange step degrades to a "not sender-constrained" state when
+/// the introspection response carries no `cnf.jkt`; every other step still
+/// renders, so a missing binding never breaks the timeline.
+pub fn timeline_steps(sender_constrained: bool) -> [TimelineStep; TIMELINE_LEN] {
+  [
+    TimelineStep {
+      title: "Authorization request",
+      body: "The BFF assembled the authorization request and bound it to this browser \
+        session with state and a PKCE S256 challenge.",
+      state: StepState::Complete,
+    },
+    TimelineStep {
+      title: "PAR",
+      body: "The request parameters were pushed over an authenticated back channel; the \
+        browser redirect carried only an opaque request_uri.",
+      state: StepState::Complete,
+    },
+    TimelineStep {
+      title: "Login and consent",
+      body: "You authenticated at the authorization server and approved the requested \
+        scope at the top level, never inside a frame.",
+      state: StepState::Complete,
+    },
+    TimelineStep {
+      title: "Authorization code",
+      body: "The server redirected back with a one-time code; state and, when present, \
+        iss were validated before any exchange.",
+      state: StepState::Complete,
+    },
+    if sender_constrained {
+      TimelineStep {
+        title: DPOP_STEP_TITLE,
+        body: "The code was redeemed server-side with a fresh DPoP proof, so the access \
+          token is bound to the BFF's key.",
+        state: StepState::Complete,
+      }
+    } else {
+      TimelineStep {
+        title: BEARER_STEP_TITLE,
+        body: "No cnf.jkt was introspected, so this token is not bound to a DPoP key and \
+          carries bearer semantics.",
+        state: StepState::Degraded,
+      }
+    },
+    TimelineStep {
+      title: "Introspection",
+      body: "The BFF introspected the access token to read the subject and granted \
+        scope; this page renders that display-safe result.",
+      state: StepState::Complete,
+    },
+  ]
+}
+
+/// Timeline for a session fixture; the DPoP step follows `cnf.jkt` presence.
+pub fn timeline_for_session(session: &SessionView) -> [TimelineStep; TIMELINE_LEN] {
+  timeline_steps(TokenView::from_session(session).sender_constrained())
+}
+
+/// Pretty-printed, display-safe introspection document for the JSON viewer.
+///
+/// Mirrors the RFC 7662 shape the BFF read (`active`, `sub`, `scope`,
+/// `token_type`, `iat`, `exp`, `cnf`, `act`) but is rebuilt from the display
+/// view: raw tokens, secrets, the CSRF token, and `Authorization` headers are
+/// absent by construction.
+pub fn introspection_json(session: &SessionView) -> String {
+  let mut document = serde_json::Map::new();
+  document.insert("active".to_owned(), serde_json::Value::Bool(true));
+  document.insert(
+    "issuer".to_owned(),
+    serde_json::Value::String(session.issuer.clone()),
+  );
+  document.insert(
+    "client_id".to_owned(),
+    serde_json::Value::String(session.client_id.clone()),
+  );
+  if let Some(subject) = session
+    .subject
+    .as_deref()
+    .filter(|subject| !subject.trim().is_empty())
+  {
+    document.insert(
+      "sub".to_owned(),
+      serde_json::Value::String(subject.to_owned()),
+    );
+  }
+  document.insert(
+    "scope".to_owned(),
+    serde_json::Value::String(session.scope.join(" ")),
+  );
+  document.insert(
+    "token_type".to_owned(),
+    serde_json::Value::String(token_type_label(&session.token_type).to_owned()),
+  );
+  if let Some(iat) = session.iat {
+    document.insert("iat".to_owned(), serde_json::Value::from(iat));
+  }
+  if let Some(exp) = session.exp {
+    document.insert("exp".to_owned(), serde_json::Value::from(exp));
+  }
+  if let Some(cnf) = &session.cnf {
+    document.insert("cnf".to_owned(), serde_json::json!({ "jkt": cnf.jkt }));
+  }
+  if let Some(act) = &session.act {
+    document.insert("act".to_owned(), act.clone());
+  }
+  serde_json::to_string_pretty(&serde_json::Value::Object(document))
+    .unwrap_or_else(|_| "{}".to_owned())
+}
+
+/// Gregorian civil date from days since 1970-01-01 (Howard Hinnant's
+/// `civil_from_days`, valid for the full `i64` range).
+fn civil_from_days(days: i64) -> (i64, i64, i64) {
+  let shifted = days + 719_468;
+  let era = if shifted >= 0 {
+    shifted
+  } else {
+    shifted - 146_096
+  } / 146_097;
+  let day_of_era = shifted - era * 146_097;
+  let year_of_era =
+    (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
+  let year = year_of_era + era * 400;
+  let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
+  let month_prime = (5 * day_of_year + 2) / 153;
+  let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
+  let month = if month_prime < 10 {
+    month_prime + 3
+  } else {
+    month_prime - 9
+  };
+  (if month <= 2 { year + 1 } else { year }, month, day)
+}
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+  use crate::model::{ApiError, CnfView, SessionProbe, SessionView, TokenView};
+
+  #[test]
+  fn sign_in_cta_targets_the_server_start_route() {
+    assert_eq!(sign_in_target(), "/oauth/start");
+    assert_eq!(SIGN_IN_PATH, "/oauth/start");
+  }
+
+  #[test]
+  fn sign_in_target_prefers_the_bff_start_url() {
+    assert_eq!(
+      sign_in_target_for("http://127.0.0.1:3001/oauth/start"),
+      "http://127.0.0.1:3001/oauth/start"
+    );
+    assert_eq!(sign_in_target_for(""), "/oauth/start");
+    assert_eq!(sign_in_target_for("   "), "/oauth/start");
+    assert_eq!(sign_in_target_for("/oauth/start"), "/oauth/start");
+  }
+
+  #[test]
+  fn landing_advertises_every_required_guarantee() {
+    let features = features();
+    assert_eq!(features.len(), 8);
+    let titles: Vec<&str> = features.iter().map(|feature| feature.title).collect();
+    assert_eq!(
+      titles,
+      [
+        "OAuth 2.1",
+        "PKCE S256",
+        "DPoP sender-constraint",
+        "PAR",
+        "Introspection",
+        "Dynamic Client Registration",
+        "Refresh-token rotation",
+        "Opaque, hashed tokens"
+      ]
+    );
+    assert!(features.iter().all(|feature| !feature.body.is_empty()));
+  }
+
+  #[test]
+  fn scope_chips_split_and_deduplicate() {
+    assert_eq!(
+      scope_chips("demo.read openid demo.read"),
+      ["demo.read", "openid"]
+    );
+    assert!(scope_chips("   ").is_empty());
+  }
+
+  #[test]
+  fn issuer_label_trims_trailing_slashes_and_guards_empty() {
+    assert_eq!(
+      issuer_label("https://localhost:8080/"),
+      "https://localhost:8080"
+    );
+    assert_eq!(issuer_label("https://as.example"), "https://as.example");
+    assert_eq!(issuer_label("  "), "(issuer not configured)");
+  }
+
+  #[test]
+  fn every_error_card_has_non_empty_copy() {
+    for kind in [
+      ErrorCardKind::DiscoveryUnavailable,
+      ErrorCardKind::ClientRejected,
+      ErrorCardKind::SessionExpired,
+      ErrorCardKind::NetworkUnavailable,
+      ErrorCardKind::ConfigUnavailable,
+      ErrorCardKind::ServerError,
+    ] {
+      let copy = error_copy(kind);
+      assert!(!copy.eyebrow.is_empty());
+      assert!(!copy.title.is_empty());
+      assert!(!copy.body.is_empty());
+    }
+  }
+
+  #[test]
+  fn forced_401_is_the_session_expired_card() {
+    let probe = SessionProbe::Unauthenticated;
+    assert_eq!(probe_card_kind(&probe), Some(ErrorCardKind::SessionExpired));
+  }
+
+  #[test]
+  fn missing_session_endpoint_is_an_empty_state_not_an_error_card() {
+    assert_eq!(probe_card_kind(&SessionProbe::EndpointMissing), None);
+    assert!(!session_endpoint_missing_copy().title.is_empty());
+  }
+
+  #[test]
+  fn probe_failures_carry_their_card_kind() {
+    let probe = SessionProbe::Unavailable(ApiError::network("connection refused"));
+    assert_eq!(
+      probe_card_kind(&probe),
+      Some(ErrorCardKind::NetworkUnavailable)
+    );
+  }
+
+  #[test]
+  fn dashboard_nav_is_visible_only_for_an_active_session() {
+    assert!(!dashboard_nav_visible(&SessionProbe::Unauthenticated));
+    assert!(!dashboard_nav_visible(&SessionProbe::EndpointMissing));
+    assert!(!dashboard_nav_visible(&SessionProbe::Unavailable(
+      ApiError::network("connection refused")
+    )));
+    assert!(dashboard_nav_visible(&SessionProbe::Active(Box::new(
+      dpop_session()
+    ))));
+  }
+
+  fn dpop_session() -> SessionView {
+    SessionView {
+      issuer: "https://localhost:8080".to_owned(),
+      client_id: "demo-client".to_owned(),
+      subject: Some("efren@example.com".to_owned()),
+      scope: vec!["demo.read".to_owned(), "profile".to_owned()],
+      missing_scopes: Vec::new(),
+      token_type: "DPoP".to_owned(),
+      expires_in: 297,
+      iat: Some(1_700_000_000),
+      exp: Some(1_700_000_297),
+      cnf: Some(CnfView {
+        jkt: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM".to_owned(),
+      }),
+      act: None,
+      csrf_token: "csrf-secret".to_owned(),
+    }
+  }
+
+  #[test]
+  fn user_card_renders_subject_client_issuer_and_one_chip() {
+    let card = user_card(&dpop_session());
+    assert_eq!(card.subject, "efren@example.com");
+    assert!(card.subject_is_person);
+    assert_eq!(card.client_id, "demo-client");
+    assert_eq!(card.issuer, "https://localhost:8080");
+    assert_eq!(card.chips, ["demo.read", "profile"]);
+    assert_eq!(card.initials, "E");
+  }
+
+  #[test]
+  fn user_card_renders_the_service_principal_label_without_sub() {
+    let session = SessionView {
+      subject: None,
+      cnf: None,
+      scope: Vec::new(),
+      ..dpop_session()
+    };
+    let card = user_card(&session);
+    assert_eq!(card.subject, SERVICE_PRINCIPAL_LABEL);
+    assert!(!card.subject_is_person);
+    assert_eq!(card.initials, "SP");
+    assert!(card.chips.is_empty());
+    assert!(!SERVICE_PRINCIPAL_HINT.is_empty());
+  }
+
+  #[test]
+  fn avatar_initials_take_up_to_two_letters_and_never_go_blank() {
+    assert_eq!(avatar_initials(Some("ada.lovelace@example.com")), "AL");
+    assert_eq!(avatar_initials(Some("efren@example.com")), "E");
+    assert_eq!(avatar_initials(Some("  ")), "SP");
+    assert_eq!(avatar_initials(None), "SP");
+    assert!(!avatar_initials(Some("...")).is_empty());
+  }
+
+  #[test]
+  fn token_panel_derives_type_constraint_and_thumbprint() {
+    let token = TokenView::from_session(&dpop_session());
+    let panel = token_panel(&token, 0);
+    assert_eq!(panel.token_type, "DPoP");
+    assert!(panel.sender_constrained);
+    assert_eq!(panel.constraint_label, SENDER_CONSTRAINED_LABEL);
+    assert_eq!(
+      panel.jkt_full.as_deref(),
+      Some("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM")
+    );
+    let short = panel.jkt_short.expect("thumbprint is truncated");
+    assert!(short.contains('…'));
+    assert!(short.len() < panel.jkt_full.expect("full thumbprint").len());
+    assert!(short.starts_with("E9Melhoa"));
+    assert!(short.ends_with("stw-cM"));
+  }
+
+  #[test]
+  fn token_panel_never_claims_bearer_for_a_dpop_token() {
+    let mut session = dpop_session();
+    session.token_type = "Bearer".to_owned();
+    let panel = token_panel(&TokenView::from_session(&session), 0);
+    assert_eq!(panel.token_type, "Bearer");
+    assert!(panel.sender_constrained);
+    assert_ne!(panel.constraint_label, BEARER_LABEL);
+  }
+
+  #[test]
+  fn token_panel_renders_the_not_sender_constrained_state_without_cnf() {
+    let session = SessionView {
+      token_type: "Bearer".to_owned(),
+      cnf: None,
+      iat: None,
+      exp: None,
+      ..dpop_session()
+    };
+    let panel = token_panel(&TokenView::from_session(&session), 0);
+    assert_eq!(panel.token_type, "Bearer");
+    assert!(!panel.sender_constrained);
+    assert_eq!(panel.constraint_label, BEARER_LABEL);
+    assert_eq!(panel.jkt_full, None);
+    assert_eq!(panel.jkt_short, None);
+    assert_eq!(panel.iat_label, TIME_UNKNOWN);
+    assert_eq!(panel.exp_label, TIME_UNKNOWN);
+  }
+
+  #[test]
+  fn countdown_ticks_down_and_expires_at_zero() {
+    assert_eq!(countdown_label(297), "4:57");
+    assert_eq!(countdown_label(65), "1:05");
+    assert_eq!(countdown_label(5), "0:05");
+    assert_eq!(countdown_label(3725), "1:02:05");
+    assert_eq!(countdown_label(0), "expired");
+    assert_eq!(countdown_label(-12), "expired");
+  }
+
+  #[test]
+  fn auto_refresh_fires_at_or_below_the_skew_and_at_zero() {
+    assert!(!should_auto_refresh(AUTO_REFRESH_AT_SECS + 1));
+    assert!(should_auto_refresh(AUTO_REFRESH_AT_SECS));
+    assert!(should_auto_refresh(AUTO_REFRESH_AT_SECS - 1));
+    assert!(should_auto_refresh(0));
+    assert!(should_auto_refresh(-5));
+  }
+
+  #[test]
+  fn local_time_formatting_uses_the_supplied_offset() {
+    assert_eq!(format_unix_local(0, 0), "1970-01-01 00:00:00");
+    assert_eq!(format_unix_local(1_700_000_000, 0), "2023-11-14 22:13:20");
+    assert_eq!(format_unix_local(1_700_000_000, 300), "2023-11-14 17:13:20");
+    assert_eq!(format_unix_local(-1, 0), "1969-12-31 23:59:59");
+    assert_eq!(format_unix_opt(Some(0), 0), "1970-01-01 00:00:00");
+    assert_eq!(format_unix_opt(None, 0), TIME_UNKNOWN);
+  }
+
+  #[test]
+  fn truncate_middle_keeps_short_values_intact() {
+    assert_eq!(truncate_middle("short", 8, 6), "short");
+    assert_eq!(truncate_middle("abcdefghij", 4, 4), "abcd…ghij");
+    assert_eq!(truncate_middle("", 8, 6), "");
+  }
+
+  #[test]
+  fn scope_narrowing_is_detected_only_for_a_strict_subset() {
+    let previous = vec!["demo.read".to_owned(), "profile".to_owned()];
+    assert!(scope_narrowed(&previous, Some("demo.read")));
+    assert!(scope_narrowed(&previous, Some("")));
+    assert!(!scope_narrowed(&previous, Some("demo.read profile")));
+    assert!(!scope_narrowed(&previous, None));
+    assert!(!scope_narrowed(&[], Some("demo.read")));
+    assert!(!scope_narrowed(&previous, Some("brand.new")));
+    let notice = scope_narrowed_notice();
+    assert!(!notice.eyebrow.is_empty());
+    assert!(!notice.title.is_empty());
+    assert!(!notice.body.is_empty());
+  }
+
+  #[test]
+  fn missing_scope_notice_is_none_when_nothing_is_missing() {
+    assert_eq!(
+      insufficient_scope_notice(&[], &["demo.read".to_owned()]),
+      None
+    );
+  }
+
+  #[test]
+  fn missing_scope_notice_names_the_missing_scope_without_granting_anything() {
+    let missing = vec!["demo.read".to_owned()];
+    let notice = insufficient_scope_notice(&missing, &[]).expect("a notice for a missing scope");
+    assert_eq!(notice.eyebrow, "Missing scope");
+    assert!(!notice.title.is_empty());
+    assert!(notice.body.contains("`demo.read`"));
+    assert!(notice.body.contains("Granted scope: none"));
+    assert!(notice.body.contains("Refresh"));
+  }
+
+  #[test]
+  fn missing_scope_notice_lists_the_granted_scopes_when_some_were_granted() {
+    let missing = vec!["demo.read".to_owned()];
+    let notice = insufficient_scope_notice(&missing, &["profile".to_owned()])
+      .expect("a notice for a missing scope");
+    assert!(notice.body.contains("`demo.read`"));
+    assert!(notice.body.contains("`profile`"));
+    assert!(!notice.body.contains("none"));
+  }
+
+  #[test]
+  fn timeline_shows_all_six_completed_steps_for_a_dpop_session() {
+    let steps = timeline_for_session(&dpop_session());
+    assert_eq!(steps.len(), TIMELINE_LEN);
+    assert!(steps.iter().all(|step| step.state == StepState::Complete));
+    assert_eq!(steps[4].title, DPOP_STEP_TITLE);
+    assert!(steps.iter().all(|step| !step.body.is_empty()));
+  }
+
+  #[test]
+  fn timeline_degrades_one_step_without_cnf_and_keeps_the_rest() {
+    let session = SessionView {
+      cnf: None,
+      ..dpop_session()
+    };
+    let steps = timeline_for_session(&session);
+    assert_eq!(steps.len(), TIMELINE_LEN);
+    assert_eq!(steps[4].title, BEARER_STEP_TITLE);
+    assert_eq!(steps[4].state, StepState::Degraded);
+    let degraded = steps
+      .iter()
+      .filter(|step| step.state == StepState::Degraded)
+      .count();
+    assert_eq!(degraded, 1);
+  }
+
+  #[test]
+  fn introspection_json_is_pretty_printed_and_token_free() {
+    let json = introspection_json(&dpop_session());
+    assert!(json.contains("{\n  \"active\": true,"));
+    assert!(json.contains("\"sub\": \"efren@example.com\""));
+    assert!(json.contains("\"scope\": \"demo.read profile\""));
+    assert!(json.contains("\"cnf\""));
+    assert!(json.contains("\"jkt\": \"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM\""));
+    for forbidden in [
+      "access_token",
+      "refresh_token",
+      "client_secret",
+      "csrf-secret",
+      "authorization",
+      "Authorization",
+    ] {
+      assert!(!json.contains(forbidden), "leak of {forbidden} in {json}");
+    }
+
+    let subjectless = SessionView {
+      subject: None,
+      cnf: None,
+      ..dpop_session()
+    };
+    let json = introspection_json(&subjectless);
+    assert!(!json.contains("\"sub\""));
+    assert!(!json.contains("\"cnf\""));
+    assert!(serde_json::from_str::<serde_json::Value>(&json).is_ok());
+  }
+
+  #[test]
+  fn total_experience_years_counts_from_1998_and_never_goes_negative() {
+    assert_eq!(total_experience_years(2026), 28);
+    assert_eq!(total_experience_years(2022), 24);
+    assert_eq!(total_experience_years(1998), 0);
+    assert_eq!(total_experience_years(1990), 0);
+    assert_eq!(total_experience_label(2026), "28 years of experience");
+    assert_eq!(total_experience_label(1990), "0 years of experience");
+  }
+
+  #[test]
+  fn experience_areas_anchor_the_highest_figure_to_the_running_total() {
+    assert_eq!(
+      experience_areas(2026),
+      vec![
+        ("Computer programming", 28),
+        ("Process & information-systems analysis", 25),
+        ("Enterprise application integration", 23),
+        ("Data analysis", 21),
+        ("Information security", 21),
+      ]
+    );
+    assert_eq!(
+      experience_areas(2022),
+      vec![
+        ("Computer programming", 24),
+        ("Process & information-systems analysis", 21),
+        ("Enterprise application integration", 19),
+        ("Data analysis", 17),
+        ("Information security", 17),
+      ]
+    );
+    let labels: Vec<&str> = EXPERIENCE_AREAS.iter().map(|area| area.label).collect();
+    let returned: Vec<&str> = experience_areas(2026)
+      .into_iter()
+      .map(|(label, _)| label)
+      .collect();
+    assert_eq!(returned, labels);
+    assert_eq!(experience_years_label(28), "28 yrs");
+  }
+
+  #[test]
+  fn experience_areas_never_go_negative_for_pre_start_years() {
+    for year in [1990, 1997, 1998, 1999] {
+      let areas = experience_areas(year);
+      assert_eq!(areas.len(), EXPERIENCE_AREAS.len());
+      assert!(
+        areas.iter().all(|(_, years)| *years >= 0),
+        "negative figure for {year}: {areas:?}"
+      );
+    }
+    assert_eq!(experience_years_label(-3), "0 yrs");
+  }
+}

+ 176 - 0
crates/web/style/input.css

@@ -0,0 +1,176 @@
+/* Tailwind CSS v4 entry point (compiled by Trunk's `tailwind-css` pipeline).
+ *
+ * This token layer is the single source of truth for the UI theme:
+ * typography scale, the 4px spacing grid, one accent family, semantic
+ * danger/success pairs, and dark-friendly surface/ink tokens. Wave 4
+ * components consume these tokens only - no ad-hoc hex values. */
+
+@import "tailwindcss";
+
+/* Explicit sources keep content detection deterministic regardless of the
+ * working directory Trunk invokes the Tailwind CLI from. */
+@source "../index.html";
+@source "../src";
+
+/* ------------------------------------------------------------------ */
+/* Semantic surface tokens: dark is the product default. Light is an    */
+/* explicit `:root[data-theme="light"]` override; there is deliberately */
+/* no `prefers-color-scheme` query - `bootstrap.js` writes the data     */
+/* attribute from localStorage (default `dark`) before the app mounts.  */
+/* ------------------------------------------------------------------ */
+
+:root {
+  color-scheme: dark;
+
+  --ui-canvas: oklch(0.165 0.012 255);
+  --ui-raised: oklch(0.205 0.014 255);
+  --ui-sunken: oklch(0.14 0.011 255);
+  --ui-ink: oklch(0.955 0.004 255);
+  --ui-ink-muted: oklch(0.705 0.014 255);
+  --ui-line: oklch(0.3 0.014 255);
+  --ui-line-strong: oklch(0.4 0.016 255);
+  --ui-accent-ink: oklch(0.8 0.1 210);
+
+  /* Dark action pair: dark ink on a bright accent stays high-contrast. */
+  --ui-btn-bg: oklch(0.845 0.09 210);
+  --ui-btn-bg-hover: oklch(0.9 0.065 210);
+  --ui-btn-ink: oklch(0.24 0.045 210);
+
+  /* Status colors: every pair is used with a glyph and text, never alone. */
+  --ui-danger: oklch(0.808 0.114 19.6);
+  --ui-danger-surface: oklch(0.258 0.092 26);
+  --ui-danger-line: oklch(0.396 0.141 25.7);
+  --ui-success: oklch(0.871 0.15 154.4);
+  --ui-success-surface: oklch(0.266 0.065 152.9);
+  --ui-success-line: oklch(0.448 0.119 151.3);
+
+  --ui-accent-soft: oklch(0.24 0.045 210);
+  --ui-accent-soft-ink: oklch(0.9 0.065 210);
+  --ui-focus: oklch(0.845 0.09 210);
+}
+
+/* Light override: the only writer is the header `ThemeToggle` (and the
+ * pre-mount init in `bootstrap.js`). Higher specificity than `:root`. */
+:root[data-theme="light"] {
+  color-scheme: light;
+
+  --ui-canvas: oklch(0.981 0.004 255);
+  --ui-raised: oklch(1 0 0);
+  --ui-sunken: oklch(0.955 0.005 255);
+  --ui-ink: oklch(0.235 0.02 255);
+  --ui-ink-muted: oklch(0.5 0.018 255);
+  --ui-line: oklch(0.905 0.006 255);
+  --ui-line-strong: oklch(0.835 0.009 255);
+  --ui-accent-ink: oklch(0.47 0.105 210);
+
+  /* Action pair: white on accent-700 clears AA (5.3:1); hover darkens. */
+  --ui-btn-bg: oklch(0.47 0.105 210);
+  --ui-btn-bg-hover: oklch(0.4 0.085 210);
+  --ui-btn-ink: oklch(1 0 0);
+
+  --ui-danger: oklch(0.505 0.17 27.5);
+  --ui-danger-surface: oklch(0.971 0.013 17.4);
+  --ui-danger-line: oklch(0.885 0.062 18.3);
+  --ui-success: oklch(0.527 0.154 150);
+  --ui-success-surface: oklch(0.982 0.018 155.8);
+  --ui-success-line: oklch(0.925 0.084 156);
+
+  --ui-accent-soft: oklch(0.972 0.018 210);
+  --ui-accent-soft-ink: oklch(0.47 0.105 210);
+  --ui-focus: oklch(0.47 0.105 210);
+}
+
+/* `inline` keeps utilities pointing at the live custom properties, so the
+ * `data-theme` override above wins without a rebuild or a `.dark` class. */
+@theme inline {
+  --color-canvas: var(--ui-canvas);
+  --color-raised: var(--ui-raised);
+  --color-sunken: var(--ui-sunken);
+  --color-ink: var(--ui-ink);
+  --color-muted: var(--ui-ink-muted);
+  --color-line: var(--ui-line);
+  --color-line-strong: var(--ui-line-strong);
+  --color-accent-ink: var(--ui-accent-ink);
+  --color-accent-soft: var(--ui-accent-soft);
+  --color-accent-soft-ink: var(--ui-accent-soft-ink);
+  --color-btn: var(--ui-btn-bg);
+  --color-btn-hover: var(--ui-btn-bg-hover);
+  --color-btn-ink: var(--ui-btn-ink);
+  --color-danger: var(--ui-danger);
+  --color-danger-surface: var(--ui-danger-surface);
+  --color-danger-line: var(--ui-danger-line);
+  --color-success: var(--ui-success);
+  --color-success-surface: var(--ui-success-surface);
+  --color-success-line: var(--ui-success-line);
+}
+
+@theme {
+  /* Spacing: explicit 4px grid (Tailwind's default, pinned here on purpose). */
+  --spacing: 0.25rem;
+
+  /* Type: system-native stacks only - no CDN fonts (CSP-friendly, offline-safe). */
+  --font-sans: "Avenir Next", "Segoe UI Variable Text", "Segoe UI", system-ui, -apple-system, "Helvetica Neue", sans-serif;
+  --font-mono: ui-monospace, "SFMono-Regular", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
+
+  /* Typography scale beyond Tailwind's defaults. */
+  --text-display: 2.75rem;
+  --text-display--line-height: 1.08;
+  --text-display--letter-spacing: -0.03em;
+  --text-title: 1.625rem;
+  --text-title--line-height: 1.2;
+  --text-title--letter-spacing: -0.02em;
+
+  /* One accent family: signal cyan. */
+  --color-accent-50: oklch(0.972 0.018 210);
+  --color-accent-100: oklch(0.945 0.038 210);
+  --color-accent-200: oklch(0.9 0.065 210);
+  --color-accent-300: oklch(0.845 0.09 210);
+  --color-accent-400: oklch(0.775 0.115 210);
+  --color-accent-500: oklch(0.7 0.13 210);
+  --color-accent-600: oklch(0.6 0.125 210);
+  --color-accent-700: oklch(0.47 0.105 210);
+  --color-accent-800: oklch(0.4 0.085 210);
+  --color-accent-900: oklch(0.33 0.065 210);
+  --color-accent-950: oklch(0.24 0.045 210);
+
+  /* Shape and elevation. */
+  --radius-card: 0.875rem;
+  --shadow-card:
+    0 1px 2px oklch(0.2 0.02 255 / 0.06),
+    0 12px 32px -16px oklch(0.2 0.02 255 / 0.22);
+}
+
+@layer base {
+  html {
+    -webkit-text-size-adjust: 100%;
+  }
+
+  @media (prefers-reduced-motion: no-preference) {
+    html {
+      scroll-behavior: smooth;
+    }
+  }
+
+  body {
+    background-color: var(--color-canvas);
+    color: var(--color-ink);
+    font-family: var(--font-sans);
+    -webkit-font-smoothing: antialiased;
+    text-rendering: optimizeLegibility;
+  }
+
+  :focus-visible {
+    outline: 2px solid var(--ui-focus);
+    outline-offset: 2px;
+  }
+
+  ::selection {
+    background-color: var(--color-accent-800);
+    color: var(--color-accent-50);
+  }
+
+  :root[data-theme="light"] ::selection {
+    background-color: var(--color-accent-200);
+    color: var(--color-accent-950);
+  }
+}

+ 15 - 0
crates/xtask/Cargo.toml

@@ -0,0 +1,15 @@
+[package]
+name = "xtask"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+clap = { version = "4", features = ["derive"] }
+thiserror = "2"
+
+[dev-dependencies]
+oauth-core = { path = "../oauth-core" }
+secrecy = { version = "0.10.3", features = ["serde"] }
+serde_json = "1.0"
+server = { path = "../server" }
+tempfile = "3.23"

+ 290 - 0
crates/xtask/src/main.rs

@@ -0,0 +1,290 @@
+//! `xtask` - development, build, and verification helper for the workspace.
+//!
+//! Run it as `cargo run -p xtask -- <subcommand>` (or via a `cargo xtask`
+//! alias). Subcommands:
+//!
+//! - `dev` - a documented supervisor for the local stack. It prints the exact
+//!   commands a human should run in two terminals; it never spawns a server.
+//! - `build` - placeholder for the workspace build driver.
+//! - `check` - the full quality gates (todo 39): `cargo fmt --all -- --check`,
+//!   `cargo clippy --all-targets -- -D warnings`,
+//!   `cargo nextest run --workspace`, `trunk build` in `crates/web`, and a
+//!   skip-if-down E2E readiness probe. Fail-fast, typed errors, non-zero exit
+//!   on any real gate failure.
+//! - `e2e-bootstrap` - placeholder for the E2E fixture bootstrap (todo 31).
+//!
+//! The executor never starts a server; `check` only *probes* the user-run
+//! stack and skips E2E with an explicit message when it is down.
+
+use std::io::{Read, Write};
+use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
+use std::path::{Path, PathBuf};
+use std::process::{Command as ProcCommand, ExitCode};
+use std::time::Duration;
+
+use clap::{Parser, Subcommand};
+use thiserror::Error;
+
+/// TCP connect timeout for the E2E readiness probe.
+const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
+
+/// BFF direct health endpoint (Trunk does not proxy `/healthz`).
+const BFF_PROBE_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 3001);
+/// BFF direct health path.
+const BFF_PROBE_PATH: &str = "/healthz";
+
+/// Authorization-server readiness endpoint from the stack contract.
+const AS_PROBE_ADDR: SocketAddr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 8081);
+/// AS readiness path.
+const AS_PROBE_PATH: &str = "/health/ready";
+
+/// Typed failures surfaced by the xtask helper.
+#[derive(Debug, Error)]
+enum XtaskError {
+  /// The requested subcommand is scaffolded but not implemented yet.
+  #[error("`{command}` is not yet implemented")]
+  NotImplemented {
+    /// Machine name of the subcommand that was invoked.
+    command: &'static str,
+  },
+  /// The workspace root could not be located or read.
+  #[error("cannot locate workspace root: {reason}")]
+  WorkspaceRoot {
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+  /// A quality-gate binary could not be spawned.
+  #[error("cannot spawn gate `{step}`: {reason}")]
+  Spawn {
+    /// Machine name of the gate that failed to start.
+    step: &'static str,
+    /// A non-secret explanation of the failure.
+    reason: String,
+  },
+  /// A quality gate ran and reported failure (fail-fast, no further gates).
+  #[error("gate `{step}` failed (exit code: {code:?})")]
+  StepFailed {
+    /// Machine name of the gate that failed.
+    step: &'static str,
+    /// Process exit code (`None` when killed by signal).
+    code: Option<i32>,
+  },
+}
+
+/// Development, build, and verification helper for the OAuth workspace.
+#[derive(Debug, Parser)]
+#[command(
+  name = "xtask",
+  version,
+  about = "Development, build, and verification helper for the OAuth workspace",
+  propagate_version = true
+)]
+struct Cli {
+  #[command(subcommand)]
+  command: XtaskCommand,
+}
+
+/// The xtask subcommand surface.
+#[derive(Debug, Subcommand)]
+enum XtaskCommand {
+  /// Supervise the local dev stack (Trunk UI + BFF). User-run convenience only.
+  Dev,
+  /// Build the workspace artifacts.
+  Build,
+  /// Run the full quality gates (fmt, clippy, nextest, trunk build, E2E probe).
+  Check,
+  /// Bootstrap the E2E fixtures (see todo 31).
+  E2eBootstrap,
+}
+
+fn main() -> ExitCode {
+  let cli = Cli::parse();
+  match run(cli.command) {
+    Ok(()) => ExitCode::SUCCESS,
+    Err(error) => {
+      eprintln!("xtask: {error}");
+      ExitCode::FAILURE
+    }
+  }
+}
+
+/// Dispatch a subcommand.
+fn run(command: XtaskCommand) -> Result<(), XtaskError> {
+  match command {
+    XtaskCommand::Dev => {
+      print_dev_plan();
+      Err(XtaskError::NotImplemented { command: "dev" })
+    }
+    XtaskCommand::Build => Err(XtaskError::NotImplemented { command: "build" }),
+    XtaskCommand::Check => run_check(),
+    XtaskCommand::E2eBootstrap => {
+      println!(
+        "xtask e2e-bootstrap delegates to provision (the single writer of `config/e2e.json`):"
+      );
+      println!(
+        "  cargo run -p provision -- bootstrap --password-file <path> [--out config/e2e.json]"
+      );
+      Err(XtaskError::NotImplemented {
+        command: "e2e-bootstrap",
+      })
+    }
+  }
+}
+
+/// Print the two commands the dev supervisor would launch, without running
+/// either of them. The BFF owns `:3001`; Trunk owns `:3000` and proxies
+/// `/oauth/`, `/callback`, and `/api/` to the BFF (todo 28).
+fn print_dev_plan() {
+  println!("xtask dev would supervise the following processes (not started):");
+  println!("  1. cargo run -p server");
+  println!("       -> BFF on http://127.0.0.1:3001 (health: /healthz)");
+  println!("  2. cd crates/web && trunk serve --port 3000");
+  println!("       -> UI on http://127.0.0.1:3000 (proxies /oauth/, /callback, /api/)");
+  println!("Readiness: curl -fsS http://127.0.0.1:3001/healthz");
+}
+
+/// Run the full quality gates in order, fail-fast.
+///
+/// `cargo fmt`, `cargo clippy`, `cargo nextest`, and `trunk build` must all
+/// pass; the E2E readiness probe never fails — it reports "E2E skipped: start
+/// the stack" when the user-run stack is down.
+fn run_check() -> Result<(), XtaskError> {
+  let root = workspace_root()?;
+  run_step("fmt", "cargo", &["fmt", "--all", "--", "--check"], &root)?;
+  run_step(
+    "clippy",
+    "cargo",
+    &["clippy", "--all-targets", "--", "-D", "warnings"],
+    &root,
+  )?;
+  run_step(
+    "nextest",
+    "cargo",
+    &["nextest", "run", "--workspace"],
+    &root,
+  )?;
+  run_step("trunk-build", "trunk", &["build"], &root.join("crates/web"))?;
+  match probe_stack() {
+    ProbeOutcome::Up => println!(
+      "xtask check: e2e readiness probe passed (stack up; run `npx playwright test` separately)"
+    ),
+    ProbeOutcome::Down { detail } => {
+      println!("E2E skipped: start the stack ({detail})");
+    }
+  }
+  println!("xtask check: all gates passed");
+  Ok(())
+}
+
+/// Locate the workspace root by walking up to the manifest holding
+/// `[workspace]`.
+fn workspace_root() -> Result<PathBuf, XtaskError> {
+  let mut dir = std::env::current_dir().map_err(|err| XtaskError::WorkspaceRoot {
+    reason: format!("cannot read current directory: {err}"),
+  })?;
+  loop {
+    let manifest = dir.join("Cargo.toml");
+    if manifest.is_file() {
+      let content =
+        std::fs::read_to_string(&manifest).map_err(|err| XtaskError::WorkspaceRoot {
+          reason: format!("cannot read {}: {err}", manifest.display()),
+        })?;
+      if content.contains("[workspace]") {
+        return Ok(dir);
+      }
+    }
+    if !dir.pop() {
+      return Err(XtaskError::WorkspaceRoot {
+        reason: "no ancestor directory holds a `[workspace]` manifest".to_owned(),
+      });
+    }
+  }
+}
+
+/// Run one gate, inheriting stdio so progress stays visible. Any non-zero
+/// exit becomes a typed failure and stops the gate sequence.
+fn run_step(
+  step: &'static str,
+  program: &str,
+  args: &[&str],
+  cwd: &Path,
+) -> Result<(), XtaskError> {
+  println!("xtask check: gate `{step}`: {} {}", program, args.join(" "));
+  let status = ProcCommand::new(program)
+    .args(args)
+    .current_dir(cwd)
+    .status()
+    .map_err(|err| XtaskError::Spawn {
+      step,
+      reason: err.to_string(),
+    })?;
+  if status.success() {
+    println!("xtask check: gate `{step}` passed");
+    Ok(())
+  } else {
+    Err(XtaskError::StepFailed {
+      step,
+      code: status.code(),
+    })
+  }
+}
+
+/// E2E readiness probe outcome. `Down` is a skip, never a gate failure.
+enum ProbeOutcome {
+  /// Both health endpoints answered 2xx; Playwright may run separately.
+  Up,
+  /// The stack is down; carries a short non-secret detail for the message.
+  Down {
+    /// Which endpoint was unreachable, e.g. `127.0.0.1:3001/healthz`.
+    detail: String,
+  },
+}
+
+/// Probe the user-run stack contract: BFF `/healthz` direct (Trunk does not
+/// proxy it) plus AS `/health/ready`. Never starts anything, never fails.
+fn probe_stack() -> ProbeOutcome {
+  if !endpoint_healthy(BFF_PROBE_ADDR, BFF_PROBE_PATH) {
+    return ProbeOutcome::Down {
+      detail: format!("{BFF_PROBE_ADDR}{BFF_PROBE_PATH} unreachable"),
+    };
+  }
+  if !endpoint_healthy(AS_PROBE_ADDR, AS_PROBE_PATH) {
+    return ProbeOutcome::Down {
+      detail: format!("{AS_PROBE_ADDR}{AS_PROBE_PATH} unreachable"),
+    };
+  }
+  ProbeOutcome::Up
+}
+
+/// Minimal blocking HTTP GET over [`TcpStream`]: connect with a short timeout,
+/// send `GET path HTTP/1.0`, and accept only a `2xx` status line.
+fn endpoint_healthy(addr: SocketAddr, path: &str) -> bool {
+  let Ok(stream) = TcpStream::connect_timeout(&addr, PROBE_TIMEOUT) else {
+    return false;
+  };
+  let Ok(mut stream) = stream_with_timeouts(stream) else {
+    return false;
+  };
+  let request = format!("GET {path} HTTP/1.0\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
+  if stream.write_all(request.as_bytes()).is_err() {
+    return false;
+  }
+  let mut head = [0_u8; 15];
+  let mut filled = 0_usize;
+  while filled < head.len() {
+    match stream.read(&mut head[filled..]) {
+      Ok(0) => break,
+      Ok(read) => filled += read,
+      Err(_) => return false,
+    }
+  }
+  let status = String::from_utf8_lossy(&head[..filled]);
+  status.starts_with("HTTP/1.0 2") || status.starts_with("HTTP/1.1 2")
+}
+
+/// Apply the probe timeout to both directions of an open stream.
+fn stream_with_timeouts(stream: TcpStream) -> std::io::Result<TcpStream> {
+  stream.set_read_timeout(Some(PROBE_TIMEOUT))?;
+  stream.set_write_timeout(Some(PROBE_TIMEOUT))?;
+  Ok(stream)
+}

+ 153 - 0
crates/xtask/tests/no_secret_output.rs

@@ -0,0 +1,153 @@
+//! No-secret-in-output gate (todo 39).
+//!
+//! Formats every loggable/`Debug` representation the BFF can emit and asserts
+//! that none of the four secret classes appears: the client secret, the DPoP
+//! private scalar, access/refresh tokens, and the PKCE verifier. Dummy
+//! sentinel strings stand in for real secrets — they are fabricated for this
+//! test, never provisioned anywhere, and this file only ever asserts their
+//! *absence* from formatted output (it never prints a secret to prove that).
+
+use std::time::Duration;
+
+use secrecy::SecretString;
+
+/// Fabricated client-secret stand-in (never a real credential).
+const CLIENT_SECRET_SENTINEL: &str = "xtask-39-client-secret-sentinel";
+/// Fabricated access-token stand-in (never a real token).
+const ACCESS_TOKEN_SENTINEL: &str = "xtask-39-access-token-sentinel";
+/// Fabricated refresh-token stand-in (never a real token).
+const REFRESH_TOKEN_SENTINEL: &str = "xtask-39-refresh-token-sentinel";
+/// Fabricated PKCE-verifier stand-in (never a real verifier).
+const VERIFIER_SENTINEL: &str = "xtask-39-pkce-verifier-sentinel";
+/// Fabricated authorization-code stand-in (never a real code).
+const CODE_SENTINEL: &str = "xtask-39-auth-code-sentinel";
+
+/// Assert that `rendered` carries none of the fabricated secrets.
+fn assert_no_secret(rendered: &str, what: &str) {
+  for sentinel in [
+    CLIENT_SECRET_SENTINEL,
+    ACCESS_TOKEN_SENTINEL,
+    REFRESH_TOKEN_SENTINEL,
+    VERIFIER_SENTINEL,
+    CODE_SENTINEL,
+  ] {
+    assert!(
+      !rendered.contains(sentinel),
+      "{what} leaked a secret class into output: {rendered:?}"
+    );
+  }
+}
+
+#[test]
+fn client_secret_never_appears_in_debug_output() {
+  let creds = oauth_core::ClientCredentials {
+    client_id: "xtask-39-demo-client".to_owned(),
+    client_secret: SecretString::from(CLIENT_SECRET_SENTINEL),
+  };
+  let rendered = format!("{creds:?}");
+  assert_no_secret(&rendered, "ClientCredentials Debug");
+  assert!(
+    rendered.contains(oauth_core::REDACTED),
+    "redaction marker must be present: {rendered:?}"
+  );
+
+  let profile = oauth_core::OAuthProfile {
+    client: creds,
+    ..oauth_core::OAuthProfile::default()
+  };
+  let rendered = format!("{profile:?}");
+  assert_no_secret(&rendered, "OAuthProfile Debug");
+}
+
+#[test]
+fn dpop_private_scalar_never_appears_in_debug_output() {
+  let key = oauth_core::dpop::key::DpopKey::generate().expect("key generates");
+  let rendered = format!("{key:?}");
+  assert!(
+    rendered.contains(oauth_core::REDACTED),
+    "DPoP key Debug must redact: {rendered:?}"
+  );
+
+  // The persisted file holds the real scalar; the Debug form must not repeat
+  // any of it. Extract the `d` member and prove absence.
+  let dir = tempfile::tempdir().expect("tempdir creates");
+  let path = dir.path().join("dpop-key.json");
+  key.save_to_file(&path).expect("key persists");
+  let raw = std::fs::read_to_string(&path).expect("key file reads");
+  let scalar = raw.parse::<serde_json::Value>().expect("key file is JSON")["d"]
+    .as_str()
+    .expect("key file carries a `d` scalar")
+    .to_owned();
+  assert!(
+    !scalar.is_empty(),
+    "scalar must be non-empty to prove absence"
+  );
+  assert!(
+    !rendered.contains(&scalar),
+    "DPoP private scalar leaked into Debug output"
+  );
+}
+
+#[test]
+fn tokens_never_appear_in_debug_or_log_output() {
+  let response = oauth_core::token::TokenResponse {
+    access_token: SecretString::from(ACCESS_TOKEN_SENTINEL),
+    token_type: "DPoP".to_owned(),
+    expires_in: 300,
+    refresh_token: Some(SecretString::from(REFRESH_TOKEN_SENTINEL)),
+    scope: Some("demo.read".to_owned()),
+  };
+  let rendered = format!("{response:?}");
+  assert_no_secret(&rendered, "TokenResponse Debug");
+
+  // The BFF log line for an outbound token call: query/fragment stripped,
+  // header presence recorded only as the marker.
+  let line = server::http_client::loggable_request(
+    "POST",
+    &format!("https://as.example/token?code={CODE_SENTINEL}"),
+    true,
+    true,
+  );
+  let decorated = format!(
+    "{line} token={} secret={}",
+    server::http_client::redact_secret(ACCESS_TOKEN_SENTINEL),
+    server::http_client::redact_secret(CLIENT_SECRET_SENTINEL),
+  );
+  assert_no_secret(&decorated, "BFF log line");
+
+  // Server-side session Debug + cookie carry no token material.
+  let store = server::session::SessionStore::new(Duration::from_secs(3600));
+  let created = store.create().expect("session creates");
+  assert!(store.store_tokens(
+    &created.id,
+    Some(SecretString::from(ACCESS_TOKEN_SENTINEL)),
+    Some(SecretString::from(REFRESH_TOKEN_SENTINEL)),
+    Some("DPoP".to_owned()),
+  ));
+  let rendered = format!("{:?}", store.get(&created.id).expect("session lives"));
+  assert_no_secret(&rendered, "SessionData Debug");
+  let cookie = server::session::set_cookie_value(&created.id, false, Duration::from_secs(60));
+  assert_no_secret(&cookie, "session cookie");
+}
+
+#[test]
+fn pkce_verifier_never_appears_in_debug_output() {
+  let pending = server::oauth::PendingStore::new();
+  pending.insert(
+    "xtask-39-state".to_owned(),
+    SecretString::from(VERIFIER_SENTINEL),
+    "xtask-39-session".to_owned(),
+    "https://as.example".to_owned(),
+  );
+  let record = pending
+    .take("xtask-39-state")
+    .expect("pending record lives");
+  let rendered = format!("{record:?}");
+  assert_no_secret(&rendered, "PendingRecord Debug");
+  assert!(
+    rendered.contains(oauth_core::REDACTED),
+    "redaction marker must be present: {rendered:?}"
+  );
+  // Single-use: the state never resolves twice (replay defense).
+  assert!(pending.take("xtask-39-state").is_none());
+}

+ 165 - 0
demo/README.md

@@ -0,0 +1,165 @@
+# Demo kit — d-OAuth provisioning runbook
+
+Provisions the demo scope, user, entitlement, and redirect URI against a local
+d-OAuth authorization server, then registers this workspace's confidential BFF
+client over Dynamic Client Registration (DCR). No real secrets appear below:
+passwords travel via `--password-file`, the IAT is printed once by the `issue`
+step and handed to DCR, and nothing secret is committed.
+
+Conventions used throughout: demo scope `demo.read`, demo user
+`demo@example.com`, redirect URI `http://127.0.0.1:3000/callback`. The binary
+is `d-oauth-server` (`cargo run --bin d-oauth-server -- <command>` from the
+d-OAuth checkout). Flag names below are taken from d-OAuth
+`docs/OPERATIONS.md` (command reference).
+
+## 0. Start the dependencies, then the AS
+
+```bash
+# From the d-OAuth checkout: migrate once, then serve.
+d-oauth-server migrate
+D_OAUTH_ISSUER=https://localhost:8080 d-oauth-server serve
+```
+
+`serve` runs the TLS public listener on `127.0.0.1:8080` (self-signed dev
+certificate) plus the plaintext health listener on `127.0.0.1:8081`. It never
+migrates and never provisions. Readiness:
+
+```bash
+curl -fsS http://127.0.0.1:8081/health/ready
+```
+
+## 1. Scope create / enable
+
+```bash
+d-oauth-server scope create demo.read --description "Demo read scope"
+d-oauth-server scope enable demo.read
+```
+
+`scope create` upserts (create-or-update-description) and `enable` flips the
+catalog flag. Stale-discovery note: if the AS was already running when the
+scope was created, `/metadata` `scopes_supported` may be stale (in-process
+cache) until restart. The OAuth flow reads Postgres directly, so either
+restart the AS after scope creation or do not assert scopes from discovery.
+
+## 2. User create + grant-scope
+
+```bash
+printf '%s' '<initial-password>' > /tmp/demo-password.txt
+d-oauth-server user create --email demo@example.com --password-file /tmp/demo-password.txt
+d-oauth-server user grant-scope --email demo@example.com --scope demo.read
+d-oauth-server user scopes --email demo@example.com
+rm /tmp/demo-password.txt
+```
+
+`user create` is a plain INSERT, not an upsert: re-running it against an
+existing user fails with an already-exists error (the `provision setup` step
+treats that as create-or-skip). `grant-scope` requires the scope to exist in
+the catalog (step 1 first) and upserts. A created user starts with
+`must_change_password = true` — see step 6.
+
+Declarative alternative (idempotent; second run reports no changes):
+
+```bash
+d-oauth-server config sync --file demo/config-sync.json
+d-oauth-server config sync --file demo/config-sync.json
+```
+
+`demo/config-sync.json` covers scopes, redirect URIs, and entitlements. It
+CANNOT create clients: the sync schema has no clients section, so the
+confidential client always comes from step 5 (DCR via `provision register`).
+
+## 3. Redirect-URI create
+
+```bash
+d-oauth-server redirect-uri create http://127.0.0.1:3000/callback
+```
+
+The URI is a global catalog entry. After DCR mints the client id, attach it:
+
+```bash
+d-oauth-server client attach-redirect-uri --client-id <client-id> --uri http://127.0.0.1:3000/callback
+```
+
+(`client_redirect_uris` in `demo/config-sync.json` is left empty because the
+client id only exists after step 5.)
+
+## 4. IAT issue (single-use, printed once)
+
+```bash
+d-oauth-server initial-access-token issue --uses 1 --ttl 600
+```
+
+The raw token prints once; only its keyed hash is stored. It is required when
+`allow_open_registration` is false. A burned IAT (even one spent on a failed
+registration) is never reused — issue a fresh one per attempt. Prefer the
+automated path instead of copy-paste:
+
+```bash
+cargo run -p provision -- setup --cli <path-to-d-oauth-server> --env-file <d-OAuth-.env> \
+  --email demo@example.com --password-file <path> --iat-out /tmp/demo-iat.txt
+cargo run -p provision -- register --issuer https://localhost:8080 --iat-file /tmp/demo-iat.txt
+rm /tmp/demo-iat.txt
+```
+
+## 5. DCR (or `provision register`)
+
+`provision register` POSTs `/register` with
+`token_endpoint_auth_method=client_secret_basic`,
+`grant_types=[authorization_code,refresh_token]`, `response_types=[code]`,
+the demo redirect URI/scope, and `dpop_bound_access_tokens=true`, then
+persists `config/client.json` (mode `0600`).
+
+Catalog-policy remediation: under the default `dcr_redirect_policy=catalog`,
+only redirect URIs present in the runtime catalog may register. An
+`invalid_redirect_uri` rejection means step 3 was skipped: provision the URI
+first, or relax the AS to `dcr_redirect_policy=catalog_loopback` (catalog
+URIs plus dynamic loopback URIs, RFC 8252) — never `open` in production.
+
+## 6. Forced password change + TOTP (one-time, browser or Playwright)
+
+The fresh demo user cannot reach `/authorize` until both complete (restricted
+session). The automated path is `provision bootstrap` + the Playwright spec
+(todo 37), which sets a new known password, captures the `otpauth://` secret
+from `/login/totp/setup`, and persists `{email, password, totp_secret}` to
+`config/e2e.json` for reuse. Manual equivalent:
+
+1. `POST /login` (`email`, `password`, hidden `csrf`; `code` empty) → sent to
+   `/login/change-password`.
+2. `POST /login/change-password` (`new_password` of at least 12 characters,
+   `confirm`, hidden `csrf`).
+3. `GET /login/totp/setup` → register the shown secret/`otpauth://` URI in
+   an authenticator now (shown once, with one-time recovery codes).
+4. `POST /login/totp/confirm` (`code`, hidden `csrf`) → full session.
+5. `POST /consent` approves with hidden `request_id`, hidden `csrf`, and
+   `decision=approve`.
+
+## 7. Run the BFF (+ UI in dev)
+
+```bash
+# BFF (confidential client): http://127.0.0.1:3001, health at /healthz.
+cargo run -p server
+curl -fsS http://127.0.0.1:3001/healthz
+```
+
+Dev UI (Trunk owns `:3000`, proxies `/oauth/`, `/callback`, `/api/` to the
+BFF on `:3001`):
+
+```bash
+cd crates/web && trunk serve --port 3000
+```
+
+Browser entry point: `http://127.0.0.1:3000`. Approve with `decision=approve`
+at `/consent`; the flow lands back at `/callback` on the BFF.
+
+## 8. Sign out / switch users
+
+`Log out` in the dashboard clears the BFF session, then navigates the browser to
+the provider logout (`<issuer>/logout`). d-OAuth ends its own session and `302`s
+back to the app root (`http://127.0.0.1:3000/`, the client's registered
+post-logout return URI), so a different account can sign in on the next run
+without restarting the authorization server.
+
+The scope `demo.read` is required. If the account authenticates without it, the
+dashboard keeps the session and shows a `Signed in without the required scope`
+notice; if the AS refuses the scope at authorization time, `/callback` renders a
+`Scope not granted` page. Grant the scope (step 2), then sign in again.

+ 22 - 0
demo/config-sync.json

@@ -0,0 +1,22 @@
+{
+  "scopes": [
+    {
+      "name": "demo.read",
+      "description": "Demo read scope for the OAuth2 WASM demo client",
+      "enabled": true
+    }
+  ],
+  "redirect_uris": [
+    {
+      "uri": "http://127.0.0.1:3000/callback",
+      "enabled": true
+    }
+  ],
+  "user_scopes": [
+    {
+      "email": "demo@example.com",
+      "scope": "demo.read"
+    }
+  ],
+  "client_redirect_uris": []
+}

+ 24 - 0
docker-bake.hcl

@@ -0,0 +1,24 @@
+# Bake definition: multi-arch static image for the BFF + WASM client.
+# Usage: `docker buildx bake` (both arches) or
+#   `docker buildx bake image --set image.platform=linux/amd64` (one arch).
+variable "TAG" {
+  default = "latest"
+}
+
+variable "SOURCE_DATE_EPOCH" {
+  default = "0"
+}
+
+group "default" {
+  targets = ["image"]
+}
+
+target "image" {
+  dockerfile = "Dockerfile"
+  context    = "."
+  platforms  = ["linux/amd64", "linux/arm64"]
+  tags       = ["oauth2-wasm-demo-client:${TAG}"]
+  args = {
+    SOURCE_DATE_EPOCH = "${SOURCE_DATE_EPOCH}"
+  }
+}

+ 23 - 0
e2e/auth.setup.ts

@@ -0,0 +1,23 @@
+import { test as setup, expect } from '@playwright/test';
+import { loadFixture, probeReadiness, readinessMessage, STORAGE_STATE_PATH } from './helpers';
+import { ensureLoggedIn } from './flow';
+
+declare const require: (id: string) => any;
+
+setup('authenticate once for the negative suite', async ({ page }) => {
+  const readiness = await probeReadiness();
+  expect(readiness.ok, readinessMessage(readiness)).toBe(true);
+  let fixture;
+  try {
+    fixture = loadFixture();
+  } catch (err) {
+    setup.skip(true, `run provision first: cargo run -p provision -- bootstrap --password-file <path> (${(err as Error).message})`);
+    throw new Error('unreachable');
+  }
+  const { authenticator } = require('otplib');
+  await ensureLoggedIn(page, fixture, (secret: string) => authenticator.generate(secret));
+  const fs = require('node:fs');
+  const path = require('node:path');
+  fs.mkdirSync(path.dirname(STORAGE_STATE_PATH), { recursive: true });
+  await page.context().storageState({ path: STORAGE_STATE_PATH });
+});

+ 208 - 0
e2e/flow.js

@@ -0,0 +1,208 @@
+'use strict';
+
+// Shared sign-in flow for the E2E specs (todos 37/38). Drives the real
+// user-run stack only: landing -> /oauth/start -> d-OAuth login (with the
+// one-time forced password change + TOTP enrollment on a fresh user) ->
+// /consent approve -> BFF dashboard. Never starts a server, never mocks.
+//
+// The TOTP code generator is injected (`totpCode(secret) -> code`) so the
+// specs can lazily require the pinned `otplib` only when the flow runs;
+// --list and the readiness probe never touch it.
+
+const crypto = require('node:crypto');
+
+const { saveSpecFields } = require('./helpers');
+
+function newPassword() {
+  return crypto.randomBytes(15).toString('base64url');
+}
+
+async function submitFirstForm(page) {
+  const btn = page.locator('form button[type="submit"], form input[type="submit"], form button:not([type])');
+  if ((await btn.count()) > 0) {
+    await btn.first().click();
+  } else {
+    await page.locator('form').first().evaluate((form) => form.submit());
+  }
+}
+
+async function extractTotpSecret(page) {
+  const text = await page.locator('body').innerText();
+  const uriMatch = text.match(/secret=([A-Z2-7]{16,})/);
+  if (uriMatch) {
+    return uriMatch[1].replace(/=+$/, '');
+  }
+  const href = await page.locator('a[href^="otpauth://"]').first().getAttribute('href').catch(() => null);
+  if (href) {
+    const secret = new URL(href).searchParams.get('secret');
+    if (secret) {
+      return secret.replace(/=+$/, '');
+    }
+  }
+  throw new Error('could not capture the TOTP secret from /login/totp/setup (shown once)');
+}
+
+async function fillLoginForm(page, fixture, totpCode) {
+  await page.locator('[name="email"]').fill(fixture.email);
+  await page.locator('[name="password"]').fill(fixture.password);
+  const codeField = page.locator('[name="code"]');
+  if ((await codeField.count()) > 0) {
+    if (!fixture.totp_secret) {
+      throw new Error('login shows a code field but the fixture has no totp_secret');
+    }
+    await codeField.fill(totpCode(fixture.totp_secret));
+  }
+  await submitAndSettle(page);
+}
+
+function msUntilNextTotpStep() {
+  return 30_000 - (Date.now() % 30_000) + 2000;
+}
+
+// Honor the AS throttle: prefer its Retry-After delay, else 60s, clamped to
+// 5s..300s so a missing or absurd header cannot stall or spin the suite.
+function retryAfterMs(resp) {
+  const raw = resp.headers()['retry-after'];
+  const secs = Number.parseInt(Array.isArray(raw) ? raw[0] : raw, 10);
+  if (Number.isFinite(secs)) {
+    return Math.min(300_000, Math.max(5_000, secs * 1000));
+  }
+  return 60_000;
+}
+
+async function waitForUrlSettled(page, timeoutMs = 15000) {
+  const deadline = Date.now() + timeoutMs;
+  let last = page.url();
+  let stableSince = Date.now();
+  for (;;) {
+    await page.waitForTimeout(500);
+    const now = page.url();
+    if (now !== last) {
+      last = now;
+      stableSince = Date.now();
+    } else if (Date.now() - stableSince >= 1000) {
+      return;
+    }
+    if (Date.now() > deadline) {
+      return;
+    }
+  }
+}
+
+async function submitAndSettle(page) {
+  await submitFirstForm(page);
+  await page.waitForLoadState('domcontentloaded');
+  await waitForUrlSettled(page);
+}
+
+async function approveConsent(page, requestId, csrf) {
+  const url = `${new URL(page.url()).origin}/consent`;
+  for (let attempt = 1; attempt <= 2; attempt += 1) {
+    const resp = await page.request.post(url, {
+      form: { request_id: requestId, csrf, decision: 'approve' },
+    });
+    if (resp.ok()) {
+      return;
+    }
+    const body = await resp.text().catch(() => '');
+    const limited = resp.status() === 429 || body.includes('rate_limited');
+    if (limited && attempt < 2) {
+      await page.waitForTimeout(retryAfterMs(resp));
+      continue;
+    }
+    if (limited) {
+      throw new Error(`BLOCKED: AS rate-limited on POST ${url} (attempt ${attempt}): ${body.slice(0, 200)}`);
+    }
+    throw new Error(`consent approve failed: ${resp.status()} at ${resp.url()}: ${body.slice(0, 200)}`);
+  }
+}
+
+async function gotoWithRetry(page, url) {
+  let lastError;
+  for (let attempt = 0; attempt < 3; attempt++) {
+    try {
+      await page.goto(url, { timeout: 30_000 });
+      return;
+    } catch (err) {
+      lastError = err;
+      if (!/net::ERR_|ECONNREFUSED|ETIMEDOUT/.test(err.message)) {
+        throw err;
+      }
+      await page.waitForTimeout(1000);
+    }
+  }
+  throw lastError;
+}
+
+async function ensureLoggedIn(page, fixture, totpCode, rateLimitRetried = false) {
+  await gotoWithRetry(page, '/oauth/start');
+  await page.waitForLoadState('domcontentloaded');
+  if ((await page.locator('[name="email"]').count()) > 0) {
+    await page.waitForTimeout(msUntilNextTotpStep());
+    await fillLoginForm(page, fixture, totpCode);
+    if (page.url().includes('/login')) {
+      const body = await page.locator('body').innerText().catch(() => '');
+      if (body.includes('rate_limited')) {
+        if (rateLimitRetried) {
+          throw new Error(`BLOCKED: AS rate-limited at ${page.url()}: ${body.slice(0, 200)}`);
+        }
+        await page.waitForTimeout(60_000);
+        return ensureLoggedIn(page, fixture, totpCode, true);
+      }
+      throw new Error(`login did not leave /login at ${page.url()}: ${body.slice(0, 200)}`);
+    }
+  }
+  if (page.url().includes('/login/change-password')) {
+    const next = newPassword();
+    await page.locator('[name="new_password"]').fill(next);
+    await page.locator('[name="confirm"]').fill(next);
+    fixture.password = next;
+    await submitAndSettle(page);
+  }
+  if (page.url().includes('/login/totp/setup')) {
+    fixture.totp_secret = await extractTotpSecret(page);
+    saveSpecFields(fixture);
+    const origin = new URL(page.url()).origin;
+    await page.goto(`${origin}/login/totp/confirm`);
+    await page.waitForLoadState('domcontentloaded');
+  }
+  if (page.url().includes('/login/totp/confirm') || page.url().includes('/login/totp')) {
+    await page.locator('[name="code"]').fill(totpCode(fixture.totp_secret));
+    await submitAndSettle(page);
+  }
+  // Approve via the context API client, not an in-page click: this Chromium
+  // build aborts following the approval 302 (https AS -> http BFF callback,
+  // net::ERR_ABORTED, proven by netlog: 302 received then CANCELLED with no
+  // follow-up request). Same endpoint, same cookies, same form fields.
+  if (page.url().includes('/consent')) {
+    const requestId = await page.locator('[name="request_id"]').inputValue();
+    const csrf = await page.locator('[name="csrf"]').inputValue();
+    await approveConsent(page, requestId, csrf);
+  }
+  const check = await page.request.get('/api/session').catch(() => null);
+  if (!check || check.status() !== 200) {
+    const body = await page.locator('body').innerText().catch(() => '');
+    const hasForm = (await page.locator('[name="email"]').count()) > 0;
+    throw new Error(
+      `no BFF session after sign-in (page at ${page.url()}, session status ${check ? check.status() : 'request failed'}, hasLoginForm=${hasForm}): ${body.slice(0, 200)}`,
+    );
+  }
+}
+
+async function awaitSession(page, timeoutMs = 15000) {
+  const deadline = Date.now() + timeoutMs;
+  let lastStatus = 0;
+  for (;;) {
+    const res = await page.request.get('/api/session');
+    lastStatus = res.status();
+    if (res.status() === 200) {
+      return await res.json();
+    }
+    if (Date.now() > deadline) {
+      throw new Error(`timed out waiting for /api/session 200 (last status ${lastStatus})`);
+    }
+    await page.waitForTimeout(500);
+  }
+}
+
+module.exports = { ensureLoggedIn, awaitSession, submitFirstForm, extractTotpSecret, newPassword };

+ 52 - 0
e2e/happy.spec.ts

@@ -0,0 +1,52 @@
+import { test, expect } from '@playwright/test';
+import { loadFixture, probeReadiness, readinessMessage } from './helpers';
+import { ensureLoggedIn, awaitSession } from './flow';
+
+declare const require: (id: string) => any;
+
+test.use({ ignoreHTTPSErrors: true });
+
+test.beforeAll(async () => {
+  const readiness = await probeReadiness();
+  expect(readiness.ok, readinessMessage(readiness)).toBe(true);
+});
+
+function loadFixtureOrSkip() {
+  try {
+    return loadFixture();
+  } catch (err) {
+    test.skip(true, `run provision first: cargo run -p provision -- bootstrap --password-file <path> (${(err as Error).message})`);
+    throw new Error('unreachable');
+  }
+}
+
+test('happy path: sign in, dashboard, refresh, logout', async ({ page }) => {
+  const fixture = loadFixtureOrSkip();
+  const { authenticator } = require('otplib');
+  const totpCode = (secret: string) => authenticator.generate(secret);
+
+  await page.goto('/');
+  await expect(page.getByRole('link', { name: /sign in/i }).first()).toBeVisible();
+
+  await ensureLoggedIn(page, fixture, totpCode);
+  await page.goto('/dashboard');
+
+  const session = await awaitSession(page);
+  expect(session.subject).toBe(fixture.email);
+  expect(session.scope).toContain('demo.read');
+  expect(session.token_type).toBe('DPoP');
+  expect(session.cnf?.jkt).toBeTruthy();
+  const config = await (await page.request.get('/api/config')).json();
+  expect(session.client_id).toBeTruthy();
+  expect(session.client_id).toBe(config.client_id);
+
+  await expect(page.getByText(fixture.email).first()).toBeVisible({ timeout: 20_000 });
+
+  await page.getByRole('button', { name: 'Refresh' }).click();
+  const afterRefresh = await awaitSession(page);
+  expect(afterRefresh.subject).toBe(fixture.email);
+
+  await page.getByRole('button', { name: 'Log out' }).click();
+  await expect.poll(async () => (await page.request.get('/api/session')).status()).toBe(401);
+  await expect(page.getByRole('link', { name: /sign in/i }).first()).toBeVisible();
+});

+ 119 - 0
e2e/helpers.js

@@ -0,0 +1,119 @@
+// E2E fixture helper (todo 31): the JavaScript half of the single-writer
+// contract. `provision bootstrap` writes `{email, password}` to
+// `config/e2e.json` only when absent; the Playwright spec (todos 37/38) is
+// the single writer of the mutable fields (`password` rotation +
+// `totp_secret` capture). Never store the TOTP secret in git; never ask a
+// human to type a TOTP code (use the pinned `otplib` instead).
+//
+// One-time account path on a fresh demo user:
+//   1. POST /login (`email`, `password`, hidden `csrf`; `code` empty)
+//      -> redirected to /login/change-password (restricted session).
+//   2. POST /login/change-password (`new_password` >= 12 chars, `confirm`,
+//      hidden `csrf`).
+//   3. GET /login/totp/setup -> read the `otpauth://` URI (shown once),
+//      persist its secret as `totp_secret` in config/e2e.json.
+//   4. POST /login/totp/confirm (`code` from otplib, hidden `csrf`)
+//      -> full session re-issued; /authorize works from here.
+//   5. Later, POST /consent approves with hidden `request_id`, hidden
+//      `csrf`, and `decision=approve`.
+'use strict';
+
+const fs = require('node:fs');
+const http = require('node:http');
+const https = require('node:https');
+const path = require('node:path');
+
+const FIXTURE_PATH = path.join(__dirname, '..', 'config', 'e2e.json');
+
+// Playwright storageState written once per run by auth.setup.ts and reused by
+// the negative project so the suite logs in once instead of per test. Holds
+// live session cookies: gitignored, never committed, never logged.
+const STORAGE_STATE_PATH = path.join(__dirname, '.auth', 'state.json');
+
+const BFF_HEALTH_URL =
+  process.env.E2E_BFF_HEALTH_URL || 'http://127.0.0.1:3000/healthz';
+const AS_READY_URL =
+  process.env.E2E_AS_DISCOVERY_URL ||
+  'https://localhost:8080/.well-known/oauth-authorization-server';
+
+// Exact copy-paste to start the user-run stack (E2E target is prod mode:
+// BFF serves `crates/web/dist` on :3000). The authorization server discovery
+// URL is supplied through `E2E_AS_DISCOVERY_URL`, so any AS can be targeted.
+const STACK_START_MESSAGE =
+  'start the stack first: ' +
+  'UI+BFF: `cd crates/web && trunk build --release` once, then ' +
+  '`SERVER_BIND_ADDR=127.0.0.1:3000 cargo run -p server`; ' +
+  'point `E2E_AS_DISCOVERY_URL` at your authorization server ' +
+  '(default: local d-OAuth on `https://localhost:8080`). ' +
+  'Readiness: `curl -fsS http://127.0.0.1:3000/healthz`, then a GET of the ' +
+  'discovery URL.';
+
+const SELECTORS = {
+  loginEmail: 'email',
+  loginPassword: 'password',
+  loginCode: 'code',
+  totpCode: 'code',
+  csrf: 'csrf',
+  changeNewPassword: 'new_password',
+  changeConfirm: 'confirm',
+  consentRequestId: 'request_id',
+  consentDecision: 'decision',
+  consentApprove: 'approve',
+};
+
+// Load the fixture written by `provision bootstrap`. A missing file is a
+// SKIP with an explicit "run provision first" message, never a false pass.
+function loadFixture(fixturePath = FIXTURE_PATH) {
+  if (!fs.existsSync(fixturePath)) {
+    const error = new Error(
+      `E2E fixture is missing: run \`cargo run -p provision -- bootstrap --password-file <path>\` first (looked at \`${fixturePath}\`)`,
+    );
+    error.code = 'E2E_FIXTURE_MISSING';
+    throw error;
+  }
+  return JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
+}
+
+// Persist the spec-owned fields after the one-time flow completes:
+// the rotated password plus the captured `otpauth://` secret.
+function saveSpecFields(fixture, fixturePath = FIXTURE_PATH) {
+  const next = {
+    email: fixture.email,
+    password: fixture.password,
+    totp_secret: fixture.totp_secret,
+  };
+  fs.writeFileSync(fixturePath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
+  fs.chmodSync(fixturePath, 0o600);
+}
+
+module.exports = { FIXTURE_PATH, STORAGE_STATE_PATH, SELECTORS, loadFixture, saveSpecFields };
+
+// Real HTTP readiness probe against both stack endpoints (todo 37/38).
+// Never mocked: a refused connection means the user-run stack is down.
+function probeEndpoint(url, timeoutMs = 3000) {
+  return new Promise((resolve) => {
+    const transport = url.startsWith('https:') ? https : http;
+    const req = transport.get(url, (res) => {
+      res.resume();
+      resolve({ url, ok: res.statusCode !== undefined && res.statusCode < 400, status: res.statusCode });
+    });
+    req.on('error', (err) => resolve({ url, ok: false, status: 0, error: err.code || err.message }));
+    req.setTimeout(timeoutMs, () => req.destroy(new Error(`timeout after ${timeoutMs}ms`)));
+  });
+}
+
+async function probeReadiness() {
+  const [bff, as] = await Promise.all([probeEndpoint(BFF_HEALTH_URL), probeEndpoint(AS_READY_URL)]);
+  return { bff, as, ok: bff.ok && as.ok };
+}
+
+function readinessMessage(readiness) {
+  const fmt = (p) => `${p.url} -> ${p.ok ? `UP (${p.status})` : `DOWN (${p.error ?? p.status})`}`;
+  return `${STACK_START_MESSAGE} [readiness: bff ${fmt(readiness.bff)}; as ${fmt(readiness.as)}]`;
+}
+
+module.exports.probeReadiness = probeReadiness;
+module.exports.readinessMessage = readinessMessage;
+module.exports.STACK_START_MESSAGE = STACK_START_MESSAGE;
+module.exports.BFF_HEALTH_URL = BFF_HEALTH_URL;
+module.exports.AS_READY_URL = AS_READY_URL;

+ 114 - 0
e2e/negative.spec.ts

@@ -0,0 +1,114 @@
+import { test, expect } from '@playwright/test';
+import { loadFixture, probeReadiness, readinessMessage } from './helpers';
+import { ensureLoggedIn, awaitSession } from './flow';
+
+declare const require: (id: string) => any;
+
+test.use({ ignoreHTTPSErrors: true });
+
+test.beforeAll(async () => {
+  const readiness = await probeReadiness();
+  expect(readiness.ok, readinessMessage(readiness)).toBe(true);
+});
+
+function loadFixtureOrSkip() {
+  try {
+    return loadFixture();
+  } catch (err) {
+    test.skip(true, `run provision first: cargo run -p provision -- bootstrap --password-file <path> (${(err as Error).message})`);
+    throw new Error('unreachable');
+  }
+}
+
+function axeOrSkip() {
+  try {
+    return require('@axe-core/playwright').AxeBuilder;
+  } catch {
+    test.skip(true, 'install required: (cd e2e && npm install) for @axe-core/playwright 4.13.0');
+    throw new Error('unreachable');
+  }
+}
+
+function noCritical(results: { violations: Array<{ impact?: string | null; id: string }> }) {
+  const critical = results.violations.filter((v) => v.impact === 'critical');
+  return { critical, detail: critical.map((v) => v.id).join(', ') };
+}
+
+test('unauthenticated /api/session is 401 with landing state', async ({ page }) => {
+  // Must see the anonymous state: drop the shared authenticated cookies first
+  // (the project storageState would otherwise send a live sid here).
+  await page.context().clearCookies();
+  const res = await page.request.get('/api/session');
+  expect(res.status()).toBe(401);
+  const body = await res.json().catch(() => null);
+  expect(body?.subject, 'anonymous session must not carry identity').toBeUndefined();
+  await page.goto('/');
+  await expect(page.getByRole('link', { name: /sign in/i }).first()).toBeVisible();
+});
+
+test('revoking both tokens clears the session: transparent refresh cannot revive it', async ({ page }) => {
+  // Own session, not the shared one: this test destroys its BFF session, so
+  // it logs in privately (empty storage) while the other tests reuse the
+  // setup project's authenticated state untouched. `/api/logout` revokes BOTH
+  // the access and refresh tokens, so the BFF's transparent renewal on
+  // `GET /api/session` has nothing left to rotate: the probe must stay 401
+  // rather than self-healing to 200.
+  await page.context().clearCookies();
+  const fixture = loadFixtureOrSkip();
+  const { authenticator } = require('otplib');
+  await ensureLoggedIn(page, fixture, (secret: string) => authenticator.generate(secret));
+  const session = await awaitSession(page);
+  expect(session.csrf_token).toBeTruthy();
+  const logout = await page.request.post('/api/logout', { headers: { 'x-csrf-token': session.csrf_token } });
+  expect(logout.status()).toBe(200);
+  const after = await page.request.get('/api/session');
+  expect(after.status(), 'revoked access + refresh must read as 401, never as identity').toBe(401);
+  const data = await after.json().catch(() => ({}));
+  expect(data.subject, 'cleared session must not carry identity').toBeUndefined();
+  const cookies = await page.context().cookies('http://127.0.0.1:3000');
+  const sid = cookies.find((c) => c.name === 'sid');
+  if (sid) {
+    const replay = await page.request.get('/api/session', { headers: { cookie: `sid=${sid.value}` } });
+    expect(replay.status(), 'replaying a destroyed sid must stay 401').toBe(401);
+  }
+});
+
+test('wrong-issuer callback shows a typed error and creates no session', async ({ page }) => {
+  // Anonymous probe: the project storageState carries a live sid, so use the
+  // cleared page context instead of the shared-state request fixture.
+  await page.context().clearCookies();
+  const forged = await page.request.get('/callback?code=forged-code&state=forged-state&iss=https://wrong-issuer.example');
+  expect(forged.status()).toBe(400);
+  expect(await forged.text()).toContain('Invalid callback');
+  expect((await page.request.get('/api/session')).status()).toBe(401);
+});
+
+test('state-changing API rejects missing CSRF and foreign origin', async ({ page }) => {
+  // Reuses the setup project's authenticated state (no fresh login: the AS
+  // rate-limits /login, so the suite authenticates once per run).
+  const session = await awaitSession(page);
+  const noCsrf = await page.request.post('/api/refresh');
+  expect(noCsrf.status(), 'refresh without CSRF must not succeed').toBe(403);
+  const forged = await page.request.post('/api/refresh', {
+    headers: { Origin: 'https://evil.example', 'x-csrf-token': session.csrf_token },
+  });
+  expect(forged.status(), 'cross-site refresh must not succeed').toBe(403);
+  expect((await page.request.get('/api/session')).status()).toBe(200);
+});
+
+test('landing has no critical accessibility violations', async ({ page }) => {
+  const AxeBuilder = axeOrSkip();
+  await page.goto('/');
+  const { critical, detail } = noCritical(await new AxeBuilder({ page }).analyze());
+  expect(critical, `critical axe violations on landing: ${detail}`).toEqual([]);
+});
+
+test('dashboard has no critical accessibility violations', async ({ page }) => {
+  const AxeBuilder = axeOrSkip();
+  // Same shared authenticated state as the CSRF test above; awaitSession
+  // proves it is live before scanning.
+  await page.goto('/dashboard');
+  await awaitSession(page);
+  const { critical, detail } = noCritical(await new AxeBuilder({ page }).analyze());
+  expect(critical, `critical axe violations on dashboard: ${detail}`).toEqual([]);
+});

+ 193 - 0
e2e/package-lock.json

@@ -0,0 +1,193 @@
+{
+  "name": "oauth2-wasm-demo-e2e",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "oauth2-wasm-demo-e2e",
+      "version": "0.1.0",
+      "devDependencies": {
+        "@axe-core/playwright": "4.13.0",
+        "@playwright/test": "1.52.0",
+        "otplib": "12.0.1"
+      }
+    },
+    "node_modules/@axe-core/playwright": {
+      "version": "4.13.0",
+      "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.13.0.tgz",
+      "integrity": "sha512-6YLx+kxXu5GJceG4ozFg+33a2EMTdjYwWGloJ3sb9Kta5pp+ZNS53uxGVog5JetIY8s++P5UrtX+cri+u0VAVg==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "dependencies": {
+        "axe-core": "~4.13.0"
+      },
+      "peerDependencies": {
+        "playwright-core": ">= 1.0.0"
+      }
+    },
+    "node_modules/@otplib/core": {
+      "version": "12.0.1",
+      "resolved": "https://registry.npmjs.org/@otplib/core/-/core-12.0.1.tgz",
+      "integrity": "sha512-4sGntwbA/AC+SbPhbsziRiD+jNDdIzsZ3JUyfZwjtKyc/wufl1pnSIaG4Uqx8ymPagujub0o92kgBnB89cuAMA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@otplib/plugin-crypto": {
+      "version": "12.0.1",
+      "resolved": "https://registry.npmjs.org/@otplib/plugin-crypto/-/plugin-crypto-12.0.1.tgz",
+      "integrity": "sha512-qPuhN3QrT7ZZLcLCyKOSNhuijUi9G5guMRVrxq63r9YNOxxQjPm59gVxLM+7xGnHnM6cimY57tuKsjK7y9LM1g==",
+      "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@otplib/core": "^12.0.1"
+      }
+    },
+    "node_modules/@otplib/plugin-thirty-two": {
+      "version": "12.0.1",
+      "resolved": "https://registry.npmjs.org/@otplib/plugin-thirty-two/-/plugin-thirty-two-12.0.1.tgz",
+      "integrity": "sha512-MtT+uqRso909UkbrrYpJ6XFjj9D+x2Py7KjTO9JDPhL0bJUYVu5kFP4TFZW4NFAywrAtFRxOVY261u0qwb93gA==",
+      "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@otplib/core": "^12.0.1",
+        "thirty-two": "^1.0.2"
+      }
+    },
+    "node_modules/@otplib/preset-default": {
+      "version": "12.0.1",
+      "resolved": "https://registry.npmjs.org/@otplib/preset-default/-/preset-default-12.0.1.tgz",
+      "integrity": "sha512-xf1v9oOJRyXfluBhMdpOkr+bsE+Irt+0D5uHtvg6x1eosfmHCsCC6ej/m7FXiWqdo0+ZUI6xSKDhJwc8yfiOPQ==",
+      "deprecated": "Please upgrade to v13 of otplib. Refer to otplib docs for migration paths",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@otplib/core": "^12.0.1",
+        "@otplib/plugin-crypto": "^12.0.1",
+        "@otplib/plugin-thirty-two": "^12.0.1"
+      }
+    },
+    "node_modules/@otplib/preset-v11": {
+      "version": "12.0.1",
+      "resolved": "https://registry.npmjs.org/@otplib/preset-v11/-/preset-v11-12.0.1.tgz",
+      "integrity": "sha512-9hSetMI7ECqbFiKICrNa4w70deTUfArtwXykPUvSHWOdzOlfa9ajglu7mNCntlvxycTiOAXkQGwjQCzzDEMRMg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@otplib/core": "^12.0.1",
+        "@otplib/plugin-crypto": "^12.0.1",
+        "@otplib/plugin-thirty-two": "^12.0.1"
+      }
+    },
+    "node_modules/@playwright/test": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.52.0.tgz",
+      "integrity": "sha512-uh6W7sb55hl7D6vsAeA+V2p5JnlAqzhqFyF0VcJkKZXkgnFcVG9PziERRHQfPLfNGx1C292a4JqbWzhR8L4R1g==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright": "1.52.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/axe-core": {
+      "version": "4.13.0",
+      "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.13.0.tgz",
+      "integrity": "sha512-UzGt8zg7Ny8djbYMhxl2zuEevVa7r2gJjYY5Lwr1xM7+XU2nd6CkIWFTVcCIbAP63vSz71NaVyyuSk9lHKcy0A==",
+      "dev": true,
+      "license": "MPL-2.0",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/otplib": {
+      "version": "12.0.1",
+      "resolved": "https://registry.npmjs.org/otplib/-/otplib-12.0.1.tgz",
+      "integrity": "sha512-xDGvUOQjop7RDgxTQ+o4pOol0/3xSZzawTiPKRrHnQWAy0WjhNs/5HdIDJCrqC4MBynmjXgULc6YfioaxZeFgg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@otplib/core": "^12.0.1",
+        "@otplib/preset-default": "^12.0.1",
+        "@otplib/preset-v11": "^12.0.1"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.52.0.tgz",
+      "integrity": "sha512-JAwMNMBlxJ2oD1kce4KPtMkDeKGHQstdpFPcPH3maElAXon/QZeTvtsfXmTMRyO9TslfoYOXkSsvao2nE1ilTw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.52.0"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.63.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
+      "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "peer": true,
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=20"
+      }
+    },
+    "node_modules/playwright/node_modules/playwright-core": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.52.0.tgz",
+      "integrity": "sha512-l2osTgLXSMeuLZOML9qYODUQoPPnUsKsb5/P6LJ2e6uPKXUdPK5WYhN4z03G+YNbWmGDY4YENauNu4ZKczreHg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/thirty-two": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/thirty-two/-/thirty-two-1.0.2.tgz",
+      "integrity": "sha512-OEI0IWCe+Dw46019YLl6V10Us5bi574EvlJEOcAkB29IzQ/mYD1A6RyNHLjZPiHCmuodxvgF6U+vZO1L15lxVA==",
+      "dev": true,
+      "engines": {
+        "node": ">=0.2.6"
+      }
+    }
+  }
+}

+ 15 - 0
e2e/package.json

@@ -0,0 +1,15 @@
+{
+  "name": "oauth2-wasm-demo-e2e",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Playwright end-to-end tests for the OAuth2 WASM demo client (todos 37/38 own the specs; this skeleton owns the fixture contract only).",
+  "type": "commonjs",
+  "scripts": {
+    "test:e2e": "playwright test"
+  },
+  "devDependencies": {
+    "@axe-core/playwright": "4.13.0",
+    "@playwright/test": "1.52.0",
+    "otplib": "12.0.1"
+  }
+}

+ 38 - 0
e2e/playwright.config.js

@@ -0,0 +1,38 @@
+// Playwright config skeleton for the OAuth2 WASM demo E2E (todo 31).
+// The specs themselves land in todos 37/38; this file only pins the stack
+// contract: browser base URL plus the self-signed-cert bypass the AS needs.
+const { defineConfig } = require('@playwright/test');
+const { STORAGE_STATE_PATH } = require('./helpers');
+
+module.exports = defineConfig({
+  // Specs live directly in e2e/ (todos 37/38: happy.spec.ts, negative.spec.ts).
+  testDir: '.',
+  // Serial: the single e2e user owns one TOTP secret and the AS rejects a
+  // replayed code for 180s, so two concurrent logins burn each other's code
+  // and trip the 5-failure lockout. One worker keeps logins sequential.
+  workers: 1,
+  // A login retry waits for the next 30s TOTP step before resubmitting, so a
+  // test that hits a replayed code needs headroom past one step boundary.
+  timeout: 120_000,
+  use: {
+    baseURL: 'http://127.0.0.1:3000',
+    // The AS public listener is TLS-only with a throwaway self-signed dev
+    // certificate, so the browser must bypass certificate errors.
+    ignoreHTTPSErrors: true,
+  },
+  // One authenticated context per run: the setup project logs in a single
+  // time and the negative project reuses that state, so the AS sees three
+  // credential logins per run (setup, happy, logout-test) instead of one per
+  // session-needing test. Contexts loaded from the file never write back,
+  // so no test can invalidate the shared state for the others.
+  projects: [
+    { name: 'setup', testMatch: /auth\.setup\.ts/ },
+    { name: 'happy', testMatch: /happy\.spec\.ts/, dependencies: ['setup'] },
+    {
+      name: 'negative',
+      testMatch: /negative\.spec\.ts/,
+      dependencies: ['setup'],
+      use: { storageState: STORAGE_STATE_PATH },
+    },
+  ],
+});

+ 11 - 0
package.json

@@ -0,0 +1,11 @@
+{
+  "name": "d-oauth-client",
+  "version": "1.0.0",
+  "description": "A Rust workspace demo client for an OAuth 2.1 authorization server: a server-side BFF handles the confidential OAuth exchange while a Leptos CSR/WASM front end renders the signed-in identity, granted scopes, and token metadata.",
+  "main": "index.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "keywords": [],
+  "type": "commonjs"
+}

+ 4 - 0
rust-toolchain.toml

@@ -0,0 +1,4 @@
+[toolchain]
+channel = "stable"
+components = ["rustfmt", "clippy"]
+targets = ["wasm32-unknown-unknown"]

+ 2 - 0
rustfmt.toml

@@ -0,0 +1,2 @@
+tab_spaces = 2
+group_imports = "StdExternalCrate"