rodauth-ciba 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +34 -0
  3. data/LICENSE +21 -0
  4. data/README.md +106 -0
  5. data/docs/adr/0003-follow-rodauth-hook-conventions.md +9 -0
  6. data/docs/adr/0004-retain-atomic-issuance-and-stage-alignment.md +16 -0
  7. data/docs/alignment-closeout.md +136 -0
  8. data/docs/alignment-roadmap.md +160 -0
  9. data/docs/api.md +337 -0
  10. data/docs/authentication-claims.md +80 -0
  11. data/docs/authorization-details.md +50 -0
  12. data/docs/claims.md +46 -0
  13. data/docs/failure-contract.md +34 -0
  14. data/docs/id-token-encryption.md +288 -0
  15. data/docs/id-token-hint.md +174 -0
  16. data/docs/login-hint-token.md +35 -0
  17. data/docs/max-age.md +65 -0
  18. data/docs/node-alignment.md +336 -0
  19. data/docs/operations.md +130 -0
  20. data/docs/outbound-http.md +52 -0
  21. data/docs/pairwise.md +76 -0
  22. data/docs/ping.md +118 -0
  23. data/docs/protocol-coverage.md +114 -0
  24. data/docs/refresh-tokens.md +151 -0
  25. data/docs/release-readiness.md +75 -0
  26. data/docs/release-validation.md +498 -0
  27. data/docs/request-context.md +51 -0
  28. data/docs/requirements-audit-current.json +3221 -0
  29. data/docs/requirements-audit-current.md +424 -0
  30. data/docs/requirements-audit.json +3190 -0
  31. data/docs/requirements-audit.md +175 -0
  32. data/docs/research/access-token-revocation-reference.md +81 -0
  33. data/docs/research/alignment-partial-triage.md +50 -0
  34. data/docs/research/claims-reference-contract.md +33 -0
  35. data/docs/research/client-assertion-algorithm-contract.md +177 -0
  36. data/docs/research/completion-error-boundaries.md +56 -0
  37. data/docs/research/consent-api-evolution.md +59 -0
  38. data/docs/research/dpop-reference-contract.md +423 -0
  39. data/docs/research/id-token-hint-reference-contract.md +254 -0
  40. data/docs/research/mtls-reference-contract.md +449 -0
  41. data/docs/research/node-ciba-lifecycle.md +30 -0
  42. data/docs/research/node-oidc-provider-comparison.md +45 -0
  43. data/docs/research/pairwise-reference-contract.md +264 -0
  44. data/docs/research/ping-reference-contract.md +165 -0
  45. data/docs/research/rar-reference-contract.md +55 -0
  46. data/docs/research/refresh-reference-contract.md +85 -0
  47. data/docs/research/registration-reference-contract.md +567 -0
  48. data/docs/research/request-parameter-inventory.md +42 -0
  49. data/docs/research/required-parameter-inventory.md +39 -0
  50. data/docs/research/resource-reference-contract.md +40 -0
  51. data/docs/research/signed-request-reference-contract.md +113 -0
  52. data/docs/resources.md +55 -0
  53. data/docs/security-review.md +294 -0
  54. data/docs/signed-requests.md +162 -0
  55. data/docs/user-code.md +73 -0
  56. data/docs/validation/node-alignment-tests.txt +10 -0
  57. data/examples/client.rb +39 -0
  58. data/examples/demo_app.rb +121 -0
  59. data/examples/dpop_smoke.rb +169 -0
  60. data/examples/ecdh_id_token_smoke.rb +84 -0
  61. data/examples/edwards_id_token_smoke.rb +48 -0
  62. data/examples/edwards_smoke.rb +87 -0
  63. data/examples/edwards_tls_smoke.rb +73 -0
  64. data/examples/encrypted_id_token_smoke.rb +73 -0
  65. data/examples/encryption_tls_smoke.rb +123 -0
  66. data/examples/extensions_smoke.rb +226 -0
  67. data/examples/fixtures/edwards-requests.json +30 -0
  68. data/examples/hmac_id_token_smoke.rb +72 -0
  69. data/examples/migration.rb +18 -0
  70. data/examples/mtls_smoke.rb +395 -0
  71. data/examples/ping_smoke.rb +226 -0
  72. data/examples/registration_smoke.rb +73 -0
  73. data/examples/server.rb +14 -0
  74. data/examples/smoke.rb +46 -0
  75. data/examples/symmetric_id_token_smoke.rb +83 -0
  76. data/lib/rodauth/ciba/authorization_details.rb +60 -0
  77. data/lib/rodauth/ciba/dpop.rb +250 -0
  78. data/lib/rodauth/ciba/http.rb +69 -0
  79. data/lib/rodauth/ciba/id_token_encryption.rb +175 -0
  80. data/lib/rodauth/ciba/jwks_cache.rb +149 -0
  81. data/lib/rodauth/ciba/mtls.rb +174 -0
  82. data/lib/rodauth/ciba/pairwise.rb +105 -0
  83. data/lib/rodauth/ciba/registration.rb +489 -0
  84. data/lib/rodauth/ciba/schema.rb +182 -0
  85. data/lib/rodauth/ciba/version.rb +6 -0
  86. data/lib/rodauth/ciba.rb +4 -0
  87. data/lib/rodauth/features/ciba.rb +2185 -0
  88. metadata +231 -0
