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,2185 @@
1
+ # frozen_string_literal: true
2
+ require "jwt"
3
+ require "rodauth/oauth"
4
+ require "rodauth/features/oidc"
5
+ require "json"
6
+ require "digest"
7
+ require "securerandom"
8
+ require "uri"
9
+ require "stringio"
10
+ require "rodauth/ciba/authorization_details"
11
+ require "rodauth/ciba/http"
12
+ require "rodauth/ciba/jwks_cache"
13
+ require "rodauth/ciba/registration"
14
+ require "rodauth/ciba/pairwise"
15
+ require "rodauth/ciba/dpop"
16
+ require "rodauth/ciba/mtls"
17
+ require "rodauth/ciba/id_token_encryption"
18
+
19
+ module Rodauth
20
+ module CibaSupport
21
+ GRANT_TYPE = "urn:openid:params:grant-type:ciba"
22
+ class CompletionError < StandardError; end
23
+ class PingDeliveryError < CompletionError; end
24
+ class NotFound < CompletionError; end
25
+ class IdentityMismatch < CompletionError; end
26
+ class Conflict < CompletionError; end
27
+ class Expired < CompletionError; end
28
+ class Ineligible < CompletionError; end
29
+ class ReentrantMutation < CompletionError; end
30
+ class ConfigurationError < StandardError; end
31
+ module ResourceTokens
32
+ private
33
+
34
+ def jwt_claims(grant)
35
+ super.tap do |claims|
36
+ if grant[oauth_grants_type_column] == GRANT_TYPE && grant[:ciba_resource]
37
+ claims[:aud] = grant[:ciba_resource_audience]
38
+ claims[:"urn:rodauth:ciba:resource"] = grant[:ciba_resource]
39
+ claims[:authorization_details] = JSON.parse(grant[:ciba_authorization_details]) if grant[:ciba_authorization_details]
40
+ end
41
+ end
42
+ end
43
+
44
+ def jwt_decode(token, verify_aud: true, **options)
45
+ userinfo = request.path == userinfo_path
46
+ introspection = features.include?(:oauth_token_introspection) && request.path == introspect_path
47
+ if userinfo || introspection
48
+ # These OP endpoints are not the API audience. Authenticate the CIBA
49
+ # marker before enforcing its endpoint restrictions. Other OAuth tokens
50
+ # retain upstream behavior.
51
+ claims = super(token, verify_aud: false, **options)
52
+ return unless claims
53
+ if claims["urn:rodauth:ciba:resource"]
54
+ return if userinfo
55
+ throw_json_response_error(oauth_invalid_response_status, "unsupported_token_type")
56
+ end
57
+ end
58
+ super
59
+ end
60
+
61
+ def json_token_introspect_payload(grant)
62
+ super.tap do |payload|
63
+ if grant && grant[oauth_grants_type_column] == GRANT_TYPE && grant[:ciba_resource]
64
+ payload[:aud] = grant[:ciba_resource_audience]
65
+ payload[:authorization_details] = JSON.parse(grant[:ciba_authorization_details]) if grant[:ciba_authorization_details]
66
+ end
67
+ end
68
+ end
69
+ end
70
+ # This boundary is prepended so enabling an upstream feature later cannot bypass it.
71
+ module ClientAuthentication
72
+ def over_max_bytesize_param_value(key, value)
73
+ if key == "token" && features.include?(:oauth_token_introspection) && request.path == introspect_path &&
74
+ value.bytesize <= 8192 && ciba_dpop_userinfo_candidate?(value)
75
+ return value
76
+ end
77
+ super
78
+ end
79
+
80
+ def logged_in?
81
+ # This protocol operation always authenticates the OAuth client and
82
+ # never derives revocation authority from a browser account session.
83
+ ciba_token_revocation_request? ? false : super
84
+ end
85
+
86
+ def check_csrf?
87
+ ciba_token_revocation_request? ? false : super
88
+ end
89
+
90
+ def validate_revoke_params(*args)
91
+ return super unless ciba_token_revocation_request?
92
+ validate_ciba_transport!
93
+ token = ciba_parameter("token", required: true, max: @ciba_revocation_jwt_candidate ? 8192 : 1024)
94
+ ciba_parameter("token_type_hint") if request.params.key?("token_type_hint")
95
+ if @ciba_revocation_jwt_candidate
96
+ ciba_error(ciba_signed_access_token?(token) ? "unsupported_token_type" : "invalid_request")
97
+ end
98
+ @ciba_token_revocation_response = true
99
+ end
100
+
101
+ def authorization_required
102
+ # Recipient lookup happens after approval/refresh mutation. Raise so
103
+ # upstream HTTP errors cannot halt and commit a consumed source.
104
+ raise ConfigurationError, "CIBA ID Token recipient lookup failed" if @ciba_authentication
105
+ raise ProtocolError, "invalid_client_metadata" if @ciba_tls_key_lookup
106
+ # A client already authenticated before this distinct signature-key lookup.
107
+ raise ProtocolError, "invalid_request" if @ciba_request_key_lookup
108
+ super
109
+ end
110
+
111
+ private
112
+
113
+ def revoke_oauth_grant
114
+ return super unless ciba_token_revocation_request?
115
+ if @ciba_revocation_access_id
116
+ ciba_revoke_access_artifacts
117
+ elsif param("token").start_with?("ciba_rt_") && db.table_exists?(ciba_refresh_tokens_table)
118
+ ds = db[ciba_refresh_tokens_table].where(token_digest: Digest::SHA256.hexdigest(param("token")))
119
+ row = ciba_lock_source_after_grant(ds)
120
+ if row && row[:expires_at] > ciba_now
121
+ consent = ciba_locked_grant(row[:grant_id])
122
+ if consent && !consent[:revoked_at]
123
+ ciba_error("invalid_request") unless row[:oauth_application_id] == oauth_application[oauth_applications_id_column]
124
+ revoke_ciba_grant(row[:grant_id])
125
+ end
126
+ end
127
+ end
128
+ # Upstream builds its success payload after the standard transactional
129
+ # before/after hooks; suppress that payload for this CIBA endpoint result.
130
+ {oauth_grants_revoked_at_column => Time.now}
131
+ end
132
+
133
+ def json_response_success(*args)
134
+ return super unless @ciba_token_revocation_response
135
+ response["Cache-Control"] = "no-store"
136
+ response["Pragma"] = "no-cache"
137
+ request.halt [200, response.headers, []]
138
+ end
139
+
140
+ def oauth_application_jwks(application)
141
+ previous = @ciba_jwks_cache_lookup
142
+ @ciba_jwks_cache_lookup = @ciba_request_key_lookup || @ciba_verifying_client_assertion || @ciba_tls_key_lookup || @ciba_authentication
143
+ super
144
+ ensure
145
+ @ciba_jwks_cache_lookup = previous
146
+ end
147
+
148
+ def http_request_cache
149
+ self.class.instance_variable_get(:@ciba_jwks_cache) || super
150
+ end
151
+
152
+ def http_request_with_cache(uri, *args)
153
+ return super unless @ciba_jwks_cache_lookup
154
+ http_request_cache.fetch(URI(uri.to_s)) { http_request(uri, *args) }
155
+ rescue CibaSupport::JwksCache::StatusError
156
+ authorization_required
157
+ end
158
+
159
+ def http_request(uri, form_data = nil, &block)
160
+ return super unless @ciba_request_key_lookup || @ciba_verifying_client_assertion || @ciba_tls_key_lookup || @ciba_authentication
161
+ raise CibaSupport::HTTP::Error, "unexpected JWKS request body" if form_data
162
+ uri = URI(uri.to_s)
163
+ outgoing = Net::HTTP::Get.new(uri.request_uri)
164
+ outgoing["accept"] = json_response_content_type
165
+ block&.call(outgoing)
166
+ result = CibaSupport::HTTP.request(uri, outgoing, address_allowed: method(:ciba_http_address_allowed?))
167
+ authorization_required unless @ciba_jwks_cache_lookup || (200..299).include?(result.code.to_i)
168
+ result
169
+ end
170
+
171
+ def require_oauth_application
172
+ return super unless ciba_protocol_request?
173
+ ciba_http do
174
+ validate_ciba_transport!
175
+ if request.params.key?("client_assertion") || request.params.key?("client_assertion_type")
176
+ authorization_required unless features.include?(:oauth_jwt_bearer_grant) &&
177
+ param("client_assertion_type") == "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" &&
178
+ request.params["client_assertion"].is_a?(String)
179
+ end
180
+ # CIBA Core section 13 classifies absent client authentication as
181
+ # invalid_client. Assertions can identify clients without client_id.
182
+ if (request.path == backchannel_authentication_path ||
183
+ (request.path == token_path && request.params["grant_type"] == CibaSupport::GRANT_TYPE)) &&
184
+ !request.env["HTTP_AUTHORIZATION"] && !request.params.key?("client_id") &&
185
+ !request.params.key?("client_assertion")
186
+ authorization_required
187
+ end
188
+ super
189
+ end
190
+ end
191
+
192
+ # grant_type/assertion are not Backchannel authentication parameters. They must
193
+ # not select upstream's JWT authorization-grant path instead of client auth.
194
+ def assertion_grant_type?(*args)
195
+ request.path == backchannel_authentication_path ? false : super
196
+ end
197
+
198
+ def require_oauth_application_from_jwt_bearer_assertion_subject(assertion)
199
+ begin
200
+ claims, header = JWT.decode(assertion, nil, false)
201
+ rescue JWT::DecodeError, ArgumentError, TypeError
202
+ authorization_required if ciba_protocol_request?
203
+ return super
204
+ end
205
+ client = claims.is_a?(Hash) && claims["sub"]
206
+ application = client.is_a?(String) && db[oauth_applications_table].where(oauth_applications_client_id_column => client).first
207
+ # Protect assertion reuse across other grant flows for the same CIBA client,
208
+ # while preserving upstream behavior for unrelated clients.
209
+ protected_client = application && application[oauth_applications_grant_types_column].to_s.split.include?(GRANT_TYPE)
210
+ return super unless ciba_protocol_request? || protected_client
211
+ authorization_required unless claims.is_a?(Hash) && header.is_a?(Hash) &&
212
+ client.is_a?(String) && !client.empty? && claims["iss"] == client &&
213
+ claims["exp"].is_a?(Numeric) && claims["exp"].finite? &&
214
+ claims["exp"] > Time.now.to_f && claims["exp"] < 9_223_372_036_854_775_807 &&
215
+ claims["jti"].is_a?(String) && !claims["jti"].empty? && claims["jti"].bytesize <= 1024
216
+ if request.params.key?("client_id") && request.params["client_id"] != client
217
+ authorization_required
218
+ end
219
+ configured = ciba_client_assertion_signing_algorithms
220
+ authorization_required if configured && !configured.include?(header["alg"])
221
+ registered_algorithm = application && application[oauth_applications_token_endpoint_auth_signing_alg_column]
222
+ authorization_required if registered_algorithm && registered_algorithm != header["alg"]
223
+ if claims.key?("iat")
224
+ issued = claims["iat"]
225
+ authorization_required unless issued.is_a?(Numeric) && issued.finite? &&
226
+ issued >= 0 && issued <= Time.now.to_f + oauth_jwt_iat_leeway
227
+ end
228
+ @ciba_verifying_client_assertion = true
229
+ application = super
230
+ ciba_consume_client_assertion!(application, claims)
231
+ application
232
+ ensure
233
+ @ciba_verifying_client_assertion = false
234
+ end
235
+
236
+ def require_oauth_application_from_private_key_jwt(client_id, assertion)
237
+ return super unless @ciba_verifying_client_assertion
238
+ application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
239
+ authorization_required unless supports_auth_method?(application, "private_key_jwt")
240
+ begin
241
+ jwks = oauth_application_jwks(application)
242
+ rescue CibaSupport::HTTP::Error, JSON::ParserError, IOError, SystemCallError, Timeout::Error,
243
+ SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
244
+ authorization_required
245
+ end
246
+ keys = jwks.is_a?(Hash) ? jwks[:keys] : jwks
247
+ # Without client keys the upstream generic JWT decoder can fall back to OP keys.
248
+ authorization_required unless keys.is_a?(Array) && !keys.empty?
249
+ options = {jwks: jwks}
250
+ algorithm = application[oauth_applications_token_endpoint_auth_signing_alg_column]
251
+ options[:jws_algorithm] = algorithm if algorithm
252
+ authorization_required unless jwt_assertion(assertion, **options)
253
+ application
254
+ end
255
+
256
+ def require_oauth_application_from_client_secret_jwt(client_id, assertion, algorithm)
257
+ return super unless @ciba_verifying_client_assertion
258
+ application = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id).first
259
+ minimum = {"HS256" => 32, "HS384" => 48, "HS512" => 64}[algorithm]
260
+ secret = application && application[oauth_applications_client_secret_column]
261
+ authorization_required unless minimum && secret.is_a?(String) && secret.bytesize >= minimum
262
+ super
263
+ end
264
+
265
+ def jwt_assertion(assertion, **kwargs)
266
+ return super unless @ciba_verifying_client_assertion
267
+ _payload, header = JWT.decode(assertion, nil, false)
268
+ claims = if %w[Ed25519 EdDSA].include?(header["alg"])
269
+ ciba_decode_edwards_client_assertion(assertion, kwargs[:jwks])
270
+ else
271
+ jwt_decode(assertion, verify_iss: false, verify_aud: false, verify_jti: false, **kwargs)
272
+ end
273
+ return unless claims.is_a?(Hash)
274
+ audiences = claims["aud"].is_a?(String) ? [claims["aud"]] : claims["aud"]
275
+ return unless audiences.is_a?(Array) && audiences.all? { |aud| aud.is_a?(String) }
276
+ expected = request.path == backchannel_authentication_path ?
277
+ [oauth_jwt_issuer, token_url, backchannel_authentication_url] : [request.url]
278
+ if request.path == token_path && (request.params["grant_type"] == GRANT_TYPE || ciba_refresh_request?)
279
+ expected << oauth_jwt_issuer
280
+ end
281
+ return if (expected & audiences).empty?
282
+ claims
283
+ end
284
+ end
285
+ class ProtocolError < StandardError
286
+ attr_reader :code
287
+ def initialize(code)
288
+ @code = code
289
+ super(code)
290
+ end
291
+ end
292
+ end
293
+
294
+ Feature.define(:ciba, :Ciba) do
295
+ depends :oidc
296
+ %w[ciba_request ciba_approve ciba_deny ciba_issue ciba_refresh].each do |action|
297
+ before action
298
+ after action
299
+ end
300
+
301
+ auth_value_method :ciba_client_assertions_table, :ciba_client_assertions
302
+ auth_value_method :ciba_dpop_nonce_secret, nil
303
+ auth_value_method :ciba_pairwise_enabled, false
304
+ auth_value_method :ciba_token_subject_column, :ciba_subject
305
+ auth_methods :ciba_pairwise_identifier
306
+ auth_value_method :oauth_applications_token_endpoint_auth_signing_alg_column, :token_endpoint_auth_signing_alg
307
+ auth_value_method :oauth_applications_default_max_age_column, :default_max_age
308
+ auth_value_method :oauth_applications_require_auth_time_column, :require_auth_time
309
+ auth_value_method :oauth_applications_default_acr_values_column, :default_acr_values
310
+ auth_value_method :ciba_grants_table, :ciba_grants
311
+ auth_value_method :ciba_grant_lifetime, 14 * 24 * 60 * 60
312
+ auth_value_method :ciba_refresh_tokens_enabled, false
313
+ auth_value_method :ciba_refresh_tokens_table, :ciba_refresh_tokens
314
+ auth_value_method :ciba_refresh_token_lifetime, 14 * 24 * 60 * 60
315
+ auth_value_method :ciba_claims_enabled, false
316
+ auth_value_method :ciba_authentication_claims_by_scope, {}.freeze
317
+ auth_value_method :ciba_request_context_enabled, false
318
+ auth_value_method :ciba_dynamic_client_registration_enabled, false
319
+ auth_value_method :ciba_issue_registration_access_token, true
320
+ auth_value_method :ciba_rotate_registration_access_token, true
321
+ auth_value_method :ciba_registration_token_digest_column, :ciba_registration_token_digest
322
+ auth_value_method :ciba_login_hint_token_enabled, false
323
+ auth_value_method :ciba_id_token_hint_enabled, false
324
+ auth_value_method :ciba_id_token_encryption_enabled, false
325
+ auth_value_method :ciba_user_code_enabled, false
326
+ auth_value_method :ciba_ping_enabled, false
327
+ auth_value_method :ciba_ping_deliveries_table, :ciba_ping_deliveries
328
+ auth_value_method :oauth_applications_backchannel_client_notification_endpoint_column, :backchannel_client_notification_endpoint
329
+ auth_value_method :oauth_applications_backchannel_user_code_parameter_column, :backchannel_user_code_parameter
330
+ auth_value_method :ciba_id_token_hint_max_age, nil
331
+ auth_value_method :ciba_request_signing_algorithms, [].freeze
332
+ auth_value_method :ciba_client_assertion_signing_algorithms, nil
333
+ auth_value_method :ciba_id_token_signing_keys, {}.freeze
334
+ auth_value_method :oauth_applications_backchannel_authentication_request_signing_alg_column, :backchannel_authentication_request_signing_alg
335
+ auth_value_method :ciba_resources_enabled, false
336
+ auth_value_method :ciba_authorization_details_enabled, false
337
+ auth_value_method :ciba_authorization_details_types, [].freeze
338
+ auth_value_method :ciba_resource_servers, {}.freeze
339
+ auth_value_method :ciba_supported_claims, (OIDC_SCOPES_MAP.reject { |scope, _| scope == "address" }.values.flatten.map(&:to_s) + ["address"]).uniq.freeze
340
+ auth_value_method :oauth_grants_ciba_grant_id_column, :ciba_grant_id
341
+ auth_value_method :ciba_requests_table, :ciba_requests
342
+ auth_value_method :ciba_request_columns, {}.freeze
343
+ auth_value_method :ciba_request_lifetime, 600
344
+ auth_value_method :ciba_max_request_lifetime, 600
345
+ auth_value_method :ciba_poll_interval, 5
346
+ auth_value_method :ciba_max_request_bytes, 16_384
347
+ auth_value_method :ciba_require_tls, true
348
+ auth_value_method :oauth_applications_backchannel_token_delivery_mode_column, :backchannel_token_delivery_mode
349
+ auth_methods :ciba_tls_client_certificate, :ciba_tls_client_certificate_authorized?, :ciba_tls_client_certificate_subject_matches?,
350
+ :ciba_id_token_hmac_secret, :ciba_id_token_encryption_secret, :ciba_dpop_endpoint_uri,
351
+ :resolve_ciba_login_hint, :resolve_ciba_login_hint_token, :resolve_ciba_id_token_subject, :verify_ciba_user_code, :ciba_http_address_allowed?, :approve_ciba_request, :deny_ciba_request,
352
+ :ciba_request, :pending_ciba_requests, :cleanup_ciba_requests,
353
+ :ciba_current_request, :ciba_current_refresh, :ciba_request_accepted, :observe_ciba_event,
354
+ :report_ciba_observer_error, :report_ciba_processing_error, :ciba_now,
355
+ :ciba_account_eligible?, :cleanup_ciba_client_assertions, :cleanup_ciba_refresh_tokens,
356
+ :validate_ciba_binding_message, :validate_ciba_request_context, :create_ciba_grant, :ciba_grant,
357
+ :revoke_ciba_grant, :backchannel_result, :trigger_ciba_authentication_device,
358
+ :ciba_resource_server, :ciba_use_granted_resource, :ciba_default_resource,
359
+ :retry_ciba_ping,
360
+ :ciba_validate_authorization_details, :ciba_authorization_details_for_token
361
+
362
+ def ciba_dpop_endpoint_uri
363
+ # Proxy headers are not a trust decision. An application behind a trusted
364
+ # proxy can override this callback with its validated external URL.
365
+ env = request.env.reject { |key, _| key == "HTTP_FORWARDED" || key.start_with?("HTTP_X_FORWARDED_") }
366
+ Rack::Request.new(env).url
367
+ end
368
+
369
+ def ciba_validate_authorization_details(details, oauth_application:)
370
+ raise CibaSupport::ConfigurationError, "configure authorization-details validation"
371
+ end
372
+
373
+ def ciba_tls_client_certificate
374
+ nil
375
+ end
376
+
377
+ def ciba_id_token_hmac_secret(oauth_application:)
378
+ if @ciba_verified_client_secret && @ciba_verified_client_secret.first == oauth_application[oauth_applications_id_column]
379
+ @ciba_verified_client_secret.last
380
+ elsif !oauth_applications_client_secret_hash_column
381
+ oauth_application[oauth_applications_client_secret_column]
382
+ end
383
+ end
384
+
385
+ def ciba_id_token_encryption_secret(oauth_application:)
386
+ ciba_id_token_hmac_secret(oauth_application: oauth_application)
387
+ end
388
+
389
+ def ciba_tls_client_certificate_authorized?
390
+ false
391
+ end
392
+
393
+ def ciba_tls_client_certificate_subject_matches?(property, expected)
394
+ false
395
+ end
396
+
397
+ def validate_ciba_request_context(context, oauth_application:)
398
+ raise CibaSupport::ConfigurationError, "configure validate_ciba_request_context"
399
+ end
400
+
401
+ def ciba_authorization_details_for_token(requested:, approved:, narrowing:, resource:, oauth_application:)
402
+ raise CibaSupport::ConfigurationError, "configure authorization-details issuance policy"
403
+ end
404
+
405
+ # A registry lookup, never an HTTP fetch of the caller-supplied identifier.
406
+ def ciba_resource_server(resource, oauth_application:)
407
+ ciba_resource_servers[resource]
408
+ end
409
+
410
+ def ciba_use_granted_resource(request_snapshot)
411
+ false
412
+ end
413
+
414
+ def ciba_default_resource(resources, oauth_application:)
415
+ # Like the reference default, leave ambiguity unresolved and reject it.
416
+ resources
417
+ end
418
+
419
+ auth_server_route(:backchannel_authentication, "backchannel-authentication") do |r|
420
+ r.post do
421
+ ciba_http do
422
+ validate_ciba_transport!
423
+ require_oauth_application
424
+ ciba_error("unauthorized_client") unless ciba_client_eligible?(oauth_application)
425
+ ciba_process_signed_request!
426
+ requested = validate_ciba_request!
427
+ delivery = ciba_ping_delivery_parameters
428
+ account_id = if requested.key?(:id_token_hint)
429
+ subject = ciba_id_token_hint_subject(requested.delete(:id_token_hint))
430
+ resolve_ciba_id_token_subject(subject, oauth_application: oauth_application)
431
+ elsif requested.key?(:login_hint_token)
432
+ resolve_ciba_login_hint_token(requested.delete(:login_hint_token), oauth_application: oauth_application)
433
+ else
434
+ resolve_ciba_login_hint(requested.delete(:login_hint), oauth_application: oauth_application)
435
+ end
436
+ ciba_error("unknown_user_id") unless ciba_account_eligible?(account_id)
437
+ if ciba_request_context_enabled
438
+ validate_ciba_request_context(requested[:request_context]&.dup&.freeze, oauth_application: oauth_application)
439
+ end
440
+ if ciba_user_code_enabled
441
+ accepted = verify_ciba_user_code(requested.delete(:user_code), account_id: account_id, oauth_application: oauth_application)
442
+ ciba_error("invalid_user_code") unless accepted == true
443
+ end
444
+ public_id = SecureRandom.urlsafe_base64(32)
445
+ delivery[:auth_req_id] = public_id if delivery
446
+ lifetime = requested.delete(:lifetime)
447
+ now = ciba_now
448
+ row = requested.merge(auth_req_id_digest: Digest::SHA256.hexdigest(public_id),
449
+ account_id: account_id, oauth_application_id: oauth_application[oauth_applications_id_column],
450
+ created_at: now, expires_at: now + lifetime,
451
+ status: "pending", lock_version: 0, interval: ciba_poll_interval)
452
+ ciba_transaction do
453
+ @ciba_current_request = ciba_snapshot(row)
454
+ before_ciba_request
455
+ row[:id] = ciba_ds.insert(ciba_values(row))
456
+ db[ciba_ping_deliveries_table].insert(delivery.merge(request_id: row[:id])) if delivery
457
+ @ciba_current_request = ciba_snapshot(row)
458
+ after_ciba_request
459
+ ciba_emit(row, "request_accepted", nil, "pending")
460
+ snapshot = ciba_snapshot(row)
461
+ db.after_commit(savepoint: true) { ciba_observe { ciba_request_accepted(snapshot) } }
462
+ end
463
+ trigger_ciba_authentication_device(ciba_request(row[:id]))
464
+ json_response_success(auth_req_id: public_id, expires_in: row[:expires_at] - row[:created_at], interval: row[:interval])
465
+ ensure
466
+ @ciba_current_request = nil
467
+ end
468
+ end
469
+ end
470
+
471
+ def resolve_ciba_login_hint(_hint, oauth_application:)
472
+ raise NotImplementedError, "configure resolve_ciba_login_hint"
473
+ end
474
+
475
+ def resolve_ciba_login_hint_token(_token, oauth_application:)
476
+ raise CibaSupport::ConfigurationError, "configure resolve_ciba_login_hint_token"
477
+ end
478
+
479
+ def resolve_ciba_id_token_subject(_subject, oauth_application:)
480
+ raise CibaSupport::ConfigurationError, "configure resolve_ciba_id_token_subject"
481
+ end
482
+
483
+ def ciba_pairwise_identifier(_account_id, oauth_application:, sector_identifier:)
484
+ raise CibaSupport::ConfigurationError, "configure ciba_pairwise_identifier"
485
+ end
486
+
487
+ def verify_ciba_user_code(_code, account_id:, oauth_application:)
488
+ raise CibaSupport::ConfigurationError, "configure verify_ciba_user_code"
489
+ end
490
+
491
+ def ciba_http_address_allowed?(address)
492
+ CibaSupport::HTTP.public_address?(address)
493
+ end
494
+
495
+ def validate_ciba_binding_message(message)
496
+ return if message.nil?
497
+ ciba_error("invalid_binding_message") unless message.match?(/\A[a-zA-Z0-9\-._+\/!?#]{1,20}\z/)
498
+ end
499
+
500
+ def ciba_now
501
+ Time.now.to_i
502
+ end
503
+
504
+ def ciba_current_request
505
+ @ciba_current_request
506
+ end
507
+
508
+ def ciba_current_refresh
509
+ @ciba_current_refresh
510
+ end
511
+
512
+ def ciba_request_accepted(_request)
513
+ end
514
+
515
+ def trigger_ciba_authentication_device(_request)
516
+ raise CibaSupport::ConfigurationError, "configure trigger_ciba_authentication_device"
517
+ end
518
+
519
+ def observe_ciba_event(_event)
520
+ end
521
+
522
+ def report_ciba_observer_error(error)
523
+ warn "CIBA observer failed (#{error.class})"
524
+ end
525
+
526
+ def report_ciba_processing_error(error)
527
+ warn "CIBA processing failed (#{error.class})"
528
+ end
529
+
530
+ def ciba_account_eligible?(id)
531
+ id && db[accounts_table].where(account_id_column => id, account_status_column => account_open_status_value).any?
532
+ end
533
+
534
+ def create_ciba_grant(account_id:, oauth_application_id:, scopes:, expires_at: ciba_now + ciba_grant_lifetime,
535
+ claims: [], rejected_claims: [], resources: {}, authorization_details: [])
536
+ claim_consent = ciba_claim_consent(claims, rejected_claims)
537
+ unless scopes.is_a?(String) && scopes.valid_encoding? && scopes.bytesize <= 4096 &&
538
+ scopes.match?(/\A[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*\z/)
539
+ raise ArgumentError, "invalid scopes"
540
+ end
541
+ unless expires_at.nil? || (expires_at.is_a?(Integer) && expires_at > ciba_now)
542
+ raise ArgumentError, "invalid expires_at"
543
+ end
544
+ scopes = scopes.split.uniq
545
+ application = oauth_application_ds(oauth_application_id).first
546
+ unless scopes.include?("openid") && ciba_account_eligible?(account_id) && ciba_client_eligible?(application, scopes)
547
+ raise CibaSupport::Ineligible, "account, client or scopes unavailable"
548
+ end
549
+ row = {account_id: account_id, oauth_application_id: oauth_application_id,
550
+ scopes: scopes.join(oauth_scope_separator), created_at: ciba_now, expires_at: expires_at, revoked_at: nil}
551
+ row[:claims] = claim_consent if ciba_claims_enabled
552
+ resource_consent = ciba_resource_consent(resources, application)
553
+ row[:resources] = resource_consent if ciba_resources_enabled
554
+ if ciba_authorization_details_enabled
555
+ row[:authorization_details] = JSON.generate(ciba_checked_authorization_details(JSON.generate(authorization_details), application))
556
+ elsif authorization_details != []
557
+ raise ArgumentError, "authorization details not enabled"
558
+ end
559
+ row[:id] = db[ciba_grants_table].insert(row)
560
+ ciba_snapshot(row)
561
+ end
562
+
563
+ def ciba_grant(id)
564
+ ciba_validate_id!(id)
565
+ row = db[ciba_grants_table].where(id: id).first
566
+ raise CibaSupport::NotFound, "grant not found" unless row
567
+ ciba_snapshot(row)
568
+ end
569
+
570
+ def revoke_ciba_grant(id)
571
+ ciba_validate_id!(id)
572
+ ciba_transaction do
573
+ grant = ciba_locked_grant(id)
574
+ raise CibaSupport::NotFound, "grant not found" unless grant
575
+ db[ciba_grants_table].where(id: id, revoked_at: nil).update(revoked_at: ciba_now)
576
+ db[oauth_grants_table].where(oauth_grants_ciba_grant_id_column => id)
577
+ .update(oauth_grants_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
578
+ end
579
+ :revoked
580
+ end
581
+
582
+ # Like node-oidc-provider's backchannelResult: trust a saved grant, not caller claims.
583
+ def backchannel_result(id, result, auth_time: nil, acr: nil, amr: nil)
584
+ if result.is_a?(CibaSupport::ProtocolError)
585
+ code = result.code
586
+ unless code.is_a?(String) && code.valid_encoding? && code.bytesize <= 1024 &&
587
+ code.match?(/\A[\x20-\x21\x23-\x5B\x5D-\x7E]+\z/)
588
+ raise ArgumentError, "invalid result error"
589
+ end
590
+ context = {completion_error: code == "access_denied" ? nil : code.dup}
591
+ return ciba_complete(id, ciba_request(id)[:account_id], "denied", context)
592
+ end
593
+ grant_id = result.is_a?(Hash) ? result.fetch(:id) : result
594
+ grant = ciba_grant(grant_id)
595
+ approve_ciba_request(id, account_id: grant[:account_id], grant_id: grant_id,
596
+ auth_time: auth_time, acr: acr, amr: amr)
597
+ end
598
+
599
+ def approve_ciba_request(id, account_id:, auth_time: nil, acr: nil, amr: nil, grant_id: nil)
600
+ unless auth_time.nil? || (auth_time.is_a?(Integer) && auth_time >= 0 && auth_time <= ciba_now)
601
+ raise ArgumentError, "invalid auth_time"
602
+ end
603
+ raise ArgumentError, "invalid acr" unless acr.nil? || (acr.is_a?(String) && acr.bytesize <= 1024 && acr.valid_encoding?)
604
+ unless amr.nil? || (amr.is_a?(Array) && amr.size <= 32 && amr.all? { |v| v.is_a?(String) && v.valid_encoding? && v.bytesize <= 128 })
605
+ raise ArgumentError, "invalid amr"
606
+ end
607
+ context = {auth_time: auth_time, acr: acr, amr: amr && JSON.generate(amr.uniq.sort)}
608
+ unless grant_id.nil?
609
+ ciba_validate_id!(grant_id)
610
+ context[:grant_id] = grant_id
611
+ end
612
+ ciba_complete(id, account_id, "approved", context)
613
+ end
614
+
615
+ def deny_ciba_request(id, account_id:)
616
+ ciba_complete(id, account_id, "denied", {completion_error: nil})
617
+ end
618
+
619
+ def retry_ciba_ping(id)
620
+ ciba_validate_id!(id)
621
+ raise CibaSupport::ConfigurationError, "ping not enabled" unless ciba_ping_enabled
622
+ raise CibaSupport::ConfigurationError, "ping delivery requires a committed transaction" if db.in_transaction?
623
+ row = ciba_read(ciba_ds.where(ciba_values(id: id)).first)
624
+ raise CibaSupport::NotFound, "request not found" unless row
625
+ return :not_deliverable unless %w[approved denied].include?(row[:status]) && row[:expires_at] > ciba_now
626
+ delivery = db[ciba_ping_deliveries_table].where(request_id: id).first
627
+ return :not_deliverable unless delivery
628
+ application = oauth_application_ds(row[:oauth_application_id]).first
629
+ unless ciba_client_eligible?(application) &&
630
+ application[oauth_applications_backchannel_token_delivery_mode_column] == "ping"
631
+ raise CibaSupport::Ineligible, "ping client unavailable"
632
+ end
633
+ # Like Client.backchannelPing, resolve the registered destination at dispatch.
634
+ # A management update does not rewrite an already constructed HTTP request.
635
+ uri = URI(application[oauth_applications_backchannel_client_notification_endpoint_column])
636
+ outgoing = Net::HTTP::Post.new(uri.request_uri)
637
+ outgoing["authorization"] = "Bearer #{delivery[:notification_token]}"
638
+ outgoing["content-type"] = "application/json"
639
+ outgoing.body = JSON.generate(auth_req_id: delivery[:auth_req_id])
640
+ begin
641
+ result = CibaSupport::HTTP.request(uri, outgoing, address_allowed: method(:ciba_http_address_allowed?), read_body: false)
642
+ raise CibaSupport::PingDeliveryError, "ping endpoint did not acknowledge notification" unless %w[200 204].include?(result.code)
643
+ rescue CibaSupport::HTTP::Error, IOError, SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
644
+ raise CibaSupport::PingDeliveryError, "ping delivery failed"
645
+ end
646
+ :delivered
647
+ end
648
+
649
+ def ciba_request(id)
650
+ ciba_validate_id!(id)
651
+ row = ciba_read(ciba_ds.where(ciba_values(id: id)).first)
652
+ raise CibaSupport::NotFound, "request not found" unless row
653
+ ciba_snapshot(row)
654
+ end
655
+
656
+ def pending_ciba_requests(after_id: nil, limit: 100)
657
+ ciba_validate_limit!(limit)
658
+ ciba_validate_id!(after_id) if after_id
659
+ ds = ciba_ds.where(ciba_values(status: "pending")).where(Sequel[ciba_column(:expires_at)] > ciba_now)
660
+ ds = ds.where(Sequel[ciba_column(:id)] > after_id) if after_id
661
+ ds.order(ciba_column(:id)).limit(limit).all.map { |row| ciba_snapshot(ciba_read(row)) }.freeze
662
+ end
663
+
664
+ def cleanup_ciba_requests(before:, limit: 100)
665
+ raise ArgumentError, "before must be a past epoch second" unless before.is_a?(Integer) && before <= ciba_now
666
+ ciba_validate_limit!(limit)
667
+ expired = Sequel.&(ciba_values(status: %w[pending approved]), Sequel[ciba_column(:expires_at)] < before)
668
+ denied = Sequel.&(ciba_values(status: "denied"), Sequel[ciba_column(:completed_at)] < before)
669
+ consumed = Sequel.&(ciba_values(status: "consumed"), Sequel[ciba_column(:consumed_at)] < before)
670
+ eligible = ciba_ds.where(Sequel.|(expired, denied, consumed))
671
+ ciba_transaction do
672
+ ids = eligible.order(ciba_column(:id)).limit(limit).select_map(ciba_column(:id))
673
+ eligible.where(ciba_values(id: ids)).delete
674
+ end
675
+ end
676
+
677
+ def cleanup_ciba_client_assertions(limit: 100)
678
+ ciba_validate_limit!(limit)
679
+ ds = db[ciba_client_assertions_table].where(Sequel[:expires_at] <= ciba_now)
680
+ ciba_transaction do
681
+ ids = ds.order(:expires_at, :digest).limit(limit).select_map(:digest)
682
+ ds.where(digest: ids).delete
683
+ end
684
+ end
685
+
686
+ # Never remove an unexpired consumed digest: it is still replay evidence.
687
+ # Expiry is checked before replay detection, matching the reference ordering.
688
+ def cleanup_ciba_refresh_tokens(limit: 100)
689
+ ciba_validate_limit!(limit)
690
+ ds = db[ciba_refresh_tokens_table].where(Sequel[:expires_at] <= ciba_now)
691
+ ciba_transaction do
692
+ ids = ds.order(:expires_at, :id).limit(limit).select_map(:id)
693
+ ds.where(id: ids).delete
694
+ end
695
+ end
696
+
697
+ def oauth_grant_types_supported
698
+ super | [CibaSupport::GRANT_TYPE]
699
+ end
700
+
701
+ def check_csrf?
702
+ request.path == backchannel_authentication_path ? false : super
703
+ end
704
+
705
+ def accepts_json?
706
+ request.path == backchannel_authentication_path || super
707
+ end
708
+
709
+ private
710
+
711
+ def post_configure
712
+ super
713
+ if __one_oauth_token_per_account
714
+ raise CibaSupport::ConfigurationError, "CIBA requires distinct issuance rows; remove the account/client/scope unique index from OAuth grant storage"
715
+ end
716
+ unless db[ciba_requests_table].columns.include?(ciba_column(:authentication_claims))
717
+ raise CibaSupport::ConfigurationError, "migrate CIBA authentication_claims request storage"
718
+ end
719
+ unless db[ciba_requests_table].columns.include?(ciba_column(:max_age))
720
+ raise CibaSupport::ConfigurationError, "migrate CIBA max_age request storage"
721
+ end
722
+ unless db[ciba_requests_table].columns.include?(ciba_column(:completion_error))
723
+ raise CibaSupport::ConfigurationError, "migrate CIBA completion_error request storage"
724
+ end
725
+ if ciba_refresh_tokens_enabled && !db[ciba_refresh_tokens_table].columns.include?(:authentication_claims)
726
+ raise CibaSupport::ConfigurationError, "migrate CIBA authentication_claims refresh storage"
727
+ end
728
+ if features.include?(:oauth_dpop) && (!ciba_dpop_nonce_secret.nil? || oauth_dpop_use_nonce) &&
729
+ !(ciba_dpop_nonce_secret.is_a?(String) && ciba_dpop_nonce_secret.bytesize == 32)
730
+ raise CibaSupport::ConfigurationError, "configure a 32-byte ciba_dpop_nonce_secret"
731
+ end
732
+ if features.include?(:oauth_dpop) && (!db.table_exists?(ciba_client_assertions_table) ||
733
+ !db[oauth_grants_table].columns.include?(oauth_grants_dpop_jkt_column))
734
+ raise CibaSupport::ConfigurationError, "migrate CIBA assertion ledger and upstream DPoP token thumbprints before enabling oauth_dpop"
735
+ end
736
+ self.class.prepend(CibaSupport::Dpop) unless self.class.ancestors.include?(CibaSupport::Dpop)
737
+ if features.include?(:oauth_tls_client_auth)
738
+ if method(:ciba_tls_client_certificate).owner == Rodauth::Ciba
739
+ raise CibaSupport::ConfigurationError, "configure a trusted ciba_tls_client_certificate callback"
740
+ end
741
+ unless db[oauth_grants_table].columns.include?(oauth_grants_certificate_thumbprint_column)
742
+ raise CibaSupport::ConfigurationError, "migrate certificate thumbprint storage before enabling oauth_tls_client_auth"
743
+ end
744
+ end
745
+ self.class.prepend(CibaSupport::Mtls) unless self.class.ancestors.include?(CibaSupport::Mtls)
746
+ self.class.prepend(CibaSupport::IdTokenEncryption) unless self.class.ancestors.include?(CibaSupport::IdTokenEncryption)
747
+ if ciba_pairwise_enabled && method(:ciba_pairwise_identifier).owner == Rodauth::Ciba
748
+ raise CibaSupport::ConfigurationError, "configure ciba_pairwise_identifier"
749
+ end
750
+ if ciba_pairwise_enabled && !db[oauth_grants_table].columns.include?(ciba_token_subject_column)
751
+ raise CibaSupport::ConfigurationError, "migrate CIBA token subject storage before enabling pairwise"
752
+ end
753
+ self.class.prepend(CibaSupport::PairwiseSubjects) unless self.class.ancestors.include?(CibaSupport::PairwiseSubjects)
754
+ unless self.class.instance_variable_defined?(:@ciba_jwks_cache)
755
+ self.class.instance_variable_set(:@ciba_jwks_cache, CibaSupport::JwksCache.new(http_request_cache))
756
+ end
757
+ unless self.class.instance_variable_defined?(:@ciba_sector_validation_cache)
758
+ self.class.instance_variable_set(:@ciba_sector_validation_cache, [Mutex.new, {}])
759
+ end
760
+ if ciba_request_context_enabled && method(:validate_ciba_request_context).owner == Rodauth::Ciba
761
+ raise CibaSupport::ConfigurationError, "configure validate_ciba_request_context"
762
+ end
763
+ if ciba_user_code_enabled && method(:verify_ciba_user_code).owner == Rodauth::Ciba
764
+ raise CibaSupport::ConfigurationError, "configure verify_ciba_user_code"
765
+ end
766
+ unless ciba_request_signing_algorithms.is_a?(Array) &&
767
+ (ciba_request_signing_algorithms - %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512 Ed25519 EdDSA]).empty?
768
+ raise CibaSupport::ConfigurationError, "configure asymmetric CIBA request signing algorithms"
769
+ end
770
+ unless ciba_client_assertion_signing_algorithms.nil? ||
771
+ (ciba_client_assertion_signing_algorithms.is_a?(Array) &&
772
+ (ciba_client_assertion_signing_algorithms - %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512 HS256 HS384 HS512 Ed25519 EdDSA]).empty?)
773
+ raise CibaSupport::ConfigurationError, "configure CIBA client assertion signing algorithms"
774
+ end
775
+ unless ciba_id_token_signing_keys.is_a?(Hash) &&
776
+ (ciba_id_token_signing_keys.keys - %w[Ed25519 EdDSA]).empty?
777
+ raise CibaSupport::ConfigurationError, "configure Edwards CIBA ID Token keys"
778
+ end
779
+ ciba_id_token_signing_keys.each_value do |configured|
780
+ keys = Array(configured)
781
+ unless !keys.empty? && keys.all? { |key| key.is_a?(OpenSSL::PKey::PKey) && key.oid == "ED25519" }
782
+ raise CibaSupport::ConfigurationError, "configure Ed25519 keys with an active private key first"
783
+ end
784
+ begin
785
+ keys.first.private_to_der
786
+ rescue OpenSSL::PKey::PKeyError
787
+ raise CibaSupport::ConfigurationError, "configure an active private Ed25519 key first"
788
+ end
789
+ end
790
+ if ciba_id_token_hint_enabled && method(:resolve_ciba_id_token_subject).owner == Rodauth::Ciba
791
+ raise CibaSupport::ConfigurationError, "configure resolve_ciba_id_token_subject"
792
+ end
793
+ unless ciba_id_token_hint_max_age.nil? || (ciba_id_token_hint_max_age.is_a?(Integer) && ciba_id_token_hint_max_age.positive?)
794
+ raise CibaSupport::ConfigurationError, "configure positive ID Token hint maximum age or nil"
795
+ end
796
+ if ciba_login_hint_token_enabled && method(:resolve_ciba_login_hint_token).owner == Rodauth::Ciba
797
+ raise CibaSupport::ConfigurationError, "configure resolve_ciba_login_hint_token"
798
+ end
799
+ if ciba_authorization_details_enabled
800
+ unless ciba_resources_enabled && ciba_authorization_details_types.is_a?(Array) &&
801
+ !ciba_authorization_details_types.empty? && ciba_authorization_details_types.all? { |type| type.is_a?(String) && !type.empty? }
802
+ raise CibaSupport::ConfigurationError, "configure resources and authorization-details types"
803
+ end
804
+ %i[ciba_validate_authorization_details ciba_authorization_details_for_token].each do |name|
805
+ raise CibaSupport::ConfigurationError, "configure #{name}" if method(name).owner == Rodauth::Ciba
806
+ end
807
+ end
808
+ if features.include?(:oauth_dynamic_client_registration) && !self.class.ancestors.include?(CibaSupport::Registration)
809
+ self.class.prepend(CibaSupport::Registration)
810
+ end
811
+ if method(:resolve_ciba_login_hint).owner == Rodauth::Ciba
812
+ raise ArgumentError, "configure resolve_ciba_login_hint"
813
+ end
814
+ if oauth_jwt_keys.empty? || oauth_jwt_keys.key?("none")
815
+ raise ArgumentError, "configure ID Token signing keys"
816
+ end
817
+ unless ciba_grant_lifetime.is_a?(Integer) && ciba_grant_lifetime.positive? && ciba_grant_lifetime <= 2_147_483_647
818
+ raise ArgumentError, "configure positive CIBA grant lifetime (maximum 2^31-1 seconds)"
819
+ end
820
+ unless ciba_refresh_token_lifetime.is_a?(Integer) && ciba_refresh_token_lifetime.positive? && ciba_refresh_token_lifetime <= 2_147_483_647
821
+ raise ArgumentError, "configure positive CIBA refresh lifetime (maximum 2^31-1 seconds)"
822
+ end
823
+ unless [ciba_request_lifetime, ciba_max_request_lifetime, ciba_poll_interval, ciba_max_request_bytes].all? { |v| v.is_a?(Integer) && v.positive? } &&
824
+ ciba_request_lifetime <= ciba_max_request_lifetime && ciba_max_request_lifetime <= 2_147_483_647 && ciba_poll_interval <= 2_147_483_647
825
+ raise ArgumentError, "configure positive CIBA lifetime/maximum/interval/body limit (lifetime <= maximum)"
826
+ end
827
+ self.class.prepend(CibaSupport::ClientAuthentication) unless self.class.ancestors.include?(CibaSupport::ClientAuthentication)
828
+ self.class.prepend(CibaSupport::ResourceTokens) unless self.class.ancestors.include?(CibaSupport::ResourceTokens)
829
+ end
830
+
831
+ public :post_configure
832
+
833
+ def ciba_metadata
834
+ metadata = {backchannel_authentication_endpoint: backchannel_authentication_url,
835
+ backchannel_token_delivery_modes_supported: ciba_ping_enabled ? %w[poll ping] : ["poll"], backchannel_user_code_parameter_supported: ciba_user_code_enabled}
836
+ metadata[:revocation_endpoint] = revoke_url if features.include?(:oauth_token_revocation)
837
+ metadata[:registration_endpoint] = register_url if features.include?(:oauth_dynamic_client_registration)
838
+ metadata[:authorization_details_types_supported] = ciba_authorization_details_types if ciba_authorization_details_enabled
839
+ unless ciba_request_signing_algorithms.empty?
840
+ metadata[:backchannel_authentication_request_signing_alg_values_supported] = ciba_request_signing_algorithms
841
+ end
842
+ metadata
843
+ end
844
+
845
+ def oauth_server_metadata_body(*)
846
+ metadata = super.merge(ciba_metadata)
847
+ # This URN names the assertion format, not a client authentication method.
848
+ # Keep upstream dispatch intact; only correct the shared advertised list.
849
+ if metadata[:token_endpoint_auth_methods_supported]
850
+ metadata[:token_endpoint_auth_methods_supported] =
851
+ metadata[:token_endpoint_auth_methods_supported] - ["urn:ietf:params:oauth:client-assertion-type:jwt-bearer"]
852
+ end
853
+ if features.include?(:oauth_jwt_bearer_grant) && ciba_client_assertion_signing_algorithms
854
+ metadata[:token_endpoint_auth_signing_alg_values_supported] =
855
+ (Array(metadata[:token_endpoint_auth_signing_alg_values_supported]) | ciba_client_assertion_signing_algorithms)
856
+ end
857
+ metadata
858
+ end
859
+
860
+ def openid_configuration_body(*)
861
+ # Upstream's OIDC allowlist drops these enabled OAuth extensions. Preserve
862
+ # their actual configured metadata rather than inventing endpoint/auth
863
+ # defaults or advertising disabled features.
864
+ extensions = oauth_server_metadata_body(*).slice(:token_endpoint_auth_signing_alg_values_supported, :introspection_endpoint,
865
+ :introspection_endpoint_auth_methods_supported,
866
+ :introspection_endpoint_auth_signing_alg_values_supported,
867
+ :dpop_signing_alg_values_supported,
868
+ :tls_client_certificate_bound_access_tokens)
869
+ metadata = super.merge(extensions).merge(ciba_metadata)
870
+ unless ciba_id_token_signing_keys.empty?
871
+ metadata[:id_token_signing_alg_values_supported] =
872
+ Array(metadata[:id_token_signing_alg_values_supported]) | ciba_id_token_signing_keys.keys
873
+ end
874
+ if ciba_id_token_encryption_enabled
875
+ metadata[:id_token_encryption_alg_values_supported] =
876
+ Array(metadata[:id_token_encryption_alg_values_supported]) | CibaSupport::IdTokenEncryption::ALGORITHMS
877
+ metadata[:id_token_encryption_enc_values_supported] =
878
+ Array(metadata[:id_token_encryption_enc_values_supported]) | CibaSupport::IdTokenEncryption::METHODS
879
+ end
880
+ metadata
881
+ end
882
+
883
+ def ciba_edwards_public_jwk(key, algorithm)
884
+ raw = OpenSSL::ASN1.decode(key.public_to_der).value.last.value
885
+ thumbprint_input = {crv: "Ed25519", kty: "OKP", x: Base64.urlsafe_encode64(raw, padding: false)}
886
+ thumbprint_input.merge(alg: algorithm, use: "sig",
887
+ kid: Base64.urlsafe_encode64(Digest::SHA256.digest(JSON.generate(thumbprint_input)), padding: false))
888
+ end
889
+
890
+ def jwks_set
891
+ super + ciba_id_token_signing_keys.flat_map do |algorithm, configured|
892
+ Array(configured).map { |key| ciba_edwards_public_jwk(key, algorithm) }
893
+ end
894
+ end
895
+
896
+ def jwt_encode(payload, signing_algorithm: oauth_jwt_keys.keys.first, headers: {}, **options)
897
+ ciba_id_token = @ciba_authentication && headers[:typ] == "id_token+jwt"
898
+ if ciba_id_token && ciba_id_token_encryption_enabled &&
899
+ (options[:encryption_algorithm] || options[:encryption_method])
900
+ signed = jwt_encode(payload, signing_algorithm: signing_algorithm, headers: headers,
901
+ **options.merge(encryption_algorithm: nil, encryption_method: nil))
902
+ return ciba_encrypt_id_token(signed, options)
903
+ end
904
+ if ciba_id_token && %w[HS256 HS384 HS512].include?(signing_algorithm)
905
+ if options[:encryption_algorithm] || options[:encryption_method]
906
+ raise CibaSupport::ConfigurationError, "CIBA ID Token encryption is not enabled"
907
+ end
908
+ return JWT.encode(payload.merge(jti: generate_jti(payload)), ciba_hmac_id_token_secret(signing_algorithm),
909
+ signing_algorithm, headers)
910
+ end
911
+ unless ciba_id_token && ciba_id_token_signing_keys.key?(signing_algorithm)
912
+ token = super
913
+ if ciba_id_token && (options[:encryption_algorithm] || options[:encryption_method])
914
+ # Some upstream backends silently return a JWS if encryption is
915
+ # unavailable or no recipient key matches. Never release that token.
916
+ parts = token.to_s.split(".", -1)
917
+ encrypted_header = begin
918
+ JSON.parse(Base64.urlsafe_decode64(parts.first)) if parts.length == 5
919
+ rescue JSON::ParserError, ArgumentError
920
+ nil
921
+ end
922
+ unless options[:encryption_algorithm] && options[:encryption_method] &&
923
+ encrypted_header.is_a?(Hash) &&
924
+ encrypted_header["alg"] == options[:encryption_algorithm] &&
925
+ encrypted_header["enc"] == options[:encryption_method]
926
+ raise CibaSupport::ConfigurationError, "CIBA ID Token encryption was not applied"
927
+ end
928
+ end
929
+ return token
930
+ end
931
+ if options[:encryption_algorithm] || options[:encryption_method]
932
+ raise CibaSupport::ConfigurationError, "Edwards CIBA ID Token encryption is not configured"
933
+ end
934
+ key = Array(ciba_id_token_signing_keys.fetch(signing_algorithm)).first
935
+ header = headers.merge(alg: signing_algorithm, kid: ciba_edwards_public_jwk(key, signing_algorithm).fetch(:kid))
936
+ payload = payload.merge(jti: generate_jti(payload))
937
+ parts = [header, payload].map { |value| Base64.urlsafe_encode64(JSON.generate(value), padding: false) }
938
+ (parts + [Base64.urlsafe_encode64(key.sign(nil, parts.join(".")), padding: false)]).join(".")
939
+ end
940
+
941
+ def id_token_hash(value, algorithm)
942
+ return super unless @ciba_authentication && %w[Ed25519 EdDSA].include?(algorithm)
943
+ Base64.urlsafe_encode64(Digest::SHA512.digest(value)[0, 32], padding: false)
944
+ end
945
+
946
+ def ciba_hmac_id_token_secret(algorithm)
947
+ secret = ciba_id_token_hmac_secret(oauth_application: oauth_application)
948
+ unless secret.is_a?(String) && secret.valid_encoding?
949
+ raise CibaSupport::ConfigurationError, "CIBA HMAC ID Token requires the client secret"
950
+ end
951
+ secret = secret.encode(Encoding::UTF_8)
952
+ if secret.bytesize < algorithm.delete_prefix("HS").to_i / 8
953
+ raise CibaSupport::ConfigurationError, "CIBA HMAC ID Token client secret is too short"
954
+ end
955
+ secret
956
+ end
957
+
958
+ def ciba_column(name)
959
+ ciba_request_columns.fetch(name, name)
960
+ end
961
+
962
+ def ciba_values(values)
963
+ values.to_h { |key, value| [ciba_column(key), value] }
964
+ end
965
+
966
+ def ciba_read(row)
967
+ return unless row
968
+ names = ciba_request_columns.invert
969
+ row.to_h { |key, value| [names.fetch(key, key), value] }
970
+ end
971
+
972
+ def ciba_ds
973
+ db[ciba_requests_table]
974
+ end
975
+
976
+ def ciba_snapshot(row)
977
+ row.reject { |key, _| [:auth_req_id_digest, :token_digest].include?(key) }.to_h do |key, value|
978
+ [key, value.is_a?(String) ? value.dup.freeze : value]
979
+ end.freeze
980
+ end
981
+
982
+ def ciba_validate_id!(id)
983
+ raise ArgumentError, "request ID must be a positive integer" unless id.is_a?(Integer) && id.positive?
984
+ end
985
+
986
+ def ciba_validate_limit!(limit)
987
+ raise ArgumentError, "limit must be 1..1000" unless limit.is_a?(Integer) && (1..1000).cover?(limit)
988
+ end
989
+
990
+ # Locking reads prevent stale REPEATABLE READ snapshots during pending polls.
991
+ # SQLite obtains its write reservation when the transaction starts instead.
992
+ def ciba_locked_row(ds)
993
+ ciba_read((db.database_type == :sqlite ? ds : ds.for_update).first)
994
+ end
995
+
996
+ # Grant-scoped revocation must use the same lock order as issuance. The first
997
+ # read only identifies the grant; always re-read the source under its lock.
998
+ # A pending request can become bound in between, so retry rather than acquire
999
+ # the newly attached grant while already holding the source lock.
1000
+ def ciba_lock_source_after_grant(ds, request_columns: false, grant_key: :grant_id)
1001
+ candidate = ds.first
1002
+ candidate = ciba_read(candidate) if request_columns
1003
+ return unless candidate
1004
+ ciba_locked_grant(candidate[grant_key]) if candidate[grant_key]
1005
+ current = (db.database_type == :sqlite ? ds : ds.for_update).first
1006
+ current = ciba_read(current) if request_columns
1007
+ if current && current[grant_key] != candidate[grant_key]
1008
+ raise Sequel::SerializationFailure, "CIBA source binding changed while locking"
1009
+ end
1010
+ current
1011
+ end
1012
+
1013
+ def ciba_claim!(row)
1014
+ changed = ciba_ds.where(ciba_values(id: row[:id], status: row[:status], lock_version: row[:lock_version]))
1015
+ .update(ciba_values(lock_version: row[:lock_version] + 1))
1016
+ raise CibaSupport::Conflict, "concurrent modification" unless changed == 1
1017
+ raise CibaSupport::Expired, "request expired" if row[:expires_at] <= ciba_now
1018
+ end
1019
+
1020
+ def ciba_guard(id)
1021
+ registry = Thread.current[:rodauth_ciba_mutations] ||= {}
1022
+ key = [db.object_id, id]
1023
+ raise CibaSupport::ReentrantMutation, "request mutation is already active" if registry[key]
1024
+ registry[key] = true
1025
+ begin
1026
+ yield
1027
+ ensure
1028
+ registry.delete(key)
1029
+ end
1030
+ end
1031
+
1032
+ def ciba_complete(id, account_id, outcome, context)
1033
+ ciba_validate_id!(id)
1034
+ ciba_guard(id) do
1035
+ ciba_transaction do
1036
+ ciba_locked_grant(context[:grant_id]) if context[:grant_id]
1037
+ row = ciba_locked_row(ciba_ds.where(ciba_values(id: id)))
1038
+ raise CibaSupport::NotFound, "request not found" unless row
1039
+ raise CibaSupport::IdentityMismatch, "account does not match" unless row[:account_id] == account_id
1040
+ raise CibaSupport::Expired, "request expired" if row[:expires_at] <= ciba_now
1041
+ if outcome == "approved" && row[:authentication_claims] &&
1042
+ JSON.parse(row[:authentication_claims]).include?("auth_time_required") && context[:auth_time].nil?
1043
+ raise CibaSupport::Ineligible, "verified auth_time is required for this CIBA request"
1044
+ end
1045
+ authorized_scopes = context[:grant_id] ? ciba_authorized_scopes!(row, context[:grant_id]) : row[:scopes].split
1046
+ if row[:status] != "pending"
1047
+ consumed_outcome = row[:grant_id] ? "approved" : "denied"
1048
+ same_outcome = row[:status] == outcome || (row[:status] == "consumed" && outcome == consumed_outcome)
1049
+ same_context = context.all? { |key, value| row[key] == value }
1050
+ raise CibaSupport::Conflict, "completion conflicts with existing result" unless same_outcome && same_context
1051
+ next :already_completed
1052
+ end
1053
+ application = oauth_application_ds(row[:oauth_application_id]).first
1054
+ unless ciba_account_eligible?(account_id) && ciba_client_eligible?(application, authorized_scopes)
1055
+ raise CibaSupport::Ineligible, "account or client unavailable"
1056
+ end
1057
+ ciba_claim!(row)
1058
+ action = outcome == "approved" ? "approve" : "deny"
1059
+ @ciba_current_request = ciba_snapshot(row)
1060
+ send("before_ciba_#{action}")
1061
+ raise CibaSupport::Expired, "request expired" if row[:expires_at] <= ciba_now
1062
+ ciba_authorized_scopes!(row, context[:grant_id]) if context[:grant_id]
1063
+ if outcome == "approved" && !context[:grant_id]
1064
+ context[:grant_id] = create_ciba_grant(account_id: account_id,
1065
+ oauth_application_id: row[:oauth_application_id], scopes: row[:scopes])[:id]
1066
+ end
1067
+ updates = context.merge(status: outcome, completed_at: ciba_now)
1068
+ ciba_ds.where(ciba_values(id: id)).update(ciba_values(updates))
1069
+ row.merge!(updates)
1070
+ @ciba_current_request = ciba_snapshot(row)
1071
+ send("after_ciba_#{action}")
1072
+ ciba_emit(row, row[:completion_error] ? "failed" : outcome, "pending", outcome)
1073
+ if ciba_ping_enabled && db[ciba_ping_deliveries_table].where(request_id: id).get(:request_id)
1074
+ db.after_commit(savepoint: true) { retry_ciba_ping(id) }
1075
+ end
1076
+ outcome.to_sym
1077
+ end
1078
+ end
1079
+ ensure
1080
+ @ciba_current_request = nil
1081
+ end
1082
+
1083
+ def ciba_locked_grant(id)
1084
+ ciba_validate_id!(id)
1085
+ ds = db[ciba_grants_table].where(id: id)
1086
+ (db.database_type == :sqlite ? ds : ds.for_update).first
1087
+ end
1088
+
1089
+ def ciba_authorized_scopes!(request, grant_id)
1090
+ grant = ciba_locked_grant(grant_id)
1091
+ raise CibaSupport::Ineligible, "grant unavailable" unless grant && !grant[:revoked_at] &&
1092
+ (!grant[:expires_at] || grant[:expires_at] > ciba_now)
1093
+ unless grant[:account_id] == request[:account_id] && grant[:oauth_application_id] == request[:oauth_application_id]
1094
+ raise CibaSupport::IdentityMismatch, "grant account or client does not match"
1095
+ end
1096
+ scopes = request[:scopes].split & grant[:scopes].split
1097
+ raise CibaSupport::Ineligible, "openid not authorized" unless scopes.include?("openid")
1098
+ scopes
1099
+ end
1100
+
1101
+ def ciba_consume_client_assertion!(application, claims)
1102
+ digest = Digest::SHA256.hexdigest(JSON.generate([application[oauth_applications_client_id_column], claims["jti"]]))
1103
+ ciba_transaction do
1104
+ db[ciba_client_assertions_table].insert(digest: digest, expires_at: claims["exp"].ceil)
1105
+ end
1106
+ rescue Sequel::UniqueConstraintViolation
1107
+ authorization_required
1108
+ end
1109
+
1110
+ def response_error_params(error_code, message = nil)
1111
+ payload = super
1112
+ return payload unless ciba_protocol_request?
1113
+ payload = payload.dup
1114
+ # OIDC/OAuth error descriptions are optional; omit invalid customization
1115
+ # rather than echoing characters outside the protocol's ASCII repertoire.
1116
+ description = payload["error_description"]
1117
+ if description && (!description.is_a?(String) || !description.valid_encoding? ||
1118
+ !description.match?(/\A[\x20-\x21\x23-\x5B\x5D-\x7E]*\z/))
1119
+ payload.delete("error_description")
1120
+ end
1121
+ payload
1122
+ end
1123
+
1124
+ def ciba_client_eligible?(application, requested_scopes = ["openid"])
1125
+ return false unless application
1126
+ algorithm = application[oauth_applications_id_token_signed_response_alg_column] || oauth_jwt_keys.keys.first
1127
+ return false unless algorithm != "none" && (oauth_jwt_keys.key?(algorithm) || ciba_id_token_signing_keys.key?(algorithm))
1128
+ request_algorithm = application[oauth_applications_backchannel_authentication_request_signing_alg_column]
1129
+ return false if request_algorithm && !ciba_request_signing_algorithms.include?(request_algorithm)
1130
+ return false if application[oauth_applications_backchannel_user_code_parameter_column] && !ciba_user_code_enabled
1131
+ return false unless (requested_scopes - oauth_application_scopes).empty?
1132
+ mode = application[oauth_applications_backchannel_token_delivery_mode_column]
1133
+ return false unless mode == "poll" || (mode == "ping" && ciba_ping_enabled &&
1134
+ ciba_valid_ping_endpoint?(application[oauth_applications_backchannel_client_notification_endpoint_column]))
1135
+ methods = application[oauth_applications_token_endpoint_auth_method_column].to_s.split
1136
+ methods = oauth_default_token_endpoint_auth_methods if methods.empty?
1137
+ # Upstream advertises this legacy assertion-type URN, but JWT verification
1138
+ # dispatches only to private_key_jwt or client_secret_jwt authentication.
1139
+ return false if methods.include?("urn:ietf:params:oauth:client-assertion-type:jwt-bearer")
1140
+ subject_type = application[oauth_applications_subject_type_column] || oauth_jwt_subject_type
1141
+ subject_valid = subject_type == "public" || (subject_type == "pairwise" &&
1142
+ ciba_pairwise_sector(application) && ciba_pairwise_sector_document_valid?(application))
1143
+ application[oauth_applications_grant_types_column].to_s.split.include?(CibaSupport::GRANT_TYPE) &&
1144
+ subject_valid &&
1145
+ !methods.empty? && (methods - (oauth_confidential_token_endpoint_auth_methods & oauth_token_endpoint_auth_methods_supported)).empty? &&
1146
+ (requested_scopes - application[oauth_applications_scopes_column].to_s.split).empty?
1147
+ end
1148
+
1149
+ def ciba_valid_ping_endpoint?(endpoint)
1150
+ return false unless endpoint.is_a?(String) && endpoint.valid_encoding? && endpoint.bytesize <= 2048
1151
+ uri = URI(endpoint)
1152
+ uri.scheme == "https" && uri.hostname && !uri.hostname.empty? && !uri.userinfo && !uri.fragment
1153
+ rescue URI::InvalidURIError
1154
+ false
1155
+ end
1156
+
1157
+ def ciba_ping_delivery_parameters
1158
+ return unless oauth_application[oauth_applications_backchannel_token_delivery_mode_column] == "ping"
1159
+ token = ciba_parameter("client_notification_token", required: true, max: 1024)
1160
+ ciba_error("invalid_request") unless token.match?(/\A[A-Za-z0-9._~+\/-]+=*\z/)
1161
+ {notification_token: token,
1162
+ endpoint: oauth_application[oauth_applications_backchannel_client_notification_endpoint_column]}
1163
+ end
1164
+
1165
+ def ciba_protocol_request?
1166
+ return true if @ciba_invalid_parameters
1167
+ ciba_token_revocation_request? || request.path == backchannel_authentication_path ||
1168
+ (request.path == token_path && (request.params["grant_type"] == CibaSupport::GRANT_TYPE || ciba_refresh_request?))
1169
+ rescue Rack::QueryParser::ParameterTypeError, Rack::QueryParser::InvalidParameterError, Rack::QueryParser::QueryLimitError
1170
+ @ciba_invalid_parameters = true
1171
+ end
1172
+
1173
+ # RFC 6749 section 2.3.1 encodes each Basic credential as form data.
1174
+ # Reuse upstream method registration and secret verification after decoding.
1175
+ def require_oauth_application_from_client_secret_basic(token)
1176
+ return super unless ciba_protocol_request?
1177
+ begin
1178
+ encoded = Base64.strict_decode64(token)
1179
+ client, secret = encoded.split(":", 2)
1180
+ authorization_required unless client && secret
1181
+ client = URI.decode_www_form_component(client)
1182
+ secret = URI.decode_www_form_component(secret)
1183
+ authorization_required unless client.valid_encoding? && secret.valid_encoding?
1184
+ rescue ArgumentError
1185
+ authorization_required
1186
+ end
1187
+ application = db[oauth_applications_table].where(oauth_applications_client_id_column => client).first
1188
+ authorization_required unless supports_auth_method?(application, "client_secret_basic") && secret_matches?(application, secret)
1189
+ @ciba_verified_client_secret = [application[oauth_applications_id_column], secret.dup.freeze].freeze
1190
+ application
1191
+ end
1192
+
1193
+ def require_oauth_application_from_client_secret_post(client_id, secret)
1194
+ application = super
1195
+ if ciba_protocol_request?
1196
+ @ciba_verified_client_secret = [application[oauth_applications_id_column], secret.dup.freeze].freeze
1197
+ end
1198
+ application
1199
+ end
1200
+
1201
+ def www_authenticate_header(*)
1202
+ ciba_protocol_request? ? 'Basic realm="CIBA"' : super
1203
+ end
1204
+
1205
+ def validate_ciba_transport!
1206
+ ciba_error("invalid_request") if @ciba_invalid_parameters
1207
+ ciba_error("invalid_request") if ciba_require_tls && !request.ssl?
1208
+ ciba_error("invalid_request") unless request.media_type == "application/x-www-form-urlencoded" && request.query_string.empty?
1209
+ # Rack 3 inputs need not support rewind. Reuse Rack's parsed form buffer,
1210
+ # or replace an as-yet unread input with our bounded copy for later parsing.
1211
+ body = request.env["rack.request.form_vars"]
1212
+ unless body
1213
+ body = request.body&.read(ciba_max_request_bytes + 1).to_s
1214
+ request.env["rack.input"] = StringIO.new(body)
1215
+ end
1216
+ ciba_error("invalid_request") if body.bytesize > ciba_max_request_bytes || body.match?(/%(?![0-9a-fA-F]{2})/)
1217
+ pairs = body.split("&").map do |part|
1218
+ part.split("=", 2).map { |value| URI.decode_www_form_component(value, Encoding::UTF_8) }
1219
+ end
1220
+ ciba_error("invalid_request") if pairs.any? { |pair| pair.any? { |value| !value.valid_encoding? } }
1221
+ pairs = URI.decode_www_form(body, Encoding::UTF_8)
1222
+ keys = pairs.map(&:first)
1223
+ if (keys.include?("client_secret") && request.env["HTTP_AUTHORIZATION"]) ||
1224
+ (keys.include?("client_assertion") && (keys.include?("client_secret") || request.env["HTTP_AUTHORIZATION"]))
1225
+ ciba_error("invalid_request")
1226
+ end
1227
+ scalar_keys = ciba_resources_enabled ? keys.reject { |key| key == "resource" } : keys
1228
+ ciba_error("invalid_request") unless scalar_keys.uniq.length == scalar_keys.length
1229
+ @ciba_form_resources = pairs.select { |key, _| key == "resource" }.map(&:last)
1230
+ ciba_error("invalid_request") if pairs.any? { |key, value| !key.valid_encoding? || !value.valid_encoding? }
1231
+ rescue ArgumentError
1232
+ ciba_error("invalid_request")
1233
+ end
1234
+
1235
+ def ciba_parameter(name, required: false, max: 1024, error: "invalid_request")
1236
+ value = request.params[name]
1237
+ if value.nil?
1238
+ ciba_error(error) if required
1239
+ return
1240
+ end
1241
+ ciba_error(error) unless value.is_a?(String) && value.valid_encoding? && !value.empty? && value.bytesize <= max
1242
+ value
1243
+ end
1244
+
1245
+ def ciba_claim_names(values)
1246
+ protected_names = %w[sub iss aud exp iat auth_time acr amr nonce at_hash c_hash sid jti]
1247
+ unless values.is_a?(Array) && values.size <= 64 && values.all? { |name|
1248
+ name.is_a?(String) && name.bytesize <= 128 && ciba_supported_claims.include?(name) && !protected_names.include?(name)
1249
+ }
1250
+ raise ArgumentError, "invalid consent claims"
1251
+ end
1252
+ values.uniq
1253
+ end
1254
+
1255
+ def ciba_valid_resource?(value)
1256
+ return false unless value.is_a?(String) && value.valid_encoding? && !value.empty? && value.bytesize <= 1024
1257
+ uri = URI.parse(value)
1258
+ uri.absolute? && uri.fragment.nil?
1259
+ rescue URI::InvalidURIError
1260
+ false
1261
+ end
1262
+
1263
+ def ciba_checked_authorization_details(json, application)
1264
+ details = CibaSupport::AuthorizationDetails.parse(json)
1265
+ allowed = JSON.parse(application[:authorization_details_types] || "[]")
1266
+ unless allowed.is_a?(Array) && details.all? { |detail|
1267
+ ciba_authorization_details_types.include?(detail["type"]) && allowed.include?(detail["type"])
1268
+ } && ciba_validate_authorization_details(details, oauth_application: ciba_snapshot(application)) == true
1269
+ raise CibaSupport::ProtocolError, "invalid_authorization_details"
1270
+ end
1271
+ details
1272
+ rescue CibaSupport::AuthorizationDetails::Invalid, JSON::ParserError
1273
+ raise CibaSupport::ProtocolError, "invalid_authorization_details"
1274
+ end
1275
+
1276
+ def ciba_compile_authorization_details(row, resource_attributes)
1277
+ if !ciba_authorization_details_enabled
1278
+ return
1279
+ end
1280
+ application = oauth_application_ds(row[:oauth_application_id]).first
1281
+ requested = ciba_checked_authorization_details(row[:requested_authorization_details] || "[]", application)
1282
+ approved = ciba_checked_authorization_details(ciba_locked_grant(row[:grant_id])[:authorization_details] || "[]", application)
1283
+ narrowing = request.params.key?("authorization_details") ?
1284
+ ciba_checked_authorization_details(request.params["authorization_details"], application) : nil
1285
+ return if requested.empty? && approved.empty? && narrowing.nil?
1286
+ raise CibaSupport::ProtocolError, "invalid_target" unless resource_attributes
1287
+ # The application owns type-specific subset/enrichment semantics. All
1288
+ # inputs are reloaded/validated and immutable; no caller grant hash is used.
1289
+ effective = ciba_authorization_details_for_token(requested: requested, approved: approved,
1290
+ narrowing: narrowing, resource: resource_attributes[:ciba_resource].dup.freeze,
1291
+ oauth_application: ciba_snapshot(application))
1292
+ effective = ciba_checked_authorization_details(JSON.generate(effective), application)
1293
+ JSON.generate(effective) unless effective.empty?
1294
+ end
1295
+
1296
+ def ciba_resource_policy(resource, application)
1297
+ raise CibaSupport::ProtocolError, "invalid_target" unless ciba_valid_resource?(resource)
1298
+ policy = ciba_resource_server(resource, oauth_application: application)
1299
+ raise CibaSupport::ProtocolError, "invalid_target" unless policy
1300
+ unless policy.is_a?(Hash) && policy[:scopes].is_a?(Array) && policy[:scopes].all? { |v|
1301
+ v.is_a?(String) && v.match?(/\A[\x21\x23-\x5B\x5D-\x7E]+\z/)
1302
+ } && policy[:audience].is_a?(String) && !policy[:audience].empty? &&
1303
+ policy[:audience].valid_encoding? && policy[:audience].bytesize <= 1024
1304
+ raise ArgumentError, "invalid CIBA resource server configuration"
1305
+ end
1306
+ policy
1307
+ end
1308
+
1309
+ def ciba_resource_consent(resources, application)
1310
+ raise ArgumentError, "resources not enabled" if !ciba_resources_enabled && resources != {}
1311
+ raise ArgumentError, "invalid resource consent" unless resources.is_a?(Hash) && resources.size <= 16
1312
+ result = resources.to_h do |resource, scope|
1313
+ policy = ciba_resource_policy(resource, application)
1314
+ unless scope.is_a?(String) && scope.bytesize <= 4096 && scope.valid_encoding? &&
1315
+ scope.match?(/\A[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*\z/)
1316
+ raise ArgumentError, "invalid resource scopes"
1317
+ end
1318
+ scopes = scope.split.uniq
1319
+ unless (scopes - policy[:scopes]).empty? && ciba_client_eligible?(application, scopes)
1320
+ raise CibaSupport::Ineligible, "resource scopes unavailable"
1321
+ end
1322
+ [resource, scopes.join(" ")]
1323
+ end
1324
+ JSON.generate(result)
1325
+ end
1326
+
1327
+ def ciba_requested_resources
1328
+ values = @ciba_form_resources || []
1329
+ raise CibaSupport::ProtocolError, "invalid_target" if values.size > 16 ||
1330
+ (request.params.key?("resource") && !request.params["resource"].is_a?(String))
1331
+ values.each { |value| ciba_resource_policy(value, oauth_application) }
1332
+ JSON.generate(values.uniq)
1333
+ end
1334
+
1335
+ def ciba_token_resource(row)
1336
+ return unless ciba_resources_enabled
1337
+ targets = @ciba_form_resources || []
1338
+ if targets.size > 1 || (request.params.key?("resource") && !request.params["resource"].is_a?(String))
1339
+ raise CibaSupport::ProtocolError, "invalid_target"
1340
+ end
1341
+ requested = JSON.parse(row[:requested_resources] || "[]")
1342
+ application = oauth_application_ds(row[:oauth_application_id]).first
1343
+ resource = targets.first
1344
+ if targets.empty? && !requested.empty?
1345
+ use_granted = ciba_use_granted_resource(ciba_snapshot(row))
1346
+ unless use_granted == true || use_granted == false
1347
+ raise ArgumentError, "ciba_use_granted_resource must return a boolean"
1348
+ end
1349
+ if use_granted
1350
+ resource = requested.size == 1 ? requested.first :
1351
+ ciba_default_resource(requested.map { |value| value.dup.freeze }.freeze,
1352
+ oauth_application: application)
1353
+ end
1354
+ end
1355
+ return if resource.nil? # Default node policy: use the OIDC/UserInfo grant.
1356
+ unless requested.include?(resource)
1357
+ raise CibaSupport::ProtocolError, "invalid_target"
1358
+ end
1359
+ policy = ciba_resource_policy(resource, application)
1360
+ consent = JSON.parse(ciba_locked_grant(row[:grant_id])[:resources] || "{}")
1361
+ scopes = row[:scopes].split & consent.fetch(resource, "").split & policy[:scopes]
1362
+ raise CibaSupport::ProtocolError, "invalid_grant" unless ciba_client_eligible?(application, scopes)
1363
+ {ciba_resource: resource, ciba_resource_audience: policy[:audience],
1364
+ oauth_grants_scopes_column => scopes.join(oauth_scope_separator)}
1365
+ end
1366
+
1367
+ def ciba_claim_consent(allowed, rejected)
1368
+ raise ArgumentError, "explicit claims not enabled" if !ciba_claims_enabled && (allowed != [] || rejected != [])
1369
+ JSON.generate(allowed: ciba_claim_names(allowed), rejected: ciba_claim_names(rejected))
1370
+ end
1371
+
1372
+ def ciba_parse_requested_claims
1373
+ value = ciba_parameter("claims", max: 8192)
1374
+ return JSON.generate({}) unless value
1375
+ data = JSON.parse(value, max_nesting: 10)
1376
+ unless data.is_a?(Hash) && (data.keys & %w[id_token userinfo]).any?
1377
+ ciba_error("invalid_request")
1378
+ end
1379
+ result = {}
1380
+ %w[id_token userinfo].each do |target|
1381
+ next unless data.key?(target)
1382
+ entries = data[target]
1383
+ unless entries.is_a?(Hash) && entries.size <= 64 && entries.all? { |name, options|
1384
+ name.bytesize <= 128 && (options.nil? || options.is_a?(Hash))
1385
+ }
1386
+ ciba_error("invalid_request")
1387
+ end
1388
+ result[target] = entries
1389
+ end
1390
+ JSON.generate(result)
1391
+ rescue JSON::ParserError, JSON::NestingError
1392
+ ciba_error("invalid_request")
1393
+ end
1394
+
1395
+ def ciba_compile_claims(row, scopes)
1396
+ requested = JSON.parse(row[:requested_claims] || "{}")
1397
+ consent = JSON.parse(ciba_locked_grant(row[:grant_id])[:claims] || '{"allowed":[],"rejected":[]}')
1398
+ allowed = ciba_claim_names(consent.fetch("allowed"))
1399
+ rejected = ciba_claim_names(consent.fetch("rejected"))
1400
+ implicit = scopes.flat_map { |scope| scope == "address" ? ["address"] : Array(OIDC_SCOPES_MAP[scope]).map(&:to_s) }
1401
+ result = {"version" => 1}
1402
+ %w[id_token userinfo].each do |target|
1403
+ names = Array(requested[target]&.keys) & allowed
1404
+ names |= implicit if target == "userinfo"
1405
+ result[target] = (names & ciba_supported_claims) - rejected
1406
+ end
1407
+ JSON.generate(result)
1408
+ end
1409
+
1410
+ def generate_id_token(grant, include_claims = false)
1411
+ return super unless grant[oauth_grants_type_column] == CibaSupport::GRANT_TYPE && grant[:ciba_claims]
1412
+ @ciba_issuing_claims = JSON.parse(grant[:ciba_claims])
1413
+ super(grant, true)
1414
+ ensure
1415
+ @ciba_issuing_claims = nil
1416
+ end
1417
+
1418
+ def fill_with_account_claims(claims, account, scopes, locales)
1419
+ projection = @ciba_issuing_claims
1420
+ target = "id_token"
1421
+ # Legacy/unbound JWTs must never inherit another issuance's explicit consent.
1422
+ if !projection && @ciba_userinfo_grant_id && request.path == userinfo_path
1423
+ token_claims = authorization_token
1424
+ if token_claims && oauth_application
1425
+ row = valid_oauth_grant_ds(oauth_grants_oauth_application_id_column => oauth_application[oauth_applications_id_column],
1426
+ **resource_owner_params_from_jwt_claims(token_claims)).first
1427
+ if row && row[oauth_grants_type_column] == CibaSupport::GRANT_TYPE && row[:ciba_claims]
1428
+ projection = JSON.parse(row[:ciba_claims])
1429
+ target = "userinfo"
1430
+ end
1431
+ end
1432
+ end
1433
+ unless projection
1434
+ # offline_access requests refresh capability, not an account attribute.
1435
+ # Upstream otherwise dispatches unknown scopes to get_additional_param.
1436
+ scopes = scopes.reject { |scope| scope == "offline_access" } if @ciba_authentication || @ciba_userinfo_grant_id
1437
+ return super(claims, account, scopes, locales)
1438
+ end
1439
+ raise CibaSupport::ConfigurationError, "unsupported claims projection" unless projection["version"] == 1
1440
+ ciba_claim_names(projection.fetch(target)).each do |name|
1441
+ if name == "address"
1442
+ address = {}
1443
+ OIDC_SCOPES_MAP.fetch("address").each do |part|
1444
+ value = ciba_account_claim_value(account, part, :get_oidc_param)
1445
+ address[part] = value unless value.nil?
1446
+ end
1447
+ claims[:address] = address unless address.empty?
1448
+ else
1449
+ standard = OIDC_SCOPES_MAP.values.any? { |names| names.include?(name.to_sym) }
1450
+ value = ciba_account_claim_value(account, name.to_sym, standard ? :get_oidc_param : :get_additional_param)
1451
+ claims[name.to_sym] = value unless value.nil?
1452
+ end
1453
+ end
1454
+ end
1455
+
1456
+ def ciba_account_claim_value(account, name, getter)
1457
+ callback = method(getter)
1458
+ callback.arity == 2 ? callback.call(account, name) : callback.call(account, name, nil)
1459
+ end
1460
+
1461
+ def jwt_claims(grant)
1462
+ super.tap do |claims|
1463
+ if grant[oauth_grants_type_column] == CibaSupport::GRANT_TYPE
1464
+ claims[:"urn:rodauth:ciba:token_id"] = grant[oauth_grants_id_column]
1465
+ claims[:sub] = grant[ciba_token_subject_column] if grant[ciba_token_subject_column]
1466
+ end
1467
+ end
1468
+ end
1469
+
1470
+ def _generate_access_token(params = {})
1471
+ return super unless @ciba_authentication && !oauth_jwt_access_tokens
1472
+ # Keep opaque CIBA credentials identifiable after their DB rows are cleaned
1473
+ # up, without changing unrelated OAuth token issuance or JWT formats.
1474
+ token = "ciba_at_#{oauth_unique_id_generator}"
1475
+ if oauth_grants_token_hash_column
1476
+ params[oauth_grants_token_hash_column] = generate_token_hash(token)
1477
+ else
1478
+ params[oauth_grants_token_column] = token
1479
+ end
1480
+ token
1481
+ end
1482
+
1483
+ def authorization_token
1484
+ token = super
1485
+ return token unless token && request.path == userinfo_path
1486
+ return if token[:ciba_resource] || token["urn:rodauth:ciba:resource"]
1487
+ if token[oauth_grants_type_column] == CibaSupport::GRANT_TYPE
1488
+ application = oauth_application_ds(token[oauth_grants_oauth_application_id_column]).first
1489
+ return unless application
1490
+ @ciba_userinfo_grant_id = token[oauth_grants_id_column]
1491
+ return token.merge("scope" => token[oauth_grants_scopes_column], "sub" => token[oauth_grants_account_id_column].to_s,
1492
+ "client_id" => application[oauth_applications_client_id_column])
1493
+ end
1494
+ if token.key?("urn:rodauth:ciba:token_id")
1495
+ id = token["urn:rodauth:ciba:token_id"]
1496
+ return unless id.is_a?(Integer) && id.positive?
1497
+ row = valid_oauth_grant_ds(oauth_grants_id_column => id, oauth_grants_type_column => CibaSupport::GRANT_TYPE).first
1498
+ return unless row
1499
+ application = oauth_application_ds(row[oauth_grants_oauth_application_id_column]).first
1500
+ return unless application && application[oauth_applications_client_id_column] == token["client_id"]
1501
+ issued_subject = row[ciba_token_subject_column] || jwt_subject(row[oauth_grants_account_id_column], application)
1502
+ return unless issued_subject == token["sub"]
1503
+ @ciba_userinfo_grant_id = id
1504
+ return token.merge("sub" => row[oauth_grants_account_id_column].to_s)
1505
+ end
1506
+ token
1507
+ end
1508
+
1509
+ def valid_oauth_grant_ds(*args)
1510
+ result = super
1511
+ if @ciba_userinfo_grant_id && request.path == userinfo_path
1512
+ result = result.where(oauth_grants_id_column => @ciba_userinfo_grant_id)
1513
+ end
1514
+ result
1515
+ end
1516
+
1517
+ def ciba_process_signed_request!
1518
+ algorithm = oauth_application[oauth_applications_backchannel_authentication_request_signing_alg_column]
1519
+ unless request.params.key?("request")
1520
+ ciba_error("invalid_request") if algorithm
1521
+ return
1522
+ end
1523
+ ciba_error("invalid_request") unless algorithm && ciba_request_signing_algorithms.include?(algorithm)
1524
+ token = ciba_parameter("request", required: true, max: ciba_max_request_bytes)
1525
+ segments = token.split(".", -1)
1526
+ ciba_error("invalid_request") unless segments.size == 3 && segments.all? { |part| part.match?(/\A[A-Za-z0-9_-]+\z/) }
1527
+ header, claims = segments.first(2).map do |part|
1528
+ json = Base64.urlsafe_decode64(part).force_encoding(Encoding::UTF_8)
1529
+ ciba_error("invalid_request") unless json.valid_encoding?
1530
+ JSON.parse(json, max_nesting: 16, allow_duplicate_key: false)
1531
+ end
1532
+ ciba_error("invalid_request") unless header.is_a?(Hash) && claims.is_a?(Hash) &&
1533
+ header["alg"] == algorithm && !header.key?("crit") &&
1534
+ (!header.key?("kid") || header["kid"].is_a?(String))
1535
+ jwks = ciba_signed_request_jwks
1536
+ ciba_error("invalid_request") unless jwks.is_a?(Hash)
1537
+ keys = jwks[:keys] || jwks["keys"]
1538
+ ciba_error("invalid_request") unless keys.is_a?(Array)
1539
+ verified = keys.any? do |data|
1540
+ next false unless data.is_a?(Hash)
1541
+ data = data.transform_keys(&:to_s)
1542
+ next false unless %w[RSA EC OKP].include?(data["kty"]) &&
1543
+ (!data.key?("use") || data["use"] == "sig") &&
1544
+ (!data.key?("alg") || data["alg"] == algorithm) &&
1545
+ (!data.key?("key_ops") || (data["key_ops"].is_a?(Array) && data["key_ops"].include?("verify"))) &&
1546
+ (!header.key?("kid") || header["kid"] == data["kid"])
1547
+ begin
1548
+ if %w[Ed25519 EdDSA].include?(algorithm)
1549
+ key = ciba_ed25519_public_key(data)
1550
+ signature = Base64.urlsafe_decode64(segments.last)
1551
+ key && signature.bytesize == 64 && key.verify(nil, signature, segments.first(2).join("."))
1552
+ else
1553
+ next false if data["kty"] == "OKP"
1554
+ key = JWT::JWK.import(data).public_key
1555
+ JWT.decode(token, key, true, algorithms: [algorithm], verify_expiration: false, verify_not_before: false)
1556
+ true
1557
+ end
1558
+ rescue JWT::DecodeError, JWT::JWKError, ArgumentError, OpenSSL::PKey::PKeyError
1559
+ false
1560
+ end
1561
+ end
1562
+ ciba_error("invalid_request") unless verified
1563
+ client_id = oauth_application[oauth_applications_client_id_column]
1564
+ now = ciba_now
1565
+ leeway = oauth_jwt_iat_leeway
1566
+ audience = claims["aud"]
1567
+ ciba_error("invalid_request") unless claims["iss"] == client_id &&
1568
+ (audience.is_a?(String) || (audience.is_a?(Array) && audience.all? { |aud| aud.is_a?(String) })) &&
1569
+ Array(audience).include?(oauth_jwt_issuer) &&
1570
+ %w[exp iat nbf].all? { |name| claims[name].is_a?(Numeric) && claims[name].finite? } &&
1571
+ claims["exp"] > now - leeway && claims["nbf"] <= now + leeway &&
1572
+ claims["jti"].is_a?(String) &&
1573
+ (!claims.key?("client_id") || claims["client_id"] == client_id) &&
1574
+ !claims.key?("request") && !claims.key?("request_uri")
1575
+ # Authentication is already complete. Replace, never merge, protocol inputs.
1576
+ parameters = {}
1577
+ %w[scope login_hint login_hint_token id_token_hint binding_message acr_values nonce user_code client_notification_token request_context].each do |name|
1578
+ next unless claims.key?(name)
1579
+ ciba_error("invalid_request") unless claims[name].is_a?(String)
1580
+ parameters[name] = claims[name]
1581
+ end
1582
+ if claims.key?("requested_expiry")
1583
+ value = claims["requested_expiry"]
1584
+ ciba_error("invalid_request") unless value.is_a?(String) || value.is_a?(Integer)
1585
+ parameters["requested_expiry"] = value.to_s
1586
+ end
1587
+ if claims.key?("max_age")
1588
+ value = claims["max_age"]
1589
+ ciba_error("invalid_request") unless value.is_a?(String) || value.is_a?(Numeric)
1590
+ parameters["max_age"] = value.to_s
1591
+ end
1592
+ %w[claims authorization_details].each do |name|
1593
+ next unless claims.key?(name)
1594
+ value = claims[name]
1595
+ parameters[name] = value.is_a?(String) ? value : JSON.generate(value)
1596
+ end
1597
+ @ciba_form_resources = []
1598
+ if claims.key?("resource")
1599
+ value = claims["resource"]
1600
+ ciba_error("invalid_target") unless value.is_a?(String) ||
1601
+ (value.is_a?(Array) && !value.empty? && value.all? { |entry| entry.is_a?(String) })
1602
+ @ciba_form_resources = Array(value)
1603
+ parameters["resource"] = @ciba_form_resources.last
1604
+ end
1605
+ request.params.replace(parameters)
1606
+ rescue JSON::ParserError, ArgumentError, JWT::DecodeError
1607
+ ciba_error("invalid_request")
1608
+ end
1609
+
1610
+ def ciba_decode_edwards_client_assertion(token, jwks)
1611
+ claims = ciba_decode_edwards_jwt(token, jwks, algorithms: Array(ciba_client_assertion_signing_algorithms))
1612
+ return unless claims
1613
+ if claims.key?("nbf")
1614
+ value = claims["nbf"]
1615
+ return unless value.is_a?(Numeric) && value.finite? && value <= Time.now.to_f
1616
+ end
1617
+ claims
1618
+ end
1619
+
1620
+ def ciba_decode_edwards_jwt(token, jwks, algorithms:, allow_encoded_payload_crit: false)
1621
+ segments = token.split(".", -1)
1622
+ return unless segments.size == 3 && segments.all? { |part| part.match?(/\A[A-Za-z0-9_-]+\z/) }
1623
+ header, claims = segments.first(2).map do |part|
1624
+ JSON.parse(Base64.urlsafe_decode64(part), max_nesting: 16, allow_duplicate_key: false)
1625
+ end
1626
+ return unless header.is_a?(Hash) && claims.is_a?(Hash) &&
1627
+ algorithms.include?(header["alg"]) &&
1628
+ (!header.key?("crit") || (allow_encoded_payload_crit && header["crit"] == ["b64"] && header["b64"] == true)) &&
1629
+ (!header.key?("kid") || header["kid"].is_a?(String))
1630
+ keys = jwks.is_a?(Hash) ? (jwks[:keys] || jwks["keys"]) : jwks
1631
+ return unless keys.is_a?(Array)
1632
+ signature = Base64.urlsafe_decode64(segments.last)
1633
+ return unless signature.bytesize == 64
1634
+ verified = keys.any? do |data|
1635
+ next false unless data.is_a?(Hash)
1636
+ data = data.transform_keys(&:to_s)
1637
+ next false if (data.key?("alg") && data["alg"] != header["alg"]) ||
1638
+ (data.key?("use") && data["use"] != "sig") ||
1639
+ (data.key?("key_ops") && !(data["key_ops"].is_a?(Array) && data["key_ops"].include?("verify"))) ||
1640
+ (header.key?("kid") && data["kid"] != header["kid"])
1641
+ key = ciba_ed25519_public_key(data)
1642
+ key && key.verify(nil, signature, segments.first(2).join("."))
1643
+ end
1644
+ return unless verified
1645
+ claims
1646
+ rescue JSON::ParserError, ArgumentError, OpenSSL::PKey::PKeyError
1647
+ nil
1648
+ end
1649
+
1650
+ # RFC 8410 SubjectPublicKeyInfo: Ed25519 OID, absent parameters, raw public key.
1651
+ # Keep this JWK adaptation local to CIBA; do not register global JWT algorithms.
1652
+ def ciba_ed25519_public_key(data)
1653
+ return unless data["kty"] == "OKP" && data["crv"] == "Ed25519"
1654
+ encoded = data["x"]
1655
+ return unless encoded.is_a?(String) && encoded.match?(/\A[A-Za-z0-9_-]{43}\z/)
1656
+ raw = Base64.urlsafe_decode64(encoded)
1657
+ return unless raw.bytesize == 32 && Base64.urlsafe_encode64(raw, padding: false) == encoded
1658
+ identifier = OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::ObjectId.new("1.3.101.112")])
1659
+ spki = OpenSSL::ASN1::Sequence.new([identifier, OpenSSL::ASN1::BitString.new(raw)])
1660
+ OpenSSL::PKey.read(spki.to_der)
1661
+ end
1662
+
1663
+ def ciba_signed_request_jwks
1664
+ @ciba_request_key_lookup = true
1665
+ oauth_application_jwks(oauth_application)
1666
+ rescue CibaSupport::HTTP::Error, IOError, SystemCallError, Timeout::Error, SocketError, OpenSSL::SSL::SSLError, Net::HTTPBadResponse
1667
+ raise CibaSupport::ProtocolError, "invalid_request"
1668
+ ensure
1669
+ @ciba_request_key_lookup = false
1670
+ end
1671
+
1672
+ # This decoder is deliberately separate from access-token/client-assertion verification.
1673
+ # Expiration is ignored only for identity hints; no authorization is inherited.
1674
+ def ciba_id_token_hint_subject(token)
1675
+ algorithm = oauth_application[oauth_applications_id_token_signed_response_alg_column] || oauth_jwt_keys.keys.first
1676
+ ciba_error("invalid_request") unless %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512 HS256 HS384 HS512].include?(algorithm) || ciba_id_token_signing_keys.key?(algorithm)
1677
+ header = JWT::EncodedToken.new(token).header
1678
+ ciba_error("invalid_request") unless header["alg"] == algorithm &&
1679
+ header["b64"] != false &&
1680
+ (!header.key?("crit") || (header["crit"] == ["b64"] && header["b64"] == true)) &&
1681
+ (!header.key?("kid") || header["kid"].is_a?(String))
1682
+ keys = algorithm.start_with?("HS") ? ciba_hmac_id_token_secret(algorithm) : (oauth_jwt_public_keys[algorithm] || oauth_jwt_keys[algorithm])
1683
+ claims = nil
1684
+ if ciba_id_token_signing_keys.key?(algorithm)
1685
+ public_keys = Array(ciba_id_token_signing_keys.fetch(algorithm)).map { |key| ciba_edwards_public_jwk(key, algorithm) }
1686
+ claims = ciba_decode_edwards_jwt(token, public_keys, algorithms: [algorithm], allow_encoded_payload_crit: true)
1687
+ end
1688
+ Array(keys).each do |key|
1689
+ next if !algorithm.start_with?("HS") && header.key?("kid") && JWT::JWK.new(key).kid != header["kid"]
1690
+ begin
1691
+ claims, = JWT.decode(token, key, true, algorithms: [algorithm], verify_expiration: false,
1692
+ verify_iss: true, iss: oauth_jwt_issuer, verify_aud: true,
1693
+ aud: oauth_application[oauth_applications_client_id_column], verify_not_before: false, verify_iat: false,
1694
+ required_claims: %w[iss aud sub])
1695
+ break
1696
+ rescue JWT::DecodeError
1697
+ # A retained key without kid may be needed; never fetch keys from the hint.
1698
+ end
1699
+ end
1700
+ ciba_error("invalid_request") unless claims && claims["iss"] == oauth_jwt_issuer &&
1701
+ Array(claims["aud"]).include?(oauth_application[oauth_applications_client_id_column]) &&
1702
+ (!claims.key?("nbf") || (claims["nbf"].is_a?(Numeric) && claims["nbf"].finite? && claims["nbf"] <= ciba_now + oauth_jwt_iat_leeway)) &&
1703
+ claims["sub"].is_a?(String) &&
1704
+ (claims["aud"].is_a?(String) || (claims["aud"].is_a?(Array) && claims["aud"].all? { |aud| aud.is_a?(String) })) &&
1705
+ (!claims.key?("jti") || claims["jti"].is_a?(String)) &&
1706
+ (!claims.key?("iat") || (claims["iat"].is_a?(Numeric) && claims["iat"].finite? &&
1707
+ (claims.key?("exp") || claims["iat"] <= ciba_now + oauth_jwt_iat_leeway)))
1708
+ if ciba_id_token_hint_max_age && (!claims["iat"].is_a?(Numeric) || ciba_now - claims["iat"] > ciba_id_token_hint_max_age)
1709
+ ciba_error("invalid_request")
1710
+ end
1711
+ claims["sub"].freeze
1712
+ rescue JWT::DecodeError
1713
+ ciba_error("invalid_request")
1714
+ end
1715
+
1716
+ def ciba_requested_max_age
1717
+ value = request.params["max_age"]
1718
+ if value.nil? || value == ""
1719
+ return oauth_application[oauth_applications_default_max_age_column]
1720
+ end
1721
+ ciba_error("invalid_request") unless value.is_a?(String) && value.valid_encoding? && value.bytesize <= 1024
1722
+ number = value.gsub(/\A[\t-\r \u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+|[\t-\r \u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+\z/, "")
1723
+ numeric = case number
1724
+ when "" then 0.0
1725
+ when /\A0[xX][0-9a-fA-F]+\z/, /\A0[bB][01]+\z/, /\A0[oO][0-7]+\z/
1726
+ Integer(number).to_f
1727
+ when /\A[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\z/
1728
+ Float(number.sub(/\.(?=[eE]|\z)/, ".0"))
1729
+ end
1730
+ unless numeric && numeric.finite? && numeric == numeric.to_i && numeric.between?(0, 9_007_199_254_740_991)
1731
+ ciba_error("invalid_request")
1732
+ end
1733
+ numeric.to_i
1734
+ rescue ArgumentError
1735
+ ciba_error("invalid_request")
1736
+ end
1737
+
1738
+ def validate_ciba_request!
1739
+ %w[request request_uri].each do |name|
1740
+ ciba_error("invalid_request") if request.params.key?(name)
1741
+ end
1742
+ ciba_error("invalid_request") if request.params.key?("user_code") && !ciba_user_code_enabled
1743
+ hint_names = %w[login_hint login_hint_token id_token_hint].select { |name| request.params.key?(name) }
1744
+ ciba_error("invalid_request") unless hint_names.size == 1
1745
+ hint_name = hint_names.first
1746
+ ciba_error("invalid_request") if hint_name == "login_hint_token" && !ciba_login_hint_token_enabled
1747
+ ciba_error("invalid_request") if hint_name == "id_token_hint" && !ciba_id_token_hint_enabled
1748
+ hint = ciba_parameter(hint_name, required: true, max: hint_name == "login_hint" ? 1024 : 8192)
1749
+ ciba_error("invalid_request") if request.params["scope"].nil?
1750
+ scope = ciba_parameter("scope", required: true, max: 4096, error: "invalid_scope")
1751
+ ciba_error("invalid_scope") unless scope.match?(/\A[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*\z/)
1752
+ scopes = scope.split.uniq
1753
+ scopes -= ["offline_access"] unless ciba_refresh_tokens_enabled
1754
+ allowed = oauth_application_scopes & oauth_application[oauth_applications_scopes_column].to_s.split
1755
+ ciba_error("invalid_scope") unless scopes.include?("openid") && (scopes - allowed).empty?
1756
+ binding = request.params["binding_message"]
1757
+ unless binding.nil? || (binding.is_a?(String) && binding.valid_encoding?)
1758
+ ciba_error("invalid_binding_message")
1759
+ end
1760
+ validate_ciba_binding_message(binding)
1761
+ acr = ciba_parameter("acr_values") unless request.params["acr_values"] == ""
1762
+ if !acr && (defaults = oauth_application[oauth_applications_default_acr_values_column])
1763
+ acr = JSON.parse(defaults).join(" ")
1764
+ acr = nil if acr.empty?
1765
+ end
1766
+ expiry = ciba_parameter("requested_expiry", max: 20)
1767
+ ciba_error("invalid_request") if expiry && !expiry.match?(/\A[0-9]+\z/)
1768
+ ciba_error("invalid_request") if expiry && expiry.to_i.zero?
1769
+ lifetime = expiry ? [expiry.to_i, ciba_max_request_lifetime].min : ciba_request_lifetime
1770
+ result = {hint_name.to_sym => hint, scopes: scopes.join(oauth_scope_separator), binding_message: binding,
1771
+ acr_values: acr, nonce: ciba_parameter("nonce"), lifetime: lifetime, max_age: ciba_requested_max_age}
1772
+ result[:request_context] = ciba_parameter("request_context", max: 8192) if ciba_request_context_enabled
1773
+ result[:user_code] = ciba_parameter("user_code") if ciba_user_code_enabled
1774
+ result[:requested_claims] = ciba_parse_requested_claims if ciba_claims_enabled
1775
+ authentication_claims = []
1776
+ if oauth_application[oauth_applications_require_auth_time_column] ||
1777
+ !result[:max_age].nil?
1778
+ authentication_claims << "auth_time"
1779
+ end
1780
+ authentication_claims << "auth_time_required" if oauth_application[oauth_applications_require_auth_time_column] == true
1781
+ authentication_claims << "acr" if acr
1782
+ if result[:requested_claims]
1783
+ authentication_claims |= JSON.parse(result[:requested_claims]).fetch("id_token", {}).keys & %w[auth_time acr amr]
1784
+ end
1785
+ result[:authentication_claims] = JSON.generate(authentication_claims)
1786
+ result[:requested_resources] = ciba_requested_resources if ciba_resources_enabled
1787
+ if ciba_authorization_details_enabled
1788
+ details = ciba_checked_authorization_details(request.params.fetch("authorization_details", "[]"), oauth_application)
1789
+ if !details.empty? && JSON.parse(result[:requested_resources]).empty?
1790
+ raise CibaSupport::ProtocolError, "invalid_target"
1791
+ end
1792
+ result[:requested_authorization_details] = JSON.generate(details)
1793
+ end
1794
+ result
1795
+ end
1796
+
1797
+ def validate_token_params
1798
+ super
1799
+ return unless param("grant_type") == CibaSupport::GRANT_TYPE
1800
+ validate_ciba_transport!
1801
+ ciba_error("unauthorized_client") unless ciba_client_eligible?(oauth_application)
1802
+ ciba_parameter("auth_req_id", required: true)
1803
+ end
1804
+
1805
+ def create_token(grant_type)
1806
+ return ciba_refresh_access_token if ciba_refresh_request?
1807
+ return super unless grant_type == CibaSupport::GRANT_TYPE
1808
+ row = ciba_lock_source_after_grant(ciba_ds.where(ciba_values(
1809
+ auth_req_id_digest: Digest::SHA256.hexdigest(param("auth_req_id")),
1810
+ oauth_application_id: oauth_application[oauth_applications_id_column])), request_columns: true)
1811
+ ciba_error("invalid_grant") unless row
1812
+ ciba_error("expired_token") if row[:expires_at] <= ciba_now
1813
+ if row[:status] == "consumed"
1814
+ revoke_ciba_grant(row[:grant_id]) if row[:grant_id]
1815
+ ciba_error("invalid_grant")
1816
+ end
1817
+ if row[:status] == "denied"
1818
+ ciba_ds.where(ciba_values(id: row[:id])).update(ciba_values(status: "consumed", consumed_at: ciba_now))
1819
+ code = row[:completion_error] || "access_denied"
1820
+ status = {"invalid_token" => 401, "insufficient_scope" => 403}.fetch(code, 400)
1821
+ ciba_error(code, status)
1822
+ end
1823
+ ciba_guard(row[:id]) do
1824
+ ciba_claim!(row)
1825
+ if row[:status] == "pending"
1826
+ too_fast = row[:last_polled_at] && ciba_now < row[:last_polled_at] + row[:interval]
1827
+ interval = row[:interval] + (too_fast ? 5 : 0)
1828
+ ciba_ds.where(ciba_values(id: row[:id])).update(ciba_values(last_polled_at: ciba_now, interval: interval))
1829
+ ciba_error(too_fast ? "slow_down" : "authorization_pending")
1830
+ end
1831
+ application = oauth_application_ds(row[:oauth_application_id]).first
1832
+ begin
1833
+ authorized_scopes = ciba_authorized_scopes!(row, row[:grant_id])
1834
+ rescue CibaSupport::Ineligible, CibaSupport::IdentityMismatch, ArgumentError
1835
+ raise CibaSupport::ProtocolError, "invalid_grant"
1836
+ end
1837
+ unless row[:status] == "approved" && ciba_account_eligible?(row[:account_id]) && ciba_client_eligible?(application, authorized_scopes)
1838
+ raise CibaSupport::ProtocolError, "invalid_grant"
1839
+ end
1840
+ @oauth_application = application
1841
+ @ciba_current_request = ciba_snapshot(row)
1842
+ before_ciba_issue
1843
+ raise CibaSupport::Expired, "request expired" if row[:expires_at] <= ciba_now
1844
+ begin
1845
+ authorized_scopes = ciba_authorized_scopes!(row, row[:grant_id])
1846
+ rescue CibaSupport::Ineligible, CibaSupport::IdentityMismatch, ArgumentError
1847
+ raise CibaSupport::ProtocolError, "invalid_grant"
1848
+ end
1849
+ ciba_validate_enabled_capabilities!(row)
1850
+ updates = {status: "consumed", consumed_at: ciba_now}
1851
+ ciba_ds.where(ciba_values(id: row[:id])).update(ciba_values(updates))
1852
+ grant = ciba_generate_access_token(row, authorized_scopes)
1853
+ if ciba_refresh_tokens_enabled && authorized_scopes.include?("offline_access") &&
1854
+ application[oauth_applications_grant_types_column].to_s.split.include?("refresh_token")
1855
+ grant[oauth_grants_refresh_token_column] = ciba_save_refresh_token(row)
1856
+ end
1857
+ row.merge!(updates)
1858
+ @ciba_current_request = ciba_snapshot(row)
1859
+ after_ciba_issue
1860
+ ciba_emit(row, "token_issued", "approved", "consumed")
1861
+ grant
1862
+ end
1863
+ ensure
1864
+ if grant_type == CibaSupport::GRANT_TYPE
1865
+ @ciba_authentication = @ciba_current_request = nil
1866
+ end
1867
+ end
1868
+
1869
+ # A namespace lets disabled installations reject issued CIBA refresh tokens
1870
+ # without querying optional storage or changing ordinary OAuth refresh flows.
1871
+ def ciba_refresh_request?
1872
+ request.params["grant_type"] == "refresh_token" &&
1873
+ request.params["refresh_token"].is_a?(String) && request.params["refresh_token"].start_with?("ciba_rt_")
1874
+ end
1875
+
1876
+ def ciba_token_revocation_request?
1877
+ return false unless features.include?(:oauth_token_revocation) && request.path == revoke_path
1878
+ token = request.params["token"]
1879
+ return false unless token.is_a?(String)
1880
+ return true if token.start_with?("ciba_rt_")
1881
+ if token.bytesize <= 8192 && token.count(".") == 2
1882
+ if @ciba_revocation_jwt_candidate.nil?
1883
+ begin
1884
+ unverified, = JWT.decode(token, nil, false)
1885
+ @ciba_revocation_jwt_candidate = unverified.is_a?(Hash) &&
1886
+ (unverified.key?("urn:rodauth:ciba:token_id") || unverified.key?("urn:rodauth:ciba:resource"))
1887
+ rescue JWT::DecodeError, ArgumentError
1888
+ @ciba_revocation_jwt_candidate = false
1889
+ end
1890
+ end
1891
+ # Unverified data selects only client authentication and a rejecting
1892
+ # validation path. It never identifies a DB row or grants authority.
1893
+ return true if @ciba_revocation_jwt_candidate
1894
+ end
1895
+ namespaced = token.start_with?("ciba_at_")
1896
+ return namespaced || !!@ciba_revocation_access_id if defined?(@ciba_revocation_access_id)
1897
+ return true if namespaced && token.bytesize > 1024 # validation rejects oversized values
1898
+ return false if token.bytesize > 1024
1899
+ column = oauth_grants_token_hash_column || oauth_grants_token_column
1900
+ value = oauth_grants_token_hash_column ? generate_token_hash(token) : token
1901
+ # Only saved CIBA opaque tokens select this behavior. Keep revoked rows
1902
+ # recognizable for idempotent retries; authorization happens under lock.
1903
+ @ciba_revocation_access_id = db[oauth_grants_table]
1904
+ .where(column => value, oauth_grants_type_column => CibaSupport::GRANT_TYPE)
1905
+ .exclude(oauth_grants_ciba_grant_id_column => nil).get(oauth_grants_id_column)
1906
+ namespaced || !!@ciba_revocation_access_id
1907
+ end
1908
+
1909
+ # Provenance only: expired signed CIBA JWTs still cannot be revoked through
1910
+ # this endpoint. This verifier is never used to authorize token consumption.
1911
+ def ciba_signed_access_token?(token)
1912
+ header = JWT::EncodedToken.new(token).header
1913
+ algorithm = header["alg"]
1914
+ return false unless header["typ"] == "at+jwt" && !header.key?("crit") &&
1915
+ %w[RS256 RS384 RS512 PS256 PS384 PS512 ES256 ES384 ES512 HS256 HS384 HS512].include?(algorithm)
1916
+ keys = algorithm.start_with?("HS") ? oauth_jwt_keys[algorithm] : (oauth_jwt_public_keys[algorithm] || oauth_jwt_keys[algorithm])
1917
+ Array(keys).any? do |key|
1918
+ next false if header.key?("kid") && JWT::JWK.new(key).kid != header["kid"]
1919
+ begin
1920
+ claims, = JWT.decode(token, key, true, algorithms: [algorithm],
1921
+ verify_iss: true, iss: oauth_jwt_issuer, required_claims: ["iss"],
1922
+ verify_expiration: false, verify_not_before: false, verify_iat: false)
1923
+ claims.key?("urn:rodauth:ciba:token_id") || claims.key?("urn:rodauth:ciba:resource")
1924
+ rescue JWT::DecodeError
1925
+ false
1926
+ end
1927
+ end
1928
+ rescue JWT::DecodeError, ArgumentError
1929
+ false
1930
+ end
1931
+
1932
+ def ciba_revoke_access_artifacts
1933
+ ds = valid_oauth_grant_ds(oauth_grants_id_column => @ciba_revocation_access_id)
1934
+ .where(oauth_grants_type_column => CibaSupport::GRANT_TYPE)
1935
+ row = ciba_lock_source_after_grant(ds, grant_key: oauth_grants_ciba_grant_id_column)
1936
+ return unless row
1937
+ ciba_error("invalid_request") unless row[oauth_grants_oauth_application_id_column] == oauth_application[oauth_applications_id_column]
1938
+ grant_id = row[oauth_grants_ciba_grant_id_column]
1939
+ # The grant lock excludes completion/issuance using this consent. Retain
1940
+ # the consent itself, but remove sources that could issue from old approval.
1941
+ ciba_ds.where(ciba_values(grant_id: grant_id)).delete
1942
+ if db.table_exists?(ciba_refresh_tokens_table)
1943
+ db[ciba_refresh_tokens_table].where(grant_id: grant_id).delete
1944
+ end
1945
+ db[oauth_grants_table].where(oauth_grants_ciba_grant_id_column => grant_id)
1946
+ .update(oauth_grants_revoked_at_column => Sequel::CURRENT_TIMESTAMP)
1947
+ end
1948
+
1949
+ def ciba_save_refresh_token(source, rotations: 0, created_at: ciba_now)
1950
+ token = "ciba_rt_#{SecureRandom.urlsafe_base64(32)}"
1951
+ keys = %i[grant_id account_id oauth_application_id scopes auth_time acr amr nonce authentication_claims
1952
+ requested_claims requested_resources requested_authorization_details]
1953
+ row = source.select { |key, _| keys.include?(key) }
1954
+ row.merge!(token_digest: Digest::SHA256.hexdigest(token), created_at: created_at,
1955
+ issued_at: ciba_now, expires_at: ciba_now + ciba_refresh_token_lifetime, rotations: rotations)
1956
+ db[ciba_refresh_tokens_table].insert(row)
1957
+ token
1958
+ end
1959
+
1960
+ def ciba_refresh_access_token
1961
+ ciba_error("invalid_grant") unless ciba_refresh_tokens_enabled
1962
+ unless oauth_application[oauth_applications_grant_types_column].to_s.split.include?("refresh_token")
1963
+ ciba_error("unauthorized_client")
1964
+ end
1965
+ token = ciba_parameter("refresh_token", required: true, max: 1024)
1966
+ ds = db[ciba_refresh_tokens_table].where(token_digest: Digest::SHA256.hexdigest(token),
1967
+ oauth_application_id: oauth_application[oauth_applications_id_column])
1968
+ row = ciba_lock_source_after_grant(ds)
1969
+ ciba_error("invalid_grant") unless row && row[:expires_at] > ciba_now
1970
+ ciba_guard([:refresh, row[:id]]) { ciba_refresh_source(row, ds, token) }
1971
+ ensure
1972
+ @ciba_authentication = @ciba_current_refresh = nil
1973
+ end
1974
+
1975
+ def ciba_validate_refresh_source!(row)
1976
+ raise CibaSupport::ProtocolError, "invalid_grant" unless row && row[:expires_at] > ciba_now && ciba_refresh_tokens_enabled
1977
+ application = oauth_application_ds(row[:oauth_application_id]).first
1978
+ unless application && application[oauth_applications_grant_types_column].to_s.split.include?("refresh_token")
1979
+ raise CibaSupport::ProtocolError, "invalid_grant"
1980
+ end
1981
+ begin
1982
+ scopes = ciba_authorized_scopes!(row, row[:grant_id])
1983
+ rescue CibaSupport::Ineligible, CibaSupport::IdentityMismatch, ArgumentError
1984
+ raise CibaSupport::ProtocolError, "invalid_grant"
1985
+ end
1986
+ unless scopes.include?("offline_access") && ciba_account_eligible?(row[:account_id]) && ciba_client_eligible?(application, scopes)
1987
+ raise CibaSupport::ProtocolError, "invalid_grant"
1988
+ end
1989
+ @oauth_application = application
1990
+ ciba_validate_enabled_capabilities!(row)
1991
+ scopes
1992
+ end
1993
+
1994
+ def ciba_refresh_source(row, ds, token)
1995
+ ciba_validate_refresh_source!(row)
1996
+ if row[:consumed_at]
1997
+ revoke_ciba_grant(row[:grant_id])
1998
+ ciba_error("invalid_grant")
1999
+ end
2000
+ requested_scope = ciba_parameter("scope", max: 4096, error: "invalid_scope")
2001
+ @ciba_current_refresh = ciba_snapshot(row)
2002
+ before_ciba_refresh
2003
+ row = (db.database_type == :sqlite ? ds : ds.for_update).first
2004
+ scopes = ciba_validate_refresh_source!(row)
2005
+ raise CibaSupport::ProtocolError, "invalid_grant" if row[:consumed_at]
2006
+ narrowed = row
2007
+ if requested_scope
2008
+ unless requested_scope.match?(/\A[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*\z/) &&
2009
+ (requested_scope.split - row[:scopes].split).empty?
2010
+ raise CibaSupport::ProtocolError, "invalid_scope"
2011
+ end
2012
+ narrowed = row.merge(scopes: requested_scope.split.uniq.join(oauth_scope_separator))
2013
+ scopes &= requested_scope.split
2014
+ end
2015
+ changed = ds.where(lock_version: row[:lock_version], consumed_at: nil)
2016
+ .update(lock_version: row[:lock_version] + 1)
2017
+ raise CibaSupport::Conflict, "concurrent refresh" unless changed == 1
2018
+ # Confidential clients match the reference default: rotate after 70% TTL,
2019
+ # stopping sliding renewal after one year from initial issuance.
2020
+ rotate = ciba_now - row[:created_at] < 31_557_600 &&
2021
+ (ciba_now - row[:issued_at]) * 10 >= (row[:expires_at] - row[:issued_at]) * 7
2022
+ if rotate
2023
+ ds.update(consumed_at: ciba_now)
2024
+ token = ciba_save_refresh_token(row, rotations: row[:rotations] + 1, created_at: row[:created_at])
2025
+ end
2026
+ grant = ciba_generate_access_token(narrowed, scopes)
2027
+ grant[oauth_grants_refresh_token_column] = token
2028
+ @ciba_current_refresh = ciba_snapshot(ds.first)
2029
+ after_ciba_refresh
2030
+ event = {version: 1, event_id: SecureRandom.uuid.freeze, type: "token_refreshed".freeze,
2031
+ refresh_token_id: row[:id], grant_id: row[:grant_id], occurred_at: ciba_now,
2032
+ account_id: row[:account_id], oauth_application_id: row[:oauth_application_id],
2033
+ rotated: rotate}.freeze
2034
+ db.after_commit(savepoint: true) { ciba_observe { observe_ciba_event(event) } }
2035
+ grant
2036
+ end
2037
+
2038
+ def ciba_generate_access_token(row, authorized_scopes)
2039
+ @ciba_authentication = row
2040
+ attributes = {oauth_grants_account_id_column => row[:account_id],
2041
+ oauth_grants_oauth_application_id_column => row[:oauth_application_id],
2042
+ oauth_grants_scopes_column => authorized_scopes.join(oauth_scope_separator),
2043
+ oauth_grants_ciba_grant_id_column => row[:grant_id], oauth_grants_type_column => CibaSupport::GRANT_TYPE}
2044
+ attributes[ciba_token_subject_column] = jwt_subject(row[:account_id]) if ciba_pairwise_enabled
2045
+ attributes[:ciba_claims] = ciba_compile_claims(row, authorized_scopes) if ciba_claims_enabled
2046
+ resource_attributes = ciba_token_resource(row)
2047
+ attributes.merge!(resource_attributes) if resource_attributes
2048
+ details = ciba_compile_authorization_details(row, resource_attributes)
2049
+ attributes[:ciba_authorization_details] = details if details
2050
+ grant = generate_token(attributes, false)
2051
+ if authorized_scopes.include?("openid")
2052
+ id_grant = grant.merge(oauth_grants_scopes_column => authorized_scopes.join(oauth_scope_separator))
2053
+ generate_id_token(id_grant)
2054
+ grant[:id_token] = id_grant[:id_token]
2055
+ raise "ID Token generation did not complete" unless grant[:id_token]
2056
+ end
2057
+ grant
2058
+ end
2059
+
2060
+ def json_access_token_payload(grant)
2061
+ payload = super
2062
+ # RFC 6749 section 5.1 requires scope when the granted scope differs
2063
+ # from the request (for example, ignoring unsupported offline_access).
2064
+ if grant[oauth_grants_type_column] == CibaSupport::GRANT_TYPE
2065
+ payload["scope"] = grant[oauth_grants_scopes_column]
2066
+ payload["authorization_details"] = JSON.parse(grant[:ciba_authorization_details]) if grant[:ciba_authorization_details]
2067
+ end
2068
+ payload
2069
+ end
2070
+
2071
+ def ciba_validate_enabled_capabilities!(row)
2072
+ if row[:authentication_claims] && JSON.parse(row[:authentication_claims]).include?("auth_time_required") && row[:auth_time].nil?
2073
+ raise CibaSupport::ProtocolError, "invalid_grant"
2074
+ end
2075
+ consent = ciba_locked_grant(row[:grant_id])
2076
+ # A legacy request may have been bound to a new-format consent after
2077
+ # migration. Neither side's restrictions may disappear on deactivation.
2078
+ [[ciba_claims_enabled, :requested_claims, :claims],
2079
+ [ciba_resources_enabled, :requested_resources, :resources],
2080
+ [ciba_authorization_details_enabled, :requested_authorization_details, :authorization_details]].each do |enabled, request_key, consent_key|
2081
+ if !enabled && (row[request_key] || consent[consent_key])
2082
+ raise CibaSupport::ProtocolError, "invalid_grant"
2083
+ end
2084
+ end
2085
+ end
2086
+
2087
+ def id_token_claims(grant, algorithm)
2088
+ return super unless @ciba_authentication
2089
+ # Upstream's id_token_claims reads a browser-session login timestamp.
2090
+ # CIBA uses the application's verified authentication result instead.
2091
+ # generate_id_token still supplies upstream scope claims and signing.
2092
+ claims = jwt_claims(grant)
2093
+ claims.delete(:"urn:rodauth:ciba:token_id")
2094
+ claims.delete(:"urn:rodauth:ciba:resource")
2095
+ claims.delete(:authorization_details)
2096
+ # DPoP/mTLS cnf describes access-token sender constraints, not the ID Token.
2097
+ claims.delete(:cnf)
2098
+ # ID Token audience is the RP, even when access-token resource audiences differ.
2099
+ claims[:aud] = oauth_application[oauth_applications_client_id_column]
2100
+ row = @ciba_authentication
2101
+ claims[:nonce] = row[:nonce] if row[:nonce]
2102
+ selected = row[:authentication_claims] ? JSON.parse(row[:authentication_claims]) : %w[auth_time acr amr]
2103
+ grant[oauth_grants_scopes_column].to_s.split(oauth_scope_separator).each do |scope|
2104
+ selected |= Array(ciba_authentication_claims_by_scope[scope]) & %w[auth_time acr amr]
2105
+ end
2106
+ claims[:auth_time] = row[:auth_time] if row[:auth_time] && selected.include?("auth_time")
2107
+ claims[:acr] = row[:acr] if row[:acr] && selected.include?("acr")
2108
+ claims[:amr] = JSON.parse(row[:amr]) if row[:amr] && selected.include?("amr")
2109
+ claims[:at_hash] = id_token_hash(grant[oauth_grants_token_column], algorithm)
2110
+ claims
2111
+ end
2112
+
2113
+ def ciba_emit(row, type, from, to)
2114
+ event = {version: 1, event_id: SecureRandom.uuid.freeze, request_id: row[:id], type: type.freeze,
2115
+ occurred_at: ciba_now, account_id: row[:account_id], oauth_application_id: row[:oauth_application_id],
2116
+ from: from&.freeze, to: to.freeze}
2117
+ event[:error] = row[:completion_error].dup.freeze if type == "failed"
2118
+ event.freeze
2119
+ db.after_commit(savepoint: true) { ciba_observe { observe_ciba_event(event) } }
2120
+ end
2121
+
2122
+ def ciba_observe
2123
+ yield
2124
+ rescue StandardError => error
2125
+ begin
2126
+ report_ciba_observer_error(error)
2127
+ rescue StandardError
2128
+ # An unavailable reporter cannot change the committed result.
2129
+ end
2130
+ end
2131
+
2132
+ def ciba_sqlite_busy_error?(error)
2133
+ db.database_type == :sqlite && error.is_a?(Sequel::DatabaseError) &&
2134
+ error.wrapped_exception.class.name == "SQLite3::BusyException"
2135
+ end
2136
+
2137
+ def ciba_http
2138
+ # Upstream authentication can halt before ciba_error is reached.
2139
+ response["Cache-Control"] = "no-store"
2140
+ response["Pragma"] = "no-cache"
2141
+ yield
2142
+ rescue Rack::QueryParser::ParameterTypeError, Rack::QueryParser::InvalidParameterError, Rack::QueryParser::QueryLimitError
2143
+ @ciba_invalid_parameters = true
2144
+ ciba_error("invalid_request")
2145
+ rescue CibaSupport::Expired
2146
+ ciba_error("expired_token")
2147
+ rescue CibaSupport::ProtocolError => error
2148
+ ciba_error(error.code)
2149
+ rescue Sequel::DatabaseLockTimeout, Sequel::SerializationFailure
2150
+ response["Retry-After"] = ciba_poll_interval.to_s
2151
+ ciba_error("temporarily_unavailable", 503)
2152
+ rescue StandardError => error
2153
+ if ciba_sqlite_busy_error?(error)
2154
+ response["Retry-After"] = ciba_poll_interval.to_s
2155
+ ciba_error("temporarily_unavailable", 503)
2156
+ end
2157
+ begin
2158
+ report_ciba_processing_error(error)
2159
+ rescue StandardError
2160
+ # Do not leak exception messages (which can contain credentials).
2161
+ end
2162
+ ciba_error("server_error", 500)
2163
+ end
2164
+
2165
+ def ciba_error(code, status = 400)
2166
+ response["Cache-Control"] = "no-store"
2167
+ response["Pragma"] = "no-cache"
2168
+ throw_json_response_error(status, code)
2169
+ end
2170
+
2171
+ def ciba_transaction(&block)
2172
+ opts = {savepoint: true}
2173
+ opts[:mode] = :immediate if db.database_type == :sqlite
2174
+ db.transaction(opts, &block)
2175
+ end
2176
+
2177
+ def transaction(opts = {}, &block)
2178
+ return super unless ciba_token_revocation_request? ||
2179
+ (request.path == token_path && (param("grant_type") == CibaSupport::GRANT_TYPE || ciba_refresh_request?))
2180
+ opts = opts.merge(savepoint: true)
2181
+ opts[:mode] = :immediate if db.database_type == :sqlite
2182
+ ciba_http { super(opts, &block) }
2183
+ end
2184
+ end
2185
+ end