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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -1
- data/README.md +210 -708
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +14 -1
- data/config/routes.rb +20 -10
- data/examples/config/initializers/devise.rb.example +44 -55
- data/examples/config/initializers/federation_endpoint.rb.example +2 -2
- data/examples/config/open_id_connect_config.rb.example +12 -15
- data/examples/config/routes.rb.example +9 -5
- data/examples/integration_test_flow.rb +4 -4
- data/examples/mock_op_server.rb +3 -3
- data/examples/mock_rp_server.rb +3 -3
- data/lib/omniauth_openid_federation/configuration.rb +8 -0
- data/lib/omniauth_openid_federation/constants.rb +5 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +6 -87
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +3 -15
- data/lib/omniauth_openid_federation/federation_endpoint.rb +39 -193
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -15
- data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
- data/lib/omniauth_openid_federation/jws.rb +23 -20
- data/lib/omniauth_openid_federation/rack_endpoint.rb +30 -5
- data/lib/omniauth_openid_federation/strategy.rb +143 -194
- data/lib/omniauth_openid_federation/tasks_helper.rb +501 -2
- data/lib/omniauth_openid_federation/time_helpers.rb +60 -0
- data/lib/omniauth_openid_federation/utils.rb +4 -7
- data/lib/omniauth_openid_federation/validators.rb +294 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +1 -0
- data/lib/tasks/omniauth_openid_federation.rake +301 -2
- data/sig/federation.rbs +0 -8
- data/sig/jwks.rbs +0 -6
- data/sig/omniauth_openid_federation.rbs +6 -1
- data/sig/strategy.rbs +0 -2
- metadata +100 -1
|
@@ -13,6 +13,7 @@ require_relative "federation/entity_statement"
|
|
|
13
13
|
require_relative "entity_statement_reader"
|
|
14
14
|
require_relative "jwks/fetch"
|
|
15
15
|
require_relative "federation/signed_jwks"
|
|
16
|
+
require_relative "string_helpers"
|
|
16
17
|
|
|
17
18
|
module OmniauthOpenidFederation
|
|
18
19
|
module TasksHelper
|
|
@@ -275,10 +276,28 @@ module OmniauthOpenidFederation
|
|
|
275
276
|
|
|
276
277
|
else
|
|
277
278
|
# Test other endpoints with simple HTTP GET
|
|
278
|
-
|
|
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
|
|
279
286
|
http = Net::HTTP.new(uri.host, uri.port)
|
|
280
287
|
http.use_ssl = (uri.scheme == "https")
|
|
281
|
-
|
|
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
|
|
282
301
|
|
|
283
302
|
request_path = uri.path
|
|
284
303
|
request_path += "?#{uri.query}" if uri.query
|
|
@@ -423,6 +442,486 @@ module OmniauthOpenidFederation
|
|
|
423
442
|
}
|
|
424
443
|
end
|
|
425
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
|
|
679
|
+
end
|
|
680
|
+
|
|
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
|
+
def self.process_callback_and_validate(
|
|
701
|
+
callback_url:,
|
|
702
|
+
base_url:,
|
|
703
|
+
client_id:, redirect_uri:, private_key:, entity_statement_url: nil,
|
|
704
|
+
entity_statement_path: nil,
|
|
705
|
+
provider_acr: nil,
|
|
706
|
+
client_entity_statement_url: nil,
|
|
707
|
+
client_entity_statement_path: nil
|
|
708
|
+
)
|
|
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
|
|
923
|
+
end
|
|
924
|
+
|
|
426
925
|
private_class_method :generate_single_key, :generate_separate_keys
|
|
427
926
|
end
|
|
428
927
|
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Time helper utilities for compatibility with ActiveSupport
|
|
2
|
+
# Provides time methods that work with or without Time.zone
|
|
3
|
+
module OmniauthOpenidFederation
|
|
4
|
+
module TimeHelpers
|
|
5
|
+
# Get current time, using Time.zone if available, otherwise Time
|
|
6
|
+
#
|
|
7
|
+
# @return [Time] Current time
|
|
8
|
+
def self.now
|
|
9
|
+
if time_zone_available?
|
|
10
|
+
Time.zone.now
|
|
11
|
+
else
|
|
12
|
+
# rubocop:disable Rails/TimeZone
|
|
13
|
+
Time.now
|
|
14
|
+
# rubocop:enable Rails/TimeZone
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Convert a timestamp to Time, using Time.zone if available, otherwise Time
|
|
19
|
+
#
|
|
20
|
+
# @param timestamp [Integer, Float] Unix timestamp
|
|
21
|
+
# @return [Time] Time object representing the timestamp
|
|
22
|
+
def self.at(timestamp)
|
|
23
|
+
if time_zone_available?
|
|
24
|
+
Time.zone.at(timestamp)
|
|
25
|
+
else
|
|
26
|
+
# rubocop:disable Rails/TimeZone
|
|
27
|
+
Time.at(timestamp)
|
|
28
|
+
# rubocop:enable Rails/TimeZone
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Parse a time string, using Time.zone if available, otherwise Time
|
|
33
|
+
#
|
|
34
|
+
# @param time_string [String] Time string to parse
|
|
35
|
+
# @return [Time] Parsed time object
|
|
36
|
+
def self.parse(time_string)
|
|
37
|
+
if time_zone_available?
|
|
38
|
+
Time.zone.parse(time_string)
|
|
39
|
+
else
|
|
40
|
+
# rubocop:disable Rails/TimeZone
|
|
41
|
+
Time.parse(time_string)
|
|
42
|
+
# rubocop:enable Rails/TimeZone
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if Time.zone is available and configured
|
|
47
|
+
#
|
|
48
|
+
# @return [Boolean] true if Time.zone is available and not nil
|
|
49
|
+
def self.time_zone_available?
|
|
50
|
+
return false unless defined?(ActiveSupport)
|
|
51
|
+
return false unless Time.respond_to?(:zone)
|
|
52
|
+
|
|
53
|
+
begin
|
|
54
|
+
!Time.zone.nil?
|
|
55
|
+
rescue
|
|
56
|
+
false
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
# Utility functions for omniauth_openid_federation
|
|
2
|
+
require_relative "string_helpers"
|
|
3
|
+
|
|
2
4
|
module OmniauthOpenidFederation
|
|
3
5
|
module Utils
|
|
4
6
|
# Convert hash to HashWithIndifferentAccess if available
|
|
@@ -18,7 +20,7 @@ module OmniauthOpenidFederation
|
|
|
18
20
|
# @param path [String, nil] The file path
|
|
19
21
|
# @return [String] Sanitized path (filename only)
|
|
20
22
|
def self.sanitize_path(path)
|
|
21
|
-
return "[REDACTED]" if
|
|
23
|
+
return "[REDACTED]" if StringHelpers.blank?(path)
|
|
22
24
|
File.basename(path)
|
|
23
25
|
end
|
|
24
26
|
|
|
@@ -27,7 +29,7 @@ module OmniauthOpenidFederation
|
|
|
27
29
|
# @param uri [String, nil] The URI
|
|
28
30
|
# @return [String] Sanitized URI
|
|
29
31
|
def self.sanitize_uri(uri)
|
|
30
|
-
return "[REDACTED]" if
|
|
32
|
+
return "[REDACTED]" if StringHelpers.blank?(uri)
|
|
31
33
|
begin
|
|
32
34
|
parsed = URI.parse(uri)
|
|
33
35
|
"#{parsed.scheme}://#{parsed.host}/[REDACTED]"
|
|
@@ -70,16 +72,13 @@ module OmniauthOpenidFederation
|
|
|
70
72
|
def self.validate_file_path!(path, allowed_dirs: nil)
|
|
71
73
|
raise SecurityError, "File path cannot be nil" if path.nil?
|
|
72
74
|
|
|
73
|
-
# Convert Pathname to string if needed
|
|
74
75
|
path_str = path.to_s
|
|
75
76
|
raise SecurityError, "File path cannot be empty" if path_str.empty?
|
|
76
77
|
|
|
77
|
-
# Check for path traversal attempts
|
|
78
78
|
if path_str.include?("..") || path_str.include?("~")
|
|
79
79
|
raise SecurityError, "Path traversal detected in: #{sanitize_path(path_str)}"
|
|
80
80
|
end
|
|
81
81
|
|
|
82
|
-
# Resolve to absolute path
|
|
83
82
|
resolved = File.expand_path(path_str)
|
|
84
83
|
|
|
85
84
|
# Validate it's within allowed directories if specified
|
|
@@ -120,7 +119,6 @@ module OmniauthOpenidFederation
|
|
|
120
119
|
n = Base64.urlsafe_encode64(key.n.to_s(2), padding: false)
|
|
121
120
|
e = Base64.urlsafe_encode64(key.e.to_s(2), padding: false)
|
|
122
121
|
|
|
123
|
-
# Generate kid (key ID) from public key
|
|
124
122
|
public_key_pem = key.public_key.to_pem
|
|
125
123
|
kid = Digest::SHA256.hexdigest(public_key_pem)[0, 16]
|
|
126
124
|
|
|
@@ -131,7 +129,6 @@ module OmniauthOpenidFederation
|
|
|
131
129
|
e: e
|
|
132
130
|
}
|
|
133
131
|
|
|
134
|
-
# Add use field if specified
|
|
135
132
|
jwk[:use] = use if use
|
|
136
133
|
|
|
137
134
|
jwk
|