omniauth_openid_federation 1.3.2 → 2.0.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.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/README.md +119 -13
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
  5. data/config/polyrun_coverage.yml +15 -0
  6. data/config/polyrun_spec_quality.yml +29 -0
  7. data/examples/README_INTEGRATION_TESTING.md +3 -0
  8. data/examples/integration_test_flow.rb +10 -8
  9. data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
  10. data/examples/jobs/federation_files_generation_job.rb.example +3 -2
  11. data/examples/mock_op_server.rb +4 -5
  12. data/examples/mock_rp_server.rb +3 -3
  13. data/examples/standalone_multiple_endpoints_example.rb +494 -0
  14. data/lib/omniauth_openid_federation/access_token.rb +91 -450
  15. data/lib/omniauth_openid_federation/cache.rb +16 -0
  16. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  17. data/lib/omniauth_openid_federation/constants.rb +3 -0
  18. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  19. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +2 -4
  20. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
  21. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +51 -6
  22. data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
  23. data/lib/omniauth_openid_federation/http_client.rb +145 -32
  24. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  25. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  26. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  27. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  28. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  29. data/lib/omniauth_openid_federation/jws.rb +2 -11
  30. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
  31. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  32. data/lib/omniauth_openid_federation/rack.rb +2 -0
  33. data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
  34. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  35. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  36. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  37. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
  38. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  39. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
  40. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
  41. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  42. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  43. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
  44. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
  45. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
  46. data/lib/omniauth_openid_federation/strategy.rb +27 -1910
  47. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  48. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  49. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  50. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  51. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  52. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
  53. data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
  54. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  55. data/lib/omniauth_openid_federation/utils.rb +10 -2
  56. data/lib/omniauth_openid_federation/validators.rb +43 -8
  57. data/lib/omniauth_openid_federation/version.rb +1 -1
  58. data/lib/omniauth_openid_federation.rb +9 -3
  59. data/lib/tasks/omniauth_openid_federation.rake +1 -2
  60. data/sig/omniauth_openid_federation.rbs +2 -1
  61. data/sig/strategy.rbs +6 -6
  62. metadata +181 -71
@@ -1,3 +1,7 @@
1
+ require "cgi"
2
+ require "json"
3
+ require "base64"
4
+ require "jwt"
1
5
  require_relative "entity_statement"
2
6
  require_relative "entity_statement_validator"
3
7
  require_relative "../http_client"
@@ -5,7 +9,7 @@ require_relative "../logger"
5
9
  require_relative "../errors"
6
10
  require_relative "../utils"
7
11
  require_relative "../string_helpers"
8
- require "cgi"
12
+ require_relative "../key_extractor"
9
13
 
10
14
  # Trust Chain Resolver for OpenID Federation 1.0
11
15
  # @see https://openid.net/specs/openid-federation-1_0.html#section-10 Section 10: Trust Chain Resolution
@@ -49,6 +53,7 @@ module OmniauthOpenidFederation
49
53
  @timeout = timeout
50
54
  @resolved_statements = []
51
55
  @visited_entities = Set.new
56
+ @entity_configurations = {}
52
57
  end
53
58
 
54
59
  # Resolve the trust chain
@@ -83,12 +88,13 @@ module OmniauthOpenidFederation
83
88
  end
84
89
 
85
90
  begin
91
+ issuer_config = fetch_entity_configuration(authority_id)
86
92
  subordinate_statement = fetch_subordinate_statement(
87
93
  issuer: authority_id,
88
- subject: current_entity_id
94
+ subject: current_entity_id,
95
+ issuer_config: issuer_config
89
96
  )
90
97
 
91
- issuer_config = fetch_entity_configuration(authority_id)
92
98
  validate_entity_statement(subordinate_statement, issuer_config)
93
99
 
94
100
  @resolved_statements << subordinate_statement
@@ -143,18 +149,20 @@ module OmniauthOpenidFederation
143
149
  end
144
150
 
145
151
  def fetch_entity_configuration(entity_id)
152
+ return @entity_configurations[entity_id] if @entity_configurations.key?(entity_id)
153
+
146
154
  entity_statement_url = OmniauthOpenidFederation::Utils.build_entity_statement_url(entity_id)
