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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/README.md +119 -13
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
  5. data/config/polyrun_coverage.yml +15 -0
  6. data/config/polyrun_spec_quality.yml +29 -0
  7. data/examples/README_INTEGRATION_TESTING.md +3 -0
  8. data/examples/integration_test_flow.rb +10 -8
  9. data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
  10. data/examples/jobs/federation_files_generation_job.rb.example +3 -2
  11. data/examples/mock_op_server.rb +4 -5
  12. data/examples/mock_rp_server.rb +3 -3
  13. data/examples/standalone_multiple_endpoints_example.rb +494 -0
  14. data/lib/omniauth_openid_federation/access_token.rb +91 -450
  15. data/lib/omniauth_openid_federation/cache.rb +16 -0
  16. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  17. data/lib/omniauth_openid_federation/constants.rb +3 -0
  18. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  19. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +2 -4
  20. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
  21. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +51 -6
  22. data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
  23. data/lib/omniauth_openid_federation/http_client.rb +145 -32
  24. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  25. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  26. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  27. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  28. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  29. data/lib/omniauth_openid_federation/jws.rb +2 -11
  30. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
  31. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  32. data/lib/omniauth_openid_federation/rack.rb +2 -0
  33. data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
  34. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  35. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  36. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  37. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
  38. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  39. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
  40. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
  41. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  42. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  43. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
  44. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
  45. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
  46. data/lib/omniauth_openid_federation/strategy.rb +27 -1910
  47. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  48. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  49. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  50. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  51. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  52. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
  53. data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
  54. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  55. data/lib/omniauth_openid_federation/utils.rb +10 -2
  56. data/lib/omniauth_openid_federation/validators.rb +43 -8
  57. data/lib/omniauth_openid_federation/version.rb +1 -1
  58. data/lib/omniauth_openid_federation.rb +9 -3
  59. data/lib/tasks/omniauth_openid_federation.rake +1 -2
  60. data/sig/omniauth_openid_federation.rbs +2 -1
  61. data/sig/strategy.rbs +6 -6
  62. metadata +181 -71
@@ -1,47 +1,25 @@
1
1
  # Tasks helper module for rake tasks
2
- # Contains all business logic that can be tested independently
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 "federation/signed_jwks"
16
- require_relative "string_helpers"
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
- return file_path if file_path.start_with?("/")
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
- unless %w[single separate].include?(key_type)
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
- entity_statement_url = "#{base_url}/.well-known/openid-federation"
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
- return {type: :unknown, count: 0, recommendation: "No keys found in entity statement"} unless jwks
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
- # Generate separate keys for signing and encryption
408
- #
409
- # @param output_path [String] Output directory path
410
- # @return [Hash] Result with :signing_key_path, :encryption_key_path, :public_jwks_path, :jwks
411
- def self.generate_separate_keys(output_path)
412
- signing_private_key = OpenSSL::PKey::RSA.new(2048)
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
- require "uri"
710
- require "cgi"
711
- require "json"
712
- require "base64"
713
- require_relative "strategy"
714
-
715
- results = {
716
- steps_completed: [],
717
- errors: [],
718
- warnings: [],
719
- compliance_checks: {},
720
- token_info: {},
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