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,264 @@
1
+ # Pairwise CIBA reference contract
2
+
3
+ Reference: installed node-oidc-provider 9.12.2, compared with rodauth-oauth 1.7.0
4
+ and the current gem. This is an implementation workstream, not a support claim.
5
+
6
+ ## Measured behavior
7
+
8
+ `test/reference/full-op/registration-remote.mjs` now enables subjectTypes public
9
+ and pairwise, with an explicit application pairwiseIdentifier callback. The
10
+ callback uses HMAC over a JSON array of sector and account for the fixture; this
11
+ is application policy, not a node default or an algorithm mandated by CIBA.
12
+
13
+ The real registration and CIBA endpoints demonstrate:
14
+
15
+ - Pairwise plus Basic authentication is rejected with invalid_client_metadata.
16
+ - private_key_jwt with only inline JWKS and no jwks_uri is rejected.
17
+ - A CIBA/authorization-code hybrid with response_types code but without
18
+ sector_identifier_uri is rejected.
19
+ - Two CIBA-only private_key_jwt clients with the same jwks_uri are registered,
20
+ authenticated against a real local JWKS server, approved via separate saved
21
+ Grants, and obtain ID Tokens. OP signatures, issuer and client audience are
22
+ verified. Both subjects differ from the internal account ID and match each
23
+ other under the configured same-sector application policy.
24
+
25
+ [Execution log](../validation/node-registration-remote-reference.txt).
26
+ This does not yet test different sectors, hint inversion, hybrid completion or
27
+ sector document validation through HTTP.
28
+
29
+ ## Source boundaries that affect the gem
30
+
31
+ `lib/helpers/client_schema.js` requires jwks_uri for pairwise CIBA and an
32
+ explicit sector URI when response_types is nonempty. It permits private_key_jwt
33
+ or self_signed_tls_client_auth for such clients. Self-signed TLS is now implemented and verified separately in the
34
+ [mTLS contract](mtls-reference-contract.md#pairwise-self-signed-tls-client-authentication).
35
+
36
+ `lib/helpers/sector_identifier.js` selects the sector URI's URL.host when given.
37
+ For a CIBA-only client without that URI it selects jwks_uri's URL.host. URL.host
38
+ includes a nondefault port. Hybrid clients use the explicit sector URI because
39
+ registration requires it. `lib/helpers/sector_validate.js` validates a fetched
40
+ array: all redirect URIs (when applicable) and the client's jwks_uri must appear.
41
+ The application's sectorIdentifierUriValidate policy controls this fetch.
42
+
43
+ Node's default pairwiseIdentifier raises until configured. Its CIBA hint code
44
+ (`lib/actions/authorization/ciba.js`) verifies id_token_hint and then passes its
45
+ sub to findAccount. It does not invert a hash itself. The application must map
46
+ that pairwise subject to its canonical account in the authenticated client's
47
+ context; returning the pairwise string as a new account would change identity.
48
+ This source observation has not yet been covered by the fixture's hint flow.
49
+
50
+ Upstream Ruby `oidc.rb#jwt_subject` instead derives a missing sector from
51
+ redirect_uri and hashes host, account ID and its configured secret. A CIBA-only
52
+ client need not have redirect_uri, so removing the gem's public-only guard is
53
+ insufficient. An opt-in CIBA wrapper now supplies the sector/callback boundary; the public-only
54
+ default remains. The initial integration and remaining verification are described
55
+ in [pairwise.md](../pairwise.md).
56
+
57
+ ## Implementation requirements
58
+
59
+ 1. Opt-in pairwise capability with an explicit application identifier callback,
60
+ leaving the current public default intact. Subject generation must work for
61
+ ID Token, UserInfo, access-token claims and refresh without changing unrelated
62
+ OAuth clients' subject handling.
63
+ 2. Validate the pairwise client authentication/URI requirements on static use,
64
+ dynamic registration and management replacement. Derive the CIBA-only sector
65
+ from jwks_uri; handle explicit sectors and hybrid clients with the reference's
66
+ document-membership rules and bounded outbound transport.
67
+ 3. Pass the authenticated client and selected sector to identifier generation;
68
+ reject unusable callback results. Avoid pretending a hashing function supplies
69
+ the application's persistence, stable-key lifecycle or identity mapping.
70
+ 4. Keep internal account IDs in saved requests/consents. ID Token hint resolution
71
+ must recover that account through the existing application resolver, followed
72
+ by the ordinary account/client/consent checks. A hint must not grant approval.
73
+ 5. Verify same-sector stability, different-sector separation, hints, UserInfo,
74
+ refresh, invalid registration and management updates, plus ordinary public
75
+ clients. Compare real node and gem scenarios before advertising support.
76
+
77
+ The approved staged plan still includes this whole boundary. Registration-only
78
+ acceptance is not completion of pairwise alignment.
79
+
80
+
81
+ ## Refresh integration
82
+
83
+ The real node fixture now requests offline_access for pairwise clients and
84
+ verifies refreshed ID Token signatures, stable subject, matching UserInfo and
85
+ canonical accountId on the saved RefreshToken. offline_access does not become
86
+ a UserInfo attribute.
87
+
88
+ The gem's opaque/JWT integration additionally checks forced refresh rotation,
89
+ old-token replay revoking the successor, refusal to exchange through another
90
+ client, and transaction rollback for nil/empty/non-ASCII/overlong identifier
91
+ callback results. Refresh source and OAuth token counts remain unchanged after
92
+ failed issuance, and a subsequent valid callback can issue successfully.
93
+
94
+ This exposed upstream UserInfo passing offline_access to its generic attribute
95
+ callback and raising when no custom callback exists. CIBA's non-projected account
96
+ claim path now removes that capability scope before delegation, preserving other
97
+ scopes and unrelated OAuth behavior. The fix is shared by opaque/JWT tokens.
98
+ [Reproduction](../validation/pairwise-refresh-red.txt),
99
+ [DB validation](../validation/pairwise-refresh-matrix.txt).
100
+
101
+
102
+ ## Real HTTPS sector documents and installed artifact
103
+
104
+ The installed gem reuses the existing TLS fixture in `examples/ping_smoke.rb`.
105
+ A hybrid pairwise client is registered through the real upstream DCR route with
106
+ jwks_uri and a redirect URI in its sector document. Missing either URI, invalid
107
+ JSON/object shape, oversized body, 503 and 302 are rejected without creating a
108
+ client. The untrusted-certificate child process rejects registration before the
109
+ HTTPS application handler receives a request. No registration bearer is sent to
110
+ the document host. All existing installed-package smoke scenarios also pass.
111
+ [Artifact execution](../validation/pairwise-sector-package.txt).
112
+
113
+ The node TLS fixture in `test/reference/full-op/ping.mjs` now measures the same
114
+ membership/JSON/error cases. It **follows** same-origin 302 and accepts a valid
115
+ resulting document; the gem's bounded transport refuses redirects. This is a
116
+ newly measured remaining difference, not evidence of full transport parity.
117
+ A future redirect implementation must validate every destination and bound the
118
+ whole operation; changing the transport globally would also affect ping, whose
119
+ measured contract rejects redirects. Cross-origin redirects were not tested.
120
+ [Node execution](../validation/node-sector-tls-reference.txt).
121
+
122
+ The fixture generates and trusts an ephemeral CA in a child process only. No
123
+ production trust-store modifications or external service calls are involved.
124
+ Installed dependencies are reused; clean network dependency resolution is not
125
+ part of this artifact test.
126
+
127
+
128
+ ## Sector redirect implementation
129
+
130
+ The previously measured 302 difference is now repaired in the sector fetch path.
131
+ It follows 301/302/303/307/308 with relative URI resolution, at most 20 redirects,
132
+ and an outer 2.5-second deadline for the entire chain. Each hop calls the existing
133
+ bounded transport with the same address policy and a fresh unauthenticated GET.
134
+ Invalid schemes, credentials in URLs, blocked destinations, loops and timeouts
135
+ are refused. The shared HTTP primitive and ping/JWKS behavior are unchanged.
136
+
137
+ The installed TLS fixture now accepts a valid redirected document and rejects
138
+ loops, file URLs and a redirected address outside its test allowlist without
139
+ client persistence or bearer forwarding. Unit tests cover all five statuses,
140
+ cross-origin dispatch through the policy, exact loop bounds, invalid URI forms
141
+ and an actual elapsed-time timeout spanning repeated stubbed responses.
142
+ [Installed artifact](../validation/sector-redirect-package.txt),
143
+ [DB tests](../validation/sector-redirect-matrix.txt).
144
+ The node same-origin 302 observation remains the comparison evidence. Arbitrary
145
+ cross-origin live TLS deployments, IDN host normalization, sector-document cache
146
+ lifecycle and registration-management effects remain separate work.
147
+
148
+
149
+ ## Sector validation lifecycle
150
+
151
+ Source inspection located node sector validation in add_client, Client.validate,
152
+ and the first static-client lookup. Dynamic lookup hashes stored metadata and
153
+ reuses a Client in a 100-entry LRU. The real TLS fixture confirms an unchanged
154
+ loaded client survives a sector endpoint 503 without another fetch; changed
155
+ management metadata fails revalidation, preserving the old client/credential,
156
+ and succeeds when the endpoint recovers.
157
+
158
+ The gem now stores successful validation in a per-Rodauth-class 100-entry LRU
159
+ keyed by a SHA-256 digest of the complete DB client row. A cold lookup fetches;
160
+ warm unchanged metadata does not. Registration and management always validate
161
+ fresh. Failed attempts are never cached or used to invalidate an old success.
162
+ The bounded map uses a mutex only around cache operations, not HTTP. Concurrent
163
+ cold misses may duplicate fetches; no cross-process cache guarantee is made.
164
+ Using the full DB row means app-owned column changes also trigger revalidation,
165
+ unlike a cache keyed only by protocol metadata. Static clients share this bound,
166
+ whereas node maintains a separate static-client map. These are explicit storage
167
+ adaptations, not proof of identical eviction in every deployment.
168
+
169
+ [Node lifecycle evidence](../validation/node-sector-tls-reference.txt),
170
+ [gem DB tests](../validation/sector-cache-matrix.txt),
171
+ [installed TLS regression](../validation/sector-cache-package.txt).
172
+ Tests cover warm reuse, failed refresh, changed metadata, recovery, and eviction
173
+ past 100 entries. Pending/issued token behavior after sector changes is still a
174
+ separate unfinished comparison.
175
+
176
+
177
+ ## Management sector changes and issued subjects
178
+
179
+ The real node fixture now changes a registered pairwise client's jwks_uri to a
180
+ second owned loopback server with a different port (thus a different sector).
181
+ The existing opaque token still obtains UserInfo, whose sub uses the new sector.
182
+ Refresh produces a verified ID Token with that new sub. Old signed tokens are
183
+ not rewritten. This outcome is fixed in the reference assertions.
184
+
185
+ Gem opaque tokens already matched; JWT tokens failed because the original signed
186
+ sub was compared with a recomputation using new metadata. The explicit nullable
187
+ `Schema.add_pairwise_subject` migration now records the issuance subject on each
188
+ new CIBA token row while pairwise is enabled (including public subjects).
189
+ Claims use the saved issuance value; UserInfo authenticates the JWT against that
190
+ value and then emits the current client's pairwise sub. Invalid signed-subject
191
+ substitutions are still refused. The DB/internal account remains unchanged.
192
+ Configuration requires the column before enabling pairwise. Legacy null rows
193
+ use the earlier current-subject comparison and may fail after sector changes;
194
+ there is no automatic historical backfill.
195
+
196
+ The gem tests perform real authenticated management PUT after refresh rotation,
197
+ then test old-token UserInfo, new refresh subject, and replay revocation for both
198
+ opaque and JWT variants. [Initial JWT failure](../validation/pairwise-sector-update-red.txt),
199
+ [DB suite](../validation/pairwise-sector-update-matrix.txt),
200
+ [reference OP](../validation/node-registration-remote-reference.txt).
201
+ The node comparison uses opaque access tokens. Its JWT resource-token profile is
202
+ not a UserInfo token profile, so this is a gem adaptation preserving its existing
203
+ JWT UserInfo capability, not a claim of identical token formats.
204
+
205
+
206
+ ## Pending requests and installed issuance
207
+
208
+ Both real node and gem fixtures now start a request before changing the client's
209
+ sector through authenticated management PUT. The pending record keeps its
210
+ canonical account and no grant; the update does not supply approval. Subsequent
211
+ application approval and token collection produce a verified ID Token using the
212
+ new sector subject. The gem opaque/JWT variants pass on three databases.
213
+ [DB comparison](../validation/pairwise-pending-matrix.txt),
214
+ [node comparison](../validation/node-registration-remote-reference.txt).
215
+ These are sequential transitions, not proof of all concurrent interleavings.
216
+
217
+ The installed-gem TLS fixture now goes beyond registration: a hybrid pairwise
218
+ client authenticates using private_key_jwt against real HTTPS JWKS, begins a
219
+ CIBA request, receives application approval, obtains an issuer/audience/signature
220
+ verified ID Token, persists its issued subject and returns the matching UserInfo
221
+ subject. It explicitly migrates both subject storage and the assertion replay
222
+ ledger. Trusted and untrusted certificate cases remain separate child processes.
223
+ [Artifact evidence](../validation/pairwise-issuance-package.txt).
224
+ This installed pairwise flow uses opaque access tokens; JWT/refresh variants
225
+ are covered in the DB integration suite, not this specific installed example.
226
+
227
+ ## Management changes overlapping issuance
228
+
229
+ Two gem tests now cover management PUT during opaque/JWT issuance. PostgreSQL
230
+ and MySQL pause issuance after it loads the client and force management to commit
231
+ before token generation resumes. Both tokens and the stored issuance subject
232
+ retain the old sector, while subsequent UserInfo uses the new sector. Only one
233
+ OAuth grant is created and the request is consumed. SQLite uses simultaneous
234
+ requests without a cross-writer commit barrier, accepts either serial order,
235
+ and checks the same token/storage consistency; a temporary busy response may
236
+ be retried with a fresh client assertion.
237
+
238
+ All three databases pass: SQLite **2 tests / 21 assertions**, PostgreSQL and
239
+ MySQL each **2 tests / 19 assertions**. [Log](../validation/pairwise-concurrent-matrix.txt).
240
+ No runtime change was needed. This validates the gem's same-DB issuance boundary;
241
+ it is not a concurrent reference-OP comparison. Client deletion, authentication
242
+ key replacement, refresh and arbitrary application hooks during these pauses are
243
+ not covered by these two sector-update cases.
244
+
245
+ ## Signed requests do not replace pairwise client authentication
246
+
247
+ The pinned 9.12.2 client schema unconditionally restricts pairwise CIBA clients
248
+ to private_key_jwt or self_signed_tls_client_auth. Enabling
249
+ backchannel_authentication_request_signing_alg does not waive that condition.
250
+ A real HTTP DCR regression registers a public-subject Basic client with RS256
251
+ request metadata and remote jwks_uri successfully, then rejects the same
252
+ metadata with pairwise using invalid_client_metadata and the explicit
253
+ client-authentication-method error. The provider explicitly enables both subject
254
+ types so an unsupported-subject rejection cannot satisfy the test.
255
+
256
+ The gem regression accepts the same public configuration and rejects its
257
+ pairwise counterpart without saving a client. No runtime change was needed.
258
+ Earlier notes treated signed-request-only proof as unfinished parity; that
259
+ classification was incorrect. It is a shared restriction of the selected
260
+ reference policy. This registration test does not claim to exercise a signed
261
+ CIBA authentication request or all authentication methods.
262
+
263
+ [Node 24 / pinned provider execution](../validation/node-pairwise-signed-auth-policy.txt),
264
+ [gem: 1 test, 4 assertions](../validation/pairwise-signed-auth-policy.txt).
@@ -0,0 +1,165 @@
1
+ # Ping delivery boundary
2
+
3
+ 2026-09-30. Reference: released node-oidc-provider 9.12.2, Node24.21.0,
4
+ complete HTTP OP plus a real HTTPS notification receiver. The gem now supports
5
+ optional [ping](../ping.md) as well as poll; the following evidence defined the
6
+ implementation boundary and still records remaining review criteria.
7
+
8
+ ## Measured behavior
9
+
10
+ [Test](../../test/reference/full-op/ping.mjs),
11
+ [full reference log](../validation/node-full-op-lts.txt).
12
+
13
+ | Scenario | Reference outcome |
14
+ |---|---|
15
+ | Missing notification token, spaces in token, >1024 characters | invalid_request |
16
+ | Accepted request before completion | No notification; token request returns authorization_pending |
17
+ | Approval | Save result, then POST notification; token retrieval remains a separate authenticated request |
18
+ | Denial | Same notification shape, followed by access_denied at token endpoint |
19
+ | Notification contents | Bearer client_notification_token; application/json with only auth_req_id |
20
+ | Receiver returns 200 or 204 | Delivery succeeds |
21
+ | Receiver returns 503 | Completion call raises; saved grant binding remains |
22
+ | Receiver returns 302 | Completion call raises; redirect is not followed |
23
+ | Delivery retry | Client.backchannelPing(savedRequest) sends explicitly without recompleting approval |
24
+ | Token retrieval after failed delivery and explicit retry | Succeeds from the saved result |
25
+ | Loopback notification destination under default transport | Rejected by special-use-IP protection before receiver obtains the HTTP request |
26
+
27
+ `Provider.backchannelResult` saves before calling `Client.backchannelPing`.
28
+ The sender accepts 200/204, uses manual redirects and has no built-in durable
29
+ retry queue. Source sets a 2500ms fetch timeout; delayed-network timeout behavior
30
+ was not measured here. The default transport checks the connected socket's IP
31
+ against special-use ranges, rather than relying only on registration syntax.
32
+
33
+ The harness first proves loopback rejection. It then allows exactly its owned
34
+ HTTPS endpoint through a test-only fetch override, retaining certificate
35
+ verification, timeout and redirect policy. A temporary self-signed test CA is
36
+ trusted only by the test process. The override is not production configuration
37
+ or evidence of delivery to a publicly routed endpoint.
38
+
39
+ ## Protocol requirements
40
+
41
+ [CIBA Core sections 4, 7.1, 9 and 10.2](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html)
42
+ define registered ping mode and an HTTPS client notification endpoint, the
43
+ request's client_notification_token, and notification using that bearer token
44
+ with the auth_req_id. The client subsequently authenticates at the token
45
+ endpoint. Notification does not deliver access or ID tokens.
46
+
47
+ ## Gem implementation boundary
48
+
49
+ 1. Opt-in ping alongside default poll; explicit client-metadata/storage migration
50
+ and accurate Discovery. Preserve existing poll clients and pending requests.
51
+ 2. Validate the registered endpoint and bearer-token syntax/size before request
52
+ persistence. Registered destinations need network-level protection, not just
53
+ an HTTPS prefix check. Do not follow redirects or use request-supplied URLs.
54
+ 3. Persist sufficient private delivery data. Current requests store only a hash
55
+ of auth_req_id, which cannot produce the notification body. Both the original
56
+ ID and notification credential must survive completion by another worker.
57
+ Exclude these from all public request APIs, hook snapshots and observations;
58
+ define cleanup/retention and any storage-protection requirements explicitly.
59
+ 4. Save approval/denial transactionally, send only after the outermost commit,
60
+ and leave that result intact on delivery failure. Retain the agreed Rodauth
61
+ rollback behavior for before/after state-change hooks. Network delivery must
62
+ not occur while holding the request's DB transaction/row lock.
63
+ 5. Provide explicit notification retry, separate from completion; do not add an
64
+ automatic queue or claim exactly-once delivery. A delivery failure does not
65
+ invalidate a saved approval. Handle response loss, duplicate notification,
66
+ expired/consumed requests and concurrent retry explicitly in tests/docs.
67
+ 6. Keep token issuance/replay revocation and customer/client binding unchanged.
68
+ Test full acceptance, approval, denial, delivery and token collection using
69
+ an independent TLS receiver and the installed gem, including failure cases.
70
+
71
+ The initial comparison found the gem's registered remote-JWKS helper lacked
72
+ node's special-use-IP guard. The [dedicated CIBA transport](../outbound-http.md)
73
+ now checks DNS answers and pins a validated address; its limits and different
74
+ policy are documented. Unrelated OP HTTP routes remain unchanged. This closes
75
+ part of the transport gap, not ping delivery itself.
76
+
77
+ The research increment itself did not implement ping. The subsequent optional
78
+ implementation adds private delivery storage, after-commit notification and
79
+ explicit retry. Production egress policy and concurrent delivery remain review
80
+ work; the feature documentation records credential retention and failure behavior.
81
+
82
+ ## Ignored response body parity
83
+
84
+ The shared HTTP transport previously read up to65,536 bytes even for ping,
85
+ turning a valid200 acknowledgement with a larger ignored body into a delivery
86
+ failure. Node's notification method checks status without consuming the body.
87
+ The gem now requests headers-only handling for ping and closes its Net::HTTP
88
+ connection through the normal start/ensure cleanup. It does not follow redirects
89
+ or disable certificate/address validation. Key and sector fetches still read and
90
+ bound their bodies.
91
+
92
+ The real TCP test sends200 headers declaring10MB and never sends a body, then
93
+ observes peer closure; this proves the Ruby path does not implicitly drain.
94
+ Both installed gem and pinned Node TLS fixtures now send140,000 bytes on200 and
95
+ pass. [Local12 tests /113 assertions](../validation/ping-response-body-current.txt),
96
+ [installed artifact](../validation/ping-response-body-installed.txt),
97
+ [reference TLS](../validation/node-ping-response-body.txt).
98
+ The artifact also reruns oversized sector/JWKS rejection and untrusted TLS
99
+ scenarios, so the ping exception does not remove their protections.
100
+
101
+ ## Initial management-update comparison (superseded below)
102
+
103
+ The gem snapshots the endpoint at request acceptance and checks current client
104
+ metadata before each dispatch. A deterministic barrier test now performs actual
105
+ registration-management PUT requests while completion's sender is paused. Both
106
+ endpoint replacement and switching to poll preserve the already approved result;
107
+ a subsequent retry raises Ineligible without sending, token collection succeeds
108
+ once, and replay is rejected. The in-flight sender retains only the original
109
+ endpoint and notification credential and runs outside a database transaction.
110
+ This is not atomic cancellation of a network operation.
111
+
112
+ Source comparison: provider9.12.2's `Client.backchannelPing` in
113
+ `lib/models/client.js` takes its destination from the Client instance on which it
114
+ is invoked, and the credential from the saved authentication request. It has no
115
+ comparison with an acceptance-time endpoint in that method. Consequently the
116
+ gem's snapshot check is a deliberate stricter credential-routing policy, not a
117
+ claim of identical management-update semantics. The real HTTP management race is now measured below.
118
+
119
+ [Gem barrier test](../../test/ciba_ping_test.rb),
120
+ [local execution](../validation/ping-management-current.txt).
121
+
122
+ [Three-database execution](../validation/ping-management-matrix.txt): three tests,
123
+ 42 assertions per database, no failures/errors/skips.
124
+
125
+ ### Reference HTTP management race
126
+
127
+ [Execution](../validation/node-ping-management.txt) uses provider9.12.2 with
128
+ actual authenticated registration and management PUT, and the existing verified
129
+ TLS receiver. A promise barrier pauses completion at the configured fetch
130
+ transport before the network request. The management update completes before
131
+ the barrier is released; no timing sleeps determine ordering.
132
+
133
+ | Update while completion is dispatching | Reference result | Gem result |
134
+ |---|---|---|
135
+ | Replace notification endpoint | In-flight send uses original endpoint; retry on a freshly loaded Client sends the saved bearer credential and request ID to the replacement endpoint | In-flight send uses original endpoint; subsequent retry raises Ineligible |
136
+ | Switch ping to poll | In-flight send uses original endpoint; retry on fresh Client raises TypeError without sending | In-flight send uses original endpoint; subsequent retry raises Ineligible without sending |
137
+ | Collect completed result after either update | First token request succeeds; repeated request returns invalid_grant | Same |
138
+
139
+ This establishes a concrete remaining policy difference, not failed CIBA token
140
+ issuance or unknown behavior. Matching the reference on endpoint replacement
141
+ would require changing the gem's acceptance-time destination binding and its
142
+ documented credential-routing guarantee. It has not been silently relaxed.
143
+ The current test does not establish cancellation, distributed-client cache
144
+ coherence, or every possible ordering of management and dispatch.
145
+
146
+ ### Alignment correction: current registered destination
147
+
148
+ The earlier snapshot restriction was an extra gem policy, not a requirement
149
+ established by the reference. To follow the requested reference design,
150
+ `retry_ciba_ping` now reads the current eligible client's registered endpoint
151
+ when constructing each outgoing request. The saved endpoint column is retained
152
+ for schema compatibility but no longer controls routing. This replaces the
153
+ gem outcomes in the endpoint-replacement row above: subsequent retries now
154
+ reach the replacement endpoint with the saved credential, as the reference does.
155
+
156
+ Mode changes to poll still reject retries. Existing sends retain their selected
157
+ endpoint; result persistence and single token collection are unchanged. HTTPS
158
+ metadata validation, DNS/IP filtering, verified TLS and redirect rejection remain
159
+ active. Registration-management authority includes authority to receive pending
160
+ notification credentials at a replacement endpoint, matching the reference.
161
+
162
+ The updated tests first reproduced the old Ineligible failures
163
+ ([red](../validation/ping-current-endpoint-red.txt)).
164
+ All ping tests then passed on three databases
165
+ ([execution](../validation/ping-current-endpoint-matrix.txt)).
@@ -0,0 +1,55 @@
1
+ # RAR in the CIBA reference OP
2
+
3
+ Implementation update: the gem now has [optional RAR integration](../authorization-details.md), including registered/client type checks, saved consent, mandatory validation/issuance policy, output validation, JWT/token-response/opaque-introspection representation and ID Token exclusion. Tests now also race different narrowing requests and issuance/revocation on distinct DB connections. The earlier foundation and design notes below describe the implementation sequence; completion concurrency and additional policy/load-order review remain.
4
+
5
+ 2026-09-30. Tested the installed, released node-oidc-provider 9.12.2 with a complete HTTP OP on Node24.21.0. RAR is enabled explicitly with a test-only `support_data` type, registered client permission for that type, and explicit application policy callbacks. The resource server uses opaque tokens for the introspection case. This is not a gem feature announcement.
6
+
7
+ ## Observed behavior
8
+
9
+ | Scenario | Reference result |
10
+ |---|---|
11
+ | A client without the requested authorization-details type | `invalid_authorization_details` |
12
+ | A valid RAR request without a resource or configured default | `invalid_target` |
13
+ | Initial request with read/delete actions | Original JSON is stored in `BackchannelAuthenticationRequest.params`; source `rar` is absent |
14
+ | A saved Grant contains read-only `rar`, but completion omits `rar` | Token response has no authorization_details; neither the grant-source nor access-token policy callback ran in this case |
15
+ | Completion explicitly supplies read-only `rar` | Source carries that permission; access-token policy runs |
16
+ | Token request tries to add delete | The test application's policy returns only completed-source details, so the token response and opaque introspection contain only read |
17
+ | ID Token in the successful RAR flow | No authorization_details claim |
18
+ | Non-array JSON, unknown type, actions as a string | `invalid_authorization_details` |
19
+
20
+ The token-time narrowing result depends on the configured application callback. It is not evidence that the library automatically compares arbitrary JSON, intersects `Grant.rar`, or enforces all type-specific constraints. The fixture deliberately implements a simple policy that ignores token-time broadening and returns the previously completed source permission.
21
+
22
+ Evidence: [executable complete-OP harness](../../test/reference/full-op/lifecycle.mjs), [execution log](../validation/node-full-op-lts.txt). Earlier lifecycle, claims and resource cases still pass with RAR configured.
23
+
24
+ ## Where the boundaries live
25
+
26
+ - `lib/shared/check_rar.js` parses and normalizes the request, checks registered types and client permission, then invokes the type's validator.
27
+ - `lib/shared/check_resource.js` requires a supplied/defaulted resource for these RAR requests. This is observed provider policy; do not label it a universal CIBA requirement.
28
+ - `lib/actions/authorization/ciba.js` retains request parameters and creates the asynchronous source. It does not run the authorization-code flow's grant-source RAR policy.
29
+ - `lib/provider.js#backchannelResult` accepts a separate `rar` completion option and stores it on the request. `Grant.addRar` alone does not populate it.
30
+ - `lib/helpers/grant_authorization.js#applyAuthorizationDetails` calls the configured access-token policy when token parameters or the completed source contain details.
31
+ - Introspection has its own policy callback. The fixture permits introspection only to the token's issuing client.
32
+
33
+ [RFC 9396](https://www.rfc-editor.org/rfc/rfc9396.html) defines authorization_details as an array of typed objects, describes token-request comparison and response/introspection representations, and leaves type semantics to the deployment. It does not provide a universal algorithm for narrowing arbitrary application data. Gem design must distinguish these protocol fields from the reference's CIBA completion API and application policy.
34
+
35
+ ## Implementation progress: storage and structural validation only
36
+
37
+ The gem now contains `Schema.add_authorization_details`, an explicit additive helper for nullable request `requested_authorization_details`, saved-grant `authorization_details`, issued-token `ciba_authorization_details` and client `authorization_details_types` fields. The table names and request column can be overridden. It does not backfill consent, run automatically or enable RAR endpoints.
38
+
39
+ `Rodauth::CibaSupport::AuthorizationDetails.parse` is an internal structural validator (explicit require `rodauth/ciba/authorization_details`). It accepts bounded UTF-8 JSON arrays of typed objects and validates the common array/identifier fields. Limits are 8,192 bytes, 32 entries and JSON nesting depth 10. Returned arrays, hashes and strings are frozen recursively. Unknown type-specific fields are preserved; no authorization or narrowing semantics are inferred.
40
+
41
+ Duplicate keys are rejected at all depths using JSON's native `allow_duplicate_key: false`. This is a deliberate stricter input boundary than the reference's ordinary JSON.parse. A custom object-class experiment failed to detect duplicates consistently across JSON versions and was removed. JSON >=2.18,<3 is now an explicit runtime dependency; the lockfile selects 2.18.0. Invalid UTF-8 is checked on the byte sequence, including binary-encoded Ruby strings.
42
+
43
+ Tests in `test/ciba_authorization_details_test.rb` cover duplicate keys, malformed common fields, byte/count/depth limits, encoding, deep immutability and preservation of existing DB rows. This foundation alone does **not** implement request acceptance or authorization. The subsequent optional feature adds those integration paths with mandatory application policies; do not activate it based only on a successful migration.
44
+
45
+ ## Integration requirements and original proposal
46
+
47
+ Use an opt-in capability and explicit additive migration. Preserve three distinct JSON values: original requested details on the CIBA request, approved details on the saved consent, and effective details on the issued OAuth token. Existing rows represent no additional permission. Do not infer approved details from the original request or mutate already-issued permissions when a saved grant changes.
48
+
49
+ Keep the existing saved-grant result API. At completion, bind the approved details to that request with application validation; at issuance, compile effective details from the stored request, saved consent, selected resource and token-time narrowing request. Any configurable policy must operate within that verified account/client/consent context. Fail closed if RAR is enabled without required validation/compilation policy. Do not implement a generic deep-merge or claim JSON equality means authorization.
50
+
51
+ Required validation includes bounded JSON, array/object/type shape, common field shapes, registered types and per-client allowed types. Type-specific account/operation/amount/target constraints remain application policy. Validate compiled output as well as input. Policy failures during completion or issuance retain the gem's accepted atomic rollback contract.
52
+
53
+ RAR output belongs in the access token/token response and opaque introspection, not the ID Token. Preserve the resource audience boundary and explicit-claims filtering. Add Discovery and static-client metadata only with implemented enforcement. Reject poll attempts to expand permission; a policy's narrower result must be testable with a concrete type.
54
+
55
+ Before claiming support: implement the schema/API and client metadata; test missing policy, disabled/mixed-version states, unrequested/unapproved details, request/result snapshot tampering, type/resource/client isolation, hook-time changes, issuance/revocation races, JWT representation and opaque introspection. Add installed-gem smoke coverage. Generic RAR support and the customer's particular support/deletion approval semantics are separate deliverables.
@@ -0,0 +1,85 @@
1
+ # CIBA refresh token: reference contract
2
+
3
+ 2026-09-30。参照はインストール済み `oidc-provider 9.12.2`。これは仕様全体の適合試験ではなく、既定設定の実測と上流コードの比較である。gem の [refresh 基本実装](../refresh-tokens.md)は開発途中で、公開可能な対応完了ではない。
4
+
5
+ ## HTTP で確認したこと
6
+
7
+ `test/reference/full-op/lifecycle.mjs` を Node 24.21.0、固定 Docker image、network none で実行した。[実行ログ](../validation/node-refresh-reference.txt)。既存 lifecycle ケースも成功。今回 ping.mjs は再実行していない。
8
+
9
+ - client が `refresh_token` grant を登録し、CIBA 要求が `offline_access` を含む場合のみ、既定 policy は refresh token を発行する。片方だけの場合は発行しない。
10
+ - 更新で元の scope にない `read` を要求すると `invalid_scope`。
11
+ - `scope=openid` に縮小した更新の次に scope を省略すると、元の `openid offline_access` に戻る。更新時の縮小はそのアクセストークンだけに適用される。
12
+ - 発行直後の confidential client の refresh token は、既定 rotation policy では同じ値で再利用できる。
13
+ - refresh token の保存モデルには元の authTime/acr/amr が残り、更新後 ID Token は元の nonce を持つ。この試験の claim 設定では auth_time/acr/amr は出力されない。保存と claim 出力の条件を混同しない。
14
+ - 保存 Grant を削除すると更新は `invalid_grant` になる。
15
+ - 保存モデルの iat/exp を変更し TTL の80%が経過した fixture では、既定 policy が rotation し、旧 token に consumed、後続 token に同じ grantId と rotations=1 を保存する。実時間で寿命の80%を待った試験ではない。
16
+ - 別の refresh 対応 client が旧 token を使用しても `invalid_grant` で拒否するだけで Grant は残る。正当な client が使用済み token を再利用すると Grant が消え、後続 refresh token は使用不能、後続 access token の introspection は active=false になる。
17
+ - exp を過去にした保存 fixture は `invalid_grant`。その Grant は保持される。memory adapter の期限管理も含むため、expired token が必ず handler の期限分岐まで到達した証拠ではない。
18
+ - resource A/B を要求して A の read token を最初に発行しても、refresh は B の write token を取得できる。resource 省略では OIDC scope に戻り、未要求の宛先は `invalid_target`。
19
+ - 保存 Grant の B/write を後から拒否すると B の refresh は成功するが scope は空になる。初回 token の scope を refresh の元許可として使ってはいけない。
20
+ - revocation の既定 policy は別 confidential client の live token 取消しを400 `invalid_request` で拒否する。正当な client の取消しは空bodyの200で、保存 Grant ごと無効化する。hint の省略・`refresh_token`・`access_token`・未知値で同じ結果。取消し後は別 client の再送も200になる。
21
+ - claims/RAR を伴う rotation は source の claims/rar/authTime を後続に保持する。保存 Grant で後から拒否した email は次の ID Token に含まれない。参照アプリの RAR policy は完了時に明示した source.rar を返し、更新パラメータで read/delete を再要求しても read のまま。RAR policy は固定したテストアプリのものであり、一般的な権限縮小アルゴリズムではない。
22
+
23
+ ## コードから確認した境界
24
+
25
+ 参照側 `lib/helpers/defaults.js` の issueRefreshToken/rotateRefreshToken、`lib/helpers/grant_common.js`、`lib/actions/grants/refresh_token.js` を確認した。
26
+
27
+ 参照側は RefreshToken を AccessToken とは別のモデルとして保存し、元の scope、resource、claims、rar、nonce、認証情報、grantId を保持する。更新では保存 Grant を検証し、resource/scope/承認から新しいアクセストークンを構成する。rotation 後の使用済み token の再利用は関連 Grant の取消しへ進む。上記 HTTP ケースで rotation/replay/resource を確認したが、claims/RAR の refresh 併用は未検証。
28
+
29
+ rodauth-oauth 1.7.0 の `oauth_base.rb#create_token` は既定で毎回 rotation し、`create_token_from_token` は既存 oauth_grants 行の token と期限を更新する。元の許可を保持する独立した RefreshToken モデルではない。比較開始時の gem は refresh grant を super に渡していた。現在の開発実装は CIBA 専用 namespace の token を別処理し、それ以外を super に渡す。
30
+
31
+ したがって `generate_token(..., true)` への変更だけでは整合しない。CIBA 元要求の許可と、今回の access token に限定された scope/resource/claims/RAR を分け、保存承認の期限・取消し・account/client を更新でも検証する必要がある。通常の OAuth refresh の既定設定を OP 全体で変更しない。
32
+
33
+ ## 次の実装前に残る検証
34
+
35
+ - refresh での claims/RAR、rotation 後の認証情報・元許可の保持。
36
+ - gem の更新処理、同時更新・取消し、発行失敗 rollback、機能無効化。保存 migration のみ下記の段階まで実装。
37
+
38
+ 上記のうち更新・rotation/replay・署名失敗 rollback・無効化・同時 rotation は基本実装へ接続した。refresh token を提示する取消し endpoint、期限切れ cleanup、更新 hooks/events も接続した。claims/RAR の opaque/JWT 更新は元の要求・現在の承認・アプリ policy の分離を試験した。access token を提示する grant-wide 取消し、Ruby matrix・配布 gem での検証は残る。
39
+
40
+ ## 保存基盤の実装段階
41
+
42
+ `Schema.create_refresh_tokens(db, ...)` を追加した。明示的な追加 migration であり、呼び出しても refresh の受付・発行は有効にならない。まだ導入用の機能として公開する段階ではない。
43
+
44
+ - `token_digest` の一意制約と `consumed_at` によって、rotation 後も旧 token の使用済み記録を保持できる。生の refresh token 用の列は持たない。
45
+ - 元の account/client、保存承認への外部キー、scope、要求した claims/resources/RAR、認証時刻・acr/amr・nonce を、access token の絞り込まれた属性と別に保存する。
46
+ - 初回作成時刻・今回発行時刻・期限・rotation 回数を持ち、node の経過時間による rotation を実装するための情報を保持する。lock_version は既存の CIBA と同様に DB の競合制御に使用する予定。
47
+ - 要求テーブルへの外部キーを持たず、短寿命の要求を cleanup しても refresh source は残る。保存承認・account/client の削除時は cascade する。承認の論理取消しは更新処理で検証する必要があり、この migration だけでは強制されない。
48
+
49
+ 保存テストは既存要求・発行 token・承認の保持、digest 重複拒否、使用済みと後続 token の共存、要求削除後の保持、承認削除時の cascade、transaction rollback、テーブル名変更を対象とする。この段階の後、基本プロトコル処理と replay 取消しを実装した。
50
+
51
+ Ruby 4.0.6 の SQLite/PostgreSQL/MySQL で各 **2 tests / 17 assertions** 成功。[限定 matrix のログ](../validation/refresh-storage-matrix.txt)。これは追加 storage テストのみの実行であり、既存全 suite の再実行や Ruby 3.3/3.4 の確認ではない。
52
+
53
+ 基本 runtime 接続後、同じ3 DBで全 suite 各 **190 tests** 成功(SQLite 1709 assertions、PostgreSQL/MySQL 1706)。[runtime matrix](../validation/refresh-runtime-matrix.txt)。同時 rotation、通常 OAuth refresh の維持、client 登録削除、認証情報・scope 保持、resource の宛先変更を含む。auth_time は既存 completion API と同じく省略可能なので nullable に修正した。Ruby 3.3/3.4・配布成果物での refresh 検証はまだ行っていない。
54
+
55
+ refresh の revocation 接続後は3 DBで各192 tests成功(1756〜1759 assertions)。その後の取消し済み token の扱いの調整は、3 DB各2 tests/54 assertionsで確認した。[全体](../validation/refresh-revocation-matrix.txt)、[最終差分](../validation/refresh-revocation-final.txt)。上流の browser session 分岐をこの名前空間の取消しだけで使わず、client 認証に固定した。通常 OAuth token の取消し経路は変更していない。
56
+
57
+ claims/RAR の opaque/JWT rotation と cleanup 追加後は3 DB各 **195 tests** 成功(1831〜1834 assertions)。[ログ](../validation/refresh-permissions-matrix.txt)。未承認操作の追加拒否、rotation rollback、現在の claim 拒否、後続 source の保持、RAR 無効化、許可縮小、期限境界・外側 rollback を含む。汎用 RAR 意味論や全 callback の組合せを網羅するものではない。
58
+
59
+ 更新フック追加後は3 DBと Ruby3.3/3.4 SQLite の5環境で各 **200 tests** 成功(1894〜1897 assertions)。[DB](../validation/refresh-hooks-matrix.txt)、[Ruby](../validation/refresh-ruby-matrix.txt)。フック後の再検証、rollback、同一 source の再入拒否、outer commit 後の観測と失敗の分離を確認。残件のうち Ruby matrix は解消したが、配布 gem での refresh と access-token 取消しの整合化は未完了。
60
+
61
+ その後、配布 gem の opaque/JWT refresh smoke を追加して成功した。[ログ](../validation/refresh-package.txt)。CIBA の signed request・claims/resource/RAR と組み合わせ、rotation・認証情報保持・取消し・cleanup を検証した。Discovery の revocation URL 欠落も修正した。配布検証の残件は解消したが、access-token を提示した grant-wide 取消しの整合化は残る。
62
+
63
+ ## JWT client assertion audience
64
+
65
+ A separate complete-OP fixture now exercises both `private_key_jwt` (RS256) and
66
+ `client_secret_jwt` (HS256), with a fresh signed client assertion at each CIBA
67
+ initiation, token collection and refresh. Node 9.12.2 accepts the issuer as aud
68
+ at all three stages. Wrong audiences and reuse of the same assertion fail client
69
+ authentication. [Reference run](../validation/node-assertion-refresh.txt),
70
+ [harness](../../test/reference/full-op/assertion-refresh.mjs).
71
+
72
+ The gem originally accepted issuer aud for initiation/collection but rejected
73
+ it for CIBA refresh with HTTP401. Both authentication methods reproduced the
74
+ failure. The corrected audience rule includes the issuer for CIBA refresh only,
75
+ selected by the existing reserved `ciba_rt_` namespace; it does not grant access
76
+ without client signature/secret, replay checks and refresh ownership/consent
77
+ validation. Unrelated authorization-code requests retain the previous audience
78
+ rule. Wrong-audience attempts preserve refresh rows, and assertion replay after
79
+ one successful refresh cannot issue another token.
80
+ [Before correction](../validation/assertion-refresh-red.txt),
81
+ [local regressions](../validation/assertion-refresh-current.txt).
82
+
83
+ This proves these two authentication methods with issuer aud and the fixture's
84
+ algorithms. It is not mTLS support or coverage of every authentication method,
85
+ JWT algorithm, key rotation or remote-key refresh failure.