147
155
  OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Fetching Entity Configuration from: #{entity_statement_url}")
148
156
 
149
157
  begin
150
- EntityStatement.fetch!(entity_statement_url, timeout: @timeout)
158
+ config = EntityStatement.fetch!(entity_statement_url, timeout: @timeout)
159
+ @entity_configurations[entity_id] = config
151
160
  rescue OmniauthOpenidFederation::NetworkError => e
152
161
  raise FetchError, "Failed to fetch entity configuration from #{entity_statement_url}: #{e.message}"
153
162
  end
154
163
  end
155
164
 
156
- def fetch_subordinate_statement(issuer:, subject:)
157
- issuer_config = fetch_entity_configuration(issuer)
165
+ def fetch_subordinate_statement(issuer:, subject:, issuer_config:)
158
166
  fetch_endpoint = extract_fetch_endpoint(issuer_config)
159
167
 
160
168
  unless fetch_endpoint
@@ -198,6 +206,43 @@ module OmniauthOpenidFederation
198
206
  issuer_entity_configuration: issuer_config
199
207
  )
200
208
  validator.validate!
209
+ verify_entity_statement_signature!(statement, issuer_config)
210
+ end
211
+
212
+ def verify_entity_statement_signature!(statement, issuer_config)
213
+ jwt_string = statement.entity_statement
214
+ parts = jwt_string.split(".")
215
+ if parts.length != EntityStatementValidator::JWT_PARTS_COUNT
216
+ raise ValidationError, "Invalid JWT format: expected #{EntityStatementValidator::JWT_PARTS_COUNT} parts, got #{parts.length}"
217
+ end
218
+
219
+ header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
220
+ kid = header["kid"] || header[:kid]
221
+
222
+ signing_keys = issuer_signing_keys(statement, issuer_config)
223
+ keys = signing_keys["keys"] || signing_keys[:keys] || []
224
+ signing_key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
225
+
226
+ unless signing_key_data
227
+ raise ValidationError, "Signing key with kid '#{kid}' not found in issuer JWKS for signature verification"
228
+ end
229
+
230
+ public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
231
+ ::JWT.decode(jwt_string, public_key, true, {algorithm: "RS256"})
232
+ rescue ::JWT::DecodeError, ::JWT::VerificationError => e
233
+ error_msg = "Entity statement signature validation failed: #{e.class} - #{e.message}"
234
+ OmniauthOpenidFederation::Logger.error("[TrustChainResolver] #{error_msg}")
235
+ raise SignatureError, error_msg, e.backtrace
236
+ end
237
+
238
+ def issuer_signing_keys(statement, issuer_config)
239
+ if issuer_config.nil?
240
+ parsed = statement.parse
241
+ parsed[:jwks] || parsed["jwks"] || {}
242
+ else
243
+ issuer_parsed = issuer_config.parse
244
+ issuer_parsed[:jwks] || issuer_parsed["jwks"] || {}
245
+ end
201
246
  end
202
247
 
203
248
  def is_trust_anchor?(entity_config)
@@ -462,7 +462,7 @@ module OmniauthOpenidFederation
462
462
  # end
463
463
  # end
464
464
  def rack_app
465
- require_relative "rack_endpoint"
465
+ require_relative "rack"
466
466
  RackEndpoint.new
467
467
  end
468
468
 
@@ -1,8 +1,19 @@
1
+ require "http"
2
+ require "openssl"
1
3
  require_relative "constants"
2
4
 
3
5
  # HTTP client with retry logic and SSL configuration
4
6
  module OmniauthOpenidFederation
5
7
  class HttpClient
8
+ RETRYABLE_ERRORS = [
9
+ HTTP::Error,
10
+ Timeout::Error,
11
+ Errno::ECONNREFUSED,
12
+ Errno::ETIMEDOUT
13
+ ].freeze
14
+
15
+ RETRYABLE_HTTP_STATUS_CODES = [429, 502, 503].freeze
16
+
6
17
  # Execute an HTTP GET request with retry logic
7
18
  #
8
19
  # @param uri [String, URI] The URI to fetch
@@ -10,61 +21,163 @@ module OmniauthOpenidFederation
10
21
  # @option options [Integer] :max_retries Maximum number of retries (default: from config)
