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
data/docs/api.md ADDED
@@ -0,0 +1,337 @@
1
+ # Configuration and Ruby API (0.1.0)
2
+
3
+ Optional [login_hint_token](login-hint-token.md) uses `ciba_login_hint_token_enabled` and a required application resolver; no schema migration is needed.
4
+
5
+ Optional [id_token_hint](id-token-hint.md) uses a dedicated signature/claim verifier and required subject-to-account resolver. It never inherits approval; no migration is needed.
6
+
7
+ Optional [signed requests](signed-requests.md) require client metadata migration, registered verification keys and `ciba_request_signing_algorithms`. Signature verification does not replace client authentication.
8
+
9
+ Optional [user codes](user-code.md) require client metadata migration and an application validation callback. Raw codes are never stored by the feature; code acceptance does not replace customer approval.
10
+
11
+ Optional [ping](ping.md) adds private delivery storage, after-commit notification and `retry_ciba_ping(request_id)`. Notification failure does not roll back saved approval/denial.
12
+
13
+ Optional [authorization details (RAR)](authorization-details.md) require resource support, a separate migration, client type registration and application validation/issuance policies.
14
+
15
+ Optional [resource indicators](resources.md) provide saved per-resource permissions and access-token audience restriction after `Schema.add_resources` and `ciba_resources_enabled true`.
16
+
17
+ Optional [explicit claim consent](claims.md) is available after its separate migration and `ciba_claims_enabled true`. It is not part of the default scope-only profile.
18
+
19
+ Opt-in [DPoP integration and its tested boundary](research/dpop-reference-contract.md)
20
+ requires upstream `:oauth_dpop`, the token thumbprint column and the shared client
21
+ assertion replay ledger. To support server nonce challenges, configure
22
+ `ciba_dpop_nonce_secret` with a persisted, issuer-specific random 32-byte String
23
+ shared by all issuer processes. Set `oauth_dpop_use_nonce true` to require nonces;
24
+ otherwise fresh proofs may omit them. Never generate this secret per request or
25
+ boot. The CIBA boundary uses the reference minute-based nonce window, not the
26
+ upstream `oauth_dpop_nonce_expires_in` lifetime. External resource-server
27
+ enforcement is not provided by this UserInfo/token-endpoint integration.
28
+
29
+ With upstream introspection or DPoP enabled, OIDC Discovery preserves their
30
+ configured endpoint/authentication and proof-algorithm metadata from OAuth
31
+ server metadata. Disabled features are not advertised. Renaming the introspection
32
+ route changes the discovered URL accordingly.
33
+
34
+ The initial [mTLS integration](research/mtls-reference-contract.md#initial-ciba-correction)
35
+ requires upstream `:oauth_tls_client_auth`, its grant thumbprint column and an
36
+ explicit `ciba_tls_client_certificate` callback returning the verified TLS
37
+ connection's peer certificate (OpenSSL certificate or PEM), or nil. PKI clients
38
+ also require `ciba_tls_client_certificate_authorized?` and
39
+ `ciba_tls_client_certificate_subject_matches?(property, expected)` to return true
40
+ for the chain and registered DN/SAN identity. These callbacks default to denying
41
+ access; the gem does not trust forwarded certificate headers. The application
42
+ must provide a trusted TLS server or terminator integration. Self-signed clients
43
+ are matched against registered x5c certificates. The packaged `mtls_smoke.rb`
44
+ demonstrates one actual Ruby TLS adapter and is verified after temporary gem
45
+ installation. Installed HTTPS certificate-rotation tests also pass; verification of a production proxy remains the application's responsibility. See the [mTLS contract](research/mtls-reference-contract.md).
46
+
47
+ With this feature enabled, both Discovery documents advertise
48
+ `tls_client_certificate_bound_access_tokens: true` as a capability. For CIBA
49
+ issuance and refresh, binding is required only by the client's
50
+ `tls_client_certificate_bound_access_tokens` metadata. The upstream global
51
+ `oauth_tls_client_certificate_bound_access_tokens` policy does not force CIBA
52
+ clients to bind tokens; it remains unchanged for other upstream OAuth grants.
53
+ Disabling client binding affects new access tokens, never existing bound tokens.
54
+
55
+ `mtls_endpoint_aliases` is application deployment metadata, not an automatically
56
+ created set of gem routes. Publish the alias URLs through Discovery middleware,
57
+ route them to the same application, keep `base_url` and
58
+ `authorization_server_url` fixed to the canonical issuer, and obtain certificates
59
+ from the trusted TLS adapter on each listener. The installed `mtls_smoke.rb`
60
+ exercises a second TLS port for CIBA, token, introspection and UserInfo while
61
+ verifying the canonical ID Token issuer. Arbitrary path rewriting and reverse
62
+ proxy behavior are not covered by that example.
63
+
64
+
65
+ Enable `:ciba` after requiring `rodauth/ciba`. It depends on upstream `:oidc`. Configure your usual accounts, OAuth application/grant tables, signing keys and issuer first. Existing authorization-code behavior is delegated to upstream.
66
+
67
+ ## Application configuration
68
+
69
+ ```ruby
70
+ ciba_request_lifetime 300 # Optional override; default is 600.
71
+ ciba_max_request_lifetime 600 # Default upper bound.
72
+ ciba_grant_lifetime 14 * 24 * 60 * 60 # Optional override; default consent lifetime.
73
+ resolve_ciba_login_hint do |hint, oauth_application:|
74
+ # Validate your own identifier format and tenant/client authorization here.
75
+ # Return a local account primary key, or nil for an unknown/ineligible user.
76
+ db[:accounts].where(email: hint).get(:id)
77
+ end
78
+ trigger_ciba_authentication_device do |request|
79
+ # Start your application-owned approval flow with request[:id].
80
+ end
81
+ ```
82
+
83
+ The default request lifetime and maximum are both 600 seconds, matching node-oidc-provider’s default BackchannelAuthenticationRequest TTL. A valid requested_expiry is capped at the configured maximum. Lifetime values are seconds, positive integers; default must not exceed maximum (maximum <= 2^31-1). There is no implicit email lookup. Explicit signing keys are required; `none` is rejected. Default account eligibility is an existing open Rodauth account; override `ciba_account_eligible?(account_id)` for another account model.
84
+
85
+ Both endpoints use upstream client authentication. CIBA uses the upstream enabled confidential client authentication methods and preserves the OP-wide Discovery metadata. It does not restrict other OAuth flows. Public clients (`none`) cannot use CIBA. CIBA Basic credentials are strictly base64-decoded and form-decoded before upstream secret verification; supplying Basic and post credentials together is rejected. Shared secrets should retain upstream's default hashing configuration. The demo explicitly disables hashing for its disposable fixture only.
86
+
87
+ Enable upstream `:oauth_jwt_bearer_grant` to use `private_key_jwt` or, where upstream permits shared-secret storage, `client_secret_jwt`. JWT signature/key verification uses upstream, with CIBA-side checks for required client claims, issuer/subject/client-ID binding, client key availability and one-time use. At the Backchannel Endpoint, assertion audiences accept the OP issuer, token URL or backchannel URL as required by CIBA Core §7.1; CIBA grant token requests accept either the OP issuer or token request URL as audience; both string and string-array audiences are supported. This does not enable signed CIBA authentication requests (`request`). Basic/post and both JWT methods have integration coverage. An explicit twelve-algorithm RS/PS/ES/HS matrix verifies advertised client assertion algorithms on both endpoints; see the [matrix and limits](research/client-assertion-algorithm-contract.md). Other upstream mechanisms require deployment-specific validation.
88
+
89
+ Create the assertion replay ledger using `Schema.create_client_assertions(db)` before enabling JWT client authentication. The example migration includes it. The `ciba_client_assertions_table` setting and helper's `table:` option customize its name. CIBA-capable clients share this ledger across Backchannel and other grant flows, preventing reuse through another endpoint. Clients without the CIBA grant retain upstream behavior. The ledger stores a SHA-256 digest of client ID plus jti and the assertion expiry, not the assertion itself.
90
+
91
+ JWT client assertions require `iss`, `sub`, `aud`, `exp`, `jti`. Issuer and subject must identify the same registered client. Use a fresh assertion for every attempt, including polling and retries. jti is limited to 1024 bytes. Shared-secret JWT keys must be at least 32/48/64 bytes for HS256/384/512 respectively. An absent client JWKS cannot fall back to OP signing keys. The OP's own signing keys and client keys are separate trust inputs.
92
+
93
+ `cleanup_ciba_client_assertions(limit: 100)` deletes only expired ledger rows in bounded batches (limit 1..1000). Schedule it in the host application; do not delete live rows. Custom error descriptions containing characters outside OAuth's allowed ASCII repertoire are omitted from CIBA responses.
94
+
95
+ Statically provision each CIBA application with:
96
+
97
+ - `grant_types` containing `urn:openid:params:grant-type:ciba` (space separated, as upstream stores it).
98
+ - `backchannel_token_delivery_mode = 'poll'`.
99
+ - `subject_type = 'public'` (or the OP's public fallback).
100
+ - Allowed `scopes` containing `openid` and a registered supported authentication method.
101
+
102
+ CIBA DCR requests are rejected. Existing non-CIBA registration validation is forwarded to upstream. Pairwise remains advertised for upstream flows; CIBA rejects a pairwise client.
103
+
104
+ ## Other settings
105
+
106
+ | Rodauth setting | Default / meaning |
107
+ |---|---|
108
+ | `ciba_requests_table` | `:ciba_requests` |
109
+ | `ciba_grants_table` | `:ciba_grants`, saved consent scopes |
110
+ | `oauth_grants_ciba_grant_id_column` | `:ciba_grant_id`, durable link from an issued OAuth grant to consent |
111
+ | `ciba_request_columns` | `{}`; canonical column symbol → physical column symbol |
112
+ | `oauth_applications_backchannel_token_delivery_mode_column` | `:backchannel_token_delivery_mode` |
113
+ | `ciba_poll_interval` | `5` seconds, positive integer <= 2^31-1 |
114
+ | `ciba_max_request_bytes` | `16384`, maximum form-body bytes |
115
+ | `ciba_require_tls` | `true`; disable only for local test/demo |
116
+ | `backchannel_authentication_route` | `"backchannel-authentication"`, normal Rodauth route setting |
117
+
118
+ `binding_message` is optional. Its default policy matches node-oidc-provider v9.12.2: 1–20 characters from `A-Z a-z 0-9 - . _ + / ! ? #`. Configure `validate_ciba_binding_message` to replace that policy; it receives the string or nil and must raise `ciba_error("invalid_binding_message")` to reject it. The form-body limit and string/encoding validation apply independently of the callback. A custom policy may require a message or allow localized text. Applications must still HTML-escape displayed values. Hint/acr_values are limited to1024 bytes and scope to4096 bytes. These are library policies, not limits imposed by CIBA. Unknown parameters are ignored. `request_uri` is rejected. Signed `request`, additional hints and `user_code` require their documented feature configuration; unsupported use is rejected. `offline_access` is removed by default; the token response reflects effective scopes. The optional [refresh implementation](refresh-tokens.md) retains it when enabled, but that implementation is still in progress. `requested_expiry` is capped at the configured maximum.
119
+
120
+ To publish Discovery in a Roda app, use upstream routing explicitly:
121
+
122
+ ```ruby
123
+ route do |r|
124
+ rodauth.load_openid_configuration_route
125
+ rodauth.load_oauth_server_metadata_route # optional OAuth metadata endpoint
126
+ r.rodauth
127
+ end
128
+ ```
129
+
130
+ ## Completion API
131
+
132
+ These are trusted in-process calls; none creates an HTTP approval endpoint. Authenticate the customer and authorize the decision in your application before calling them. Use the internal integer `id`, never the client's `auth_req_id` as proof of approval.
133
+
134
+ The canonical result API mirrors the request/saved-Grant boundary of node-oidc-provider. A CIBA grant represents consent, independently of both the pending request and upstream rows containing issued tokens:
135
+
136
+ ```ruby
137
+ request = rodauth.ciba_request(id)
138
+ grant = rodauth.create_ciba_grant(
139
+ account_id: local_customer_id,
140
+ oauth_application_id: request.fetch(:oauth_application_id),
141
+ scopes: "openid read", # the scopes the authenticated customer actually approved
142
+ expires_at: Time.now.to_i + 300 # optional; omitted defaults to 14 days, explicit nil disables expiry
143
+ )
144
+ rodauth.backchannel_result(id, grant,
145
+ auth_time: authentication_time.to_i, acr: "urn:example:mfa", amr: ["pwd", "otp"])
146
+ # A saved grant's integer ID can also be passed as the second argument.
147
+ ```
148
+
149
+ `create_ciba_grant` returns a frozen snapshot. Scope syntax and the account/client's eligibility are validated; the initial CIBA profile requires `openid`. This method does not authenticate the customer or issue a token. The application must authorize creation. The grant may cover several requests for the same account/client; each request still needs its own completion call.
150
+
151
+ `backchannel_result` reloads the saved grant by ID: modifying a supplied hash cannot grant extra scope or claims. It checks client/account identity, expiry and revocation. Token issuance intersects requested scope with saved consent and current client eligibility. Requested scopes remain unchanged in the request snapshot. A different grant on a completion retry is a conflict. Optional [claims](claims.md), [resource permissions](resources.md) and [RAR consent](authorization-details.md) have separate configuration and saved-consent contracts.
152
+
153
+ To report refusal through the same result API:
154
+
155
+ ```ruby
156
+ rodauth.backchannel_result(id,
157
+ Rodauth::CibaSupport::ProtocolError.new("access_denied"))
158
+ ```
159
+
160
+ `ProtocolError` now supports standard or application-defined error codes. The call is trusted: authenticate and authorize the application actor before recording a result. Codes must be nonempty OAuth error ASCII (no quotes, backslashes or control characters), at most1024 bytes. The first poll returns the saved code and consumes the request; later polls return `invalid_grant`. Submitting `authorization_pending` or `slow_down` as a completion therefore terminates the request, like the reference API; use ordinary pending state for ongoing work. No description parameter is exposed: the pinned reference does not retain an application-provided description through this path. See the [comparison](research/completion-error-boundaries.md).
161
+
162
+ Existing databases must explicitly run `Rodauth::CibaSupport::Schema.add_completion_error(db)` before upgrading. Custom tables/columns use `table:`/`column:` plus `ciba_request_columns`. New schemas include the nullable text field. NULL preserves `access_denied` for existing denials. Negative completions retain the internal `denied` state, `:denied` return value and transactional `before_ciba_deny` / `after_ciba_deny` hooks. Inspect `completion_error` to distinguish technical failure from customer refusal. Non-denial failures emit a post-commit `failed` event with `error`; customer refusal continues to emit `denied`. An identical completion retry does nothing; changing its code or replacing it with approval/refusal raises `Conflict`.
163
+
164
+ The existing convenience API is retained. Approval creates a saved consent grant for the whole requested scope in the completion transaction; identical retries do not create duplicate grants:
165
+
166
+ ```ruby
167
+ request = rodauth.ciba_request(id)
168
+ rodauth.approve_ciba_request(id, account_id: local_customer_id,
169
+ auth_time: authentication_time.to_i, acr: "urn:example:mfa", amr: ["pwd", "otp"])
170
+ rodauth.deny_ciba_request(id, account_id: local_customer_id)
171
+ ```
172
+
173
+ Returns `:approved` / `:denied` on a new decision; `:already_completed` on an identical retry. Approval retries compare account, auth_time, acr and sorted/deduplicated amr. An identical approval after consumption is permitted until request expiry but never returns a token. Completion retries do not repeat hooks/events. Expiry wins over retry, including terminal states. Authentication context is optional unless the accepted client policy requires `auth_time`; a missing required time raises `Ineligible` before approval. Do not substitute approval time for unknown authentication time. Requested `acr_values` is visible in the snapshot; your application decides which authentication is necessary and reports the achieved acr.
174
+
175
+ Exceptions in `Rodauth::CibaSupport`:
176
+
177
+ | Class | Meaning |
178
+ |---|---|
179
+ | `NotFound` | Request absent/cleaned up |
180
+ | `IdentityMismatch` | Supplied customer differs from the fixed request target |
181
+ | `Expired` | Deadline reached |
182
+ | `Conflict` | Different decision/context, or a conflicting modification |
183
+ | `Ineligible` | Account/client no longer eligible |
184
+ | `ReentrantMutation` | Another mutation of this request is active in the same execution context |
185
+
186
+ All inherit `CompletionError`. Invalid argument shapes raise `ArgumentError`. DB and application-hook failures propagate to the Ruby caller and roll back its operation. HTTP processing failures roll back first and return a generic server error; exception text is not returned.
187
+
188
+ ## Request correlation extension
189
+
190
+ An optional `nonce` on the Backchannel Authentication Request is persisted and copied into the signed ID Token, matching node-oidc-provider v9.12.2. This is additional implementation support, not a mandatory CIBA Core request parameter or a new Discovery field. If supplied, it must be a nonempty valid string of at most 1024 bytes; that bound is a gem policy. Omission produces no nonce claim. The saved snapshot exposes it to the trusted application, but observation events omit it. A Token Endpoint nonce cannot overwrite the saved value. This value does not authenticate a customer or authorize completion.
191
+
192
+ Existing development databases require the explicit nullable-column migration described in [operations](operations.md) before running the updated code. New schemas already include the column. Old requests with a null nonce retain their original behavior.
193
+
194
+ ## Saved consent and revocation
195
+
196
+ `ciba_grant(id)` reads a frozen consent snapshot. `revoke_ciba_grant(id)` serializes with issuance, marks consent revoked and sets upstream `revoked_at` on all OAuth grant rows linked to it. This link survives request cleanup. It prevents further CIBA issuance; it does not make an already distributed JWT disappear from an offline resource server. Use the upstream revocation/introspection and resource-server enforcement appropriate to the access-token format.
197
+
198
+ New consent defaults to 14 days, matching node-oidc-provider's Grant TTL. `ciba_grant_lifetime` configures the default in positive integer seconds (maximum 2^31-1). Omitting `expires_at` applies it; an explicit future timestamp overrides it and explicit nil retains the application's choice of no consent expiry. Convenience approval also uses the default. Existing rows keep their stored expiry, including nil; no schema migration or retroactive expiry is applied.
199
+
200
+ Consent expiry is checked at completion and token collection. Issued access tokens have their own expiry. Request cleanup does not delete consent records. The application owns consent retention; deleting a consent row cascades to its linked requests and issued OAuth rows, so deletion is a destructive action and is not used for ordinary request cleanup.
201
+
202
+ ## Snapshots and recovery
203
+
204
+ `ciba_request(id)` returns a frozen hash with frozen strings; canonical column names are used even with custom columns. It excludes the public-ID digest. The stored status can remain pending/approved after expiry; always check `expires_at` when displaying it. Completion and polling check deadlines themselves.
205
+
206
+ ```ruby
207
+ rodauth.pending_ciba_requests(after_id: nil, limit: 100)
208
+ rodauth.cleanup_ciba_requests(before: Time.now.to_i - 86_400, limit: 100)
209
+ ```
210
+
211
+ Pending lookup returns unexpired pending requests sorted by internal ID, limit1..1000. For notification recovery, periodically restart scanning from the beginning and deduplicate deliveries in the app; do not permanently advance a cursor past undelivered requests.
212
+
213
+ Cleanup returns the deleted count. The cutoff must be an integer epoch second not in the future. It removes bounded batches of expired pending/approved rows, denied rows completed before cutoff, and consumed rows consumed before cutoff. It never deletes OAuth grants/tokens. No background worker or automatic migration is installed.
214
+
215
+ ## Authentication Device initiation
216
+
217
+ Implement `trigger_ciba_authentication_device(snapshot)` to initiate your application’s authentication/approval flow. It runs synchronously after the request transaction/savepoint finishes and before the successful Backchannel response. The frozen snapshot contains the saved internal request ID and account/client references, not the public auth_req_id or its digest. The callback may enqueue work or call the Ruby result API. It must return normally to allow a success response; it must not perform a Rack halt/throw. Its default raises `CibaSupport::ConfigurationError`, reported as HTTP 500 `server_error`. A deliberate no-op is appropriate only if the application already processes pending requests independently (as the local demo does).
218
+
219
+ Dispatch should enqueue work promptly instead of waiting for customer interaction.
220
+ Its synchronous execution consumes the saved request lifetime; `expires_in` is not
221
+ reduced for this elapsed time. Both the gem and reference OP can acknowledge an
222
+ already expired request if dispatch outlasts its lifetime. See the
223
+ [measured timing contract](research/acknowledgement-time-contract.md).
224
+
225
+
226
+ A callback exception goes to `report_ciba_processing_error`, not the observer-error reporter. Without a surrounding application transaction, the saved request remains: notification may already have happened, so failure cannot safely undo external effects. The client does not receive an auth_req_id on that failed response. A client retry creates a new request; the app must deduplicate notifications and expire/recover abandoned requests. This is not durable delivery.
227
+
228
+ The callback is outside the gem’s request transaction, but it does not wait for an application-owned outer transaction. Do not wrap the HTTP route in a larger transaction if notification workers need committed state. For durable dispatch, save an outbox record in `after_ciba_request` and use the trigger only to wake your worker (or explicitly do nothing when a worker scans the outbox). The worker must read committed records and own delivery/retry policy.
229
+
230
+ This follows the persistence-before-trigger ordering in node-oidc-provider v9.12.2's CIBA route. Both the gem and complete reference OP harness now test a trigger exception leaving a pending request while initiation returns 500 without its identifier. Neither test establishes durable notification delivery.
231
+
232
+ ## Hooks and events
233
+
234
+ Configure `before_ciba_request`, `after_ciba_request`, `before_ciba_approve`, `after_ciba_approve`, `before_ciba_deny`, `after_ciba_deny`, `before_ciba_issue`, `after_ciba_issue` using Rodauth's usual block configuration. They run in the state change's transaction/savepoint. Exceptions roll back. `ciba_current_request` is a frozen snapshot: before hooks see the previous state, after hooks see the resulting state; the before-request hook has no assigned database ID yet. Hooks must not perform a Rack halt/throw or send an HTTP response; signal failure by raising an exception.
235
+
236
+ Use a transactional after hook to persist required audit data or an outbox entry in the same database. Avoid external network calls there. After does not mean after commit.
237
+
238
+ `ciba_request_accepted(snapshot)` is retained as a legacy best-effort post-commit callback. Move required initiation logic to `trigger_ciba_authentication_device`; do not configure both to send the same notification. `observe_ciba_event(event)` receives post-commit events: `request_accepted`, `approved`, `denied`, `failed`, `token_issued`.
239
+
240
+ Events contain `version: 1`, `event_id`, internal `request_id`, `occurred_at` (epoch seconds), local `account_id`, local `oauth_application_id`, `type`, `from`, `to`. Tokens, raw hints, public auth_req_id, secrets and binding messages are excluded. Event hashes are frozen. Observer/legacy-callback failure is passed to `report_ciba_observer_error(error)`; failure of that reporter cannot change an already committed result. The default reporter writes only the exception class to stderr. Callbacks are best-effort, not durable delivery, and process death can lose an event.
241
+
242
+ ## Collecting results and replay
243
+
244
+ See the [failure and retry contract](failure-contract.md) for confirmed rollback, ambiguous HTTP outcomes and external side effects.
245
+
246
+ An unexpired approved or denied result can be collected once. A negative result first returns its saved error code (`access_denied` for customer refusal) and becomes consumed; another collection returns `invalid_grant`. Replaying an unexpired consumed approved request revokes its saved consent and every OAuth token row linked to that consent before returning `invalid_grant`, following node-oidc-provider. This includes tokens from other requests if the same consent was reused. Expired requests return `expired_token` before replay handling. Ruby completion retries are separate from Token Endpoint collection and do not trigger replay revocation. Do not repeat a successful poll; after a lost token response, start a new authorization rather than treating polling as idempotent. Database revocation alone does not invalidate offline-verified JWTs.
247
+
248
+
249
+ CIBA dynamic registration accepts optional `token_endpoint_auth_signing_alg`
250
+ when it matches the JWT client authentication method and the OP's advertised
251
+ algorithms. Add its nullable String storage column (configurable with
252
+ `oauth_applications_token_endpoint_auth_signing_alg_column`) before accepting
253
+ this metadata. The registered value restricts subsequent client assertions;
254
+ it is separate from `backchannel_authentication_request_signing_alg`.
255
+
256
+
257
+ ### Remote certificates for self-signed mTLS clients
258
+
259
+ With `jwks_uri`, certificate `x5c` values are fetched through the guarded CIBA
260
+ HTTP transport and cached by upstream HTTP cache policy. A certificate mismatch
261
+ does not force a refetch. Publish overlapping keys/certificates as needed and
262
+ account for cache lifetime during rotation. After cache expiry, retrieval or
263
+ malformed-JWKS failure returns `invalid_client_metadata`; an empty key set or
264
+ nonmatching certificate returns `invalid_client`. The gem does not fall back to
265
+ expired cached keys after retrieval failure. See the [measured reference
266
+ boundary](research/mtls-reference-contract.md#remote-x5c-rotation-and-retrieval-failures).
267
+
268
+ ### Hint and display policy communicated to clients
269
+
270
+ Publish the accepted hint formats and, where relevant, trusted issuers and
271
+ maximum ages as part of the deployment's client integration contract. The gem's
272
+ resolver methods do not communicate that policy automatically. `login_hint`
273
+ can be an application-defined reference rather than an email address.
274
+ Applications and clients also own safe display of binding_message and actual
275
+ user correlation across AD/CD. A stored message or callback does not prove that
276
+ the user saw it or approved the intended action.
277
+
278
+ ### Explicit pre-interaction policy denial
279
+
280
+ An application authentication-request callback may call
281
+ `ciba_error("access_denied", 403)` to reject a start request before device dispatch.
282
+ For example, a login-hint resolver can reject a client/account combination by
283
+ policy. Do not use this for an unavailable policy service; unexpected failures
284
+ remain server_error. `CibaSupport::ProtocolError` uses HTTP400 by default, so
285
+ raising that error with access_denied does not choose HTTP403 automatically.
286
+ After a request exists, use the completion denial API instead; poll then returns
287
+ the normal token error response. The pinned reference likewise needs an explicit
288
+ HTTP403 application error; its default AccessDenied maps to HTTP400.
289
+ The supplied callback fixture proves the response and absence of persistence,
290
+ not the correctness of a deployment's access policy.
291
+
292
+ The JWT assertion-type URN belongs in the `client_assertion_type` request field.
293
+ Register `private_key_jwt` or `client_secret_jwt` as the authentication method;
294
+ CIBA registration rejects the URN as a method. Shared OAuth/OIDC Discovery
295
+ removes this upstream legacy advertisement while retaining all actual method
296
+ names. Upstream authentication dispatch for other OP flows is unchanged.
297
+
298
+ ## Edwards client assertion authentication
299
+
300
+ Enable `oauth_jwt_bearer_grant` and configure, for example,
301
+ `ciba_client_assertion_signing_algorithms %w[RS256 Ed25519 EdDSA]`. The default
302
+ `nil` keeps existing registration defaults. An explicit list governs CIBA client
303
+ assertions, including other grant flows for those CIBA clients. Register Edwards
304
+ clients with `private_key_jwt`, a matching `token_endpoint_auth_signing_alg`, and
305
+ a public OKP/Ed25519 JWK. No Edwards OP signing key is needed.
306
+
307
+ Shared OAuth/OIDC authentication metadata retains upstream capabilities and adds
308
+ this list. This does not install Edwards signing/verification globally in
309
+ ruby-jwt or enable Edwards ID Token issuance. Non-CIBA-only clients continue to
310
+ use upstream authentication. See the
311
+ [implementation and evidence](research/client-assertion-algorithm-contract.md#initial-edwards-authentication-implementation).
312
+
313
+ ## Edwards CIBA ID Tokens
314
+
315
+ `ciba_id_token_signing_keys({"Ed25519" => private_key, "EdDSA" => private_key})`
316
+ adds explicit CIBA ID Token signing keys without changing the OP's default key
317
+ configuration or JWT access-token signing. Register a matching
318
+ `id_token_signed_response_alg`. An array value uses its first private key for
319
+ new signatures and retains remaining keys for hint verification; only public
320
+ keys are exposed through JWKS. Default is `{}`.
321
+
322
+ [Configuration and current limits](id-token-hint.md#explicit-edwards-id-token-keys)
323
+ include optional [recipient encryption](id-token-encryption.md) when
324
+ `ciba_id_token_encryption_enabled true` is configured.
325
+
326
+ For CIBA HS256/HS384/HS512 ID Tokens, signing and hint verification now use the
327
+ client secret. Successful Basic/POST authentication supplies it within the
328
+ request even when the DB stores a hash. Other methods can use plaintext storage
329
+ or `ciba_id_token_hmac_secret(oauth_application:)` to retrieve the original secret
330
+ from application-managed storage. Missing/short keys fail with rollback; OP keys
331
+ are never substituted. See [the HMAC contract](id-token-hint.md#hmac-client-secret-ownership).
332
+
333
+ Authentication results are stored separately from their ID Token disclosure. See [authentication claim selection and migration](authentication-claims.md) for require_auth_time, explicit claims, scope configuration and the required authentication_claims storage.
334
+
335
+ The device request snapshot includes saved `max_age` (nil, zero for fresh login, or seconds). See [maximum authentication age](max-age.md) for request/default precedence, signed-input isolation and the required request-column migration.
336
+
337
+ CIBA DPoP binds proofs to the reached endpoint through `ciba_dpop_endpoint_uri`. Its default ignores forwarding headers; trusted-proxy applications may supply their validated external URL. See [alias and proxy trust](research/dpop-reference-contract.md#alternate-endpoint-origin-and-proxy-trust).
@@ -0,0 +1,80 @@
1
+ # Authentication context in CIBA ID Tokens
2
+
3
+ Approval saves the application's verified `auth_time`, `acr` and `amr`. Saving a
4
+ value does not by itself select it for disclosure in a newly accepted request.
5
+ Initial and refreshed ID Tokens use the same stored selection:
6
+
7
+ - `require_auth_time: true` or configured `default_max_age` client metadata selects
8
+ `auth_time` at request acceptance. This is output selection, not freshness enforcement.
9
+ - A nonempty `acr_values` request selects `acr`. If omitted or empty, registered
10
+ `default_acr_values` supplies the ordered preferences; the emitted value is the achieved
11
+ application result, not the requested string.
12
+ - With `ciba_claims_enabled`, the `claims.id_token` keys `auth_time`, `acr` and `amr`
13
+ select those authentication claims. These are not ordinary account attributes:
14
+ a consent list or an account attribute callback cannot replace their values.
15
+ - Applications can additionally map authorized scopes to authentication claims:
16
+
17
+ ```ruby
18
+ ciba_authentication_claims_by_scope "openid" => %w[auth_time acr amr]
19
+ ```
20
+
21
+ The default mapping is empty. Only the three names above are considered. Scope
22
+ selection uses the scopes authorized for the current issuance, including refresh
23
+ narrowing. It is application configuration, not a client-supplied mapping.
24
+ Client metadata and explicit request selection are saved at acceptance; replacing
25
+ client metadata later does not reinterpret an accepted request or refresh source.
26
+ An accepted `require_auth_time: true` is saved as the `auth_time_required` marker
27
+ in the existing authentication selection. Approval requires a verified
28
+ `auth_time` and rejects a missing value before changing the pending request.
29
+ Issuance and refresh reject an incomplete required context with `invalid_grant`
30
+ without consuming its source. Later client metadata changes do not relax this
31
+ saved requirement. Optional selected authentication values remain absent when
32
+ not supplied. The gem does not
33
+ invent a login timestamp, echo a requested ACR as achieved, or verify the truth of
34
+ application-provided authentication context.
35
+
36
+ ## Existing development databases
37
+
38
+ New `Schema.create` and `Schema.create_refresh_tokens` tables include nullable text
39
+ `authentication_claims` columns. Existing installations must migrate explicitly
40
+ before starting upgraded workers:
41
+
42
+ ```ruby
43
+ Rodauth::CibaSupport::Schema.add_authentication_claims(db)
44
+ # Only if the refresh source table already exists:
45
+ Rodauth::CibaSupport::Schema.add_authentication_claims(db, table: :ciba_refresh_tokens)
46
+ ```
47
+
48
+ For renamed request storage, pass `table:` and `column:` and set the matching
49
+ `ciba_request_columns` mapping. Startup checks require the request column and,
50
+ when refresh is enabled, its refresh-source column. No automatic migration runs.
51
+ Upgrade workers together: old workers do not record selection for new requests.
52
+ The required-time marker needs no new column. Requests and refresh sources
53
+ accepted by earlier workers do not carry it; drain or explicitly invalidate them
54
+ before relying on mandatory-time enforcement. Do not fabricate missing timestamps.
55
+ Existing NULL rows retain the previous output contract (all supplied context
56
+ claims). New requests store an explicit list, including an empty list. Do not
57
+ backfill NULL with an empty list unless changing pending requests' output is
58
+ intended. Refresh copies this distinction without losing authentication evidence.
59
+
60
+ ## Evidence and scope
61
+
62
+ The pinned reference fixture `auth-time.mjs` exercises omitted/false/true client
63
+ metadata, omitted/provided time, explicit claims, ACR requests and a configured
64
+ scope mapping, with independently verified initial and refreshed ID Tokens.
65
+ ACR and AMR support are explicitly configured in the reference; their unconfigured
66
+ reference defaults are not inferred to be identical to every application setup.
67
+ The gem tests additionally cover metadata replacement after acceptance and legacy
68
+ request migration. Browser authorization and UserInfo claim behavior are outside
69
+ this change; their existing upstream behavior is preserved.
70
+
71
+ Validation: [full Ruby4.0.6/SQLite run](validation/authentication-claims-full.txt)
72
+ passes385 tests /10,152 assertions with two row-lock skips;
73
+ [the Ruby3.4 three-DB subset](validation/authentication-claims-matrix.txt) passes13
74
+ tests /951 assertions on each DB. The [installed artifact](validation/authentication-claims-installed.txt)
75
+ passes its smoke suite including explicit scope-selected authentication time.
76
+ These do not establish every Ruby × DB combination or arbitrary application policies.
77
+
78
+ See [registration defaults](research/registration-reference-contract.md#default_acr_values-and-authentication-defaults)
79
+ for JSON-array storage and supported ACR configuration. The [maximum-age policy input](max-age.md) is now saved independently;
80
+ empty-string ACR requests now use the same defaulting as omitted values.
@@ -0,0 +1,50 @@
1
+ # Optional authorization details (RAR)
2
+
3
+ This opt-in CIBA extension accepts RFC9396-style authorization details with registered, application-defined type semantics. It depends on [resource indicators](resources.md). The gem validates protocol shape and identity/client/consent binding; the application must decide which details represent an allowed operation and how to narrow them. It does not compare arbitrary JSON as a substitute for authorization.
4
+
5
+ ## Migration and activation
6
+
7
+ After the base and resource schemas, explicitly run:
8
+
9
+ ```ruby
10
+ Rodauth::CibaSupport::Schema.add_authorization_details(DB)
11
+ ```
12
+
13
+ This adds nullable JSON TEXT fields to requests (`requested_authorization_details`), saved grants (`authorization_details`), issued tokens (`ciba_authorization_details`) and clients (`authorization_details_types`). Table names and the request column are configurable keyword arguments. For a custom request column, set the corresponding `ciba_request_columns` mapping. Existing rows have no additional RAR permission; migration does not infer consent or activate the feature.
14
+
15
+ Configure `ciba_authorization_details_enabled true`, `ciba_resources_enabled true`, `ciba_authorization_details_types` as the supported type-name array, and both policy methods below in the same configuration block. Missing policy methods or missing resource/type configuration prevent startup. Store each statically registered client's allowed type-name array as JSON in `authorization_details_types`. An omitted client value permits no types. Discovery advertises `authorization_details_types_supported` only when enabled; this support currently applies to CIBA, not an added authorization-code RAR implementation.
16
+
17
+ Upgrade all workers before activation. Requests or their saved consent carrying the new field cannot be redeemed after disabling RAR; they return invalid_grant. This includes an old request bound to a new RAR consent after migration. Already-issued tokens retain their stored authorization details. Do not downgrade readers while such tokens remain active. Existing request/consent pairs without the fields remain compatible; enabling the feature must not backfill requested or approved data.
18
+
19
+ ## Application policies
20
+
21
+ `ciba_validate_authorization_details(details, oauth_application:)` must return exactly true to accept details. It runs for requests, saved consent and compiled issuance output. Inputs have passed bounded JSON/common-field validation and registered/client-type checks. Define all permitted fields and their values for each supported type. Empty arrays represent no additional permission. This method should not perform external side effects.
22
+
23
+ `ciba_authorization_details_for_token(requested:, approved:, narrowing:, resource:, oauth_application:)` returns an array of effective details. Inputs come from the original request, the reloaded saved grant, optional token-request details (nil if omitted), selected resource and current client. Arrays, hashes and strings are recursively frozen. The method runs inside the issuance transaction after the before-issue hook. `ciba_current_request` supplies the verified account/client request context. The gem validates the returned shape/type/client eligibility again and persists that output on the issued token.
24
+
25
+ **The application must enforce type-specific narrowing and resource association in this method.** Generic code cannot determine whether a changed amount, path, customer, operation or target grants more access. An output of valid JSON shape is not proof of proper authorization. Reject invalid narrowing with `Rodauth::CibaSupport::ProtocolError.new("invalid_authorization_details")`; an empty result grants no RAR permission and omits the response field. Exceptions roll back consumption and token writes. The [installed-gem example](../examples/extensions_smoke.rb) demonstrates only a fixed read/delete test type; it is not a production business policy.
26
+
27
+ Create saved consent with a separate approved value:
28
+
29
+ ```ruby
30
+ grant = rodauth.create_ciba_grant(
31
+ account_id: customer_id, oauth_application_id: client_id,
32
+ scopes: "openid", resources: {resource_uri => "read"},
33
+ authorization_details: [{type: "support_data", actions: ["read"]}]
34
+ )
35
+ rodauth.backchannel_result(request_id, grant)
36
+ ```
37
+
38
+ The result API reloads the grant by ID, so editing a returned hash cannot broaden approval. This gem's adapter derives issuance policy input from saved consent; node's CIBA result API takes a separate `rar` completion option. Both require application policy, and their Ruby/JavaScript APIs are not identical. Completion binds the saved grant to the account/client; semantic request/approval/resource comparison occurs in the issuance policy before any token commits.
39
+
40
+ ## Protocol behavior and verification
41
+
42
+ Requests use a JSON array in the form parameter `authorization_details`. A nonempty RAR request must include a registered resource. At token issuance a resource must be selected explicitly or through the configured resource omission policy. The compiled details appear in the token response, resource JWT and opaque introspection, never the ID Token or UserInfo. Resource JWT introspection remains unsupported; use opaque tokens for online revocation checks. Introspection caller authorization remains an application/upstream responsibility.
43
+
44
+ Limits: 8,192 JSON bytes, 32 objects, nesting depth 10. Invalid UTF-8 and duplicate keys are rejected, as are invalid common fields, unknown/disallowed types and rejected application policy. Size/depth/duplicate-key restrictions are implementation limits, not claims that CIBA Core requires them.
45
+
46
+ `test/ciba_rar_test.rb` covers opaque/JWT output, client/type/resource rejection, snapshot tampering, invalid output, poll broadening, hook-time client changes, rollback, disabled pending requests and empty permission. The installed-gem smoke exercises RAR together with claims/resources, signature verification, ID Token exclusion and replay. The [complete reference OP](research/rar-reference-contract.md) establishes comparable application-policy cases.
47
+
48
+ Additional tests use distinct DB connections to race two polls with different RAR narrowing values and to race issuance with revocation. Only one poll's complete permission is stored; values are never combined. After replay/revocation finishes, opaque introspection exposes neither active permission nor authorization details. Migration tests also reject legacy requests bound to new consent when the corresponding feature is disabled. The node HTTP harness independently verifies RAR introspection becomes inactive after replay.
49
+
50
+ Remaining review includes RAR-specific concurrent completion, all feature load orders, separate introspection redaction policy, more complex type examples, and bounds on trusted policy execution time. This is not full RFC9396 conformance or certification.
data/docs/claims.md ADDED
@@ -0,0 +1,46 @@
1
+ # Optional explicit claim consent
2
+
3
+ This capability aligns the request/consent separation and ID Token/UserInfo filtering with node-oidc-provider. It is disabled by default. It does not enable other hints, signed requests, ping, resources or RAR.
4
+
5
+ ## Enable after migration
6
+
7
+ In an application-owned numbered Sequel migration, after the base CIBA schema:
8
+
9
+ ```ruby
10
+ Rodauth::CibaSupport::Schema.add_claims(self)
11
+ ```
12
+
13
+ This adds nullable TEXT columns `ciba_requests.requested_claims`, `ciba_grants.claims` and `oauth_grants.ciba_claims`. Custom tables can be supplied with `requests_table:`, `grants_table:`, `tokens_table:`; a custom request column uses `request_column:` and matching `ciba_request_columns`. The consent/token columns have fixed names. Existing rows retain null values; requested data is never copied into old consent. The helper is explicit, not idempotent, and does not run at startup or as part of the base schema.
14
+
15
+ Then configure `ciba_claims_enabled true`. Upgrade every worker before activation. Old workers cannot enforce the token projection and must not serve UserInfo for tokens issued with this capability. Disabling new claim requests on upgraded workers does not remove enforcement for existing tokens. Do not drop these columns or roll back to old workers while affected tokens remain usable.
16
+
17
+ ## Request and grant
18
+
19
+ Backchannel requests may include a JSON `claims` parameter containing `id_token` and/or `userinfo` objects. Each claim value is null or an object, matching the tested reference shape. Input is bounded to 8192 bytes, 64 claims per target and nesting depth 10. Invalid JSON or shape returns invalid_request. With the capability disabled this parameter remains unimplemented and is ignored, as before.
20
+
21
+ ```ruby
22
+ grant = rodauth.create_ciba_grant(
23
+ account_id: customer_id, oauth_application_id: client_id,
24
+ scopes: "openid", claims: ["email"], rejected_claims: ["email_verified"]
25
+ )
26
+ rodauth.backchannel_result(request_id, grant, auth_time: authentication_time)
27
+ ```
28
+
29
+ Claim names must be in `ciba_supported_claims`; the default includes ordinary standard profile/email/phone claims and address. Applications can explicitly configure additional names and supply their values with upstream `get_additional_param`. Configure upstream Discovery consistently for custom claims; the CIBA option does not rewrite OP-wide metadata. Identity/protocol claims such as sub, iss, aud, nonce and auth_time cannot be supplied through this consent list. They continue to come from the protocol and verified authentication context. Authentication claim output is selected separately as described in [authentication claims](authentication-claims.md).
30
+
31
+ Requested JSON and consent JSON are stored separately. Snapshot fields are frozen JSON strings. Passing a modified grant snapshot cannot add permission: completion reloads its ID. Convenience approval authorizes requested scopes only, not extra explicit claims; use saved consent to approve those.
32
+
33
+ ## Disclosure rules
34
+
35
+ - ID Token explicit attributes must be requested for that target and allowed by consent. With this capability enabled, scope-derived profile/email attributes are returned through UserInfo rather than automatically added to the ID Token, matching the reference default.
36
+ - UserInfo combines permitted scopes and explicitly requested/approved UserInfo attributes. Rejected names override both sources.
37
+ - Rejected/unselected attributes are not requested from the account callback. Poll parameters cannot expand the stored request. Client-supplied `value` never becomes identity data.
38
+ - The compiled selection is persisted on the issued OAuth row, separately from the request. UserInfo binds to the exact presented opaque token or a signed JWT's `urn:rodauth:ciba:token_id` reference, with account/client checks and DB revocation checks. The internal reference is omitted from ID Tokens. It is not authorization by itself.
39
+
40
+ The application remains responsible for evaluating essential/value/values, acr, and localized claim requirements before approving. The stored request preserves those objects for that purpose; default attribute acquisition returns trusted account values rather than asserting that requested constraints were met. Aggregated/distributed claim processing is not implemented by this capability. These limits prevent claiming complete OIDC claims parity.
41
+
42
+ Legacy JWTs without an exact issuance reference never inherit explicit claims from another OAuth row for the same account/client. They retain the upstream scope-based path; this does not retroactively add per-row revocation to those JWTs. In particular, removing a legacy issuance row is not permission to reuse a newer row's explicit consent. This migration boundary has a regression test using an actual pre-capability JWT.
43
+
44
+ ## Verification
45
+
46
+ `test/ciba_claims_test.rb` checks the five [reference cases](research/claims-reference-contract.md), both response targets, rejection overriding scope, request-value injection, altered grant snapshots, poll tampering, malformed input, JWT issuance isolation/revocation, and preservation of legacy rows. Existing tests cover capability-disabled scope behavior and ordinary authorization-code integration. The default upstream getter's interpretation of claim request objects is deliberately not used to source identity values.
@@ -0,0 +1,34 @@
1
+ # Failure, retry and external effects
2
+
3
+ Accepted policy: [ADR 0004](adr/0004-retain-atomic-issuance-and-stage-alignment.md). This contract describes current behavior, not proof that a particular deployment handles every failure. It intentionally retains same-database atomic issuance while aligning the successful and replay paths with node-oidc-provider.
4
+
5
+ ## Client recovery
6
+
7
+ | Observed outcome | State and recovery |
8
+ |---|---|
9
+ | `authorization_pending` | Continue polling within the request lifetime, respecting the advertised interval. |
10
+ | `slow_down` | Increase the polling interval as instructed; do not immediately retry. |
11
+ | Issuance failure with rollback confirmed by trusted server-side inspection | Participating token writes and consumption roll back. Retry is possible only while the request, account, client and consent remain eligible. Use a fresh client assertion if JWT client authentication is used. |
12
+ | Timeout, disconnected connection, generic 500 or 503 | The response alone does not prove rollback. `Retry-After` is a timing constraint, not an idempotency guarantee. If the client cannot establish the outcome through application-owned recovery, start a new CIBA request rather than blindly replaying collection. |
13
+ | Successful issuance committed, response lost | Tokens may already exist. Start a new authorization; there is no token-response replay cache. Recovery/revocation of an uncertain earlier issuance is an application responsibility. |
14
+ | Unexpired, already collected approved request is polled again | Revoke its saved consent and linked token rows, then return `invalid_grant`. Reusing consent across requests extends this revocation to those linked tokens. |
15
+ | Negative completion | First collection consumes the result and returns its saved error code (`access_denied` for customer refusal); another collection returns `invalid_grant`. It cannot become a successful issuance through retry. |
16
+ | Request expired | Return `expired_token` before replay handling. An expired replay does not execute the replay-triggered revocation path. |
17
+
18
+ Database revocation does not by itself invalidate a JWT verified offline by a resource server. Trusted Ruby completion retries are separate from Token Endpoint polling; their idempotent behavior does not make token collection idempotent. A new request may notify the customer again and requires its own valid completion.
19
+
20
+ ## Transaction and delivery boundaries
21
+
22
+ Request consumption and token writes participate in the same DB transaction. Signing and before/after hooks execute within that boundary, but only participating DB writes can be rolled back. A remote signer, HTTP notification or other external action may have completed before an exception or process failure. The gem does not automatically retry hooks.
23
+
24
+ For required audit records or durable dispatch, write to the same DB from a transactional hook. An application-owned outbox worker can deliver committed records with its own retry and deduplication policy. Do not interpret an `after` hook as after commit. Observers run after the outermost commit and their failures do not change the result; they are best-effort and can be lost on process death.
25
+
26
+ The authentication-device trigger runs after saving the request and before the successful initiation response. Without an application-owned outer transaction, a trigger exception leaves the saved request; notification may already have happened, while the client did not receive its identifier. Retrying initiation creates a new request. The application owns notification deduplication and abandoned-request cleanup. A trigger inside an application-owned outer transaction does not wait for its commit: use committed outbox records when workers require visibility.
27
+
28
+ Never send a successful response before an enclosing transaction commits. A savepoint cannot make consumption durable independently of an outer rollback. JWT client-assertion consumption normally precedes the CIBA transaction, so a failed CIBA operation can still consume the assertion; generate a fresh one on retry.
29
+
30
+ ## Evidence and remaining validation
31
+
32
+ The latest SQLite/PostgreSQL/MySQL matrix covers signing-failure rollback and retry, outer rollback, hook exceptions, savepoint-aware observers, device-trigger failure, replay revocation and client isolation. See the [DB log](validation/db-matrix-latest.txt) and [alignment status](node-alignment.md). In addition to the isolated handler harness, the [complete node OP test](../test/reference/full-op/README.md) verifies HTTP behavior with real signing and its built-in nontransactional memory adapter.
33
+
34
+ Before deployment, test the actual client's response-loss recovery and any external signer/notification side effects under faults. The latest DB matrix, Ruby3.3/3.4 SQLite matrix and the documented complete-OP lifecycle scenarios have passed. Process-crash recovery and production node adapters remain outside that evidence.