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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +34 -0
- data/LICENSE +21 -0
- data/README.md +106 -0
- data/docs/adr/0003-follow-rodauth-hook-conventions.md +9 -0
- data/docs/adr/0004-retain-atomic-issuance-and-stage-alignment.md +16 -0
- data/docs/alignment-closeout.md +136 -0
- data/docs/alignment-roadmap.md +160 -0
- data/docs/api.md +337 -0
- data/docs/authentication-claims.md +80 -0
- data/docs/authorization-details.md +50 -0
- data/docs/claims.md +46 -0
- data/docs/failure-contract.md +34 -0
- data/docs/id-token-encryption.md +288 -0
- data/docs/id-token-hint.md +174 -0
- data/docs/login-hint-token.md +35 -0
- data/docs/max-age.md +65 -0
- data/docs/node-alignment.md +336 -0
- data/docs/operations.md +130 -0
- data/docs/outbound-http.md +52 -0
- data/docs/pairwise.md +76 -0
- data/docs/ping.md +118 -0
- data/docs/protocol-coverage.md +114 -0
- data/docs/refresh-tokens.md +151 -0
- data/docs/release-readiness.md +75 -0
- data/docs/release-validation.md +498 -0
- data/docs/request-context.md +51 -0
- data/docs/requirements-audit-current.json +3221 -0
- data/docs/requirements-audit-current.md +424 -0
- data/docs/requirements-audit.json +3190 -0
- data/docs/requirements-audit.md +175 -0
- data/docs/research/access-token-revocation-reference.md +81 -0
- data/docs/research/alignment-partial-triage.md +50 -0
- data/docs/research/claims-reference-contract.md +33 -0
- data/docs/research/client-assertion-algorithm-contract.md +177 -0
- data/docs/research/completion-error-boundaries.md +56 -0
- data/docs/research/consent-api-evolution.md +59 -0
- data/docs/research/dpop-reference-contract.md +423 -0
- data/docs/research/id-token-hint-reference-contract.md +254 -0
- data/docs/research/mtls-reference-contract.md +449 -0
- data/docs/research/node-ciba-lifecycle.md +30 -0
- data/docs/research/node-oidc-provider-comparison.md +45 -0
- data/docs/research/pairwise-reference-contract.md +264 -0
- data/docs/research/ping-reference-contract.md +165 -0
- data/docs/research/rar-reference-contract.md +55 -0
- data/docs/research/refresh-reference-contract.md +85 -0
- data/docs/research/registration-reference-contract.md +567 -0
- data/docs/research/request-parameter-inventory.md +42 -0
- data/docs/research/required-parameter-inventory.md +39 -0
- data/docs/research/resource-reference-contract.md +40 -0
- data/docs/research/signed-request-reference-contract.md +113 -0
- data/docs/resources.md +55 -0
- data/docs/security-review.md +294 -0
- data/docs/signed-requests.md +162 -0
- data/docs/user-code.md +73 -0
- data/docs/validation/node-alignment-tests.txt +10 -0
- data/examples/client.rb +39 -0
- data/examples/demo_app.rb +121 -0
- data/examples/dpop_smoke.rb +169 -0
- data/examples/ecdh_id_token_smoke.rb +84 -0
- data/examples/edwards_id_token_smoke.rb +48 -0
- data/examples/edwards_smoke.rb +87 -0
- data/examples/edwards_tls_smoke.rb +73 -0
- data/examples/encrypted_id_token_smoke.rb +73 -0
- data/examples/encryption_tls_smoke.rb +123 -0
- data/examples/extensions_smoke.rb +226 -0
- data/examples/fixtures/edwards-requests.json +30 -0
- data/examples/hmac_id_token_smoke.rb +72 -0
- data/examples/migration.rb +18 -0
- data/examples/mtls_smoke.rb +395 -0
- data/examples/ping_smoke.rb +226 -0
- data/examples/registration_smoke.rb +73 -0
- data/examples/server.rb +14 -0
- data/examples/smoke.rb +46 -0
- data/examples/symmetric_id_token_smoke.rb +83 -0
- data/lib/rodauth/ciba/authorization_details.rb +60 -0
- data/lib/rodauth/ciba/dpop.rb +250 -0
- data/lib/rodauth/ciba/http.rb +69 -0
- data/lib/rodauth/ciba/id_token_encryption.rb +175 -0
- data/lib/rodauth/ciba/jwks_cache.rb +149 -0
- data/lib/rodauth/ciba/mtls.rb +174 -0
- data/lib/rodauth/ciba/pairwise.rb +105 -0
- data/lib/rodauth/ciba/registration.rb +489 -0
- data/lib/rodauth/ciba/schema.rb +182 -0
- data/lib/rodauth/ciba/version.rb +6 -0
- data/lib/rodauth/ciba.rb +4 -0
- data/lib/rodauth/features/ciba.rb +2185 -0
- 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
|