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,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
|