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,489 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Rodauth
4
+ module CibaSupport
5
+ module Registration
6
+ TLS_SUBJECT_METADATA = %w[tls_client_auth_subject_dn tls_client_auth_san_dns
7
+ tls_client_auth_san_uri tls_client_auth_san_ip tls_client_auth_san_email].freeze
8
+ # Protocol metadata, never arbitrary database column names. Applications
9
+ # with proprietary metadata should validate it in their own registration
10
+ # policy rather than expose internal storage through upstream's fallback.
11
+ METADATA = %w[
12
+ redirect_uris grant_types response_types token_endpoint_auth_method
13
+ token_endpoint_auth_signing_alg client_uri logo_uri tos_uri policy_uri
14
+ jwks_uri jwks scope contacts client_name subject_type sector_identifier_uri
15
+ id_token_signed_response_alg id_token_encrypted_response_alg id_token_encrypted_response_enc
16
+ userinfo_signed_response_alg userinfo_encrypted_response_alg userinfo_encrypted_response_enc
17
+ request_object_signing_alg request_object_encryption_alg request_object_encryption_enc
18
+ default_max_age require_auth_time default_acr_values initiate_login_uri request_uris
19
+ dpop_bound_access_tokens
20
+ tls_client_certificate_bound_access_tokens tls_client_auth_subject_dn
21
+ tls_client_auth_san_dns tls_client_auth_san_uri tls_client_auth_san_ip tls_client_auth_san_email
22
+ post_logout_redirect_uris authorization_details_types backchannel_token_delivery_mode
23
+ backchannel_client_notification_endpoint backchannel_user_code_parameter
24
+ backchannel_authentication_request_signing_alg
25
+ ].freeze
26
+
27
+ def oauth_client_registration_required_params
28
+ return super unless ciba_dynamic_client_registration_enabled && ciba_registration_grant?(request.params)
29
+ required = super - ["client_name"]
30
+ request.params["response_types"] == [] ? required - ["redirect_uris"] : required
31
+ end
32
+
33
+ # Upstream loads/authenticates the registration resource in this route.
34
+ # Hold its row through validation and update so merged metadata cannot be
35
+ # invalidated by another registration update between those operations.
36
+ def load_registration_client_uri_routes
37
+ prefix = "/#{registration_client_uri_route}/"
38
+ path = request.remaining_path
39
+ return super unless %w[GET PUT DELETE].include?(request.request_method) && path.start_with?(prefix)
40
+ segment = path.delete_prefix(prefix)
41
+ return super if segment.empty? || segment.include?("/")
42
+ client_id = Rack::Utils.unescape_path(segment)
43
+ @ciba_registration_route = true
44
+ if ciba_issued_management_bearer?
45
+ token = request.env["HTTP_AUTHORIZATION"].to_s[/\A *Bearer (ciba_reg_[A-Za-z0-9_-]{43})\z/, 1]
46
+ authorization_required unless token && db[oauth_applications_table].columns.include?(ciba_registration_token_digest_column)
47
+ return ciba_transaction do
48
+ # Lock the credential's owner, not the requested target. Opposite
49
+ # cross-client requests never acquire each other's application row.
50
+ ds = db[oauth_applications_table].where(ciba_registration_token_digest_column => Digest::SHA256.hexdigest(token))
51
+ delete_owner = ciba_lock_registration_delete_grants(ds, token, client_id)
52
+ ds = ds.for_update unless db.database_type == :sqlite
53
+ source = ds.first
54
+ authorization_required unless source && source[oauth_applications_registration_access_token_column] &&
55
+ password_hash_match?(source[oauth_applications_registration_access_token_column], token)
56
+ if source[oauth_applications_client_id_column] != client_id
57
+ ds.update(ciba_registration_token_digest_column => nil, oauth_applications_registration_access_token_column => nil)
58
+ # Rodauth halts with a throw, so Sequel commits this deliberate
59
+ # credential invalidation while preserving the unrelated target.
60
+ authorization_required
61
+ end
62
+ if delete_owner && delete_owner != source[oauth_applications_id_column]
63
+ raise Sequel::SerializationFailure, "Registration credential owner changed while locking"
64
+ end
65
+ @ciba_registration_application = source
66
+ ciba_delete_registered_client(source) if request.request_method == "DELETE"
67
+ super
68
+ end
69
+ end
70
+ return super unless %w[PUT DELETE].include?(request.request_method)
71
+ ciba_transaction do
72
+ ds = db[oauth_applications_table].where(oauth_applications_client_id_column => client_id)
73
+ token = request.env["HTTP_AUTHORIZATION"].to_s[/\A *Bearer (.*)\Z/, 1]
74
+ delete_owner = ciba_lock_registration_delete_grants(ds, token, client_id)
75
+ ds = ds.for_update unless db.database_type == :sqlite
76
+ @ciba_registration_application = ds.first
77
+ if delete_owner && @ciba_registration_application && delete_owner != @ciba_registration_application[oauth_applications_id_column]
78
+ raise Sequel::SerializationFailure, "Registration client changed while locking"
79
+ end
80
+ if request.request_method == "DELETE" && @ciba_registration_application &&
81
+ @ciba_registration_application[oauth_applications_grant_types_column].to_s.split.include?(GRANT_TYPE)
82
+ authorization_required unless token && password_hash_match?(
83
+ @ciba_registration_application[oauth_applications_registration_access_token_column], token)
84
+ ciba_delete_registered_client(@ciba_registration_application)
85
+ end
86
+ super
87
+ end
88
+ rescue Sequel::DatabaseLockTimeout, Sequel::SerializationFailure
89
+ response["Retry-After"] = ciba_poll_interval.to_s
90
+ ciba_error("temporarily_unavailable", 503)
91
+ rescue Sequel::DatabaseError => error
92
+ # The SQLite adapter wraps BusyException as generic DatabaseError.
93
+ # Retry only this recognized contention outcome, never other DB errors.
94
+ raise unless ciba_sqlite_busy_error?(error)
95
+ response["Retry-After"] = ciba_poll_interval.to_s
96
+ ciba_error("temporarily_unavailable", 503)
97
+ ensure
98
+ @ciba_registration_application = nil
99
+ @ciba_registration_route = false
100
+ @ciba_registration_replacement = false
101
+ @ciba_rotated_registration_token = nil
102
+ end
103
+
104
+ private
105
+
106
+ def ciba_lock_registration_delete_grants(dataset, token, client_id)
107
+ return unless request.request_method == "DELETE"
108
+ candidate = dataset.first
109
+ return unless candidate && candidate[oauth_applications_client_id_column] == client_id &&
110
+ (ciba_issued_management_bearer? || candidate[oauth_applications_grant_types_column].to_s.split.include?(GRANT_TYPE))
111
+ authorization_required unless token && candidate[oauth_applications_registration_access_token_column] &&
112
+ password_hash_match?(candidate[oauth_applications_registration_access_token_column], token)
113
+ id = candidate.fetch(oauth_applications_id_column)
114
+ # Issuance/refresh already lock consent before touching parent-client
115
+ # FKs. Acquire existing consents in that order before the client lock;
116
+ # the caller then reloads and authenticates the credential under lock.
117
+ grants = db[ciba_grants_table].where(oauth_application_id: id).select(:id).order(:id)
118
+ grants = grants.for_update unless db.database_type == :sqlite
119
+ grants.all
120
+ id
121
+ end
122
+
123
+ def ciba_delete_registered_client(application)
124
+ id = application.fetch(oauth_applications_id_column)
125
+ # Ordinary grants have no CIBA consent FK and therefore do not cascade
126
+ # through ciba_grants. Remove all grants owned by this authorized client;
127
+ # the outer transaction restores them if deleting the client fails.
128
+ db[oauth_grants_table].where(oauth_grants_oauth_application_id_column => id).delete
129
+ db[oauth_applications_table].where(oauth_applications_id_column => id).delete
130
+ response.status = 204
131
+ response["Cache-Control"] = "no-store"
132
+ response["Pragma"] = "no-cache"
133
+ request.halt(response.finish)
134
+ end
135
+
136
+ def password_hash_match?(hash, password)
137
+ # A deliberately invalidated or never-issued management credential has
138
+ # no verifier. Upstream BCrypt.new(nil) would otherwise raise a 500.
139
+ return false if @ciba_registration_route && hash.nil?
140
+ super
141
+ end
142
+
143
+ def ciba_registration_grant?(params)
144
+ Array(params["grant_types"]).include?(GRANT_TYPE)
145
+ end
146
+
147
+ def ciba_issued_management_bearer?
148
+ request.env["HTTP_AUTHORIZATION"].to_s.match?(/\A *Bearer ciba_reg_/)
149
+ end
150
+
151
+ def validate_client_registration_params(params, *args)
152
+ existing = @ciba_registration_application
153
+ relevant = ciba_registration_grant?(params) || params.keys.any? { |key| key.start_with?("backchannel_") } ||
154
+ existing && (ciba_issued_management_bearer? || existing[oauth_applications_grant_types_column].to_s.split.include?(GRANT_TYPE))
155
+ return super unless relevant
156
+ unless ciba_dynamic_client_registration_enabled
157
+ register_throw_json_response_error("invalid_client_metadata", "CIBA clients require static registration")
158
+ end
159
+ if existing
160
+ unless params["client_id"] == existing[oauth_applications_client_id_column]
161
+ register_throw_json_response_error("invalid_request", "client_id must match the authenticated client")
162
+ end
163
+ if %w[registration_access_token registration_client_uri client_secret_expires_at client_id_issued_at].any? { |key| params.key?(key) }
164
+ register_throw_json_response_error("invalid_request", "Server-issued metadata cannot be supplied in an update")
165
+ end
166
+ end
167
+ known = %w[backchannel_token_delivery_mode backchannel_client_notification_endpoint backchannel_user_code_parameter backchannel_authentication_request_signing_alg]
168
+ allowed = existing ? METADATA + ["client_secret"] : METADATA
169
+ normalized = params.select { |key, _| allowed.include?(key) }
170
+ ignored = params.keys - normalized.keys
171
+ if existing
172
+ normalized = normalized.reject { |_, value| value.nil? || value == "" }
173
+ normalized["grant_types"] ||= ["authorization_code"]
174
+ normalized["response_types"] ||= ["code"]
175
+ normalized["token_endpoint_auth_method"] ||= "client_secret_basic"
176
+ normalized["scope"] ||= oauth_application_scopes.join(" ")
177
+ if normalized["response_types"] != [] && !normalized.key?("redirect_uris")
178
+ register_throw_json_response_error("invalid_client_metadata", "redirect_uris required for browser response types")
179
+ end
180
+ end
181
+ normalized["redirect_uris"] = [] if normalized["response_types"] == [] && !normalized.key?("redirect_uris")
182
+ if features.include?(:oauth_dpop)
183
+ if !normalized.key?("dpop_bound_access_tokens") && db[oauth_applications_table].columns.include?(oauth_applications_dpop_bound_access_tokens_column)
184
+ normalized["dpop_bound_access_tokens"] = false
185
+ end
186
+ if normalized.key?("dpop_bound_access_tokens") && ![true, false].include?(normalized["dpop_bound_access_tokens"])
187
+ register_throw_json_response_error("invalid_client_metadata", "dpop_bound_access_tokens must be a boolean")
188
+ end
189
+ elsif normalized.key?("dpop_bound_access_tokens")
190
+ normalized.delete("dpop_bound_access_tokens")
191
+ ignored << "dpop_bound_access_tokens"
192
+ end
193
+ tls_bound = "tls_client_certificate_bound_access_tokens"
194
+ if features.include?(:oauth_tls_client_auth)
195
+ if !normalized.key?(tls_bound) && db[oauth_applications_table].columns.include?(oauth_applications_tls_client_certificate_bound_access_tokens_column)
196
+ normalized[tls_bound] = false
197
+ end
198
+ if normalized.key?(tls_bound) && ![true, false].include?(normalized[tls_bound])
199
+ register_throw_json_response_error("invalid_client_metadata", "#{tls_bound} must be a boolean")
200
+ end
201
+ if normalized[tls_bound] && normalized["dpop_bound_access_tokens"]
202
+ register_throw_json_response_error("invalid_client_metadata", "Only one token binding mechanism may be required")
203
+ end
204
+ subjects = TLS_SUBJECT_METADATA & normalized.keys
205
+ if subjects.any? { |key| !normalized[key].is_a?(String) || normalized[key].empty? }
206
+ register_throw_json_response_error("invalid_client_metadata", "Certificate subject values must be nonempty strings")
207
+ end
208
+ if normalized["token_endpoint_auth_method"] == "tls_client_auth"
209
+ if subjects.length != 1
210
+ register_throw_json_response_error("invalid_client_metadata", "tls_client_auth requires one certificate subject value")
211
+ end
212
+ else
213
+ subjects.each { |key| normalized.delete(key); ignored << key }
214
+ end
215
+ else
216
+ (TLS_SUBJECT_METADATA + [tls_bound]).each do |key|
217
+ ignored << key if normalized.key?(key)
218
+ normalized.delete(key)
219
+ end
220
+ end
221
+ default_acr_values = normalized.delete("default_acr_values")
222
+ unless default_acr_values.nil?
223
+ unless default_acr_values.is_a?(Array) && default_acr_values.all? { |value|
224
+ value.is_a?(String) && oauth_acr_values_supported.include?(value)
225
+ }
226
+ register_throw_json_response_error("invalid_client_metadata", "default_acr_values must contain supported ACR strings")
227
+ end
228
+ default_acr_values = default_acr_values.uniq
229
+ end
230
+ if normalized.key?("require_auth_time") && ![true, false].include?(normalized["require_auth_time"])
231
+ register_throw_json_response_error("invalid_client_metadata", "require_auth_time must be a boolean")
232
+ end
233
+ if normalized.key?("default_max_age")
234
+ age = normalized["default_max_age"]
235
+ # JSON numbers such as 1.0 are also integers in node's number model.
236
+ # Validate before Sequel can coerce strings or truncate fractions.
237
+ integer = age.is_a?(Integer) || age.is_a?(Float) && age.finite? && age == age.to_i
238
+ unless integer && age.between?(0, 9_007_199_254_740_991)
239
+ register_throw_json_response_error("invalid_client_metadata", "default_max_age must be a non-negative safe integer")
240
+ end
241
+ normalized["default_max_age"] = age.to_i
242
+ end
243
+ types = normalized.fetch("authorization_details_types", [])
244
+ normalized.delete("authorization_details_types")
245
+ if ciba_authorization_details_enabled
246
+ unless types.is_a?(Array) && types.all? { |type| type.is_a?(String) && type.valid_encoding? &&
247
+ !type.empty? && ciba_authorization_details_types.include?(type) }
248
+ register_throw_json_response_error("invalid_client_metadata", "Unsupported authorization_details_types")
249
+ end
250
+ elsif params.key?("authorization_details_types")
251
+ ignored << "authorization_details_types"
252
+ end
253
+ normalized.each do |key, value|
254
+ if key.start_with?("backchannel_") && (!known.include?(key) ||
255
+ (key == "backchannel_user_code_parameter" ? ![true, false].include?(value) : !value.is_a?(String)))
256
+ register_throw_json_response_error("invalid_client_metadata", "Invalid CIBA client metadata")
257
+ end
258
+ end
259
+ if normalized.key?("token_endpoint_auth_signing_alg")
260
+ signing_algorithm = normalized["token_endpoint_auth_signing_alg"]
261
+ method = normalized.fetch("token_endpoint_auth_method", "client_secret_basic")
262
+ allowed = case method
263
+ when "private_key_jwt"
264
+ (ciba_client_assertion_signing_algorithms || oauth_jwt_keys.keys).reject { |alg| alg.start_with?("HS") || alg == "none" }
265
+ when "client_secret_jwt"
266
+ (ciba_client_assertion_signing_algorithms || oauth_jwt_keys.keys).select { |alg| %w[HS256 HS384 HS512].include?(alg) }
267
+ else
268
+ []
269
+ end
270
+ unless signing_algorithm.is_a?(String) && allowed.include?(signing_algorithm)
271
+ register_throw_json_response_error("invalid_client_metadata", "Unsupported client authentication signing algorithm")
272
+ end
273
+ end
274
+ if normalized.key?("jwks") && !ciba_registration_jwks_valid?(normalized["jwks"])
275
+ register_throw_json_response_error("invalid_client_metadata", "Invalid client JSON Web Key Set")
276
+ end
277
+ encryption = {}
278
+ unless ciba_id_token_encryption_enabled
279
+ ignored_encryption = normalized.keys & %w[id_token_encrypted_response_alg id_token_encrypted_response_enc]
280
+ ignored.concat(ignored_encryption)
281
+ normalized = normalized.reject { |key, _| ignored_encryption.include?(key) }
282
+ end
283
+ upstream_params = normalized
284
+ if normalized.keys.any? { |key| %w[id_token_encrypted_response_alg id_token_encrypted_response_enc].include?(key) }
285
+ algorithm = normalized["id_token_encrypted_response_alg"]
286
+ method = normalized.fetch("id_token_encrypted_response_enc", "A128CBC-HS256")
287
+ unless IdTokenEncryption::ALGORITHMS.include?(algorithm) && IdTokenEncryption::METHODS.include?(method)
288
+ register_throw_json_response_error("invalid_client_metadata", "Unsupported CIBA ID Token encryption")
289
+ end
290
+ encryption[oauth_applications_id_token_encrypted_response_alg_column] = algorithm
291
+ encryption[oauth_applications_id_token_encrypted_response_enc_column] = method
292
+ # Validate only these two fields here. Do not widen upstream's lists
293
+ # for unrelated UserInfo, request object or authorization encryption.
294
+ upstream_params = normalized.reject { |key, _| %w[id_token_encrypted_response_alg id_token_encrypted_response_enc].include?(key) }
295
+ end
296
+ super(upstream_params, *args)
297
+ @oauth_application_params.merge!(encryption)
298
+ unless default_acr_values.nil?
299
+ @oauth_application_params[oauth_applications_default_acr_values_column] = JSON.generate(default_acr_values)
300
+ end
301
+ @oauth_application_params[:authorization_details_types] = JSON.generate(types) if ciba_authorization_details_enabled
302
+ @oauth_application_unrecognized_params.concat(ignored)
303
+ columns = db[oauth_applications_table].columns
304
+ if @oauth_application_params.keys.any? { |column| !columns.include?(column.to_sym) }
305
+ register_throw_json_response_error("invalid_client_metadata", "Client metadata storage is not configured")
306
+ end
307
+ if existing
308
+ # Only protocol metadata is replaced. Preserve identifiers, credential
309
+ # verifiers and application-owned columns not part of this protocol.
310
+ aliases = {"scope" => "scopes", "client_name" => "name", "client_uri" => "homepage_url", "redirect_uris" => "redirect_uri"}
311
+ reset = METADATA.each_with_object({}) do |key, values|
312
+ name = aliases.fetch(key, key)
313
+ method_name = :"oauth_applications_#{name}_column"
314
+ column = respond_to?(method_name) ? __send__(method_name) : name.to_sym
315
+ values[column] = nil if columns.include?(column)
316
+ end
317
+ @oauth_application_params = reset.merge(@oauth_application_params)
318
+ end
319
+ candidate = (existing || {}).merge(@oauth_application_params)
320
+ if !encryption.empty? &&
321
+ !IdTokenEncryption::SYMMETRIC_ALGORITHMS.include?(candidate[oauth_applications_id_token_encrypted_response_alg_column]) &&
322
+ !candidate[oauth_applications_jwks_column] && !candidate[oauth_applications_jwks_uri_column]
323
+ register_throw_json_response_error("invalid_client_metadata", "CIBA encrypted ID Token requires recipient keys")
324
+ end
325
+ unless existing
326
+ candidate[oauth_applications_scopes_column] ||= oauth_application_scopes.join(" ")
327
+ candidate[oauth_applications_token_endpoint_auth_method_column] ||= "client_secret_basic"
328
+ end
329
+ if candidate[oauth_applications_grant_types_column].to_s.split.include?(GRANT_TYPE) &&
330
+ candidate[oauth_applications_subject_type_column] == "pairwise" && ciba_pairwise_sector(candidate) &&
331
+ !ciba_pairwise_sector_document_valid?(candidate, fresh: true)
332
+ register_throw_json_response_error("invalid_client_metadata", "Invalid pairwise sector document")
333
+ end
334
+ if candidate[oauth_applications_grant_types_column].to_s.split.include?(GRANT_TYPE) && !ciba_client_eligible?(candidate)
335
+ register_throw_json_response_error("invalid_client_metadata", "Unsupported CIBA client configuration")
336
+ end
337
+ algorithm = candidate[oauth_applications_backchannel_authentication_request_signing_alg_column]
338
+ methods = candidate[oauth_applications_token_endpoint_auth_method_column].to_s.split
339
+ if (algorithm || (methods & %w[private_key_jwt self_signed_tls_client_auth]).any?) &&
340
+ !candidate[oauth_applications_jwks_column] && !candidate[oauth_applications_jwks_uri_column]
341
+ register_throw_json_response_error("invalid_client_metadata", "CIBA client requires registered verification keys")
342
+ end
343
+ @ciba_registration_replacement = true if existing
344
+ end
345
+
346
+ def do_register(return_params = request.params.dup)
347
+ if ciba_dynamic_client_registration_enabled && ciba_registration_grant?(return_params)
348
+ if (defaults = @oauth_application_params[oauth_applications_default_acr_values_column])
349
+ return_params["default_acr_values"] = JSON.parse(defaults)
350
+ end
351
+ if ciba_id_token_encryption_enabled && @oauth_application_params[oauth_applications_id_token_encrypted_response_alg_column]
352
+ return_params["id_token_encrypted_response_enc"] = @oauth_application_params[oauth_applications_id_token_encrypted_response_enc_column]
353
+ end
354
+ return_params["redirect_uris"] ||= [] if return_params["response_types"] == []
355
+ if features.include?(:oauth_dpop)
356
+ return_params["dpop_bound_access_tokens"] = @oauth_application_params[oauth_applications_dpop_bound_access_tokens_column] || false
357
+ end
358
+ if features.include?(:oauth_tls_client_auth)
359
+ return_params["tls_client_certificate_bound_access_tokens"] = @oauth_application_params[oauth_applications_tls_client_certificate_bound_access_tokens_column] || false
360
+ end
361
+ if ciba_authorization_details_enabled
362
+ return_params["authorization_details_types"] = JSON.parse(@oauth_application_params.fetch(:authorization_details_types))
363
+ end
364
+ end
365
+ super(return_params)
366
+ end
367
+
368
+ # Match node's registration-time structural/public-key checks. This is not
369
+ # proof that a key is usable: signature verification still happens at use.
370
+ def ciba_registration_jwks_valid?(jwks)
371
+ return false unless jwks.is_a?(Hash) && jwks["keys"].is_a?(Array)
372
+ string = ->(value) { value.is_a?(String) && !value.empty? }
373
+ jwks["keys"].all? do |key|
374
+ next false unless key.is_a?(Hash) && string.call(key["kty"])
375
+ required, private_fields = case key["kty"]
376
+ when "RSA" then [%w[e n], %w[d p q dp dq qi oth]]
377
+ when "EC" then [%w[x y], %w[d]]
378
+ when "OKP" then [%w[x], %w[d]]
379
+ when "AKP" then [%w[alg pub], %w[priv]]
380
+ when "oct" then next false
381
+ else next true # Unknown key types are not usable keys.
382
+ end
383
+ if %w[EC OKP].include?(key["kty"])
384
+ next false unless string.call(key["crv"])
385
+ curves = key["kty"] == "EC" ? %w[P-256 P-384 P-521] : %w[Ed25519 X25519]
386
+ next true unless curves.include?(key["crv"])
387
+ end
388
+ required.all? { |field| string.call(key[field]) } &&
389
+ private_fields.none? { |field| key.key?(field) } &&
390
+ %w[alg kid use].all? { |field| !key.key?(field) || string.call(key[field]) } &&
391
+ (!key.key?("x5c") || (key["x5c"].is_a?(Array) && key["x5c"].all?(&string)))
392
+ end
393
+ end
394
+
395
+ def initialize_create_params(create_params, return_params)
396
+ super
397
+ return unless ciba_dynamic_client_registration_enabled &&
398
+ create_params[oauth_applications_grant_types_column].to_s.split.include?(GRANT_TYPE)
399
+ algorithm = create_params[oauth_applications_id_token_signed_response_alg_column] || oauth_jwt_keys.keys.first
400
+ if %w[HS256 HS384 HS512].include?(algorithm) && return_params["client_secret"].is_a?(String)
401
+ required_bytes = algorithm.delete_prefix("HS").to_i / 8
402
+ if return_params["client_secret"].bytesize < required_bytes
403
+ # Upstream's default secret has 32 random bytes: enough for HS256,
404
+ # but not for HS384/512. Keep its configured storage/hash handling.
405
+ secret = SecureRandom.urlsafe_base64(required_bytes)
406
+ set_client_secret(create_params, secret)
407
+ return_params["client_secret"] = secret
408
+ end
409
+ end
410
+ return unless ciba_issue_registration_access_token
411
+ token, credentials = ciba_new_registration_credentials
412
+ create_params.merge!(credentials)
413
+ return_params["registration_access_token"] = token
414
+ return_params["registration_client_uri"] = ciba_registration_client_uri(return_params.fetch("client_id"))
415
+ end
416
+
417
+ def ciba_new_registration_credentials
418
+ column = oauth_applications_registration_access_token_column
419
+ columns = db[oauth_applications_table].columns
420
+ unless columns.include?(column) && columns.include?(ciba_registration_token_digest_column)
421
+ raise ConfigurationError, "configure registration access-token hash and digest storage before enabling CIBA dynamic registration"
422
+ end
423
+ token = "ciba_reg_#{SecureRandom.urlsafe_base64(32)}"
424
+ [token, {column => password_hash(token).to_s,
425
+ ciba_registration_token_digest_column => Digest::SHA256.hexdigest(token)}]
426
+ end
427
+
428
+ def __update_and_return__(dataset, values, *args)
429
+ return super unless @ciba_registration_replacement && ciba_rotate_registration_access_token
430
+ token, credentials = ciba_new_registration_credentials
431
+ result = super(dataset, values.merge(credentials), *args)
432
+ @ciba_rotated_registration_token = token
433
+ result
434
+ end
435
+
436
+ def ciba_registration_client_uri(client_id)
437
+ "#{route_url(registration_client_uri_route)}/#{URI.encode_www_form_component(client_id)}"
438
+ end
439
+
440
+ def json_response_oauth_application(application)
441
+ safe = application.dup
442
+ safe.delete(oauth_applications_registration_access_token_column)
443
+ ciba = ciba_issued_management_bearer? || [application, @ciba_registration_application].compact.any? do |entry|
444
+ entry[oauth_applications_grant_types_column].to_s.split.include?(GRANT_TYPE)
445
+ end
446
+ return super(safe) unless ciba
447
+ # Upstream's management serializer includes every configured column and
448
+ # splits token_endpoint_auth_method as if it were an array. Keep its
449
+ # metadata mapping, but suppress credentials and restore protocol types.
450
+ @ciba_registration_response = {
451
+ "client_id" => application[oauth_applications_client_id_column],
452
+ "token_endpoint_auth_method" => application[oauth_applications_token_endpoint_auth_method_column],
453
+ "registration_client_uri" => ciba_registration_client_uri(application[oauth_applications_client_id_column])
454
+ }
455
+ user_code = application[oauth_applications_backchannel_user_code_parameter_column]
456
+ @ciba_registration_response["backchannel_user_code_parameter"] = user_code unless user_code.nil?
457
+ require_auth_time = application[oauth_applications_require_auth_time_column]
458
+ @ciba_registration_response["require_auth_time"] = require_auth_time unless require_auth_time.nil?
459
+ if (defaults = application[oauth_applications_default_acr_values_column])
460
+ @ciba_registration_response["default_acr_values"] = JSON.parse(defaults)
461
+ end
462
+ if features.include?(:oauth_dpop)
463
+ @ciba_registration_response["dpop_bound_access_tokens"] = application[oauth_applications_dpop_bound_access_tokens_column] || false
464
+ end
465
+ if features.include?(:oauth_tls_client_auth)
466
+ @ciba_registration_response["tls_client_certificate_bound_access_tokens"] = application[oauth_applications_tls_client_certificate_bound_access_tokens_column] || false
467
+ end
468
+ if ciba_authorization_details_enabled || application[:authorization_details_types]
469
+ @ciba_registration_response["authorization_details_types"] = JSON.parse(application[:authorization_details_types] || "[]")
470
+ end
471
+ # Upstream has authenticated this bearer before invoking the serializer.
472
+ # Echo the presented credential as node does, never its stored verifier.
473
+ token = @ciba_rotated_registration_token || request.env["HTTP_AUTHORIZATION"].to_s[/\A *Bearer (.*)\Z/, 1]
474
+ @ciba_registration_response["registration_access_token"] = token if token
475
+ super(safe)
476
+ ensure
477
+ @ciba_registration_response = nil
478
+ end
479
+
480
+ def _json_response_body(body)
481
+ if @ciba_registration_response && body.is_a?(Hash)
482
+ body = body.reject { |key, _| %w[registration_access_token client_secret client_secret_hash].include?(key.to_s) }
483
+ body = body.merge(@ciba_registration_response)
484
+ end
485
+ super(body)
486
+ end
487
+ end
488
+ end
489
+ end