11
22
  # @option options [Integer] :retry_delay Base retry delay in seconds (default: from config)
12
23
  # @option options [Integer] :timeout Request timeout in seconds (default: from config)
24
+ # @option options [Integer] :connect_timeout Connect timeout in seconds
25
+ # @option options [Integer] :read_timeout Read timeout in seconds
26
+ # @option options [Hash] :headers Request headers
13
27
  # @return [HTTP::Response] The HTTP response
14
28
  # @raise [NetworkError] If the request fails after all retries
15
29
  def self.get(uri, options = {})
16
- max_retries = options[:max_retries] || Configuration.config.max_retries
17
- retry_delay = options[:retry_delay] || Configuration.config.retry_delay
18
- timeout = options[:timeout] || Configuration.config.http_timeout
30
+ request(:get, uri, options)
31
+ end
32
+
33
+ # Execute an HTTP POST request with retry logic
34
+ #
35
+ # POST defaults to max_retries: 0 because retries are not safe for non-idempotent requests.
36
+ # Pass max_retries explicitly when duplicate POST attempts are acceptable.
37
+ #
38
+ # @param uri [String, URI] The URI to post to
39
+ # @param options [Hash] Request options (same as get, plus :form)
40
+ # @option options [Hash] :form Form parameters
41
+ # @return [HTTP::Response] The HTTP response
42
+ # @raise [NetworkError] If the request fails after all retries
43
+ def self.post(uri, options = {})
44
+ request(:post, uri, options)
45
+ end
19
46
 
20
- http_client = build_http_client(timeout)
47
+ def self.request(method, uri, options = {})
48
+ max_retries = max_retries_for(options, method)
49
+ retry_delay = options[:retry_delay] || Configuration.config.retry_delay
50
+ http_client = build_http_client(resolve_timeout(options))
51
+ headers = options[:headers] || {}
52
+ form = options[:form]
53
+ max_attempts = max_retries + 1
21
54
 
22
55
  retries = 0
23
- begin
24
- http_client.get(uri)
25
- rescue HTTP::Error, Timeout::Error, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
26
- retries += 1
27
- if retries > max_retries
28
- error_msg = "Failed to fetch #{uri} after #{max_retries} retries: #{e.class} - #{e.message}"
29
- OmniauthOpenidFederation::Logger.error("[HttpClient] #{error_msg}")
30
- raise OmniauthOpenidFederation::NetworkError, error_msg, e.backtrace
56
+
57
+ loop do
58
+ response = perform_request(http_client, headers, method, uri, form)
59
+
60
+ if should_retry_for_status?(method, response, retries, max_retries)
61
+ retries = retry_after_status(response, retries, max_attempts, retry_delay)
62
+ next
31
63
  end
32
64
 
33
- delay = [retry_delay * retries, Constants::MAX_RETRY_DELAY_SECONDS].min
34
- OmniauthOpenidFederation::Logger.warn("[HttpClient] Request failed (attempt #{retries}/#{max_retries}), retrying in #{delay}s: #{e.message}")
35
- sleep(delay)
36
- retry
65
+ return response
66
+ rescue *RETRYABLE_ERRORS => error
67
+ retries = handle_network_error(method, uri, error, retries, max_retries, max_attempts, retry_delay)
37
68
  end
38
69
  end
39
70
 
