omniauth_openid_federation 1.2.2 → 1.3.2

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 (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -1
  3. data/README.md +210 -708
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +14 -1
  5. data/config/routes.rb +20 -10
  6. data/examples/config/initializers/devise.rb.example +44 -55
  7. data/examples/config/initializers/federation_endpoint.rb.example +2 -2
  8. data/examples/config/open_id_connect_config.rb.example +12 -15
  9. data/examples/config/routes.rb.example +9 -5
  10. data/examples/integration_test_flow.rb +4 -4
  11. data/examples/mock_op_server.rb +3 -3
  12. data/examples/mock_rp_server.rb +3 -3
  13. data/lib/omniauth_openid_federation/configuration.rb +8 -0
  14. data/lib/omniauth_openid_federation/constants.rb +5 -0
  15. data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
  16. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
  17. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
  18. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +6 -87
  19. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +3 -15
  20. data/lib/omniauth_openid_federation/federation_endpoint.rb +39 -193
  21. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -15
  22. data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
  23. data/lib/omniauth_openid_federation/jws.rb +23 -20
  24. data/lib/omniauth_openid_federation/rack_endpoint.rb +30 -5
  25. data/lib/omniauth_openid_federation/strategy.rb +143 -194
  26. data/lib/omniauth_openid_federation/tasks_helper.rb +501 -2
  27. data/lib/omniauth_openid_federation/time_helpers.rb +60 -0
  28. data/lib/omniauth_openid_federation/utils.rb +4 -7
  29. data/lib/omniauth_openid_federation/validators.rb +294 -8
  30. data/lib/omniauth_openid_federation/version.rb +1 -1
  31. data/lib/omniauth_openid_federation.rb +1 -0
  32. data/lib/tasks/omniauth_openid_federation.rake +301 -2
  33. data/sig/federation.rbs +0 -8
  34. data/sig/jwks.rbs +0 -6
  35. data/sig/omniauth_openid_federation.rbs +6 -1
  36. data/sig/strategy.rbs +0 -2
  37. metadata +100 -1
@@ -1,4 +1,8 @@
1
1
  # Input validation utilities for omniauth_openid_federation
2
+ require_relative "constants"
3
+ require_relative "configuration"
4
+ require_relative "string_helpers"
5
+
2
6
  module OmniauthOpenidFederation
3
7
  module Validators
4
8
  # Validate that a private key is present and valid
@@ -10,7 +14,6 @@ module OmniauthOpenidFederation
10
14
  raise ConfigurationError, "Private key is required for signed request objects"
11
15
  end
12
16
 
13
- # Try to parse if it's a string
14
17
  if private_key.is_a?(String)
15
18
  begin
16
19
  OpenSSL::PKey::RSA.new(private_key)
@@ -71,17 +74,16 @@ module OmniauthOpenidFederation
71
74
  true
72
75
  end
73
76
 
74
- # Validate client options hash
77
+ # Validate client options hash (for configuration validation only)
78
+ # Note: This validates configuration structure, not security (config is trusted)
75
79
  #
76
80
  # @param client_options [Hash] The client options to validate
77
81
  # @raise [ConfigurationError] If required options are missing
78
82
  def self.validate_client_options!(client_options)
79
83
  client_options ||= {}
80
84
 
81
- # Normalize hash keys to symbols
82
85
  normalized = normalize_hash(client_options)
83
86
 
84
- # Validate required fields
85
87
  if StringHelpers.blank?(normalized[:identifier])
86
88
  raise ConfigurationError, "Client identifier is required"
87
89
  end
@@ -90,13 +92,17 @@ module OmniauthOpenidFederation
90
92
  raise ConfigurationError, "Redirect URI is required"
91
93
  end
92
94
 
93
- # Validate redirect URI format
94
- validate_uri!(normalized[:redirect_uri], required: true)
95
+ begin
96
+ parsed = URI.parse(normalized[:redirect_uri].to_s)
97
+ unless parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
98
+ raise ConfigurationError, "Redirect URI must be HTTP or HTTPS: #{normalized[:redirect_uri]}"
99
+ end
100
+ rescue URI::InvalidURIError => e
101
+ raise ConfigurationError, "Invalid redirect URI format: #{e.message}"
102
+ end
95
103
 
96
- # Validate private key
97
104
  validate_private_key!(normalized[:private_key])
98
105
 
99
- # Validate endpoints if provided
100
106
  %i[authorization_endpoint token_endpoint jwks_uri].each do |endpoint|
101
107
  if normalized.key?(endpoint) && !StringHelpers.blank?(normalized[endpoint])
102
108
  # Endpoints can be paths or full URLs
@@ -122,5 +128,285 @@ module OmniauthOpenidFederation
122
128
  result[key] = v
123
129
  end
124
130
  end
131
+
132
+ # Validate and sanitize user input from HTTP requests only (not config values)
133
+ # Prevents URI exploitation, ReDoS, string overflow, and control character attacks
134
+ # Default max_length uses Configuration.config.max_string_length (8KB default) - large enough for legitimate use, prevents DoS attacks
135
+ def self.sanitize_request_param(value, max_length: nil, allow_control_chars: false)
136
+ max_length ||= ::OmniauthOpenidFederation::Configuration.config.max_string_length
137
+ return nil if value.nil?
138
+
139
+ str = value.to_s.strip
140
+ return nil if str.length > max_length
141
+
142
+ unless allow_control_chars
143
+ str = str.gsub(/[^\x20-\x7E]/, "")
144
+ end
145
+
146
+ str.empty? ? nil : str
147
+ end
148
+
149
+ # Validate URI for user input only (not config values)
150
+ # Prevents URI gem exploitation and validates scheme/length
151
+ def self.validate_uri_safe!(uri_str, max_length: nil, allowed_schemes: ["http", "https"])
152
+ max_length ||= ::OmniauthOpenidFederation::Configuration.config.max_string_length
153
+ raise SecurityError, "URI cannot be nil" if uri_str.nil?
154
+
155
+ original_str = uri_str.to_s
156
+ sanitized = original_str.gsub(/[^\x20-\x7E]/, "")
157
+ raise SecurityError, "URI contains invalid characters (only printable ASCII allowed)" if sanitized != original_str
158
+
159
+ str = sanitized.strip
160
+ raise SecurityError, "URI cannot be empty" if str.empty?
161
+ raise SecurityError, "URI exceeds maximum length of #{max_length} characters" if str.length > max_length
162
+
163
+ begin
164
+ parsed = URI.parse(str)
165
+ rescue URI::InvalidURIError => e
166
+ raise SecurityError, "Invalid URI format: #{e.message}"
167
+ end
168
+
169
+ unless parsed.scheme && allowed_schemes.include?(parsed.scheme.downcase)
170
+ raise SecurityError, "URI scheme must be one of: #{allowed_schemes.join(", ")}"
171
+ end
172
+
173
+ unless parsed.is_a?(URI::HTTP) || parsed.is_a?(URI::HTTPS)
174
+ raise SecurityError, "URI must be HTTP or HTTPS"
175
+ end
176
+
177
+ raise SecurityError, "URI host cannot be empty" if StringHelpers.blank?(parsed.host)
178
+
179
+ parsed
180
+ end
181
+
182
+ # Validate and normalize acr_values parameter per OIDC Core 1.0 spec
183
+ # acr_values is a space-separated string of ACR values
184
+ # Security: Uses allowed characters approach - only allows printable ASCII characters
185
+ #
186
+ # @param acr_values [String, Array, nil] ACR values in any format
187
+ # @param max_length [Integer] Maximum total length (default: Configuration.config.max_string_length)
188
+ # @param skip_sanitization [Boolean] Skip sanitization if values are already sanitized (default: false)
189
+ # @return [String, nil] Normalized space-separated string or nil
190
+ def self.normalize_acr_values(acr_values, max_length: nil, skip_sanitization: false)
191
+ max_length ||= ::OmniauthOpenidFederation::Configuration.config.max_string_length
192
+ return nil if StringHelpers.blank?(acr_values)
193
+
194
+ case acr_values
195
+ when Array
196
+ values = acr_values.map(&:to_s).map(&:strip).reject { |v| StringHelpers.blank?(v) }
197
+ when String
198
+ trimmed = acr_values.strip
199
+ values = trimmed.split(" ").map(&:strip).reject { |v| StringHelpers.blank?(v) }
200
+ else
201
+ str = acr_values.to_s.strip
202
+ return nil if str.length > max_length
203
+ values = str.split(" ").map(&:strip).reject { |v| StringHelpers.blank?(v) }
204
+ end
205
+
206
+ unless skip_sanitization
207
+ values = values.map { |v| sanitize_request_param(v) }.compact
208
+ end
209
+
210
+ return nil if values.empty?
211
+
212
+ result = values.join(" ")
213
+ return nil if result.length > max_length
214
+
215
+ result
216
+ end
217
+
218
+ # Validate and sanitize client_id per OIDC Core 1.0 spec
219
+ # client_id is REQUIRED and must be a valid string
220
+ #
221
+ # @param client_id [String, nil] Client identifier
222
+ # @return [String] Sanitized client_id
223
+ # @raise [ConfigurationError] If client_id is invalid
224
+ def self.validate_client_id!(client_id)
225
+ if StringHelpers.blank?(client_id)
226
+ raise ConfigurationError, "client_id is REQUIRED per OIDC Core 1.0 spec"
227
+ end
228
+
229
+ str = client_id.to_s.strip
230
+ if str.empty?
231
+ raise ConfigurationError, "client_id cannot be empty after trimming"
232
+ end
233
+
234
+ sanitized = sanitize_request_param(str)
235
+ if StringHelpers.blank?(sanitized)
236
+ raise ConfigurationError, "client_id contains invalid characters"
237
+ end
238
+
239
+ sanitized
240
+ end
241
+
242
+ # Validate and sanitize redirect_uri per OIDC Core 1.0 spec
243
+ # redirect_uri is REQUIRED and must be a valid absolute URI
244
+ #
245
+ # @param redirect_uri [String, nil] Redirect URI
246
+ # @return [String] Validated redirect_uri
247
+ # @raise [ConfigurationError] If redirect_uri is invalid
248
+ def self.validate_redirect_uri!(redirect_uri)
249
+ if StringHelpers.blank?(redirect_uri)
250
+ raise ConfigurationError, "redirect_uri is REQUIRED per OIDC Core 1.0 spec"
251
+ end
252
+
253
+ str = redirect_uri.to_s.strip
254
+ if str.empty?
255
+ raise ConfigurationError, "redirect_uri cannot be empty after trimming"
256
+ end
257
+
258
+ validated = validate_uri_safe!(str, allowed_schemes: ["http", "https"])
259
+ validated.to_s
260
+ end
261
+
262
+ # Validate and sanitize scope per OIDC Core 1.0 spec
263
+ # scope is space-delimited, case-sensitive list of ASCII string values
264
+ # MUST include "openid" scope value
265
+ #
266
+ # @param scope [String, Array, nil] Scope value(s)
267
+ # @param require_openid [Boolean] Whether to require "openid" scope (default: true)
268
+ # @return [String] Normalized space-separated scope string
269
+ # @raise [ConfigurationError] If scope is invalid
270
+ def self.validate_scope!(scope, require_openid: true)
271
+ if StringHelpers.blank?(scope)
272
+ if require_openid
273
+ raise ConfigurationError, "scope is REQUIRED and MUST include 'openid' per OIDC Core 1.0 spec"
274
+ end
275
+ return nil
276
+ end
277
+
278
+ scopes = case scope
279
+ when Array
280
+ scope.map(&:to_s).map(&:strip).reject { |s| StringHelpers.blank?(s) }
281
+ when String
282
+ scope.strip.split(" ").map(&:strip).reject { |s| StringHelpers.blank?(s) }
283
+ else
284
+ scope.to_s.strip.split(" ").map(&:strip).reject { |s| StringHelpers.blank?(s) }
285
+ end
286
+
287
+ scopes = scopes.map { |s| sanitize_request_param(s) }.compact
288
+
289
+ if scopes.empty?
290
+ raise ConfigurationError, "scope cannot be empty after validation"
291
+ end
292
+
293
+ if require_openid && !scopes.include?("openid")
294
+ raise ConfigurationError, "scope MUST include 'openid' per OIDC Core 1.0 spec"
295
+ end
296
+
297
+ result = scopes.join(" ")
298
+ max_length = ::OmniauthOpenidFederation::Configuration.config.max_string_length
299
+ if result.length > max_length
300
+ raise ConfigurationError, "scope exceeds maximum length of #{max_length} characters"
301
+ end
302
+
303
+ result
304
+ end
305
+
306
+ # Validate and sanitize state parameter
307
+ # state is REQUIRED for CSRF protection
308
+ #
309
+ # @param state [String, nil] State value
310
+ # @return [String] Sanitized state value
311
+ # @raise [ConfigurationError] If state is invalid
312
+ def self.validate_state!(state)
313
+ if StringHelpers.blank?(state)
314
+ raise ConfigurationError, "state is REQUIRED for CSRF protection"
315
+ end
316
+
317
+ str = state.to_s.strip
318
+ if str.empty?
319
+ raise ConfigurationError, "state cannot be empty after trimming"
320
+ end
321
+
322
+ sanitized = sanitize_request_param(str)
323
+ if StringHelpers.blank?(sanitized)
324
+ raise ConfigurationError, "state contains invalid characters"
325
+ end
326
+
327
+ sanitized
328
+ end
329
+
330
+ # Validate and sanitize nonce parameter
331
+ # nonce is REQUIRED for Implicit and Hybrid flows, RECOMMENDED for Authorization Code flow
332
+ #
333
+ # @param nonce [String, nil] Nonce value
334
+ # @param required [Boolean] Whether nonce is required (default: false)
335
+ # @return [String, nil] Sanitized nonce value or nil
336
+ # @raise [ConfigurationError] If nonce is required but invalid
337
+ def self.validate_nonce(nonce, required: false)
338
+ return nil unless nonce
339
+
340
+ str = nonce.to_s.strip
341
+ if str.empty?
342
+ if required
343
+ raise ConfigurationError, "nonce is REQUIRED but is empty after trimming"
344
+ end
345
+ return nil
346
+ end
347
+
348
+ sanitized = sanitize_request_param(str)
349
+ if StringHelpers.blank?(sanitized)
350
+ if required
351
+ raise ConfigurationError, "nonce contains invalid characters"
352
+ end
353
+ return nil
354
+ end
355
+
356
+ sanitized
357
+ end
358
+
359
+ # Validate and sanitize response_type per OIDC Core 1.0 spec
360
+ # response_type is REQUIRED and must be a valid value
361
+ #
362
+ # @param response_type [String, nil] Response type
363
+ # @return [String] Validated response_type
364
+ # @raise [ConfigurationError] If response_type is invalid
365
+ def self.validate_response_type!(response_type)
366
+ if StringHelpers.blank?(response_type)
367
+ raise ConfigurationError, "response_type is REQUIRED per OIDC Core 1.0 spec"
368
+ end
369
+
370
+ str = response_type.to_s.strip
371
+ if str.empty?
372
+ raise ConfigurationError, "response_type cannot be empty after trimming"
373
+ end
374
+
375
+ sanitized = sanitize_request_param(str)
376
+ if StringHelpers.blank?(sanitized)
377
+ raise ConfigurationError, "response_type contains invalid characters"
378
+ end
379
+
380
+ valid_types = ["code", "id_token", "token", "id_token token", "code id_token", "code token", "code id_token token"]
381
+ types = sanitized.split(" ").map(&:strip)
382
+ unless types.all? { |t| valid_types.include?(t) || t.match?(/^[a-z_]+$/) }
383
+ raise ConfigurationError, "response_type contains invalid value: #{sanitized}"
384
+ end
385
+
386
+ sanitized
387
+ end
388
+
389
+ # Validate entity identifier per OpenID Federation 1.0 spec
390
+ # Entity identifiers are URIs that identify entities in the federation
391
+ #
392
+ # @param entity_id [String, nil] Entity identifier
393
+ # @param max_length [Integer] Maximum length (default: Configuration.config.max_string_length)
394
+ # @return [String] Validated and trimmed entity identifier
395
+ # @raise [SecurityError] If entity identifier is invalid
396
+ def self.validate_entity_identifier!(entity_id, max_length: nil)
397
+ max_length ||= ::OmniauthOpenidFederation::Configuration.config.max_string_length
398
+ if StringHelpers.blank?(entity_id)
399
+ raise SecurityError, "Entity identifier cannot be nil or empty"
400
+ end
401
+
402
+ str = entity_id.to_s.strip
403
+ if str.empty?
404
+ raise SecurityError, "Entity identifier cannot be empty after trimming"
405
+ end
406
+
407
+ validate_uri_safe!(str, max_length: max_length, allowed_schemes: ["http", "https"])
408
+
409
+ str
410
+ end
125
411
  end
126
412
  end
@@ -1,3 +1,3 @@
1
1
  module OmniauthOpenidFederation
2
- VERSION = "1.2.2".freeze
2
+ VERSION = "1.3.2".freeze
3
3
  end
@@ -28,6 +28,7 @@ end
28
28
 
29
29
  require_relative "omniauth_openid_federation/version"
30
30
  require_relative "omniauth_openid_federation/string_helpers"
31
+ require_relative "omniauth_openid_federation/time_helpers"
31
32
  require_relative "omniauth_openid_federation/logger"
32
33
  require_relative "omniauth_openid_federation/configuration"
33
34
  require_relative "omniauth_openid_federation/errors"
@@ -1,5 +1,6 @@
1
1
  # Rake tasks for OmniAuth OpenID Federation
2
2
  # Thin wrappers around TasksHelper for CLI interface
3
+ require_relative "../omniauth_openid_federation/time_helpers"
3
4
 
4
5
  namespace :openid_federation do
5
6
  desc "Fetch entity statement from OpenID Federation provider"
@@ -262,8 +263,8 @@ namespace :openid_federation do
262
263
  end
263
264
  puts " Issuer: #{metadata[:issuer]}"
264
265
  puts " Subject: #{metadata[:sub]}"
265
- puts " Expires: #{Time.at(metadata[:exp])}" if metadata[:exp]
266
- puts " Issued At: #{Time.at(metadata[:iat])}" if metadata[:iat]
266
+ puts " Expires: #{OmniauthOpenidFederation::TimeHelpers.at(metadata[:exp])}" if metadata[:exp]
267
+ puts " Issued At: #{OmniauthOpenidFederation::TimeHelpers.at(metadata[:iat])}" if metadata[:iat]
267
268
  puts
268
269
 
269
270
  # Key status information
@@ -373,4 +374,302 @@ namespace :openid_federation do
373
374
  exit 1
374
375
  end
375
376
  end
377
+
378
+ desc "Test full OpenID Federation authentication flow with a real provider"
379
+ task :test_authentication_flow, [:login_page_url, :base_url, :provider_acr] => :environment do |_t, args|
380
+ require "omniauth_openid_federation"
381
+ require "cgi"
382
+ require "base64"
383
+ require "openssl"
384
+ require "uri"
385
+ require "time"
386
+
387
+ login_page_url = args[:login_page_url] || ENV["LOGIN_PAGE_URL"]
388
+ base_url = args[:base_url] || ENV["BASE_URL"] || (login_page_url ? URI.parse(login_page_url).tap { |u|
389
+ u.path = ""
390
+ u.query = nil
391
+ u.fragment = nil
392
+ }.to_s : "http://localhost:3000")
393
+ provider_acr = args[:provider_acr] || ENV["PROVIDER_ACR"]
394
+
395
+ # Required configuration from environment
396
+ client_id = ENV["CLIENT_ID"]
397
+ redirect_uri = ENV["REDIRECT_URI"] || "#{base_url}/users/auth/openid_federation/callback"
398
+ private_key_pem = ENV["PRIVATE_KEY"] || (ENV["PRIVATE_KEY_BASE64"] ? Base64.decode64(ENV["PRIVATE_KEY_BASE64"]) : nil)
399
+ private_key_path = ENV["PRIVATE_KEY_PATH"]
400
+
401
+ # Entity statement configuration
402
+ entity_statement_url = ENV["ENTITY_STATEMENT_URL"]
403
+ entity_statement_path = ENV["ENTITY_STATEMENT_PATH"]
404
+ client_entity_statement_url = ENV["CLIENT_ENTITY_STATEMENT_URL"]
405
+ client_entity_statement_path = ENV["CLIENT_ENTITY_STATEMENT_PATH"]
406
+
407
+ puts "=" * 80
408
+ puts "OpenID Federation Authentication Flow Test"
409
+ puts "=" * 80
410
+ puts
411
+ puts "Login Page URL: #{login_page_url || "Not provided"}"
412
+ puts "Base URL: #{base_url}"
413
+ puts "Provider ACR: #{provider_acr || "Not specified (will use default)"}"
414
+ puts
415
+
416
+ # Validate required parameters
417
+ unless login_page_url
418
+ puts "❌ LOGIN_PAGE_URL is required"
419
+ puts " Set it as an environment variable or pass as first argument:"
420
+ puts " rake openid_federation:test_authentication_flow[https://example.com/login]"
421
+ exit 1
422
+ end
423
+
424
+ # Load private key
425
+ private_key = nil
426
+ if private_key_pem
427
+ begin
428
+ private_key = OpenSSL::PKey::RSA.new(private_key_pem)
429
+ rescue => e
430
+ puts "❌ Failed to parse private key from PRIVATE_KEY or PRIVATE_KEY_BASE64: #{e.message}"
431
+ exit 1
432
+ end
433
+ elsif private_key_path
434
+ begin
435
+ private_key = OpenSSL::PKey::RSA.new(File.read(private_key_path))
436
+ rescue => e
437
+ puts "❌ Failed to load private key from #{private_key_path}: #{e.message}"
438
+ exit 1
439
+ end
440
+ end
441
+
442
+ unless private_key
443
+ puts "❌ Private key is required"
444
+ puts " Set one of: PRIVATE_KEY, PRIVATE_KEY_BASE64, or PRIVATE_KEY_PATH"
445
+ exit 1
446
+ end
447
+
448
+ unless client_id
449
+ puts "❌ CLIENT_ID is required"
450
+ puts " Set it as an environment variable: CLIENT_ID=your_client_id"
451
+ exit 1
452
+ end
453
+
454
+ # Try to resolve entity statement if not provided
455
+ unless entity_statement_url || entity_statement_path
456
+ # Try to fetch from well-known endpoint
457
+ begin
458
+ well_known_url = "#{base_url}/.well-known/openid-federation"
459
+ puts "📥 Attempting to fetch entity statement from: #{well_known_url}"
460
+ require "http"
461
+ response = HTTP.timeout(connect: 5, read: 5).get(well_known_url)
462
+ if response.status.success?
463
+ entity_statement_path = "/tmp/entity_statement_#{Time.now.to_i}.json"
464
+ File.write(entity_statement_path, response.body.to_s)
465
+ puts " ✅ Entity statement cached to: #{entity_statement_path}"
466
+ end
467
+ rescue => e
468
+ puts " ⚠️ Could not fetch entity statement: #{e.message}"
469
+ puts " Set ENTITY_STATEMENT_URL or ENTITY_STATEMENT_PATH manually"
470
+ end
471
+ end
472
+
473
+ # Display configuration status
474
+ puts "📋 Configuration Status:"
475
+ puts " ✅ Client ID: #{client_id}"
476
+ puts " ✅ Redirect URI: #{redirect_uri}"
477
+ puts " ✅ Private Key: Loaded"
478
+ if entity_statement_url
479
+ puts " ✅ Provider Entity Statement URL: #{entity_statement_url}"
480
+ elsif entity_statement_path
481
+ puts " ✅ Provider Entity Statement Path: #{entity_statement_path}"
482
+ else
483
+ puts " ⚠️ Provider Entity Statement: Not configured"
484
+ end
485
+ if client_entity_statement_url
486
+ puts " ✅ Client Entity Statement URL: #{client_entity_statement_url}"
487
+ elsif client_entity_statement_path
488
+ puts " ✅ Client Entity Statement Path: #{client_entity_statement_path}"
489
+ end
490
+ puts
491
+
492
+ begin
493
+ # Step 1: Request authorization URL
494
+ puts "=" * 80
495
+ puts "📋 Step 1: Requesting Authorization URL"
496
+ puts "-" * 80
497
+ puts
498
+
499
+ result = OmniauthOpenidFederation::TasksHelper.test_authentication_flow(
500
+ login_page_url: login_page_url,
501
+ base_url: base_url,
502
+ provider_acr: provider_acr
503
+ )
504
+
505
+ if result[:errors].any?
506
+ puts "❌ Errors occurred:"
507
+ result[:errors].each { |error| puts " - #{error}" }
508
+ exit 1
509
+ end
510
+
511
+ puts "✅ CSRF token extracted: #{result[:csrf_token][0..20]}..." if result[:csrf_token]
512
+ puts "✅ Cookies received: #{result[:cookies].length} cookie(s)"
513
+ puts
514
+ puts "✅ Authorization URL received"
515
+ puts
516
+ puts "🔗 Authorization URL:"
517
+ puts result[:authorization_url]
518
+ puts
519
+ puts "📋 Instructions:"
520
+ result[:instructions].each { |instruction| puts " #{instruction}" }
521
+ puts
522
+
523
+ # Step 2: Get callback URL from user
524
+ puts "=" * 80
525
+ puts "📥 Step 2: Waiting for Callback URL"
526
+ puts "-" * 80
527
+ puts
528
+ print "Paste the callback URL here (or press Enter to skip to manual code entry): "
529
+ callback_url = $stdin.gets.chomp
530
+
531
+ if callback_url.empty?
532
+ puts
533
+ print "Enter authorization code manually: "
534
+ auth_code = $stdin.gets.chomp
535
+
536
+ if auth_code.empty?
537
+ puts "❌ No authorization code provided"
538
+ exit 1
539
+ end
540
+
541
+ # Build callback URL with code
542
+ callback_url = "#{base_url}/users/auth/openid_federation/callback?code=#{CGI.escape(auth_code)}"
543
+ end
544
+
545
+ # Step 3: Process callback and validate
546
+ puts
547
+ puts "=" * 80
548
+ puts "🔄 Step 3: Processing Callback and Validating"
549
+ puts "-" * 80
550
+ puts
551
+
552
+ callback_result = OmniauthOpenidFederation::TasksHelper.process_callback_and_validate(
553
+ callback_url: callback_url,
554
+ base_url: base_url,
555
+ entity_statement_url: entity_statement_url,
556
+ entity_statement_path: entity_statement_path,
557
+ client_id: client_id,
558
+ redirect_uri: redirect_uri,
559
+ private_key: private_key,
560
+ provider_acr: provider_acr,
561
+ client_entity_statement_url: client_entity_statement_url,
562
+ client_entity_statement_path: client_entity_statement_path
563
+ )
564
+
565
+ if callback_result[:errors].any?
566
+ puts "❌ Errors occurred:"
567
+ callback_result[:errors].each { |error| puts " - #{error}" }
568
+ exit 1
569
+ end
570
+
571
+ puts "✅ Authorization code extracted"
572
+ puts "✅ Strategy initialized"
573
+ puts "✅ Tokens received"
574
+ puts
575
+ puts "📋 Token Information:"
576
+ callback_result[:token_info].each do |key, value|
577
+ if value
578
+ label = key.to_s.split("_").map(&:capitalize).join(" ")
579
+ puts " #{label}: #{value}"
580
+ end
581
+ end
582
+ puts
583
+
584
+ # Step 4: ID Token validation
585
+ puts "=" * 80
586
+ puts "🔐 Step 4: ID Token Validation"
587
+ puts "-" * 80
588
+ puts
589
+
590
+ if callback_result[:id_token_valid]
591
+ puts "✅ ID token decrypted and validated"
592
+ puts
593
+ puts "📋 ID Token Claims:"
594
+ callback_result[:id_token_claims].each do |key, value|
595
+ if value
596
+ if [:exp, :iat, :auth_time].include?(key)
597
+ time_value = begin
598
+ OmniauthOpenidFederation::TimeHelpers.at(value)
599
+ rescue
600
+ value
601
+ end
602
+ puts " #{key}: #{value} (#{time_value})"
603
+ else
604
+ puts " #{key}: #{value}"
605
+ end
606
+ end
607
+ end
608
+ puts
609
+ puts "✅ All required claims present"
610
+ else
611
+ puts "❌ ID token validation failed"
612
+ end
613
+ puts
614
+
615
+ # Step 5: OpenID Federation Compliance
616
+ puts "=" * 80
617
+ puts "✅ Step 5: OpenID Federation Compliance Check"
618
+ puts "-" * 80
619
+ puts
620
+
621
+ callback_result[:compliance_checks].each do |check_name, check_data|
622
+ status_icon = check_data[:verified] ? "✅" : "❌"
623
+ puts "#{status_icon} #{check_name}"
624
+ puts " Status: #{check_data[:status]}"
625
+ puts " Description: #{check_data[:description]}"
626
+ puts
627
+ end
628
+
629
+ all_verified = callback_result[:all_compliance_verified]
630
+
631
+ if all_verified
632
+ puts "✅ All OpenID Federation requirements verified"
633
+ else
634
+ puts "⚠️ Some requirements not verified"
635
+ end
636
+ puts
637
+
638
+ # Step 6: Summary
639
+ puts "=" * 80
640
+ puts "📊 Test Summary"
641
+ puts "=" * 80
642
+ puts
643
+ puts "Provider ACR: #{provider_acr || "Default"}"
644
+ if callback_result[:id_token_claims][:sub]
645
+ puts "Subject (sub): #{callback_result[:id_token_claims][:sub]}"
646
+ end
647
+ if callback_result[:id_token_claims][:iss]
648
+ puts "Issuer (iss): #{callback_result[:id_token_claims][:iss]}"
649
+ end
650
+ if callback_result[:id_token_claims][:aud]
651
+ puts "Audience (aud): #{callback_result[:id_token_claims][:aud]}"
652
+ end
653
+ if callback_result[:id_token_claims][:acr]
654
+ puts "Authentication Context (acr): #{callback_result[:id_token_claims][:acr]}"
655
+ end
656
+ puts
657
+ puts "OpenID Federation Compliance: #{all_verified ? "✅ PASS" : "❌ FAIL"}"
658
+ puts
659
+ puts "=" * 80
660
+
661
+ if all_verified
662
+ puts "✅ All tests passed! Implementation is compliant."
663
+ exit 0
664
+ else
665
+ puts "⚠️ Some checks failed. Review the output above."
666
+ exit 1
667
+ end
668
+ rescue => e
669
+ puts "❌ Test failed: #{e.message}"
670
+ puts " #{e.class}"
671
+ puts " #{e.backtrace.first(10).join("\n ")}"
672
+ exit 1
673
+ end
674
+ end
376
675
  end
data/sig/federation.rbs CHANGED
@@ -152,14 +152,6 @@ module OmniauthOpenidFederation
152
152
  def self.generate_signed_jwks: () -> String
153
153
  def self.current_jwks: () -> Hash[String, untyped]
154
154
  def self.rack_app: () -> RackEndpoint
155
- def self.mount_routes: (
156
- untyped router,
157
- ?entity_statement_path: String,
158
- ?fetch_path: String,
159
- ?jwks_path: String,
160
- ?signed_jwks_path: String,
161
- ?as: Symbol
162
- ) -> void
163
155
 
164
156
  def self.generate_fresh_keys: (
165
157
  entity_statement_path: String,