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.
@@ -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
- uri = URI(url)
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