40
- # Build HTTP client with SSL configuration
41
- #
42
- # @param timeout [Integer] Request timeout in seconds
43
- # @return [HTTP::Client] Configured HTTP client
44
- def self.build_http_client(timeout)
45
- http_options_hash = build_http_options_hash || {}
46
- http_options = HTTP::Options.new(http_options_hash)
47
- HTTP::Client.new(http_options).timeout(timeout)
71
+ def self.perform_request(http_client, headers, method, uri, form)
72
+ client = http_client
73
+ client = client.headers(headers) unless headers.empty?
74
+
75
+ case method
76
+ when :get
77
+ client.get(uri)
78
+ when :post
79
+ client.post(uri, form: form || {})
80
+ else
81
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
82
+ end
83
+ end
84
+
85
+ def self.should_retry_for_status?(method, response, retries, max_retries)
86
+ method == :get && retryable_http_status?(response.status.code) && retries < max_retries
87
+ end
88
+
89
+ def self.retry_after_status(response, retries, max_attempts, retry_delay)
90
+ retries += 1
91
+ delay = retry_delay_for(retries, retry_delay)
92
+ OmniauthOpenidFederation::Logger.warn(
93
+ "[HttpClient] HTTP #{response.status.code} on attempt #{retries}/#{max_attempts}, retrying in #{delay}s"
94
+ )
95
+ sleep(delay)
96
+ retries
97
+ end
98
+
99
+ def self.handle_network_error(method, uri, error, retries, max_retries, max_attempts, retry_delay)
100
+ retries += 1
101
+ if retries > max_retries
102
+ error_message = "Failed to #{method.to_s.upcase} #{uri} after #{max_attempts} attempts: #{error.class} - #{error.message}"
103
+ OmniauthOpenidFederation::Logger.error("[HttpClient] #{error_message}")
104
+ raise OmniauthOpenidFederation::NetworkError, error_message, error.backtrace
105
+ end
106
+
107
+ delay = retry_delay_for(retries, retry_delay)
108
+ OmniauthOpenidFederation::Logger.warn(
109
+ "[HttpClient] Request failed on attempt #{retries}/#{max_attempts}, retrying in #{delay}s: #{error.message}"
110
+ )
111
+ sleep(delay)
112
+ retries
113
+ end
114
+
115
+ def self.build_http_client(timeout_config)
116
+ http_options = HTTP::Options.new(build_http_options_hash)
117
+ HTTP::Client.new(http_options).timeout(timeout_config)
48
118
  end
49
119
 
50
- # Build HTTP options hash from configuration
51
- #
52
- # @return [Hash, nil] HTTP options hash or nil
53
120
  def self.build_http_options_hash
54
121
  config = Configuration.config
55
-
56
- # If http_options is configured, use it (can be Hash or Proc)
57
- if config.http_options
122
+ user_options = if config.http_options
58
123
  if config.http_options.is_a?(Proc)
59
124
  config.http_options.call
60
125
  else
61
126
  config.http_options
62
127
  end
63
128
  end
129
+
130
+ options = (user_options || {}).dup
131
+ options[:ssl] = (options[:ssl] || {}).dup
132
+ options[:ssl][:verify_mode] ||= ssl_verify_mode(config.verify_ssl)
133
+ if options[:ssl][:verify_mode] == OpenSSL::SSL::VERIFY_PEER && !options[:ssl][:ca_file]
134
+ ca_file = ssl_ca_file
135
+ options[:ssl][:ca_file] = ca_file if ca_file
136
+ end
137
+ options
138
+ end
139
+
140
+ def self.ssl_verify_mode(verify_ssl)
141
+ verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
142
+ end
143
+
144
+ def self.ssl_ca_file
145
+ if ENV["SSL_CERT_FILE"] && File.file?(ENV["SSL_CERT_FILE"])
146
+ ENV["SSL_CERT_FILE"]
147
+ elsif File.exist?(OpenSSL::X509::DEFAULT_CERT_FILE)
148
+ OpenSSL::X509::DEFAULT_CERT_FILE
149
+ end
150
+ end
151
+
152
+ def self.max_retries_for(options, method)
153
+ return options[:max_retries] if options.key?(:max_retries)
154
+
155
+ (method == :post) ? 0 : Configuration.config.max_retries
64
156
  end
65
157
 
66
- private_class_method :build_http_options_hash
158
+ def self.resolve_timeout(options)
159
+ default_timeout = Configuration.config.http_timeout
160
+
161
+ if options[:connect_timeout] || options[:read_timeout]
162
+ {
163
+ connect: options[:connect_timeout] || default_timeout,
164
+ read: options[:read_timeout] || default_timeout
165
+ }
166
+ else
167
+ options[:timeout] || default_timeout
168
+ end
169
+ end
170
+
171
+ def self.retryable_http_status?(status_code)
172
+ RETRYABLE_HTTP_STATUS_CODES.include?(status_code)
173
+ end
174
+
175
+ def self.retry_delay_for(retry_count, base_delay)
176
+ [base_delay * retry_count, Constants::MAX_RETRY_DELAY_SECONDS].min
177
+ end
67
178
 
