omniauth_openid_federation 1.3.0 → 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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -1
  3. data/README.md +120 -14
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +3 -3
  5. data/config/polyrun_coverage.yml +15 -0
  6. data/config/polyrun_spec_quality.yml +29 -0
  7. data/config/routes.rb +20 -10
  8. data/examples/README_INTEGRATION_TESTING.md +3 -0
  9. data/examples/integration_test_flow.rb +10 -8
  10. data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
  11. data/examples/jobs/federation_files_generation_job.rb.example +3 -2
  12. data/examples/mock_op_server.rb +4 -5
  13. data/examples/mock_rp_server.rb +3 -3
  14. data/examples/standalone_multiple_endpoints_example.rb +494 -0
  15. data/lib/omniauth_openid_federation/access_token.rb +91 -450
  16. data/lib/omniauth_openid_federation/cache.rb +16 -0
  17. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  18. data/lib/omniauth_openid_federation/constants.rb +3 -0
  19. data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
  20. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  21. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
  22. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
  23. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +8 -91
  24. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
  25. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +54 -21
  26. data/lib/omniauth_openid_federation/federation_endpoint.rb +40 -172
  27. data/lib/omniauth_openid_federation/http_client.rb +145 -32
  28. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  29. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  30. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  31. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  32. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  33. data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
  34. data/lib/omniauth_openid_federation/jws.rb +3 -11
  35. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
  36. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  37. data/lib/omniauth_openid_federation/rack.rb +2 -0
  38. data/lib/omniauth_openid_federation/rack_endpoint.rb +21 -9
  39. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  40. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  41. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  42. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
  43. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  44. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
  45. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
  46. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  47. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  48. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
  49. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
  50. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
  51. data/lib/omniauth_openid_federation/strategy.rb +27 -1910
  52. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  53. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  54. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  55. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  56. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  57. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -790
  58. data/lib/omniauth_openid_federation/time_helpers.rb +67 -0
  59. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  60. data/lib/omniauth_openid_federation/utils.rb +14 -9
  61. data/lib/omniauth_openid_federation/validators.rb +55 -44
  62. data/lib/omniauth_openid_federation/version.rb +1 -1
  63. data/lib/omniauth_openid_federation.rb +10 -3
  64. data/lib/tasks/omniauth_openid_federation.rake +5 -5
  65. data/sig/omniauth_openid_federation.rbs +8 -1
  66. data/sig/strategy.rbs +6 -6
  67. metadata +252 -43
@@ -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,544 +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
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
289
-
290
- request_path = uri.path
291
- request_path += "?#{uri.query}" if uri.query
292
- request = Net::HTTP::Get.new(request_path)
293
- response = http.request(request)
294
-
295
- results[name] = if response.code.to_i < 400
296
- {status: :success, code: response.code}
297
- else
298
- {status: :warning, code: response.code, body: response.body}
299
- end
300
- end
301
- rescue FetchError, Federation::SignedJWKS::FetchError => e
302
- results[name] = {status: :error, message: e.message}
303
- rescue Federation::SignedJWKS::ValidationError => e
304
- results[name] = {status: :error, message: e.message}
305
- rescue => e
306
- results[name] = {status: :error, message: e.message}
307
- end
308
- end
309
-
310
- {
311
- success: true,
312
- entity_statement: entity_statement,
313
- metadata: metadata,
314
- results: results,
315
- key_status: key_status,
316
- validation_warnings: validation_warnings
317
- }
109
+ Tasks::LocalEndpointTester.run(base_url: base_url)
318
110
  end
319
111
 
320
- # Detect key configuration (single vs separate keys)
321
- #
322
- # @param jwks [Hash, nil] JWKS hash with keys array
323
- # @return [Hash] Hash with :type, :count, :recommendation
324
112
  def self.detect_key_status(jwks)
