omniauth_openid_federation 1.2.2 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -1
- data/README.md +210 -708
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +13 -0
- 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/lib/omniauth_openid_federation/configuration.rb +8 -0
- data/lib/omniauth_openid_federation/constants.rb +5 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +0 -22
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -15
- data/lib/omniauth_openid_federation/jws.rb +21 -19
- data/lib/omniauth_openid_federation/rack_endpoint.rb +13 -0
- data/lib/omniauth_openid_federation/strategy.rb +143 -194
- data/lib/omniauth_openid_federation/tasks_helper.rb +482 -1
- data/lib/omniauth_openid_federation/validators.rb +316 -6
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/tasks/omniauth_openid_federation.rake +298 -0
- data/sig/federation.rbs +0 -8
- data/sig/jwks.rbs +0 -6
- data/sig/omniauth_openid_federation.rbs +0 -1
- data/sig/strategy.rbs +0 -2
- metadata +1 -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,7 +276,13 @@ 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
|
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
|
|
@@ -423,6 +430,480 @@ module OmniauthOpenidFederation
|
|
|
423
430
|
}
|
|
424
431
|
end
|
|
425
432
|
|
|
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
|
|
661
|
+
end
|
|
662
|
+
|
|
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
|
+
def self.process_callback_and_validate(
|
|
683
|
+
callback_url:,
|
|
684
|
+
base_url:,
|
|
685
|
+
client_id:, redirect_uri:, private_key:, entity_statement_url: nil,
|
|
686
|
+
entity_statement_path: nil,
|
|
687
|
+
provider_acr: nil,
|
|
688
|
+
client_entity_statement_url: nil,
|
|
689
|
+
client_entity_statement_path: nil
|
|
690
|
+
)
|
|
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
|
|
905
|
+
end
|
|
906
|
+
|
|
426
907
|
private_class_method :generate_single_key, :generate_separate_keys
|
|
427
908
|
end
|
|
428
909
|
end
|