68
- private_class_method :build_http_client
179
+ private_class_method :request, :perform_request, :should_retry_for_status?, :retry_after_status,
180
+ :handle_network_error, :build_http_client, :build_http_options_hash, :ssl_verify_mode, :ssl_ca_file,
181
+ :max_retries_for, :resolve_timeout, :retryable_http_status?, :retry_delay_for
69
182
  end
70
183
  end
@@ -0,0 +1,14 @@
1
+ module OmniauthOpenidFederation
2
+ class HttpError < Error
3
+ attr_reader :response
4
+
5
+ def initialize(status, message = nil, response = nil)
6
+ @response = response
7
+ super(message || "HTTP error #{status}")
8
+ end
9
+ end
10
+
11
+ class BadRequest < HttpError; end
12
+ class Unauthorized < HttpError; end
13
+ class Forbidden < HttpError; end
14
+ end
@@ -0,0 +1,19 @@
1
+ module OmniauthOpenidFederation
2
+ class IdToken
3
+ CLAIM_READERS = %i[iss sub aud exp iat nonce acr auth_time amr].freeze
4
+
5
+ attr_reader :raw_attributes
6
+
7
+ def initialize(raw_attributes)
8
+ @raw_attributes = raw_attributes.each_with_object({}) do |(key, value), claims|
9
+ claims[key.to_sym] = value
10
+ end
11
+ end
12
+
13
+ CLAIM_READERS.each do |claim_name|
14
+ define_method(claim_name) do
15
+ raw_attributes[claim_name]
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ require "jwe"
2
+
3
+ require_relative "errors"
4
+
5
+ module OmniauthOpenidFederation
6
+ # Compact-serialization JWE helpers for nested JWTs (sign then encrypt).
7
+ module Jwe
8
+ PARTS_COUNT = 5
9
+
10
+ def self.encrypted?(token)
11
+ token.to_s.count(".") + 1 == PARTS_COUNT
12
+ end
13
+
14
+ def self.encrypt(plaintext, public_key, alg:, enc:)
15
+ ::JWE.encrypt(plaintext.to_s, public_key, alg: alg.to_s, enc: enc.to_s)
16
+ end
17
+
18
+ def self.decrypt(ciphertext, private_key)
19
+ ::JWE.decrypt(ciphertext.to_s, private_key)
20
+ rescue ::JWE::DecodeError, ::JWE::InvalidData, ::JWE::BadCEK, ::JWE::NotImplementedError, ArgumentError => error
21
+ raise OmniauthOpenidFederation::DecryptionError,
22
+ "Failed to decrypt JWE: #{error.message}",
23
+ error.backtrace
24
+ end
25
+ end
26
+ end
@@ -113,19 +113,6 @@ module OmniauthOpenidFederation
113
113
  else
114
114
  raise e
115
115
  end
116
- rescue => e
117
- # Other errors might be due to key rotation
118
- if retried
119
- # If already re-tried to fetch, raise error
120
- error_msg = "JWT decode failed after cache refresh: #{e.class} - #{e.message}"
121
- OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
122
- raise ValidationError, error_msg, e.backtrace
123
- else
124
- OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] JWT decode error (clearing cache and retrying - possible key rotation): #{e.class} - #{e.message}")
125
- # Reset cache to force re-fetching of keys
126
- OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
127
- run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
128
- end
129
116
  end
130
117
  end
131
118
 
@@ -1,4 +1,3 @@
1
- require "http"
2
1
  require "jwt"
3
2
  require "openssl"
4
3
  require_relative "../logger"
@@ -44,52 +43,30 @@ module OmniauthOpenidFederation
44
43
  cache_ttl ||= config.cache_ttl
45
44
  rotate_on_errors = config.rotate_on_errors
46
45
 
47
- # Use cache adapter if available, otherwise fetch directly
48
46
  if CacheAdapter.available?
49
- if force_refresh
50
- # Force refresh: clear cache and fetch fresh
51
- CacheAdapter.delete(cache_key)
52
- end
47
+ CacheAdapter.delete(cache_key) if force_refresh
53
48
 