325
- return {type: :unknown, count: 0, recommendation: "No keys found in entity statement"} unless jwks
326
-
327
- keys = jwks.is_a?(Hash) ? (jwks["keys"] || jwks[:keys] || []) : []
328
- return {type: :unknown, count: 0, recommendation: "No keys found in entity statement"} if keys.empty?
329
-
330
- # Check for duplicate kids (indicates single key used for both signing and encryption)
331
- kids = keys.map { |k| k["kid"] || k[:kid] }.compact
332
- duplicate_kids = kids.length != kids.uniq.length
333
-
334
- # Check use fields
335
- uses = keys.map { |k| k["use"] || k[:use] }.compact.uniq
336
- has_sig = uses.include?("sig")
337
- has_enc = uses.include?("enc")
338
- has_both_uses = has_sig && has_enc
339
-
340
- if duplicate_kids
341
- {
342
- type: :single,
343
- count: keys.length,
344
- 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]"
345
- }
346
- elsif has_both_uses && keys.length >= 2
347
- {
348
- type: :separate,
349
- count: keys.length,
350
- recommendation: "✅ Separate keys detected (recommended for production)"
351
- }
352
- elsif keys.length == 1
353
- {
354
- type: :single,
355
- count: 1,
356
- 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]"
357
- }
358
- else
359
- {
360
- type: :unknown,
361
- count: keys.length,
362
- recommendation: "Key configuration unclear. Ensure keys have unique Key IDs and proper 'use' fields ('sig' for signing, 'enc' for encryption)"
363
- }
364
- end
365
- end
366
-
367
- # Generate single key for both signing and encryption
368
- #
369
- # @param output_path [String] Output directory path
370
- # @return [Hash] Result with :private_key_path, :public_jwks_path, :jwks
371
- def self.generate_single_key(output_path)
372
- private_key = OpenSSL::PKey::RSA.new(2048)
373
- jwk_hash = Utils.rsa_key_to_jwk(private_key, use: "sig")
374
-
375
- # Remove private key components and 'use' field for backward compatibility
376
- public_jwk = jwk_hash.reject { |k, _v| %w[d p q dp dq qi use].include?(k.to_s) }
377
- jwks = {keys: [public_jwk]}
378
-
379
- # Save private key
380
- private_key_path = File.join(output_path, "client-private-key.pem")
381
- File.write(private_key_path, private_key.to_pem)
382
- File.chmod(0o600, private_key_path)
383
-
384
- # Save public JWKS
385
- public_jwks_path = File.join(output_path, "client-jwks.json")
386
- File.write(public_jwks_path, JSON.pretty_generate(jwks))
387
-
388
- {
389
- private_key_path: private_key_path,
390
- public_jwks_path: public_jwks_path,
391
- jwks: jwks
392
- }
393
- end
394
-
395
- # Generate separate keys for signing and encryption
396
- #
397
- # @param output_path [String] Output directory path
398
- # @return [Hash] Result with :signing_key_path, :encryption_key_path, :public_jwks_path, :jwks
399
- def self.generate_separate_keys(output_path)
400
- signing_private_key = OpenSSL::PKey::RSA.new(2048)
401
- encryption_private_key = OpenSSL::PKey::RSA.new(2048)
402
-
403
- signing_jwk_hash = Utils.rsa_key_to_jwk(signing_private_key, use: "sig")
404
- encryption_jwk_hash = Utils.rsa_key_to_jwk(encryption_private_key, use: "enc")
405
-
406
- # Remove private key components and add 'use' field
407
- signing_public_jwk = signing_jwk_hash.reject { |k, _v| %w[d p q dp dq qi].include?(k.to_s) }.merge("use" => "sig")
408
- encryption_public_jwk = encryption_jwk_hash.reject { |k, _v| %w[d p q dp dq qi].include?(k.to_s) }.merge("use" => "enc")
409
-
410
- jwks = {keys: [signing_public_jwk, encryption_public_jwk]}
411
-
412
- # Save private keys
413
- signing_key_path = File.join(output_path, "client-signing-private-key.pem")
414
- encryption_key_path = File.join(output_path, "client-encryption-private-key.pem")
415
-
416
- File.write(signing_key_path, signing_private_key.to_pem)
417
- File.write(encryption_key_path, encryption_private_key.to_pem)
418
- File.chmod(0o600, signing_key_path)
419
- File.chmod(0o600, encryption_key_path)
420
-
421
- # Save public JWKS
422
- public_jwks_path = File.join(output_path, "client-jwks.json")
423
- File.write(public_jwks_path, JSON.pretty_generate(jwks))
424
-
425
- {
426
- signing_key_path: signing_key_path,
427
- encryption_key_path: encryption_key_path,
428
- public_jwks_path: public_jwks_path,
429
- jwks: jwks
430
- }
113
+ Tasks::LocalEndpointTester.detect_key_status(jwks)
431
114
  end
