omniauth_openid_federation 1.3.2 → 2.0.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 +4 -4
- data/CHANGELOG.md +32 -1
- data/README.md +119 -13
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
- data/config/polyrun_coverage.yml +15 -0
- data/config/polyrun_spec_quality.yml +29 -0
- data/examples/README_INTEGRATION_TESTING.md +3 -0
- data/examples/integration_test_flow.rb +10 -8
- data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
- data/examples/jobs/federation_files_generation_job.rb.example +3 -2
- data/examples/mock_op_server.rb +4 -5
- data/examples/mock_rp_server.rb +3 -3
- data/examples/standalone_multiple_endpoints_example.rb +494 -0
- data/lib/omniauth_openid_federation/access_token.rb +91 -450
- data/lib/omniauth_openid_federation/cache.rb +16 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
- data/lib/omniauth_openid_federation/constants.rb +3 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +2 -4
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +51 -6
- data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
- data/lib/omniauth_openid_federation/http_client.rb +145 -32
- data/lib/omniauth_openid_federation/http_errors.rb +14 -0
- data/lib/omniauth_openid_federation/id_token.rb +19 -0
- data/lib/omniauth_openid_federation/jwe.rb +26 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
- data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
- data/lib/omniauth_openid_federation/jws.rb +2 -11
- data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
- data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
- data/lib/omniauth_openid_federation/rack.rb +2 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
- data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
- data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
- data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
- data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
- data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
- data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
- data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
- data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
- data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
- data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
- data/lib/omniauth_openid_federation/strategy.rb +27 -1910
- data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
- data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
- data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
- data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
- data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
- data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
- data/lib/omniauth_openid_federation/user_info.rb +15 -0
- data/lib/omniauth_openid_federation/utils.rb +10 -2
- data/lib/omniauth_openid_federation/validators.rb +43 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +9 -3
- data/lib/tasks/omniauth_openid_federation.rake +1 -2
- data/sig/omniauth_openid_federation.rbs +2 -1
- data/sig/strategy.rbs +6 -6
- metadata +181 -71
|
@@ -1,47 +1,25 @@
|
|
|
1
1
|
# Tasks helper module for rake tasks
|
|
2
|
-
# Contains
|
|
2
|
+
# Contains business logic that can be tested independently
|
|
3
3
|
require "json"
|
|
4
4
|
require "fileutils"
|
|
5
|
-
require "net/http"
|
|
6
|
-
require "uri"
|
|
7
|
-
require "openssl"
|
|
8
5
|
require_relative "utils"
|
|
9
6
|
require_relative "configuration"
|
|
10
7
|
require_relative "errors"
|
|
11
|
-
require_relative "http_client"
|
|
12
8
|
require_relative "federation/entity_statement"
|
|
13
9
|
require_relative "entity_statement_reader"
|
|
14
10
|
require_relative "jwks/fetch"
|
|
15
|
-
require_relative "
|
|
16
|
-
require_relative "
|
|
11
|
+
require_relative "tasks/path_resolver"
|
|
12
|
+
require_relative "tasks/local_endpoint_tester"
|
|
13
|
+
require_relative "tasks/client_keys_preparer"
|
|
14
|
+
require_relative "tasks/authentication_flow_tester"
|
|
15
|
+
require_relative "tasks/callback_processor"
|
|
17
16
|
|
|
18
17
|
module OmniauthOpenidFederation
|
|
19
18
|
module TasksHelper
|
|
20
|
-
# Resolve file path using configuration
|
|
21
|
-
#
|
|
22
|
-
# @param file_path [String] Relative or absolute file path
|
|
23
|
-
# @return [String] Resolved absolute path
|
|
24
19
|
def self.resolve_path(file_path)
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
config = Configuration.config
|
|
28
|
-
if defined?(Rails) && Rails.root
|
|
29
|
-
Rails.root.join(file_path).to_s
|
|
30
|
-
elsif config.root_path
|
|
31
|
-
File.join(config.root_path, file_path)
|
|
32
|
-
else
|
|
33
|
-
File.expand_path(file_path)
|
|
34
|
-
end
|
|
20
|
+
Tasks::PathResolver.resolve(file_path)
|
|
35
21
|
end
|
|
36
22
|
|
|
37
|
-
# Fetch entity statement and save to file
|
|
38
|
-
#
|
|
39
|
-
# @param url [String] Entity statement URL
|
|
40
|
-
# @param fingerprint [String, nil] Expected fingerprint
|
|
41
|
-
# @param output_file [String] Output file path
|
|
42
|
-
# @return [Hash] Result hash with :success, :entity_statement, :output_path, :metadata
|
|
43
|
-
# @raise [Federation::EntityStatement::FetchError] If fetching fails
|
|
44
|
-
# @raise [Federation::EntityStatement::ValidationError] If validation fails
|
|
45
23
|
def self.fetch_entity_statement(url:, output_file:, fingerprint: nil)
|
|
46
24
|
output_path = resolve_path(output_file)
|
|
47
25
|
|
|
@@ -63,12 +41,6 @@ module OmniauthOpenidFederation
|
|
|
63
41
|
}
|
|
64
42
|
end
|
|
65
43
|
|
|
66
|
-
# Validate entity statement file
|
|
67
|
-
#
|
|
68
|
-
# @param file_path [String] Path to entity statement file
|
|
69
|
-
# @param expected_fingerprint [String, nil] Expected fingerprint
|
|
70
|
-
# @return [Hash] Result hash with :success, :fingerprint, :metadata
|
|
71
|
-
# @raise [Federation::EntityStatement::ValidationError] If validation fails
|
|
72
44
|
def self.validate_entity_statement(file_path:, expected_fingerprint: nil)
|
|
73
45
|
resolved_path = resolve_path(file_path)
|
|
74
46
|
|
|
@@ -97,12 +69,6 @@ module OmniauthOpenidFederation
|
|
|
97
69
|
}
|
|
98
70
|
end
|
|
99
71
|
|
|
100
|
-
# Fetch JWKS and save to file
|
|
101
|
-
#
|
|
102
|
-
# @param jwks_uri [String] JWKS URI
|
|
103
|
-
# @param output_file [String] Output file path
|
|
104
|
-
# @return [Hash] Result hash with :success, :jwks, :output_path
|
|
105
|
-
# @raise [FetchError] If fetching fails
|
|
106
72
|
def self.fetch_jwks(jwks_uri:, output_file:)
|
|
107
73
|
output_path = resolve_path(output_file)
|
|
108
74
|
|
|
@@ -117,12 +83,6 @@ module OmniauthOpenidFederation
|
|
|
117
83
|
}
|
|
118
84
|
end
|
|
119
85
|
|
|
120
|
-
# Parse entity statement and return metadata
|
|
121
|
-
#
|
|
122
|
-
# @param file_path [String] Path to entity statement file
|
|
123
|
-
# @return [Hash] Metadata hash
|
|
124
|
-
# @raise [ConfigurationError] If file not found
|
|
125
|
-
# @raise [ValidationError] If parsing fails
|
|
126
86
|
def self.parse_entity_statement(file_path:)
|
|
127
87
|
resolved_path = resolve_path(file_path)
|
|
128
88
|
|
|
@@ -141,562 +101,26 @@ module OmniauthOpenidFederation
|
|
|
141
101
|
metadata
|
|
142
102
|
end
|
|
143
103
|
|
|
144
|
-
# Generate client keys
|
|
145
|
-
#
|
|
146
|
-
# @param key_type [String] "single" or "separate"
|
|
147
|
-
# @param output_dir [String] Output directory
|
|
148
|
-
# @return [Hash] Result hash with :success, :keys, :jwks, :output_path
|
|
149
|
-
# @raise [ArgumentError] If key_type is invalid
|
|
150
104
|
def self.prepare_client_keys(key_type:, output_dir:)
|
|
151
|
-
|
|
152
|
-
raise ArgumentError, "Invalid key_type: #{key_type}. Valid options: 'single' or 'separate'"
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
output_path = resolve_path(output_dir)
|
|
156
|
-
|
|
157
|
-
# Create output directory if it doesn't exist
|
|
158
|
-
FileUtils.mkdir_p(output_path) unless File.directory?(output_path)
|
|
159
|
-
|
|
160
|
-
result = if key_type == "single"
|
|
161
|
-
generate_single_key(output_path)
|
|
162
|
-
else
|
|
163
|
-
generate_separate_keys(output_path)
|
|
164
|
-
end
|
|
165
|
-
|
|
166
|
-
{
|
|
167
|
-
success: true,
|
|
168
|
-
output_path: output_path,
|
|
169
|
-
**result
|
|
170
|
-
}
|
|
105
|
+
Tasks::ClientKeysPreparer.prepare(key_type: key_type, output_dir: output_dir)
|
|
171
106
|
end
|
|
172
107
|
|
|
173
|
-
# Test local entity statement endpoint
|
|
174
|
-
#
|
|
175
|
-
# @param base_url [String] Base URL of the local server
|
|
176
|
-
# @return [Hash] Result hash with :success, :results, :entity_statement, :key_status, :validation_warnings
|
|
177
|
-
# @raise [Federation::EntityStatement::FetchError] If fetching fails
|
|
178
108
|
def self.test_local_endpoint(base_url:)
|
|
179
|
-
|
|
180
|
-
validation_warnings = []
|
|
181
|
-
|
|
182
|
-
# Fetch and parse entity statement
|
|
183
|
-
begin
|
|
184
|
-
entity_statement = Federation::EntityStatement.fetch!(
|
|
185
|
-
entity_statement_url,
|
|
186
|
-
fingerprint: nil # Don't validate fingerprint for local testing
|
|
187
|
-
)
|
|
188
|
-
rescue ValidationError => e
|
|
189
|
-
# Don't block execution - treat validation errors as warnings
|
|
190
|
-
validation_warnings << e.message
|
|
191
|
-
# Try to parse anyway for diagnostic purposes
|
|
192
|
-
begin
|
|
193
|
-
require "json"
|
|
194
|
-
require "base64"
|
|
195
|
-
response = HttpClient.get(entity_statement_url)
|
|
196
|
-
entity_statement = Federation::EntityStatement.new(response.body.to_s)
|
|
197
|
-
rescue
|
|
198
|
-
raise FetchError, "Failed to fetch entity statement: #{e.message}"
|
|
199
|
-
end
|
|
200
|
-
end
|
|
201
|
-
|
|
202
|
-
begin
|
|
203
|
-
metadata = entity_statement.parse
|
|
204
|
-
rescue ValidationError => e
|
|
205
|
-
validation_warnings << e.message
|
|
206
|
-
# Try to extract basic info even if validation fails
|
|
207
|
-
begin
|
|
208
|
-
require "json"
|
|
209
|
-
require "base64"
|
|
210
|
-
jwt_parts = entity_statement.entity_statement.split(".")
|
|
211
|
-
payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
212
|
-
# Preserve original structure (string keys from JSON)
|
|
213
|
-
metadata = {
|
|
214
|
-
issuer: payload["iss"],
|
|
215
|
-
sub: payload["sub"],
|
|
216
|
-
exp: payload["exp"],
|
|
217
|
-
iat: payload["iat"],
|
|
218
|
-
jwks: payload["jwks"],
|
|
219
|
-
metadata: payload["metadata"] || {}
|
|
220
|
-
}
|
|
221
|
-
rescue
|
|
222
|
-
raise FetchError, "Failed to parse entity statement: #{e.message}"
|
|
223
|
-
end
|
|
224
|
-
end
|
|
225
|
-
|
|
226
|
-
# Extract endpoints - handle both provider and relying party entity types
|
|
227
|
-
metadata_section = metadata[:metadata] || {}
|
|
228
|
-
provider_metadata = metadata_section[:openid_provider] || metadata_section["openid_provider"] || {}
|
|
229
|
-
rp_metadata = metadata_section[:openid_relying_party] || metadata_section["openid_relying_party"] || {}
|
|
230
|
-
|
|
231
|
-
endpoints = {}
|
|
232
|
-
|
|
233
|
-
# Provider endpoints
|
|
234
|
-
if provider_metadata.any?
|
|
235
|
-
endpoints["Authorization Endpoint"] = provider_metadata[:authorization_endpoint] || provider_metadata["authorization_endpoint"]
|
|
236
|
-
endpoints["Token Endpoint"] = provider_metadata[:token_endpoint] || provider_metadata["token_endpoint"]
|
|
237
|
-
endpoints["UserInfo Endpoint"] = provider_metadata[:userinfo_endpoint] || provider_metadata["userinfo_endpoint"]
|
|
238
|
-
endpoints["JWKS URI"] = provider_metadata[:jwks_uri] || provider_metadata["jwks_uri"]
|
|
239
|
-
endpoints["Signed JWKS URI"] = provider_metadata[:signed_jwks_uri] || provider_metadata["signed_jwks_uri"]
|
|
240
|
-
endpoints["End Session Endpoint"] = provider_metadata[:end_session_endpoint] || provider_metadata["end_session_endpoint"]
|
|
241
|
-
end
|
|
242
|
-
|
|
243
|
-
# Relying Party endpoints (JWKS only)
|
|
244
|
-
if rp_metadata.any?
|
|
245
|
-
endpoints["JWKS URI"] ||= rp_metadata[:jwks_uri] || rp_metadata["jwks_uri"]
|
|
246
|
-
endpoints["Signed JWKS URI"] ||= rp_metadata[:signed_jwks_uri] || rp_metadata["signed_jwks_uri"]
|
|
247
|
-
end
|
|
248
|
-
|
|
249
|
-
endpoints.compact!
|
|
250
|
-
|
|
251
|
-
# Detect key configuration status
|
|
252
|
-
key_status = detect_key_status(metadata[:jwks])
|
|
253
|
-
|
|
254
|
-
# Test endpoints
|
|
255
|
-
results = {}
|
|
256
|
-
entity_jwks = metadata[:jwks]
|
|
257
|
-
|
|
258
|
-
endpoints.each do |name, url|
|
|
259
|
-
next unless url
|
|
260
|
-
|
|
261
|
-
begin
|
|
262
|
-
case name
|
|
263
|
-
when "JWKS URI"
|
|
264
|
-
jwks = Jwks::Fetch.run(url)
|
|
265
|
-
key_count = jwks["keys"]&.length || 0
|
|
266
|
-
results[name] = {status: :success, keys: key_count}
|
|
267
|
-
|
|
268
|
-
when "Signed JWKS URI"
|
|
269
|
-
signed_jwks = Federation::SignedJWKS.fetch!(
|
|
270
|
-
url,
|
|
271
|
-
entity_jwks,
|
|
272
|
-
force_refresh: true
|
|
273
|
-
)
|
|
274
|
-
key_count = signed_jwks["keys"]&.length || 0
|
|
275
|
-
results[name] = {status: :success, keys: key_count}
|
|
276
|
-
|
|
277
|
-
else
|
|
278
|
-
# Test other endpoints with simple HTTP GET
|
|
279
|
-
# Note: Rake tasks are developer tools, no security validation needed
|
|
280
|
-
begin
|
|
281
|
-
uri = URI.parse(url)
|
|
282
|
-
rescue URI::InvalidURIError => e
|
|
283
|
-
results[name] = {status: :error, message: "Invalid URL: #{e.message}"}
|
|
284
|
-
next
|
|
285
|
-
end
|
|
286
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
287
|
-
http.use_ssl = (uri.scheme == "https")
|
|
288
|
-
if uri.scheme == "https"
|
|
289
|
-
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
290
|
-
|
|
291
|
-
# Set ca_file directly - this is the simplest and most reliable approach
|
|
292
|
-
# Try SSL_CERT_FILE first, then default cert file
|
|
293
|
-
ca_file = if ENV["SSL_CERT_FILE"] && File.file?(ENV["SSL_CERT_FILE"])
|
|
294
|
-
ENV["SSL_CERT_FILE"]
|
|
295
|
-
elsif File.exist?(OpenSSL::X509::DEFAULT_CERT_FILE)
|
|
296
|
-
OpenSSL::X509::DEFAULT_CERT_FILE
|
|
297
|
-
end
|
|
298
|
-
|
|
299
|
-
http.ca_file = ca_file if ca_file
|
|
300
|
-
end
|
|
301
|
-
|
|
302
|
-
request_path = uri.path
|
|
303
|
-
request_path += "?#{uri.query}" if uri.query
|
|
304
|
-
request = Net::HTTP::Get.new(request_path)
|
|
305
|
-
response = http.request(request)
|
|
306
|
-
|
|
307
|
-
results[name] = if response.code.to_i < 400
|
|
308
|
-
{status: :success, code: response.code}
|
|
309
|
-
else
|
|
310
|
-
{status: :warning, code: response.code, body: response.body}
|
|
311
|
-
end
|
|
312
|
-
end
|
|
313
|
-
rescue FetchError, Federation::SignedJWKS::FetchError => e
|
|
314
|
-
results[name] = {status: :error, message: e.message}
|
|
315
|
-
rescue Federation::SignedJWKS::ValidationError => e
|
|
316
|
-
results[name] = {status: :error, message: e.message}
|
|
317
|
-
rescue => e
|
|
318
|
-
results[name] = {status: :error, message: e.message}
|
|
319
|
-
end
|
|
320
|
-
end
|
|
321
|
-
|
|
322
|
-
{
|
|
323
|
-
success: true,
|
|
324
|
-
entity_statement: entity_statement,
|
|
325
|
-
metadata: metadata,
|
|
326
|
-
results: results,
|
|
327
|
-
key_status: key_status,
|
|
328
|
-
validation_warnings: validation_warnings
|
|
329
|
-
}
|
|
109
|
+
Tasks::LocalEndpointTester.run(base_url: base_url)
|
|
330
110
|
end
|
|
331
111
|
|
|
332
|
-
# Detect key configuration (single vs separate keys)
|
|
333
|
-
#
|
|
334
|
-
# @param jwks [Hash, nil] JWKS hash with keys array
|
|
335
|
-
# @return [Hash] Hash with :type, :count, :recommendation
|
|
336
112
|
def self.detect_key_status(jwks)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
keys = jwks.is_a?(Hash) ? (jwks["keys"] || jwks[:keys] || []) : []
|
|
340
|
-
return {type: :unknown, count: 0, recommendation: "No keys found in entity statement"} if keys.empty?
|
|
341
|
-
|
|
342
|
-
# Check for duplicate kids (indicates single key used for both signing and encryption)
|
|
343
|
-
kids = keys.map { |k| k["kid"] || k[:kid] }.compact
|
|
344
|
-
duplicate_kids = kids.length != kids.uniq.length
|
|
345
|
-
|
|
346
|
-
# Check use fields
|
|
347
|
-
uses = keys.map { |k| k["use"] || k[:use] }.compact.uniq
|
|
348
|
-
has_sig = uses.include?("sig")
|
|
349
|
-
has_enc = uses.include?("enc")
|
|
350
|
-
has_both_uses = has_sig && has_enc
|
|
351
|
-
|
|
352
|
-
if duplicate_kids
|
|
353
|
-
{
|
|
354
|
-
type: :single,
|
|
355
|
-
count: keys.length,
|
|
356
|
-
recommendation: "⚠️ Single key detected (duplicate Key IDs). This is NOT RECOMMENDED for production. Use separate signing and encryption keys for better security. Generate with: rake openid_federation:prepare_client_keys[separate]"
|
|
357
|
-
}
|
|
358
|
-
elsif has_both_uses && keys.length >= 2
|
|
359
|
-
{
|
|
360
|
-
type: :separate,
|
|
361
|
-
count: keys.length,
|
|
362
|
-
recommendation: "✅ Separate keys detected (recommended for production)"
|
|
363
|
-
}
|
|
364
|
-
elsif keys.length == 1
|
|
365
|
-
{
|
|
366
|
-
type: :single,
|
|
367
|
-
count: 1,
|
|
368
|
-
recommendation: "⚠️ Single key detected. This is NOT RECOMMENDED for production. Use separate signing and encryption keys for better security. Generate with: rake openid_federation:prepare_client_keys[separate]"
|
|
369
|
-
}
|
|
370
|
-
else
|
|
371
|
-
{
|
|
372
|
-
type: :unknown,
|
|
373
|
-
count: keys.length,
|
|
374
|
-
recommendation: "Key configuration unclear. Ensure keys have unique Key IDs and proper 'use' fields ('sig' for signing, 'enc' for encryption)"
|
|
375
|
-
}
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
# Generate single key for both signing and encryption
|
|
380
|
-
#
|
|
381
|
-
# @param output_path [String] Output directory path
|
|
382
|
-
# @return [Hash] Result with :private_key_path, :public_jwks_path, :jwks
|
|
383
|
-
def self.generate_single_key(output_path)
|
|
384
|
-
private_key = OpenSSL::PKey::RSA.new(2048)
|
|
385
|
-
jwk_hash = Utils.rsa_key_to_jwk(private_key, use: "sig")
|
|
386
|
-
|
|
387
|
-
# Remove private key components and 'use' field for backward compatibility
|
|
388
|
-
public_jwk = jwk_hash.reject { |k, _v| %w[d p q dp dq qi use].include?(k.to_s) }
|
|
389
|
-
jwks = {keys: [public_jwk]}
|
|
390
|
-
|
|
391
|
-
# Save private key
|
|
392
|
-
private_key_path = File.join(output_path, "client-private-key.pem")
|
|
393
|
-
File.write(private_key_path, private_key.to_pem)
|
|
394
|
-
File.chmod(0o600, private_key_path)
|
|
395
|
-
|
|
396
|
-
# Save public JWKS
|
|
397
|
-
public_jwks_path = File.join(output_path, "client-jwks.json")
|
|
398
|
-
File.write(public_jwks_path, JSON.pretty_generate(jwks))
|
|
399
|
-
|
|
400
|
-
{
|
|
401
|
-
private_key_path: private_key_path,
|
|
402
|
-
public_jwks_path: public_jwks_path,
|
|
403
|
-
jwks: jwks
|
|
404
|
-
}
|
|
113
|
+
Tasks::LocalEndpointTester.detect_key_status(jwks)
|
|
405
114
|
end
|
|
406
115
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
encryption_private_key = OpenSSL::PKey::RSA.new(2048)
|
|
414
|
-
|
|
415
|
-
signing_jwk_hash = Utils.rsa_key_to_jwk(signing_private_key, use: "sig")
|
|
416
|
-
encryption_jwk_hash = Utils.rsa_key_to_jwk(encryption_private_key, use: "enc")
|
|
417
|
-
|
|
418
|
-
# Remove private key components and add 'use' field
|
|
419
|
-
signing_public_jwk = signing_jwk_hash.reject { |k, _v| %w[d p q dp dq qi].include?(k.to_s) }.merge("use" => "sig")
|
|
420
|
-
encryption_public_jwk = encryption_jwk_hash.reject { |k, _v| %w[d p q dp dq qi].include?(k.to_s) }.merge("use" => "enc")
|
|
421
|
-
|
|
422
|
-
jwks = {keys: [signing_public_jwk, encryption_public_jwk]}
|
|
423
|
-
|
|
424
|
-
# Save private keys
|
|
425
|
-
signing_key_path = File.join(output_path, "client-signing-private-key.pem")
|
|
426
|
-
encryption_key_path = File.join(output_path, "client-encryption-private-key.pem")
|
|
427
|
-
|
|
428
|
-
File.write(signing_key_path, signing_private_key.to_pem)
|
|
429
|
-
File.write(encryption_key_path, encryption_private_key.to_pem)
|
|
430
|
-
File.chmod(0o600, signing_key_path)
|
|
431
|
-
File.chmod(0o600, encryption_key_path)
|
|
432
|
-
|
|
433
|
-
# Save public JWKS
|
|
434
|
-
public_jwks_path = File.join(output_path, "client-jwks.json")
|
|
435
|
-
File.write(public_jwks_path, JSON.pretty_generate(jwks))
|
|
436
|
-
|
|
437
|
-
{
|
|
438
|
-
signing_key_path: signing_key_path,
|
|
439
|
-
encryption_key_path: encryption_key_path,
|
|
440
|
-
public_jwks_path: public_jwks_path,
|
|
441
|
-
jwks: jwks
|
|
442
|
-
}
|
|
443
|
-
end
|
|
444
|
-
|
|
445
|
-
# Test full OpenID Federation authentication flow
|
|
446
|
-
#
|
|
447
|
-
# This method tests the complete authentication flow with a real provider:
|
|
448
|
-
# 1. Fetches CSRF token and cookies from login page URL
|
|
449
|
-
# 2. Finds authorization form/button in HTML
|
|
450
|
-
# 3. Makes authorization request with signed request object
|
|
451
|
-
# 4. Returns authorization URL for user interaction
|
|
452
|
-
#
|
|
453
|
-
# @param login_page_url [String] Full URL to login page that contains CSRF token and authorization form
|
|
454
|
-
# @param base_url [String] Base URL of the application (for resolving relative URLs)
|
|
455
|
-
# @param provider_acr [String, nil] Optional ACR (Authentication Context Class Reference) value for provider selection
|
|
456
|
-
# @return [Hash] Result hash with authorization URL, CSRF token, cookies, and instructions
|
|
457
|
-
# @raise [StandardError] If critical errors occur during testing
|
|
458
|
-
def self.test_authentication_flow(
|
|
459
|
-
login_page_url:,
|
|
460
|
-
base_url:,
|
|
461
|
-
provider_acr: nil
|
|
462
|
-
)
|
|
463
|
-
require "uri"
|
|
464
|
-
require "cgi"
|
|
465
|
-
require "json"
|
|
466
|
-
require "base64"
|
|
467
|
-
require "http"
|
|
468
|
-
require "openssl"
|
|
469
|
-
|
|
470
|
-
results = {
|
|
471
|
-
steps_completed: [],
|
|
472
|
-
errors: [],
|
|
473
|
-
warnings: [],
|
|
474
|
-
csrf_token: nil,
|
|
475
|
-
cookies: [],
|
|
476
|
-
authorization_url: nil,
|
|
477
|
-
instructions: []
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
# HTTP client helper for custom requests
|
|
481
|
-
build_http_client = lambda do |connect_timeout: 10, read_timeout: 10|
|
|
482
|
-
HTTP.timeout(connect: connect_timeout, read: read_timeout)
|
|
483
|
-
end
|
|
484
|
-
|
|
485
|
-
# Step 1: Fetch login page for CSRF token and cookies
|
|
486
|
-
results[:steps_completed] << "fetch_csrf_token"
|
|
487
|
-
|
|
488
|
-
html_body = nil
|
|
489
|
-
cookie_header = nil
|
|
490
|
-
csrf_token = nil
|
|
491
|
-
cookies = []
|
|
492
|
-
|
|
493
|
-
begin
|
|
494
|
-
http_client = build_http_client.call(connect_timeout: 10, read_timeout: 10)
|
|
495
|
-
login_response = http_client.get(login_page_url)
|
|
496
|
-
|
|
497
|
-
unless login_response.status.success?
|
|
498
|
-
raise "Failed to fetch login page: #{login_response.status.code} #{login_response.status.reason}"
|
|
499
|
-
end
|
|
500
|
-
|
|
501
|
-
# Extract cookies
|
|
502
|
-
set_cookie_headers = login_response.headers["Set-Cookie"]
|
|
503
|
-
if set_cookie_headers
|
|
504
|
-
cookie_list = set_cookie_headers.is_a?(Array) ? set_cookie_headers : [set_cookie_headers]
|
|
505
|
-
cookie_list.each do |set_cookie|
|
|
506
|
-
cookie_str = set_cookie.to_s
|
|
507
|
-
# Security: Limit cookie header size to prevent DoS attacks (max 4KB per cookie)
|
|
508
|
-
next if cookie_str.length > 4096
|
|
509
|
-
# Security: Use non-greedy matching with length limits to prevent ReDoS
|
|
510
|
-
cookie_match = cookie_str.match(/^([^=]{1,256})=([^;]{1,4096})/)
|
|
511
|
-
cookies << "#{cookie_match[1]}=#{cookie_match[2]}" if cookie_match
|
|
512
|
-
end
|
|
513
|
-
end
|
|
514
|
-
|
|
515
|
-
cookie_header = cookies.join("; ")
|
|
516
|
-
|
|
517
|
-
# Extract CSRF token from HTML
|
|
518
|
-
html_body = login_response.body.to_s
|
|
519
|
-
|
|
520
|
-
# Security: Limit HTML body size to prevent DoS attacks (max 1MB)
|
|
521
|
-
if html_body.bytesize > 1_048_576
|
|
522
|
-
raise "HTML response too large (#{html_body.bytesize} bytes), possible DoS attack"
|
|
523
|
-
end
|
|
524
|
-
|
|
525
|
-
# Try meta tag first
|
|
526
|
-
# Security: Use non-greedy matching and limit capture group to prevent ReDoS
|
|
527
|
-
csrf_meta_match = html_body.match(/<meta\s+name=["']csrf-token["']\s+content=["']([^"']{1,256})["']/i)
|
|
528
|
-
csrf_token = csrf_meta_match[1] if csrf_meta_match
|
|
529
|
-
|
|
530
|
-
# Try form input if not found
|
|
531
|
-
# Security: Use non-greedy matching and limit capture group to prevent ReDoS
|
|
532
|
-
unless csrf_token
|
|
533
|
-
csrf_input_match = html_body.match(/<input[^>]*name=["']authenticity_token["'][^>]*value=["']([^"']{1,256})["']/i)
|
|
534
|
-
csrf_token = csrf_input_match[1] if csrf_input_match
|
|
535
|
-
end
|
|
536
|
-
|
|
537
|
-
unless csrf_token
|
|
538
|
-
raise "Failed to extract CSRF token from login page"
|
|
539
|
-
end
|
|
540
|
-
|
|
541
|
-
results[:csrf_token] = csrf_token
|
|
542
|
-
results[:cookies] = cookies
|
|
543
|
-
results[:steps_completed] << "extract_csrf_and_cookies"
|
|
544
|
-
rescue => e
|
|
545
|
-
results[:errors] << "Step 1 (CSRF token): #{e.message}"
|
|
546
|
-
raise
|
|
547
|
-
end
|
|
548
|
-
|
|
549
|
-
# Step 2: Find authorization form/button in HTML
|
|
550
|
-
results[:steps_completed] << "find_authorization_form"
|
|
551
|
-
|
|
552
|
-
begin
|
|
553
|
-
# Try to find form with action containing "openid_federation"
|
|
554
|
-
# Security: Use non-greedy matching and limit capture group to prevent ReDoS
|
|
555
|
-
form_match = html_body.match(/<form[^>]*action=["']([^"']{0,2048}openid[_-]?federation[^"']{0,2048})["'][^>]*>/i)
|
|
556
|
-
auth_endpoint = nil
|
|
557
|
-
|
|
558
|
-
if form_match
|
|
559
|
-
form_action = form_match[1]
|
|
560
|
-
# Note: Rake tasks are developer tools, no security validation needed
|
|
561
|
-
begin
|
|
562
|
-
auth_endpoint = if form_action.start_with?("http://", "https://")
|
|
563
|
-
URI.parse(form_action).to_s
|
|
564
|
-
else
|
|
565
|
-
URI.join(base_url, form_action).to_s
|
|
566
|
-
end
|
|
567
|
-
rescue URI::InvalidURIError => e
|
|
568
|
-
raise "Invalid form action URI: #{e.message}"
|
|
569
|
-
end
|
|
570
|
-
else
|
|
571
|
-
# Try to find button/link with href containing "openid_federation"
|
|
572
|
-
# Security: Use non-greedy matching and limit capture group to prevent ReDoS
|
|
573
|
-
button_match = html_body.match(/<a[^>]*href=["']([^"']{0,2048}openid[_-]?federation[^"']{0,2048})["'][^>]*>/i)
|
|
574
|
-
if button_match
|
|
575
|
-
button_href = button_match[1]
|
|
576
|
-
# Note: Rake tasks are developer tools, no security validation needed
|
|
577
|
-
begin
|
|
578
|
-
auth_endpoint = if button_href.start_with?("http://", "https://")
|
|
579
|
-
URI.parse(button_href).to_s
|
|
580
|
-
else
|
|
581
|
-
URI.join(base_url, button_href).to_s
|
|
582
|
-
end
|
|
583
|
-
rescue URI::InvalidURIError => e
|
|
584
|
-
raise "Invalid button href URI: #{e.message}"
|
|
585
|
-
end
|
|
586
|
-
else
|
|
587
|
-
# Fallback: try common paths
|
|
588
|
-
common_paths = [
|
|
589
|
-
"/users/auth/openid_federation",
|
|
590
|
-
"/auth/openid_federation",
|
|
591
|
-
"/openid_federation"
|
|
592
|
-
]
|
|
593
|
-
auth_endpoint = nil
|
|
594
|
-
common_paths.each do |path|
|
|
595
|
-
test_url = URI.join(base_url, path).to_s
|
|
596
|
-
begin
|
|
597
|
-
http_client = build_http_client.call(connect_timeout: 5, read_timeout: 5)
|
|
598
|
-
test_response = http_client.get(test_url)
|
|
599
|
-
if test_response.status.code >= 300 && test_response.status.code < 400
|
|
600
|
-
auth_endpoint = test_url
|
|
601
|
-
break
|
|
602
|
-
end
|
|
603
|
-
rescue
|
|
604
|
-
# Continue to next path
|
|
605
|
-
end
|
|
606
|
-
end
|
|
607
|
-
auth_endpoint ||= URI.join(base_url, "/users/auth/openid_federation").to_s
|
|
608
|
-
end
|
|
609
|
-
end
|
|
610
|
-
|
|
611
|
-
results[:auth_endpoint] = auth_endpoint
|
|
612
|
-
results[:steps_completed] << "resolve_auth_endpoint"
|
|
613
|
-
rescue => e
|
|
614
|
-
results[:errors] << "Step 2 (Find authorization form): #{e.message}"
|
|
615
|
-
raise
|
|
616
|
-
end
|
|
617
|
-
|
|
618
|
-
# Step 3: Request authorization URL
|
|
619
|
-
results[:steps_completed] << "request_authorization"
|
|
620
|
-
|
|
621
|
-
begin
|
|
622
|
-
headers = {
|
|
623
|
-
"X-CSRF-Token" => csrf_token,
|
|
624
|
-
"X-Requested-With" => "XMLHttpRequest",
|
|
625
|
-
"Referer" => login_page_url
|
|
626
|
-
}
|
|
627
|
-
headers["Cookie"] = cookie_header unless cookie_header.empty?
|
|
628
|
-
|
|
629
|
-
form_data = {}
|
|
630
|
-
# Include acr_values if provided (must be configured in request_object_params to be included in JWT)
|
|
631
|
-
form_data[:acr_values] = provider_acr if StringHelpers.present?(provider_acr)
|
|
632
|
-
|
|
633
|
-
http_client = build_http_client.call(connect_timeout: 10, read_timeout: 10)
|
|
634
|
-
auth_response = http_client
|
|
635
|
-
.headers(headers)
|
|
636
|
-
.post(auth_endpoint, form: form_data)
|
|
637
|
-
|
|
638
|
-
authorization_url = nil
|
|
639
|
-
|
|
640
|
-
if auth_response.status.code >= 300 && auth_response.status.code < 400
|
|
641
|
-
location = auth_response.headers["Location"]
|
|
642
|
-
if location
|
|
643
|
-
# Security: Validate location header
|
|
644
|
-
if location.length > 2048
|
|
645
|
-
raise "Location header exceeds maximum length"
|
|
646
|
-
end
|
|
647
|
-
authorization_url = if location.start_with?("http://", "https://")
|
|
648
|
-
# Note: Rake tasks are developer tools, no security validation needed
|
|
649
|
-
location
|
|
650
|
-
else
|
|
651
|
-
URI.join(base_url, location).to_s
|
|
652
|
-
end
|
|
653
|
-
end
|
|
654
|
-
elsif auth_response.status.code == 200
|
|
655
|
-
authorization_url = auth_response.headers["Location"] || auth_response.body.to_s
|
|
656
|
-
authorization_url = nil unless authorization_url&.start_with?("http")
|
|
657
|
-
end
|
|
658
|
-
|
|
659
|
-
unless authorization_url
|
|
660
|
-
raise "Failed to get authorization URL: #{auth_response.status.code} #{auth_response.status.reason}"
|
|
661
|
-
end
|
|
662
|
-
|
|
663
|
-
results[:authorization_url] = authorization_url
|
|
664
|
-
results[:steps_completed] << "authorization_url_received"
|
|
665
|
-
rescue => e
|
|
666
|
-
results[:errors] << "Step 3 (Authorization request): #{e.message}"
|
|
667
|
-
raise
|
|
668
|
-
end
|
|
669
|
-
|
|
670
|
-
# Return results with instructions
|
|
671
|
-
results[:instructions] = [
|
|
672
|
-
"1. Copy the authorization URL and open it in your browser",
|
|
673
|
-
"2. Complete the authentication with your provider",
|
|
674
|
-
"3. After authentication, you'll be redirected to a callback URL",
|
|
675
|
-
"4. Copy the ENTIRE callback URL (including all parameters) and provide it when prompted"
|
|
676
|
-
]
|
|
677
|
-
|
|
678
|
-
results
|
|
116
|
+
def self.test_authentication_flow(login_page_url:, base_url:, provider_acr: nil)
|
|
117
|
+
Tasks::AuthenticationFlowTester.run(
|
|
118
|
+
login_page_url: login_page_url,
|
|
119
|
+
base_url: base_url,
|
|
120
|
+
provider_acr: provider_acr
|
|
121
|
+
)
|
|
679
122
|
end
|
|
680
123
|
|
|
681
|
-
# Process callback URL and complete authentication flow
|
|
682
|
-
#
|
|
683
|
-
# This method processes the callback from the provider and validates the authentication:
|
|
684
|
-
# 1. Parses callback URL and extracts authorization code
|
|
685
|
-
# 2. Exchanges authorization code for tokens
|
|
686
|
-
# 3. Decrypts and validates ID token
|
|
687
|
-
# 4. Validates OpenID Federation compliance
|
|
688
|
-
#
|
|
689
|
-
# @param callback_url [String] Full callback URL from provider
|
|
690
|
-
# @param base_url [String] Base URL of the application
|
|
691
|
-
# @param entity_statement_url [String, nil] Provider entity statement URL (for resolving configuration)
|
|
692
|
-
# @param entity_statement_path [String, nil] Provider entity statement path (cached copy)
|
|
693
|
-
# @param client_id [String] Client ID
|
|
694
|
-
# @param redirect_uri [String] Redirect URI
|
|
695
|
-
# @param private_key [OpenSSL::PKey::RSA] Private key for client authentication
|
|
696
|
-
# @param provider_acr [String, nil] Optional ACR value
|
|
697
|
-
# @param client_entity_statement_url [String, nil] Client entity statement URL (for automatic registration)
|
|
698
|
-
# @param client_entity_statement_path [String, nil] Client entity statement path (cached copy)
|
|
699
|
-
# @return [Hash] Result hash with tokens, ID token claims, and compliance status
|
|
700
124
|
def self.process_callback_and_validate(
|
|
701
125
|
callback_url:,
|
|
702
126
|
base_url:,
|
|
@@ -706,222 +130,18 @@ module OmniauthOpenidFederation
|
|
|
706
130
|
client_entity_statement_url: nil,
|
|
707
131
|
client_entity_statement_path: nil
|
|
708
132
|
)
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
id_token_claims: {}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
# Parse callback URL
|
|
725
|
-
begin
|
|
726
|
-
# Note: Rake tasks are developer tools, no security validation needed
|
|
727
|
-
begin
|
|
728
|
-
uri = URI.parse(callback_url)
|
|
729
|
-
rescue URI::InvalidURIError => e
|
|
730
|
-
raise "Invalid callback URL: #{e.message}"
|
|
731
|
-
end
|
|
732
|
-
params = CGI.parse(uri.query || "")
|
|
733
|
-
|
|
734
|
-
auth_code = params["code"]&.first
|
|
735
|
-
state = params["state"]&.first
|
|
736
|
-
error = params["error"]&.first
|
|
737
|
-
error_description = params["error_description"]&.first
|
|
738
|
-
|
|
739
|
-
if error
|
|
740
|
-
raise "Authorization error: #{error}#{" - #{error_description}" if error_description}"
|
|
741
|
-
end
|
|
742
|
-
|
|
743
|
-
unless auth_code
|
|
744
|
-
raise "No authorization code found in callback URL"
|
|
745
|
-
end
|
|
746
|
-
|
|
747
|
-
results[:authorization_code] = auth_code
|
|
748
|
-
results[:state] = state
|
|
749
|
-
results[:steps_completed] << "parse_callback"
|
|
750
|
-
rescue => e
|
|
751
|
-
results[:errors] << "Callback parsing: #{e.message}"
|
|
752
|
-
raise
|
|
753
|
-
end
|
|
754
|
-
|
|
755
|
-
# Build strategy options from provided parameters
|
|
756
|
-
begin
|
|
757
|
-
# Resolve entity statement URL if only path provided
|
|
758
|
-
resolved_entity_statement_url = entity_statement_url
|
|
759
|
-
if resolved_entity_statement_url.nil? && entity_statement_path
|
|
760
|
-
# If only path provided, try to resolve from base_url
|
|
761
|
-
resolved_entity_statement_url = "#{base_url}/.well-known/openid-federation"
|
|
762
|
-
end
|
|
763
|
-
|
|
764
|
-
# Resolve client entity statement URL if only path provided
|
|
765
|
-
resolved_client_entity_statement_url = client_entity_statement_url
|
|
766
|
-
if resolved_client_entity_statement_url.nil? && client_entity_statement_path
|
|
767
|
-
resolved_client_entity_statement_url = "#{base_url}/.well-known/openid-federation"
|
|
768
|
-
end
|
|
769
|
-
|
|
770
|
-
# Build strategy options
|
|
771
|
-
strategy_options = {
|
|
772
|
-
discovery: true,
|
|
773
|
-
scope: [:openid],
|
|
774
|
-
response_type: "code",
|
|
775
|
-
client_auth_method: :jwt_bearer,
|
|
776
|
-
client_signing_alg: :RS256,
|
|
777
|
-
always_encrypt_request_object: true,
|
|
778
|
-
entity_statement_url: resolved_entity_statement_url,
|
|
779
|
-
entity_statement_path: entity_statement_path,
|
|
780
|
-
client_entity_statement_url: resolved_client_entity_statement_url,
|
|
781
|
-
client_entity_statement_path: client_entity_statement_path,
|
|
782
|
-
client_options: {
|
|
783
|
-
identifier: client_id,
|
|
784
|
-
redirect_uri: redirect_uri,
|
|
785
|
-
private_key: private_key
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
|
|
789
|
-
# Store client_auth_method before filtering nil values
|
|
790
|
-
client_auth_method = strategy_options[:client_auth_method] || :jwt_bearer
|
|
791
|
-
|
|
792
|
-
# Remove nil values
|
|
793
|
-
strategy_options = strategy_options.reject { |_k, v| v.nil? }
|
|
794
|
-
strategy_options[:client_options] = strategy_options[:client_options].reject { |_k, v| v.nil? }
|
|
795
|
-
|
|
796
|
-
strategy = OmniAuth::Strategies::OpenIDFederation.new(nil, strategy_options)
|
|
797
|
-
oidc_client = strategy.client
|
|
798
|
-
|
|
799
|
-
unless oidc_client
|
|
800
|
-
raise "Failed to initialize OpenID Connect client"
|
|
801
|
-
end
|
|
802
|
-
|
|
803
|
-
unless oidc_client.private_key
|
|
804
|
-
raise "Private key not set on OpenID Connect client (required for private_key_jwt)"
|
|
805
|
-
end
|
|
806
|
-
|
|
807
|
-
results[:steps_completed] << "initialize_strategy"
|
|
808
|
-
rescue => e
|
|
809
|
-
results[:errors] << "Strategy initialization: #{e.message}"
|
|
810
|
-
raise
|
|
811
|
-
end
|
|
812
|
-
|
|
813
|
-
# Exchange authorization code for tokens
|
|
814
|
-
begin
|
|
815
|
-
oidc_client.authorization_code = auth_code
|
|
816
|
-
oidc_client.redirect_uri = redirect_uri
|
|
817
|
-
access_token = oidc_client.access_token!(client_auth_method)
|
|
818
|
-
|
|
819
|
-
id_token_raw = access_token.id_token
|
|
820
|
-
access_token_value = access_token.access_token
|
|
821
|
-
refresh_token = access_token.refresh_token
|
|
822
|
-
|
|
823
|
-
results[:token_info] = {
|
|
824
|
-
access_token: access_token_value ? "#{access_token_value[0..30]}..." : nil,
|
|
825
|
-
refresh_token: refresh_token ? "Present" : "Not provided",
|
|
826
|
-
id_token_encrypted: id_token_raw ? "#{id_token_raw[0..50]}..." : nil
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
results[:steps_completed] << "token_exchange"
|
|
830
|
-
rescue => e
|
|
831
|
-
results[:errors] << "Token exchange: #{e.message}"
|
|
832
|
-
raise
|
|
833
|
-
end
|
|
834
|
-
|
|
835
|
-
# Decrypt and validate ID token
|
|
836
|
-
begin
|
|
837
|
-
id_token = strategy.send(:decode_id_token, id_token_raw)
|
|
838
|
-
|
|
839
|
-
results[:id_token_claims] = {
|
|
840
|
-
iss: id_token.iss,
|
|
841
|
-
sub: id_token.sub,
|
|
842
|
-
aud: id_token.aud,
|
|
843
|
-
exp: id_token.exp,
|
|
844
|
-
iat: id_token.iat,
|
|
845
|
-
nonce: id_token.nonce,
|
|
846
|
-
acr: id_token.acr,
|
|
847
|
-
auth_time: id_token.auth_time,
|
|
848
|
-
amr: id_token.amr
|
|
849
|
-
}
|
|
850
|
-
|
|
851
|
-
# Validate required claims
|
|
852
|
-
required_claims = {
|
|
853
|
-
iss: id_token.iss,
|
|
854
|
-
sub: id_token.sub,
|
|
855
|
-
aud: id_token.aud,
|
|
856
|
-
exp: id_token.exp,
|
|
857
|
-
iat: id_token.iat
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
missing_claims = required_claims.select { |_k, v| v.nil? }
|
|
861
|
-
if missing_claims.empty?
|
|
862
|
-
results[:id_token_valid] = true
|
|
863
|
-
else
|
|
864
|
-
results[:errors] << "Missing required ID token claims: #{missing_claims.keys.join(", ")}"
|
|
865
|
-
end
|
|
866
|
-
|
|
867
|
-
results[:steps_completed] << "id_token_validation"
|
|
868
|
-
rescue => e
|
|
869
|
-
results[:errors] << "ID token validation: #{e.message}"
|
|
870
|
-
raise
|
|
871
|
-
end
|
|
872
|
-
|
|
873
|
-
# Validate OpenID Federation compliance
|
|
874
|
-
results[:compliance_checks] = {
|
|
875
|
-
"Signed Request Objects" => {
|
|
876
|
-
status: "✅ MANDATORY",
|
|
877
|
-
description: "All requests use signed request objects (RFC 9101)",
|
|
878
|
-
verified: true
|
|
879
|
-
},
|
|
880
|
-
"ID Token Encryption" => {
|
|
881
|
-
status: "✅ MANDATORY",
|
|
882
|
-
description: "ID tokens are encrypted (RSA-OAEP + A128CBC-HS256)",
|
|
883
|
-
verified: id_token_raw.split(".").length == 5 # JWE has 5 parts
|
|
884
|
-
},
|
|
885
|
-
"Client Assertion (private_key_jwt)" => {
|
|
886
|
-
status: "✅ MANDATORY",
|
|
887
|
-
description: "Token endpoint uses private_key_jwt authentication",
|
|
888
|
-
verified: true
|
|
889
|
-
},
|
|
890
|
-
"Entity Statement JWKS" => {
|
|
891
|
-
status: "✅ MANDATORY",
|
|
892
|
-
description: "JWKS extracted from entity statement",
|
|
893
|
-
verified: StringHelpers.present?(entity_statement_path) || StringHelpers.present?(entity_statement_url)
|
|
894
|
-
},
|
|
895
|
-
"Signed JWKS Support" => {
|
|
896
|
-
status: "✅ MANDATORY",
|
|
897
|
-
description: "Supports OpenID Federation signed JWKS for key rotation",
|
|
898
|
-
verified: true
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
# Check for client entity statement (optional but recommended)
|
|
903
|
-
if StringHelpers.present?(client_entity_statement_path) || StringHelpers.present?(client_entity_statement_url)
|
|
904
|
-
results[:compliance_checks]["Client Entity Statement"] = {
|
|
905
|
-
status: "✅ RECOMMENDED",
|
|
906
|
-
description: "Client entity statement for federation-based key management",
|
|
907
|
-
verified: true
|
|
908
|
-
}
|
|
909
|
-
end
|
|
910
|
-
|
|
911
|
-
# Check registration type (automatic if client entity statement is provided)
|
|
912
|
-
if StringHelpers.present?(client_entity_statement_path) || StringHelpers.present?(client_entity_statement_url)
|
|
913
|
-
results[:compliance_checks]["Automatic Registration"] = {
|
|
914
|
-
status: "✅ ENABLED",
|
|
915
|
-
description: "Automatic client registration using entity statement",
|
|
916
|
-
verified: true
|
|
917
|
-
}
|
|
918
|
-
end
|
|
919
|
-
|
|
920
|
-
results[:all_compliance_verified] = results[:compliance_checks].all? { |_k, v| v[:verified] }
|
|
921
|
-
|
|
922
|
-
results
|
|
133
|
+
Tasks::CallbackProcessor.process(
|
|
134
|
+
callback_url: callback_url,
|
|
135
|
+
base_url: base_url,
|
|
136
|
+
client_id: client_id,
|
|
137
|
+
redirect_uri: redirect_uri,
|
|
138
|
+
private_key: private_key,
|
|
139
|
+
entity_statement_url: entity_statement_url,
|
|
140
|
+
entity_statement_path: entity_statement_path,
|
|
141
|
+
provider_acr: provider_acr,
|
|
142
|
+
client_entity_statement_url: client_entity_statement_url,
|
|
143
|
+
client_entity_statement_path: client_entity_statement_path
|
|
144
|
+
)
|
|
923
145
|
end
|
|
924
|
-
|
|
925
|
-
private_class_method :generate_single_key, :generate_separate_keys
|
|
926
146
|
end
|
|
927
147
|
end
|