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
@@ -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
- 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
- http.verify_mode = OpenSSL::SSL::VERIFY_NONE if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
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 path.nil? || path.empty?
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 uri.nil? || uri.empty?
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