432
115
 
433
- # Test full OpenID Federation authentication flow
434
- #
435
- # This method tests the complete authentication flow with a real provider:
436
- # 1. Fetches CSRF token and cookies from login page URL
437
- # 2. Finds authorization form/button in HTML
438
- # 3. Makes authorization request with signed request object
439
- # 4. Returns authorization URL for user interaction
440
- #
441
- # @param login_page_url [String] Full URL to login page that contains CSRF token and authorization form
442
- # @param base_url [String] Base URL of the application (for resolving relative URLs)
443
- # @param provider_acr [String, nil] Optional ACR (Authentication Context Class Reference) value for provider selection
444
- # @return [Hash] Result hash with authorization URL, CSRF token, cookies, and instructions
445
- # @raise [StandardError] If critical errors occur during testing
446
- def self.test_authentication_flow(
447
- login_page_url:,
448
- base_url:,
449
- provider_acr: nil
450
- )
451
- require "uri"
452
- require "cgi"
453
- require "json"
454
- require "base64"
455
- require "http"
456
- require "openssl"
457
-
458
- results = {
459
- steps_completed: [],
460
- errors: [],
461
- warnings: [],
462
- csrf_token: nil,
463
- cookies: [],
464
- authorization_url: nil,
465
- instructions: []
466
- }
467
-
468
- # HTTP client helper for custom requests
469
-
470
- # Step 1: Fetch login page for CSRF token and cookies
471
- results[:steps_completed] << "fetch_csrf_token"
472
-
473
- html_body = nil
474
- cookie_header = nil
475
- csrf_token = nil
476
- cookies = []
477
-
478
- begin
479
- login_response = build_http_client(connect_timeout: 10, read_timeout: 10).get(login_page_url)
480
-
481
- unless login_response.status.success?
482
- raise "Failed to fetch login page: #{login_response.status.code} #{login_response.status.reason}"
483
- end
484
-
485
- # Extract cookies
486
- set_cookie_headers = login_response.headers["Set-Cookie"]
487
- if set_cookie_headers
488
- cookie_list = set_cookie_headers.is_a?(Array) ? set_cookie_headers : [set_cookie_headers]
489
- cookie_list.each do |set_cookie|
490
- cookie_str = set_cookie.to_s
491
- # Security: Limit cookie header size to prevent DoS attacks (max 4KB per cookie)
492
- next if cookie_str.length > 4096
493
- # Security: Use non-greedy matching with length limits to prevent ReDoS
494
- cookie_match = cookie_str.match(/^([^=]{1,256})=([^;]{1,4096})/)
495
- cookies << "#{cookie_match[1]}=#{cookie_match[2]}" if cookie_match
496
- end
497
- end
498
-
499
- cookie_header = cookies.join("; ")
500
-
501
- # Extract CSRF token from HTML
502
- html_body = login_response.body.to_s
503
-
504
- # Security: Limit HTML body size to prevent DoS attacks (max 1MB)
505
- if html_body.bytesize > 1_048_576
506
- raise "HTML response too large (#{html_body.bytesize} bytes), possible DoS attack"
507
- end
508
-
509
- # Try meta tag first
510
- # Security: Use non-greedy matching and limit capture group to prevent ReDoS
511
- csrf_meta_match = html_body.match(/<meta\s+name=["']csrf-token["']\s+content=["']([^"']{1,256})["']/i)
512
- csrf_token = csrf_meta_match[1] if csrf_meta_match
513
-
514
- # Try form input if not found
515
- # Security: Use non-greedy matching and limit capture group to prevent ReDoS
516
- unless csrf_token
517
- csrf_input_match = html_body.match(/<input[^>]*name=["']authenticity_token["'][^>]*value=["']([^"']{1,256})["']/i)
518
- csrf_token = csrf_input_match[1] if csrf_input_match
519
- end
520
-
521
- unless csrf_token
522
- raise "Failed to extract CSRF token from login page"
523
- end
524
-
525
- results[:csrf_token] = csrf_token
526
- results[:cookies] = cookies
527
- results[:steps_completed] << "extract_csrf_and_cookies"
528
- rescue => e
529
- results[:errors] << "Step 1 (CSRF token): #{e.message}"
530
- raise
531
- end
532
-
533
- # Step 2: Find authorization form/button in HTML
534
- results[:steps_completed] << "find_authorization_form"
535
-
536
- begin
537
- # Try to find form with action containing "openid_federation"
538
- # Security: Use non-greedy matching and limit capture group to prevent ReDoS
539
- form_match = html_body.match(/<form[^>]*action=["']([^"']{0,2048}openid[_-]?federation[^"']{0,2048})["'][^>]*>/i)
540
- auth_endpoint = nil
541
-
542
- if form_match
543
- form_action = form_match[1]
544
- # Note: Rake tasks are developer tools, no security validation needed
545
- begin
546
- auth_endpoint = if form_action.start_with?("http://", "https://")
547
- URI.parse(form_action).to_s
548
- else
549
- URI.join(base_url, form_action).to_s
550
- end
551
- rescue URI::InvalidURIError => e
552
- raise "Invalid form action URI: #{e.message}"
553
- end
554
- else
555
- # Try to find button/link with href containing "openid_federation"
556
- # Security: Use non-greedy matching and limit capture group to prevent ReDoS
557
- button_match = html_body.match(/<a[^>]*href=["']([^"']{0,2048}openid[_-]?federation[^"']{0,2048})["'][^>]*>/i)
558
- if button_match
559
- button_href = button_match[1]
560
- # Note: Rake tasks are developer tools, no security validation needed
561
- begin
562
- auth_endpoint = if button_href.start_with?("http://", "https://")
563
- URI.parse(button_href).to_s
564
- else
565
- URI.join(base_url, button_href).to_s
566
- end
567
- rescue URI::InvalidURIError => e
568
- raise "Invalid button href URI: #{e.message}"
569
- end
570
- else
571
- # Fallback: try common paths
572
- common_paths = [
573
- "/users/auth/openid_federation",
574
- "/auth/openid_federation",
575
- "/openid_federation"
576
- ]
577
- auth_endpoint = nil
578
- common_paths.each do |path|
579
- test_url = URI.join(base_url, path).to_s
580
- begin
581
- test_response = build_http_client(connect_timeout: 5, read_timeout: 5).get(test_url)
582
- if test_response.status.code >= 300 && test_response.status.code < 400
583
- auth_endpoint = test_url
584
- break
585
- end
586
- rescue
587
- # Continue to next path
588
- end
589
- end
590
- auth_endpoint ||= URI.join(base_url, "/users/auth/openid_federation").to_s
591
- end
592
- end
593
-
594
- results[:auth_endpoint] = auth_endpoint
595
- results[:steps_completed] << "resolve_auth_endpoint"
596
- rescue => e
597
- results[:errors] << "Step 2 (Find authorization form): #{e.message}"
598
- raise
599
- end
600
-
601
- # Step 3: Request authorization URL
602
- results[:steps_completed] << "request_authorization"
603
-
604
- begin
605
- headers = {
606
- "X-CSRF-Token" => csrf_token,
607
- "X-Requested-With" => "XMLHttpRequest",
608
- "Referer" => login_page_url
609
- }
610
- headers["Cookie"] = cookie_header unless cookie_header.empty?
611
-
612
- form_data = {}
613
- # Include acr_values if provided (must be configured in request_object_params to be included in JWT)
614
- form_data[:acr_values] = provider_acr if StringHelpers.present?(provider_acr)
615
-
616
- auth_response = build_http_client(connect_timeout: 10, read_timeout: 10)
617
- .headers(headers)
618
- .post(auth_endpoint, form: form_data)
619
-
620
- authorization_url = nil
621
-
622
- if auth_response.status.code >= 300 && auth_response.status.code < 400
623
- location = auth_response.headers["Location"]
624
- if location
625
- # Security: Validate location header
626
- if location.length > 2048
627
- raise "Location header exceeds maximum length"
628
- end
629
- authorization_url = if location.start_with?("http://", "https://")
630
- # Note: Rake tasks are developer tools, no security validation needed
631
- location
632
- else
633
- URI.join(base_url, location).to_s
634
- end
635
- end
636
- elsif auth_response.status.code == 200
637
- authorization_url = auth_response.headers["Location"] || auth_response.body.to_s
638
- authorization_url = nil unless authorization_url&.start_with?("http")
639
- end
640
-
641
- unless authorization_url
642
- raise "Failed to get authorization URL: #{auth_response.status.code} #{auth_response.status.reason}"
643
- end
644
-
645
- results[:authorization_url] = authorization_url
646
- results[:steps_completed] << "authorization_url_received"
647
- rescue => e
648
- results[:errors] << "Step 3 (Authorization request): #{e.message}"
649
- raise
650
- end
651
-
652
- # Return results with instructions
653
- results[:instructions] = [
654
- "1. Copy the authorization URL and open it in your browser",
655
- "2. Complete the authentication with your provider",
656
- "3. After authentication, you'll be redirected to a callback URL",
657
- "4. Copy the ENTIRE callback URL (including all parameters) and provide it when prompted"
658
- ]
659
-
660
- 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
+ )
661
122
  end
662
123
 
663
- # Process callback URL and complete authentication flow
664
- #
665
- # This method processes the callback from the provider and validates the authentication:
666
- # 1. Parses callback URL and extracts authorization code
667
- # 2. Exchanges authorization code for tokens
668
- # 3. Decrypts and validates ID token
669
- # 4. Validates OpenID Federation compliance
670
- #
671
- # @param callback_url [String] Full callback URL from provider
672
- # @param base_url [String] Base URL of the application
673
- # @param entity_statement_url [String, nil] Provider entity statement URL (for resolving configuration)
674
- # @param entity_statement_path [String, nil] Provider entity statement path (cached copy)
675
- # @param client_id [String] Client ID
676
- # @param redirect_uri [String] Redirect URI
677
- # @param private_key [OpenSSL::PKey::RSA] Private key for client authentication
678
- # @param provider_acr [String, nil] Optional ACR value
679
- # @param client_entity_statement_url [String, nil] Client entity statement URL (for automatic registration)
680
- # @param client_entity_statement_path [String, nil] Client entity statement path (cached copy)
681
- # @return [Hash] Result hash with tokens, ID token claims, and compliance status
682
124
  def self.process_callback_and_validate(
683
125
  callback_url:,
684
126
  base_url:,
@@ -688,222 +130,18 @@ module OmniauthOpenidFederation
688
130
  client_entity_statement_url: nil,
689
131
  client_entity_statement_path: nil
690
132
  )
691
- require "uri"
692
- require "cgi"
693
- require "json"
694
- require "base64"
695
- require_relative "../strategy"
696
-
697
- results = {
698
- steps_completed: [],
699
- errors: [],
700
- warnings: [],
701
- compliance_checks: {},
702
- token_info: {},
703
- id_token_claims: {}
704
- }
705
-
706
- # Parse callback URL
707
- begin
708
- # Note: Rake tasks are developer tools, no security validation needed
709
- begin
710
- uri = URI.parse(callback_url)
711
- rescue URI::InvalidURIError => e
712
- raise "Invalid callback URL: #{e.message}"
713
- end
714
- params = CGI.parse(uri.query || "")
715
-
716
- auth_code = params["code"]&.first
717
- state = params["state"]&.first
718
- error = params["error"]&.first
719
- error_description = params["error_description"]&.first
720
-
721
- if error
722
- raise "Authorization error: #{error}#{" - #{error_description}" if error_description}"
723
- end
724
-
725
- unless auth_code
726
- raise "No authorization code found in callback URL"
727
- end
728
-
729
- results[:authorization_code] = auth_code
730
- results[:state] = state
731
- results[:steps_completed] << "parse_callback"
732
- rescue => e
733
- results[:errors] << "Callback parsing: #{e.message}"
734
- raise
735
- end
736
-
737
- # Build strategy options from provided parameters
738
- begin
739
- # Resolve entity statement URL if only path provided
740
- resolved_entity_statement_url = entity_statement_url
741
- if resolved_entity_statement_url.nil? && entity_statement_path
742
- # If only path provided, try to resolve from base_url
743
- resolved_entity_statement_url = "#{base_url}/.well-known/openid-federation"
744
- end
745
-
746
- # Resolve client entity statement URL if only path provided
747
- resolved_client_entity_statement_url = client_entity_statement_url
748
- if resolved_client_entity_statement_url.nil? && client_entity_statement_path
749
- resolved_client_entity_statement_url = "#{base_url}/.well-known/openid-federation"
750
- end
751
-
752
- # Build strategy options
753
- strategy_options = {
754
- discovery: true,
755
- scope: [:openid],
756
- response_type: "code",
757
- client_auth_method: :jwt_bearer,
758
- client_signing_alg: :RS256,
759
- always_encrypt_request_object: true,
760
- entity_statement_url: resolved_entity_statement_url,
761
- entity_statement_path: entity_statement_path,
762
- client_entity_statement_url: resolved_client_entity_statement_url,
763
- client_entity_statement_path: client_entity_statement_path,
764
- client_options: {
765
- identifier: client_id,
766
- redirect_uri: redirect_uri,
767
- private_key: private_key
768
- }
769
- }
770
-
771
- # Store client_auth_method before filtering nil values
772
- client_auth_method = strategy_options[:client_auth_method] || :jwt_bearer
773
-
774
- # Remove nil values
775
- strategy_options = strategy_options.reject { |_k, v| v.nil? }
776
- strategy_options[:client_options] = strategy_options[:client_options].reject { |_k, v| v.nil? }
777
-
778
- strategy = OmniAuth::Strategies::OpenIDFederation.new(nil, strategy_options)
779
- oidc_client = strategy.client
780
-
781
- unless oidc_client
782
- raise "Failed to initialize OpenID Connect client"
783
- end
784
-
785
- unless oidc_client.private_key
786
- raise "Private key not set on OpenID Connect client (required for private_key_jwt)"
787
- end
788
-
789
- results[:steps_completed] << "initialize_strategy"
790
- rescue => e
791
- results[:errors] << "Strategy initialization: #{e.message}"
792
- raise
793
- end
794
-
795
- # Exchange authorization code for tokens
796
- begin
797
- oidc_client.authorization_code = auth_code
798
- oidc_client.redirect_uri = redirect_uri
799
- access_token = oidc_client.access_token!(client_auth_method)
800
-
801
- id_token_raw = access_token.id_token
802
- access_token_value = access_token.access_token
803
- refresh_token = access_token.refresh_token
804
-
805
- results[:token_info] = {
806
- access_token: access_token_value ? "#{access_token_value[0..30]}..." : nil,
807
- refresh_token: refresh_token ? "Present" : "Not provided",
808
- id_token_encrypted: id_token_raw ? "#{id_token_raw[0..50]}..." : nil
809
- }
810
-
811
- results[:steps_completed] << "token_exchange"
812
- rescue => e
813
- results[:errors] << "Token exchange: #{e.message}"
814
- raise
815
- end
816
-
817
- # Decrypt and validate ID token
818
- begin
819
- id_token = strategy.send(:decode_id_token, id_token_raw)
820
-
821
- results[:id_token_claims] = {
822
- iss: id_token.iss,
823
- sub: id_token.sub,
824
- aud: id_token.aud,
825
- exp: id_token.exp,
826
- iat: id_token.iat,
827
- nonce: id_token.nonce,
828
- acr: id_token.acr,
829
- auth_time: id_token.auth_time,
830
- amr: id_token.amr
831
- }
832
-
833
- # Validate required claims
834
- required_claims = {
835
- iss: id_token.iss,
836
- sub: id_token.sub,
837
- aud: id_token.aud,
838
- exp: id_token.exp,
839
- iat: id_token.iat
840
- }
841
-
842
- missing_claims = required_claims.select { |_k, v| v.nil? }
843
- if missing_claims.empty?
844
- results[:id_token_valid] = true
845
- else
846
- results[:errors] << "Missing required ID token claims: #{missing_claims.keys.join(", ")}"
847
- end
848
-
849
- results[:steps_completed] << "id_token_validation"
850
- rescue => e
851
- results[:errors] << "ID token validation: #{e.message}"
852
- raise
853
- end
854
-
855
- # Validate OpenID Federation compliance
856
- results[:compliance_checks] = {
857
- "Signed Request Objects" => {
858
- status: "✅ MANDATORY",
859
- description: "All requests use signed request objects (RFC 9101)",
860
- verified: true
861
- },
862
- "ID Token Encryption" => {
863
- status: "✅ MANDATORY",
864
- description: "ID tokens are encrypted (RSA-OAEP + A128CBC-HS256)",
865
- verified: id_token_raw.split(".").length == 5 # JWE has 5 parts
866
- },
867
- "Client Assertion (private_key_jwt)" => {
868
- status: "✅ MANDATORY",
869
- description: "Token endpoint uses private_key_jwt authentication",
870
- verified: true
871
- },
872
- "Entity Statement JWKS" => {
873
- status: "✅ MANDATORY",
874
- description: "JWKS extracted from entity statement",
875
- verified: StringHelpers.present?(entity_statement_path) || StringHelpers.present?(entity_statement_url)
876
- },
877
- "Signed JWKS Support" => {
878
- status: "✅ MANDATORY",
879
- description: "Supports OpenID Federation signed JWKS for key rotation",
880
- verified: true
881
- }
882
- }
883
-
884
- # Check for client entity statement (optional but recommended)
885
- if StringHelpers.present?(client_entity_statement_path) || StringHelpers.present?(client_entity_statement_url)
886
- results[:compliance_checks]["Client Entity Statement"] = {
887
- status: "✅ RECOMMENDED",
888
- description: "Client entity statement for federation-based key management",
889
- verified: true
890
- }
891
- end
892
-
893
- # Check registration type (automatic if client entity statement is provided)
894
- if StringHelpers.present?(client_entity_statement_path) || StringHelpers.present?(client_entity_statement_url)
895
- results[:compliance_checks]["Automatic Registration"] = {
896
- status: "✅ ENABLED",
897
- description: "Automatic client registration using entity statement",
898
- verified: true
899
- }
900
- end
901
-
902
- results[:all_compliance_verified] = results[:compliance_checks].all? { |_k, v| v[:verified] }
903
-
904
- 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
+ )
905
145
  end
906
-
907
- private_class_method :generate_single_key, :generate_separate_keys
908
146
  end
909
147
  end