@@ -0,0 +1,423 @@
1
+ # CIBA DPoP integration: measured contract and open implementation
2
+
3
+ The complete node-oidc-provider 9.12.2 runs over loopback HTTP with a confidential
4
+ Basic client, `dpop_bound_access_tokens: true`, ES256 proof keys, poll delivery,
5
+ offline_access and refresh. The initial cases disable nonce challenges; later
6
+ providers exercise required and optional nonce policies. The
7
+ built-in memory adapter is used; no protocol or cryptographic code is stubbed.
8
+ [Harness](../../test/reference/full-op/dpop.mjs),
9
+ [initial static-client run](../validation/node-dpop-reference.txt).
10
+ The harness now creates its proof-required client through authenticated dynamic
11
+ registration and also exercises management replacement.
12
+ [Current registration run](../validation/node-dpop-registration.txt).
13
+
14
+ ## Measured reference behavior
15
+
16
+ - Initiation needs client authentication but no DPoP proof. Poll without proof
17
+ returns invalid_grant for this client. A valid proof before approval returns
18
+ authorization_pending.
19
+ - A random nonempty jti is accepted. Wrong method, wrong target URI, old iat and
20
+ empty jti produce invalid_dpop_proof without consuming the approved request.
21
+ - Successful issuance returns token_type DPoP and stores the proof's JWK
22
+ thumbprint in the access token's jkt.
23
+ - UserInfo rejects bearer presentation of the bound token, another proof key,
24
+ and incorrect ath. Matching key and access-token hash succeeds. Reusing the
25
+ same proof jti is rejected with HTTP401.
26
+ - This confidential client's refresh token has no jkt. Refresh still needs a
27
+ proof due to the client's policy, but a new key is accepted and binds the new
28
+ access token. Reusing exactly the same refresh proof is rejected.
29
+
30
+ The distinction between access-token binding and confidential refresh-token
31
+ authentication is intentional in the measured code (`set_rt_bindings.js`). Do
32
+ not impose a fixed refresh key based solely on the access token's jkt. Public
33
+ clients are not part of this CIBA fixture. Nonces, DCR, JWT resource access
34
+ tokens, revocation, key changes during issuance, all URI-normalization cases,
35
+ and concurrent proof replays are not established by this run.
36
+
37
+ ## Baseline Ruby integration gaps
38
+
39
+ Enabling upstream `oauth_dpop` alone is not CIBA DPoP support. A separate
40
+ characterization probe enables it in the actual gem Rack stack with the
41
+ upstream proof table and token thumbprint column:
42
+
43
+ ```sh
44
+ mise exec ruby -- ruby test/ciba_dpop_test.rb
45
+ ```
46
+
47
+ The probe confirms that a cryptographically valid random-jti proof is rejected
48
+ with invalid_dpop_proof / Invalid DPoP jti. The upstream claim validator requires
49
+ the SHA256 hex digest of method, full URL and iat instead of an arbitrary unique
50
+ identifier. With that custom identifier, a one-hour-old proof issues an access
51
+ token; the identical proof also issues a second independently approved request.
52
+ The upstream first-use table does not reject reuse within its configured window.
53
+ [Evidence](../validation/upstream-dpop-probe.txt): 1 characterization test,
54
+ 13 assertions. That historical characterization probe has now been replaced
55
+ by the normal regression tests above; it is no longer an executable expectation
56
+ of defective behavior.
57
+
58
+ These observations do not show bypass of client authentication or customer
59
+ approval: both were valid in each request. They show why proof freshness and
60
+ replay guarantees must not be inferred from the advertised upstream feature.
61
+ The initial production correction is described below; this historical log does
62
+ not describe the corrected token endpoint.
63
+
64
+ ## Initial CIBA token-endpoint correction
65
+
66
+ When upstream `oauth_dpop` is enabled, a prepended CIBA boundary now validates
67
+ proofs for the CIBA grant and this gem's refresh tokens. Other OAuth grant types
68
+ retain upstream behavior. It accepts nonempty random jti values, verifies the
69
+ embedded public key/signature and the configured asymmetric algorithm, rejects
70
+ critical extensions and private key material, and checks method, token URI and
71
+ iat within 300 seconds in either direction. Query/fragment are removed from the
72
+ proof URI before comparison. Limits are 8192 proof bytes and 1024 jti bytes.
73
+
74
+ Proof replay is claimed only when issuing a token, in the same DB transaction
75
+ as request consumption and token insertion. The existing explicit
76
+ `Schema.create_client_assertions` ledger is reused with a distinct `ciba_dpop`
77
+ digest namespace and client ID; no raw proof or jti is stored. Expiry extends
78
+ beyond the last accepted proof instant, so `cleanup_ciba_client_assertions`
79
+ cannot reopen replay within the validation window. The upstream token table
80
+ must have its configured `oauth_grants_dpop_jkt_column`; missing storage prevents
81
+ startup when the upstream feature is enabled. Ordinary upstream DPoP flows still
82
+ need their own upstream proof-table setup.
83
+
84
+ Pending polls do not reserve a proof. Simultaneous use across two approved
85
+ requests produces only one issuance; the losing request remains approved.
86
+ Signing failures roll back both consumption and replay insertion, permitting a
87
+ retry. This follows the accepted gem atomicity policy instead of node's
88
+ nontransactional failure consumption. A proof-required client without a proof
89
+ gets invalid_grant. Required proofs no longer implicitly enable nonce challenges;
90
+ `oauth_dpop_use_nonce` remains an explicit option.
91
+
92
+ Opaque and JWT confidential refresh accepts a new proof key and binds the new
93
+ access token to it. Reusing that proof fails without changing refresh state.
94
+ Seven local tests pass (90 assertions), including failure rollback, replay
95
+ concurrency, malformed/old proofs, URI handling and preservation of ordinary
96
+ grant validation. [Local log](../validation/ciba-dpop-current.txt).
97
+ The full three-DB suite passed 273 tests before the last malformed JOSE/signature
98
+ assertions; all seven final DPoP tests (90 assertions) then passed on each DB.
99
+ [Full run](../validation/ciba-dpop-matrix.txt), [final cases](../validation/ciba-dpop-final.txt).
100
+
101
+ This is **not full DPoP support**. External resource integration,
102
+ cleanup/retained-key combinations,
103
+ broader algorithm and URI normalization equivalence, and deployment tests still
104
+ need integration. The nonce extension below supersedes the unconditional
105
+ 300-second iat restriction. Do not advertise a completed DPoP feature on this basis.
106
+
107
+ ## CIBA UserInfo binding and replay
108
+
109
+ Upstream's UserInfo route calls `authorization_token` directly, bypassing
110
+ `require_oauth_authorization`. The extension therefore applies its proof checks
111
+ at that actual entry point for recognized CIBA credentials. Namespaced/legacy
112
+ opaque tokens and marked JWTs select this path; unverified JWT metadata is used
113
+ only to select stricter validation, never to authorize access. Signature,
114
+ expiry, client/subject provenance and a stored grant are still required.
115
+
116
+ A DPoP presentation must have a fresh, signed proof with the UserInfo method and
117
+ URI, matching ath, and a key thumbprint matching both the access-token claims
118
+ and the saved issuance row. Replay is claimed only after token and key checks.
119
+ Unknown tokens, wrong keys/hashes, forged JWTs, expired/revoked rows and bearer
120
+ presentation of bound tokens return HTTP401. Unbound CIBA Bearer Tokens continue
121
+ to work. Successful UserInfo proof claims commit before account-claim rendering;
122
+ a later application rendering failure requires a fresh proof on retry.
123
+
124
+ The upstream DPoP datasets append an OR for matching keys to the whole query.
125
+ That can admit revoked/expired rows, or rows not matching the presented token.
126
+ The CIBA UserInfo path rebuilds the predicates conjunctively: identity/token,
127
+ type, expiration, revocation and key binding must all match. Both the initial
128
+ lookup and UserInfo's later grant lookup retain those constraints. Other OAuth
129
+ flows are delegated unchanged; this is not a global upstream security repair.
130
+
131
+ The opaque/JWT tests verify concurrent proof reuse has one success and one401,
132
+ negative requests do not claim proofs, a known key cannot authorize an unknown
133
+ token, and expiration/revocation remain effective. Local DPoP tests pass
134
+ **9 tests / 135 assertions**. [Log](../validation/ciba-dpop-userinfo-current.txt).
135
+ Full Ruby4.0.6 suites pass **275 tests** on SQLite, PostgreSQL and MySQL
136
+ (SQLite skips two row-lock-specific tests).
137
+ [Three-DB log](../validation/ciba-dpop-userinfo-matrix.txt).
138
+ The original failure is retained in [this log](../validation/ciba-dpop-userinfo-red.txt).
139
+ The later POST/storage checks below extend this evidence. Pairwise combinations
140
+ and external resource-server enforcement remain to be exercised for this addition.
141
+
142
+ ## Dynamic registration of proof-required clients
143
+
144
+ With `oauth_dpop` enabled, CIBA registration accepts only JSON booleans for
145
+ `dpop_bound_access_tokens`. Strings such as "true"/"false", numeric values,
146
+ arrays and objects are rejected before insertion. Supplying the property requires
147
+ a Boolean client column at `oauth_applications_dpop_bound_access_tokens_column`
148
+ (default `dpop_bound_access_tokens`); an absent storage column causes rejection.
149
+ Omission means false. When that column exists, the default is persisted as false;
150
+ when it is absent and the property is omitted, the implicit false needs no storage.
151
+
152
+ The returned registration credentials can initiate CIBA normally, but the token
153
+ endpoint rejects collection without a proof if the registered flag is true.
154
+ Valid proofs issue bound tokens. Invalid management PUT preserves both policy
155
+ and management credentials. A full replacement that omits the flag resets it to
156
+ false, including the management response; false must not disappear because of
157
+ upstream's truthy-only serializer. New token issuance can then omit the proof,
158
+ but already-issued bound tokens still require it at UserInfo.
159
+
160
+ With the upstream DPoP feature disabled, this metadata is ignored and is not
161
+ stored or echoed. Both reference and gem fixtures check this explicitly, even
162
+ when a similarly named DB column exists in the gem application.
163
+
164
+ The node harness performs DCR, CIBA issuance, UserInfo, confidential refresh and
165
+ management with the issued client. Gem tests additionally check missing storage,
166
+ no insertion for invalid values, whole-row preservation on failed updates and
167
+ retention of the old token's binding. [Reference](../validation/node-dpop-registration.txt),
168
+ [local integration](../validation/dpop-registration-current.txt).
169
+ The registration/management/DPoP subset passes **44 tests** on each of SQLite,
170
+ PostgreSQL and MySQL, without failures/errors/skips.
171
+ [DB log](../validation/dpop-registration-matrix.txt). This is targeted evidence,
172
+ not a rerun of the complete Ruby/version/package matrix.
173
+
174
+ ## Server nonce policy and replay retention
175
+
176
+ The CIBA boundary now follows the reference nonce policy: a missing required
177
+ nonce produces `use_dpop_nonce` with `DPoP-Nonce` (HTTP400 at token, HTTP401 at
178
+ UserInfo). A valid nonce replaces proof iat freshness, but never replaces
179
+ signature, method, URI, key, token, client or approval checks. Non-string nonce
180
+ claims are invalid proofs; an incorrect or expired string nonce gets a fresh
181
+ challenge. One server nonce can be used across these endpoints with distinct
182
+ proofs. Reusing a proof still fails. With nonce support enabled but not required,
183
+ a fresh proof may omit it; an old proof gets a challenge.
184
+
185
+ Enable upstream `oauth_dpop` and configure `ciba_dpop_nonce_secret` with an
186
+ independent random **32-byte String**, persisted and shared by all processes of
187
+ the issuer. Keep different issuers' secrets separate. Set `oauth_dpop_use_nonce`
188
+ to true to require nonces; its default remains false. A missing secret with
189
+ required nonces, or a supplied secret of the wrong type/length, prevents startup.
190
+ Do not regenerate the secret per request or process start. Rotation invalidates
191
+ old challenges; clients obtain a fresh one and retry. No raw secret is stored in
192
+ the replay ledger.
193
+
194
+ The construction follows reference `helpers/challenge.js`: HKDF-SHA256 with
195
+ `DPoP` info, a big-endian 64-bit minute counter as salt, and a 32-byte base64url
196
+ result. The accepted counters are current minus two through current plus two;
197
+ the issued counter is current plus one. This is a moving five-step window, not
198
+ a fixed five-minute lifetime after receipt. It supersedes upstream's
199
+ `oauth_dpop_nonce_expires_in` setting for the CIBA boundary only.
200
+
201
+ Nonce-validated proof claims remain in the shared DB replay ledger until at
202
+ least validation time plus 301 seconds. Using an ancient proof iat for this
203
+ expiry would let cleanup reopen replay while the nonce remains valid. Opaque
204
+ and JWT integration tests exercise cleanup followed by replay, token/UserInfo
205
+ challenge and retry, expiry and secret rotation. Without a verified nonce, the
206
+ ledger still retains the proof beyond its accepted iat window.
207
+
208
+ [Real OP nonce run](../validation/node-dpop-nonce.txt),
209
+ [local gem run](../validation/dpop-nonce-current.txt): **13 tests / 218 assertions**.
210
+ Both implementations also reject zero iat even with a valid nonce; the added
211
+ regression originally failed for both opaque and JWT gem issuance.
212
+ [Failure before correction](../validation/dpop-nonce-zero-iat-red.txt).
213
+ The local cases include both opaque and JWT access tokens; the reference uses
214
+ opaque access tokens. Multi-process deployment, clock-skew boundaries and
215
+ installed-package validation are not established by these runs.
216
+
217
+ ## Installed-artifact integration
218
+
219
+ `bin/verify-package` now builds the gem, installs it into a temporary directory,
220
+ and runs the packaged `examples/dpop_smoke.rb` without repository load paths.
221
+ It explicitly checks that both the CIBA feature and DPoP module were loaded from
222
+ the installed gem. No test helpers or stubbed proof validators are used.
223
+
224
+ For opaque and JWT access tokens, the example dynamically registers a
225
+ proof-required confidential client, initiates and approves CIBA, receives a nonce
226
+ challenge and issues tokens with an old-iat proof plus valid nonce. It verifies
227
+ the ID Token signature/issuer/audience, JWT access-token signature and cnf when
228
+ applicable, and stored key binding. Cleanup followed by proof replay cannot
229
+ consume a second approval. UserInfo rejects bearer presentation, a wrong ath,
230
+ another proof key and replay, while the correctly bound proof returns the
231
+ customer subject. Refresh accepts a new proof key, returns a token bound to it,
232
+ and that token accesses UserInfo with the same customer subject. Reusing the
233
+ refresh proof fails.
234
+
235
+ [Package run](../validation/dpop-installed-package.txt). This uses Ruby4.0.6,
236
+ SQLite and Rack requests in-process, along with the existing independent HTTPS
237
+ ping/JWKS/sector-document fixtures. It does not prove DPoP reverse-proxy/header
238
+ handling, external resource-server enforcement or multi-process behavior.
239
+ Dependencies are reused locally; fresh network dependency resolution and
240
+ publication are not part of this verification.
241
+
242
+ ## POST UserInfo and token storage
243
+
244
+ The reference OP accepts POST UserInfo with a POST-bound proof, rejects a
245
+ GET-bound proof on that route and rejects replay of the accepted proof.
246
+ [Reference run](../validation/node-dpop-post.txt).
247
+ The gem exercises POST for both opaque and JWT tokens with the same checks as
248
+ GET: unbound bearer success, bound bearer rejection, key/ath/method validation,
249
+ subject matching, concurrent proof exclusion, unknown/forged credentials and
250
+ expired/revoked token rejection. These cases pass without a production change.
251
+
252
+ Correction to the earlier coverage assessment: upstream defaults
253
+ `oauth_grants_token_hash_column` to `:token`. The original gem fixtures already
254
+ used hashed opaque-token storage, despite the column name; they did not test
255
+ only plaintext storage. Tests now explicitly assert the stored hash and add
256
+ POST cases for a custom `:token_digest` column and for the opt-in plaintext
257
+ configuration (`oauth_grants_token_hash_column nil`). The custom-column case
258
+ also asserts that the ordinary `:token` column remains empty. No storage default
259
+ has changed. [Local run](../validation/dpop-post-storage-current.txt):
260
+ **17 tests / 321 assertions**.
261
+ Including DPoP registration cases, all **19 tests / 370 assertions** pass on
262
+ SQLite, PostgreSQL and MySQL with no failures/errors/skips.
263
+ [DB run](../validation/dpop-post-storage-matrix.txt).
264
+
265
+ These Rack tests use Authorization headers. They do not establish support for
266
+ body/query token presentation or every proxy/header transformation.
267
+
268
+ ## Opaque-token introspection binding
269
+
270
+ The real reference OP returns `active: true`, `token_type: DPoP` and `cnf.jkt`
271
+ for an active opaque bound token without a DPoP proof on introspection. The
272
+ caller authenticates as an introspection client; this is distinct from presenting
273
+ the access token to a resource server. A refreshed token reports the new proof
274
+ key. Unknown and revoked tokens return only `active: false`.
275
+ [Reference](../validation/node-dpop-introspection.txt).
276
+
277
+ The gem initially returned inactive for these valid tokens: upstream's grant
278
+ lookup filtered for a nil DPoP key when no proof was present. Also, upstream's
279
+ introspection serializer read cnf from JWT claims, not the opaque issuance row.
280
+ The CIBA-only correction looks up recognized opaque CIBA tokens by their exact
281
+ stored token/hash, grant type, expiry and revocation, and returns the saved jkt
282
+ with token type DPoP. It retains existing client authentication. It does not
283
+ change ordinary OAuth grant lookup or authorize resource access.
284
+
285
+ Regression tests cover both ordinary OIDC and resource-targeted CIBA opaque
286
+ tokens, cnf/type, resource audience/scope, wrong client credentials, unknown,
287
+ expired and revoked tokens, and unchanged handling of an unrelated bound OAuth
288
+ grant. Introspection does not claim a DPoP proof or touch the replay ledger.
289
+ [Original failure](../validation/dpop-introspection-red.txt),
290
+ [local tests](../validation/dpop-introspection-current.txt).
291
+ The full Ruby4.0.6 suite passes **287 tests** on each of SQLite, PostgreSQL and
292
+ MySQL; SQLite skips two row-lock-specific tests.
293
+ [Full DB matrix](../validation/dpop-introspection-matrix.txt).
294
+
295
+ Applications still own which authenticated introspection callers may receive
296
+ token information. Resource servers must themselves enforce cnf/proof matching,
297
+ signature, ath, HTTP method/URI, freshness, replay and their audience/scope policy.
298
+ This change supplies the OP-side binding information; it does not implement an
299
+ external resource server. Existing resource-JWT introspection restrictions remain.
300
+
301
+ The installed-package fixture initially retrieved the introspection URL from OAuth
302
+ authorization-server metadata and verified active/cnf/type for an opaque token.
303
+ [Package run](../validation/dpop-introspection-package.txt).
304
+ Upstream's OIDC metadata allowlist had removed that URL and DPoP algorithm
305
+ metadata. The correction below supersedes this earlier restriction.
306
+
307
+ ## Discovery extension metadata
308
+
309
+ OIDC Discovery now retains introspection endpoint/auth metadata and
310
+ `dpop_signing_alg_values_supported` from the actual OAuth authorization-server
311
+ metadata. It does not hardcode a URL or a new authentication method. Disabled
312
+ features remain absent. Tests cover a renamed introspection route, its caller
313
+ authentication and response, a configured ES256-only DPoP list, and disabled
314
+ features. [Failure before correction](../validation/discovery-extensions-red.txt),
315
+ [tests](../validation/discovery-extensions-current.txt).
316
+
317
+ The actual node 9.12.2 OP advertises the introspection endpoint and DPoP algorithms
318
+ when enabled, and omits these when disabled. It does **not** advertise an
319
+ introspection authentication-method list in this fixture. The gem retains the
320
+ upstream list (currently Basic) in both metadata documents; this is additional
321
+ metadata, not an assertion that both responses are byte-for-byte equivalent.
322
+ [Reference](../validation/node-discovery-extensions.txt).
323
+
324
+ The installed-gem DPoP smoke compares those values across both discovery
325
+ documents and now uses the OIDC-discovered introspection URL to verify an opaque
326
+ token's active/cnf/type response. All package smoke fixtures pass.
327
+ [Installed run](../validation/discovery-extensions-package.txt).
328
+
329
+ ## Implementation requirements
330
+
331
+ Reuse upstream JWT/JWK primitives and token thumbprint storage where suitable,
332
+ but provide a CIBA-scoped validation boundary with the measured nonempty jti,
333
+ proof freshness, method/URI and proof-key rules. Persist replay claims atomically
334
+ and test concurrent use across requests without changing unrelated OAuth flows.
335
+ Preserve the gem's accepted same-DB rollback behavior explicitly; do not silently
336
+ adopt node's nontransactional consumed-on-failure behavior.
337
+
338
+ Cover initial issuance, confidential refresh, proof-required client policy,
339
+ opaque/JWT token key binding and proof-aware UserInfo before advertising support.
340
+ Malformed keys, private key material, unsupported algorithms, stale/future
341
+ proofs, duplicate proofs and incorrect ath must fail without granting access.
342
+ Configuration/migrations, key-selection scope and nonce policy must be explicit.
343
+ The reference cases above are the initial executable comparison, not completion
344
+ of this workstream.
345
+
346
+ ## Alternate endpoint origin and proxy trust
347
+
348
+ A fixed canonical base_url previously made CIBA DPoP compare htu with token_url /
349
+ userinfo_url even when the application served the route at another origin.
350
+ A proof for the canonical endpoint was incorrectly accepted at the alternate
351
+ endpoint, while an actual alternate-origin proof was rejected. The
352
+ [pre-fix test](../validation/dpop-alias-red.txt) reproduces issuance with the wrong
353
+ origin in both opaque and JWT access-token modes.
354
+
355
+ The CIBA boundary now compares against the actual endpoint URL, excluding query
356
+ and fragment as before. It retains a fixed ID Token issuer independently of the
357
+ origin handling the HTTP request. Initial issuance, UserInfo and refresh reject
358
+ cross-origin/cross-path proofs; failed initial proofs do not consume approval or
359
+ create grants. Normal OP DPoP handling outside the CIBA boundary is unchanged.
360
+
361
+ The default `ciba_dpop_endpoint_uri` reconstructs the Rack URL without Forwarded
362
+ or X-Forwarded-* headers. Incoming authority/path and server-provided scheme are
363
+ used; accepting Host names and routing aliases remains the application's job.
364
+ A trusted ingress deployment can override the callback with a validated external
365
+ URL, for example a value placed in an internal Rack environment key by trusted
366
+ middleware. Do not populate that key directly from arbitrary client headers.
367
+
368
+ ```ruby
369
+ ciba_dpop_endpoint_uri do
370
+ request.env.fetch("myapp.trusted_external_endpoint")
371
+ end
372
+ ```
373
+
374
+ [Reference HTTP evidence](../validation/node-dpop-alias-trust.txt) uses two actual
375
+ listeners sharing one issuer and verifies that forwarded-header spoofing alone
376
+ does not authorize a canonical proof at the other origin. Gem tests cover
377
+ opaque/JWT tokens, wrong origin/path, initial/refresh/UserInfo, unchanged issuer,
378
+ spoofed forwarding headers and an explicit trusted-URL callback.
379
+ [Local regression](../validation/dpop-alias-trust-current.txt).
380
+ The installed smoke additionally exercises alternate-port URLs through Rack and
381
+ verifies ID Token signatures against published JWKS. That Rack fixture alone is not real-TLS evidence. The subsequent
382
+ [combined mTLS/DPoP integration](mtls-reference-contract.md#real-tls-aliases-with-certificate-authentication-and-dpop-tokens)
383
+ now verifies actual TLS aliases. Production reverse-proxy behavior remains separate.
384
+
385
+ The intermediate dpop-alias-installed.txt failed because the new smoke expected
386
+ invalid_token instead of invalid_dpop_proof; its verification code was corrected
387
+ to use published JWKS. It is not a passing artifact run.
388
+
389
+ Final validation: [Ruby3.4 three-DB subset](../validation/dpop-alias-trust-matrix.txt)
390
+ passes21 tests /390 assertions on SQLite, PostgreSQL and MySQL with no
391
+ failures/errors/skips. [Installed artifact](../validation/dpop-alias-trust-installed.txt)
392
+ passes the full smoke harness; alias cases use Rack transport. The earlier full
393
+ 391-test Ruby3.3 run predates this correction and is not a full regression of it.
394
+
395
+ ## ID Token versus access-token confirmation claims
396
+
397
+ The pinned reference emits no cnf in initial or refreshed ID Tokens for DPoP or
398
+ certificate-bound access tokens. Reference fixtures now verify ID Token signatures
399
+ and assert this absence, while retaining their access-token binding and UserInfo
400
+ checks ([DPoP](../validation/node-dpop-id-token-binding.txt),
401
+ [real TLS mTLS](../validation/node-mtls-id-token-binding.txt)).
402
+
403
+ The gem's CIBA ID Token builder called upstream jwt_claims, which includes the
404
+ access-token sender binding. The [pre-fix test](../validation/id-token-binding-red.txt)
405
+ reproduces inherited cnf in both DPoP and mTLS cases (the same run also contains
406
+ an unrelated local-listener permission error and is not a passing suite).
407
+ The builder now removes that inherited claim in the CIBA ID Token projection,
408
+ just as it already removes token provenance/resource/authorization_details fields.
409
+ Access-token generation, stored proof/certificate thumbprints, introspection cnf,
410
+ and resource proof enforcement retain their existing behavior. Ordinary OIDC
411
+ flows are not modified by this CIBA-scoped correction.
412
+
413
+ The installed smoke checks both initial and refreshed ID Tokens and verifies
414
+ access-token/introspection binding remains present; mTLS cases use actual TLS
415
+ with PKI and self-signed authentication, each in opaque/JWT access-token modes.
416
+ This is separation of automatically inherited sender constraints, not a ban on
417
+ application-defined custom ID Token claims.
418
+
419
+ Final evidence: [Ruby3.4 three-DB subset](../validation/id-token-binding-matrix.txt)
420
+ passes35 tests /729 assertions on each of SQLite/PostgreSQL/MySQL, with no
421
+ failures/errors/skips. [Installed artifact](../validation/id-token-binding-installed.txt)
422
+ passes its full smoke harness, including the new ID Token assertions. This is
423
+ focused regression evidence; the earlier full-suite logs predate this correction.