54
- if cache_ttl.nil?
55
- # Manual rotation: cache forever, only rotate on errors if rotate_on_errors is enabled
56
- begin
57
- CacheAdapter.fetch(cache_key, expires_in: nil) do
58
- fetch_jwks(jwks_uri, entity_statement_keys)
59
- end
60
- rescue KeyRelatedError => e
61
- # Rotate on key-related errors if configured
62
- if rotate_on_errors
63
- OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
64
- CacheAdapter.delete(cache_key)
65
- fetch_jwks(jwks_uri, entity_statement_keys)
66
- else
67
- raise
68
- end
69
- end
70
- else
71
- # TTL-based cache: expires after cache_ttl seconds
72
- # Rotate on errors if configured
73
- begin
74
- CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
75
- fetch_jwks(jwks_uri, entity_statement_keys)
76
- end
77
- rescue KeyRelatedError => e
78
- # Rotate on key-related errors if configured
79
- if rotate_on_errors
80
- OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
81
- CacheAdapter.delete(cache_key)
82
- fetch_jwks(jwks_uri, entity_statement_keys)
83
- else
84
- raise
85
- end
86
- end
49
+ fetch_from_cache(cache_key, cache_ttl: cache_ttl, rotate_on_errors: rotate_on_errors) do
50
+ fetch_jwks(jwks_uri, entity_statement_keys)
87
51
  end
88
52
  else
89
53
  fetch_jwks(jwks_uri, entity_statement_keys)
90
54
  end
91
55
  end
92
56
 
57
+ def self.fetch_from_cache(cache_key, cache_ttl:, rotate_on_errors:)
58
+ CacheAdapter.fetch(cache_key, expires_in: cache_ttl) { yield }
59
+ rescue KeyRelatedError => error
60
+ if rotate_on_errors
61
+ OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{error.message}")
62
+ CacheAdapter.delete(cache_key)
63
+ yield
64
+ else
65
+ raise
66
+ end
67
+ end
68
+ private_class_method :fetch_from_cache
69
+
93
70
  def self.fetch_jwks(jwks_uri, entity_statement_keys)
94
71
  # Rate limiting to prevent DoS
95
72
  unless RateLimiter.allow?(jwks_uri)
@@ -1,5 +1,4 @@
1
1
  require "jwt"
2
- require "jwe"
3
2
  require "securerandom"
4
3
  require "base64"
5
4
  require_relative "string_helpers"
@@ -56,10 +55,6 @@ module OmniauthOpenidFederation
56
55
  # )
57
56
  # signed_jwt = jws.sign
58
57
  class Jws
59
- # Request object expiration constants
60
- REQUEST_OBJECT_EXPIRATION_SECONDS = 600 # 10 minutes in seconds
61
- REQUEST_OBJECT_EXPIRATION_MINUTES = 10
62
-
63
58
  # State generation constants
64
59
  STATE_BYTES = 16 # Number of hex bytes for state parameter
65
60
 
@@ -223,7 +218,7 @@ module OmniauthOpenidFederation
223
218
  response_type: @response_type,
224
219
  scope: @scope,
225
220
  state: state,
226
- exp: (TimeHelpers.now + (defined?(ActiveSupport) ? REQUEST_OBJECT_EXPIRATION_MINUTES.minutes : REQUEST_OBJECT_EXPIRATION_SECONDS)).to_i,
221
+ exp: (TimeHelpers.now + TimeHelpers.request_object_expiration_offset).to_i,
227
222
  jti: SecureRandom.uuid # JWT ID to prevent replay
228
223
  }
229
224
 
@@ -393,11 +388,7 @@ module OmniauthOpenidFederation
393
388
  # Convert JWK to OpenSSL public key
394
389
  public_key = KeyExtractor.jwk_to_openssl_key(encryption_key_data)
395
390
 
396
- # Encrypt the signed JWT using JWE gem
397
- # For JWE, we encrypt the signed JWT string as plaintext
398
- # The pattern is: sign first, then encrypt (nested JWT)
399
- # JWE.encrypt(plaintext, key, alg: "RSA-OAEP", enc: "A128CBC-HS256")
400
- JWE.encrypt(
391
+ OmniauthOpenidFederation::Jwe.encrypt(
401
392
  signed_jwt,
402
393
  public_key,
403
394
  alg: encryption_alg,