omniauth_openid_federation 1.3.2 → 2.1.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/README.md +136 -15
  4. data/SECURITY.md +11 -14
  5. data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
  6. data/config/polyrun_coverage.yml +15 -0
  7. data/config/polyrun_spec_quality.yml +29 -0
  8. data/examples/README_INTEGRATION_TESTING.md +3 -0
  9. data/examples/integration_test_flow.rb +10 -8
  10. data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
  11. data/examples/jobs/federation_files_generation_job.rb.example +3 -2
  12. data/examples/mock_op_server.rb +4 -5
  13. data/examples/mock_rp_server.rb +3 -3
  14. data/examples/standalone_multiple_endpoints_example.rb +494 -0
  15. data/lib/omniauth_openid_federation/access_token.rb +91 -450
  16. data/lib/omniauth_openid_federation/cache.rb +42 -4
  17. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  18. data/lib/omniauth_openid_federation/constants.rb +6 -0
  19. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  20. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +33 -21
  21. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +4 -2
  22. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +85 -20
  23. data/lib/omniauth_openid_federation/federation/trust_chain_signature.rb +62 -0
  24. data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
  25. data/lib/omniauth_openid_federation/http_client.rb +182 -32
  26. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  27. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  28. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  29. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  30. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  31. data/lib/omniauth_openid_federation/jws.rb +2 -11
  32. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +288 -0
  33. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  34. data/lib/omniauth_openid_federation/rack.rb +2 -0
  35. data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
  36. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  37. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  38. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  39. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +133 -0
  40. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  41. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +216 -0
  42. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +432 -0
  43. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  44. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  45. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +262 -0
  46. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +135 -0
  47. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +93 -0
  48. data/lib/omniauth_openid_federation/strategy.rb +30 -1911
  49. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  50. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  51. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  52. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  53. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  54. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
  55. data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
  56. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  57. data/lib/omniauth_openid_federation/utils.rb +11 -3
  58. data/lib/omniauth_openid_federation/validators.rb +43 -8
  59. data/lib/omniauth_openid_federation/version.rb +1 -1
  60. data/lib/omniauth_openid_federation.rb +9 -3
  61. data/lib/tasks/omniauth_openid_federation.rake +1 -2
  62. data/sig/omniauth_openid_federation.rbs +2 -1
  63. data/sig/strategy.rbs +6 -6
  64. metadata +188 -63
@@ -1,4 +1,3 @@
1
- require "net/http"
2
1
  require "digest"
3
2
  require "jwt"
4
3
  require "base64"
@@ -94,9 +93,6 @@ module OmniauthOpenidFederation
94
93
  # @raise [FetchError] If fetching fails
95
94
  # @raise [ValidationError] If validation fails
96
95
  def self.fetch!(url, fingerprint: nil, previous_statement: nil, timeout: 10)
97
- # Use HttpClient for retry logic and configurable SSL verification
98
- # Note: HttpClient uses HTTP gem, but entity statements might need Net::HTTP
99
- # For now, we'll use a simple HTTP.get approach with HttpClient's retry logic
100
96
  begin
101
97
  # Convert URL to URI for HttpClient
102
98
  response = HttpClient.get(url, timeout: timeout)
@@ -39,9 +39,13 @@ module OmniauthOpenidFederation
39
39
  # @param issuer_entity_configuration [Hash, EntityStatement, nil] Optional: Entity Configuration of the issuer
40
40
  # Required for validating Subordinate Statements (when iss != sub)
41
41
  # @param clock_skew_tolerance [Integer, nil] Clock skew tolerance in seconds (default: from config)
42
- def initialize(jwt_string:, issuer_entity_configuration: nil, clock_skew_tolerance: nil)
42
+ def initialize(jwt_string:, issuer_entity_configuration: nil, subject_entity_configuration: nil, clock_skew_tolerance: nil)
43
43
  @jwt_string = jwt_string
44
44
  @issuer_entity_configuration = issuer_entity_configuration
45
+ # Subject Entity Configuration supplies authority_hints for Subordinate Statements.
46
+ # When omitted, issuer_entity_configuration is used for backward compatibility with
47
+ # callers that historically passed the subject config under that name.
48
+ @subject_entity_configuration = subject_entity_configuration
45
49
  @clock_skew_tolerance = clock_skew_tolerance || OmniauthOpenidFederation.config.clock_skew_tolerance
46
50
  @header = nil
47
51
  @payload = nil
@@ -153,32 +157,42 @@ module OmniauthOpenidFederation
153
157
  @is_subordinate_statement = !@is_entity_configuration
154
158
  end
155
159
 
156
- # Step 7: For Subordinate Statements, validate iss matches authority_hints
160
+ # Step 7: For Subordinate Statements, validate iss matches subject's authority_hints
157
161
  def validate_authority_hints
158
- unless @issuer_entity_configuration
159
- # If issuer configuration not provided, we can't validate authority_hints
160
- # This is acceptable for basic validation, but should be done for full validation
161
- OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Cannot validate authority_hints: issuer entity configuration not provided")
162
+ subject_source = @subject_entity_configuration || @issuer_entity_configuration
163
+ unless subject_source
164
+ OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Cannot validate authority_hints: subject entity configuration not provided")
162
165
  return
163
166
  end
164
167
 
165
- issuer_config = if @issuer_entity_configuration.is_a?(Hash)
166
- @issuer_entity_configuration
167
- elsif @issuer_entity_configuration.respond_to?(:parse)
168
- @issuer_entity_configuration.parse
169
- else
170
- raise ValidationError, "Invalid issuer entity configuration format"
171
- end
172
-
173
- authority_hints = issuer_config[:claims]&.fetch("authority_hints", []) ||
174
- issuer_config["claims"]&.fetch("authority_hints", []) ||
175
- issuer_config.fetch("authority_hints", [])
168
+ subject_config = normalize_entity_configuration_hash(subject_source)
169
+ authority_hints = extract_authority_hints_from_config(subject_config)
176
170
 
177
171
  unless authority_hints.is_a?(Array) && authority_hints.include?(@payload["iss"])
178
172
  raise ValidationError, "Subordinate Statement issuer '#{@payload["iss"]}' MUST be listed in the authority_hints array of the subject's Entity Configuration"
179
173
  end
180
174
  end
181
175
 
176
+ def normalize_entity_configuration_hash(config)
177
+ if config.is_a?(Hash)
178
+ config
179
+ elsif config.respond_to?(:parse)
180
+ config.parse
181
+ else
182
+ raise ValidationError, "Invalid entity configuration format"
183
+ end
184
+ end
185
+
186
+ def extract_authority_hints_from_config(config)
187
+ claims = config[:claims] || config["claims"]
188
+ if claims.is_a?(Hash)
189
+ hints = claims[:authority_hints] || claims["authority_hints"]
190
+ return hints if hints
191
+ end
192
+
193
+ config[:authority_hints] || config["authority_hints"] || []
194
+ end
195
+
182
196
  def validate_iat_claim
183
197
  iat = @payload["iat"]
184
198
  if iat.nil?
@@ -319,10 +333,8 @@ module OmniauthOpenidFederation
319
333
  unknown_claims = crit - standard_claims
320
334
 
321
335
  if unknown_claims.any?
322
- # For now, we'll log a warning but not reject
323
- # In a strict implementation, we should reject if we don't understand the claims
324
- # Future enhancement: Add strict_mode option to reject unknown crit claims
325
- OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Entity statement contains crit claim with unknown claims: #{unknown_claims.join(", ")}. These claims MUST be understood and processed.")
336
+ raise ValidationError,
337
+ "Entity statement crit claim contains unknown claims that MUST be understood: #{unknown_claims.join(", ")}"
326
338
  end
327
339
  end
328
340
 
@@ -1,4 +1,3 @@
1
- require "http"
2
1
  require "jwt"
3
2
  require "base64"
4
3
  require "openssl"
@@ -61,7 +60,10 @@ module OmniauthOpenidFederation
61
60
  # @raise [FetchError] If fetching fails
62
61
  # @raise [ValidationError] If validation fails
63
62
  def self.fetch!(signed_jwks_uri, entity_jwks, cache_key: nil, cache_ttl: nil, force_refresh: false)
64
- cache_key ||= OmniauthOpenidFederation::Cache.key_for_signed_jwks(signed_jwks_uri)
63
+ cache_key ||= OmniauthOpenidFederation::Cache.key_for_signed_jwks(
64
+ signed_jwks_uri,
65
+ entity_jwks: entity_jwks
66
+ )
65
67
  config = OmniauthOpenidFederation::Configuration.config
66
68
  cache_ttl ||= config.cache_ttl
67
69
  rotate_on_errors = config.rotate_on_errors
@@ -1,3 +1,9 @@
1
+ require_relative "trust_chain_signature"
2
+
3
+ require "cgi"
4
+ require "json"
5
+ require "base64"
6
+ require "jwt"
1
7
  require_relative "entity_statement"
2
8
  require_relative "entity_statement_validator"
3
9
  require_relative "../http_client"
@@ -5,7 +11,7 @@ require_relative "../logger"
5
11
  require_relative "../errors"
6
12
  require_relative "../utils"
7
13
  require_relative "../string_helpers"
8
- require "cgi"
14
+ require_relative "../key_extractor"
9
15
 
10
16
  # Trust Chain Resolver for OpenID Federation 1.0
11
17
  # @see https://openid.net/specs/openid-federation-1_0.html#section-10 Section 10: Trust Chain Resolution
@@ -33,6 +39,8 @@ module OmniauthOpenidFederation
33
39
  #
34
40
  # Resolves and validates trust chains from a Leaf Entity to a Trust Anchor.
35
41
  class TrustChainResolver
42
+ include TrustChainSignature
43
+
36
44
  # Initialize resolver
37
45
  #
38
46
  # @param leaf_entity_id [String] Entity Identifier of the Leaf Entity
@@ -49,6 +57,7 @@ module OmniauthOpenidFederation
49
57
  @timeout = timeout
50
58
  @resolved_statements = []
51
59
  @visited_entities = Set.new
60
+ @entity_configurations = {}
52
61
  end
53
62
 
54
63
  # Resolve the trust chain
@@ -67,7 +76,7 @@ module OmniauthOpenidFederation
67
76
  current_entity_id = @leaf_entity_id
68
77
  current_config = leaf_config
69
78
 
70
- while current_config && !is_trust_anchor?(current_config)
79
+ while current_config && !trust_anchor_for(current_entity_id)
71
80
  authority_hints = extract_authority_hints(current_config)
72
81
 
73
82
  if StringHelpers.blank?(authority_hints)
@@ -83,13 +92,22 @@ module OmniauthOpenidFederation
83
92
  end
84
93
 
85
94
  begin
95
+ issuer_config = fetch_entity_configuration(authority_id)
96
+ validate_entity_statement(issuer_config, nil)
97
+
86
98
  subordinate_statement = fetch_subordinate_statement(
87
99
  issuer: authority_id,
88
- subject: current_entity_id
100
+ subject: current_entity_id,
101
+ issuer_config: issuer_config
89
102
  )
90
103
 
91
- issuer_config = fetch_entity_configuration(authority_id)
92
- validate_entity_statement(subordinate_statement, issuer_config)
104
+ validate_entity_statement(
105
+ subordinate_statement,
106
+ issuer_config,
107
+ subject_config: current_config,
108
+ expected_issuer: authority_id,
109
+ expected_subject: current_entity_id
110
+ )
93
111
 
94
112
  @resolved_statements << subordinate_statement
95
113
  @visited_entities.add(authority_id)
@@ -98,7 +116,8 @@ module OmniauthOpenidFederation
98
116
  current_config = issuer_config
99
117
  found_next = true
100
118
  break
101
- rescue ValidationError, FetchError => e
119
+ rescue ValidationError, FetchError, SignatureError => e
120
+ @visited_entities.add(authority_id)
102
121
  OmniauthOpenidFederation::Logger.warn("[TrustChainResolver] Failed to resolve via #{authority_id}: #{e.message}")
103
122
  OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
104
123
  entity_id: current_entity_id,
@@ -116,7 +135,7 @@ module OmniauthOpenidFederation
116
135
  end
117
136
  end
118
137
 
119
- unless is_trust_anchor?(current_config)
138
+ unless trust_anchor_for(current_entity_id)
120
139
  error_msg = "Trust chain did not terminate at a configured Trust Anchor"
121
140
  OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
122
141
  entity_id: @leaf_entity_id,
@@ -143,18 +162,32 @@ module OmniauthOpenidFederation
143
162
  end
144
163
 
145
164
  def fetch_entity_configuration(entity_id)
165
+ return @entity_configurations[entity_id] if @entity_configurations.key?(entity_id)
166
+
146
167
  entity_statement_url = OmniauthOpenidFederation::Utils.build_entity_statement_url(entity_id)
147
168
  OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Fetching Entity Configuration from: #{entity_statement_url}")
148
169
 
149
170
  begin
150
- EntityStatement.fetch!(entity_statement_url, timeout: @timeout)
171
+ config = EntityStatement.fetch!(entity_statement_url, timeout: @timeout)
172
+ assert_entity_configuration_ids!(config, entity_id)
173
+ @entity_configurations[entity_id] = config
151
174
  rescue OmniauthOpenidFederation::NetworkError => e
152
175
  raise FetchError, "Failed to fetch entity configuration from #{entity_statement_url}: #{e.message}"
153
176
  end
154
177
  end
155
178
 
156
- def fetch_subordinate_statement(issuer:, subject:)
157
- issuer_config = fetch_entity_configuration(issuer)
179
+ def assert_entity_configuration_ids!(config, expected_entity_id)
180
+ parsed = config.parse
181
+ iss = entity_issuer(parsed)
182
+ sub = field(parsed, :sub)
183
+
184
+ unless iss == expected_entity_id && sub == expected_entity_id
185
+ raise ValidationError,
186
+ "Entity configuration iss/sub must equal requested entity id '#{expected_entity_id}' (iss=#{iss}, sub=#{sub})"
187
+ end
188
+ end
189
+
190
+ def fetch_subordinate_statement(issuer:, subject:, issuer_config:)
158
191
  fetch_endpoint = extract_fetch_endpoint(issuer_config)
159
192
 
160
193
  unless fetch_endpoint
@@ -178,10 +211,17 @@ module OmniauthOpenidFederation
178
211
  end
179
212
 
180
213
  def extract_fetch_endpoint(entity_config)
181
- # Fetch endpoint is typically at /.well-known/openid-federation/fetch
182
- # or can be specified in metadata
183
214
  parsed = entity_config.parse
184
- issuer = parsed[:issuer] || parsed[:iss] || parsed["issuer"] || parsed["iss"]
215
+ payload = entity_config.decode_payload
216
+ metadata = field(payload, :metadata) || {}
217
+ federation_entity = field(metadata, :federation_entity) || {}
218
+ openid_provider = field(metadata, :openid_provider) || {}
219
+
220
+ endpoint = field(federation_entity, :federation_fetch_endpoint) || field(openid_provider, :federation_fetch_endpoint)
221
+
222
+ return endpoint if StringHelpers.present?(endpoint)
223
+
224
+ issuer = entity_issuer(parsed)
185
225
  return nil unless issuer
186
226
 
187
227
  "#{issuer}/.well-known/openid-federation/fetch"
@@ -192,21 +232,46 @@ module OmniauthOpenidFederation
192
232
  parsed[:authority_hints] || parsed["authority_hints"]
193
233
  end
194
234
 
195
- def validate_entity_statement(statement, issuer_config)
235
+ def validate_entity_statement(statement, issuer_config, subject_config: nil, expected_issuer: nil, expected_subject: nil)
196
236
  validator = EntityStatementValidator.new(
197
237
  jwt_string: statement.entity_statement,
198
- issuer_entity_configuration: issuer_config
238
+ issuer_entity_configuration: issuer_config,
239
+ subject_entity_configuration: subject_config
199
240
  )
200
241
  validator.validate!
242
+
243
+ if expected_issuer || expected_subject
244
+ parsed = statement.parse
245
+ iss = entity_issuer(parsed)
246
+ sub = parsed[:sub] || parsed["sub"]
247
+
248
+ if expected_issuer && iss != expected_issuer
249
+ raise ValidationError, "Subordinate statement iss '#{iss}' does not match expected issuer '#{expected_issuer}'"
250
+ end
251
+ if expected_subject && sub != expected_subject
252
+ raise ValidationError, "Subordinate statement sub '#{sub}' does not match expected subject '#{expected_subject}'"
253
+ end
254
+ end
255
+
256
+ verify_entity_statement_signature!(statement, issuer_config)
257
+ end
258
+
259
+ def normalize_jwks(jwks)
260
+ return {} if jwks.nil?
261
+ return jwks if jwks.is_a?(Hash)
262
+ return {"keys" => jwks} if jwks.is_a?(Array)
263
+
264
+ {}
265
+ end
266
+
267
+ def trust_anchor_for(entity_id)
268
+ @trust_anchors.find { |ta| ta[:entity_id] == entity_id }
201
269
  end
202
270
 
203
271
  def is_trust_anchor?(entity_config)
204
272
  parsed = entity_config.parse
205
- entity_id = parsed[:issuer] || parsed[:iss] || parsed["issuer"] || parsed["iss"]
206
-
207
- @trust_anchors.any? do |ta|
208
- ta[:entity_id] == entity_id
209
- end
273
+ entity_id = entity_issuer(parsed)
274
+ !trust_anchor_for(entity_id).nil?
210
275
  end
211
276
  end
212
277
  end
@@ -0,0 +1,62 @@
1
+ module OmniauthOpenidFederation
2
+ module Federation
3
+ module TrustChainSignature
4
+ private
5
+
6
+ def verify_entity_statement_signature!(statement, issuer_config)
7
+ jwt_string = statement.entity_statement
8
+ kid, algorithm = signature_header(jwt_string)
9
+ public_key = signing_public_key(statement, issuer_config, kid)
10
+ ::JWT.decode(jwt_string, public_key, true, {algorithm: algorithm})
11
+ rescue ::JWT::DecodeError, ::JWT::VerificationError => e
12
+ error_msg = "Entity statement signature validation failed: #{e.class} - #{e.message}"
13
+ OmniauthOpenidFederation::Logger.error("[TrustChainResolver] #{error_msg}")
14
+ raise SignatureError, error_msg, e.backtrace
15
+ end
16
+
17
+ def signature_header(jwt_string)
18
+ parts = jwt_string.split(".")
19
+ if parts.length != EntityStatementValidator::JWT_PARTS_COUNT
20
+ raise ValidationError, "Invalid JWT format: expected #{EntityStatementValidator::JWT_PARTS_COUNT} parts, got #{parts.length}"
21
+ end
22
+
23
+ header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
24
+ kid = field(header, :kid)
25
+ algorithm = field(header, :alg) || "RS256"
26
+
27
+ unless EntityStatementValidator::SUPPORTED_ALGORITHMS.include?(algorithm)
28
+ raise ValidationError, "Unsupported entity statement algorithm: #{algorithm}"
29
+ end
30
+
31
+ [kid, algorithm]
32
+ end
33
+
34
+ def signing_public_key(statement, issuer_config, kid)
35
+ signing_keys = issuer_signing_keys(statement, issuer_config)
36
+ keys = field(signing_keys, :keys) || []
37
+ signing_key_data = keys.find { |key| field(key, :kid) == kid }
38
+ unless signing_key_data
39
+ raise ValidationError, "Signing key with kid '#{kid}' not found in issuer JWKS for signature verification"
40
+ end
41
+
42
+ OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
43
+ end
44
+
45
+ def issuer_signing_keys(statement, issuer_config)
46
+ parsed = (issuer_config || statement).parse
47
+ trust_anchor = trust_anchor_for(entity_issuer(parsed))
48
+ return normalize_jwks(trust_anchor[:jwks]) if trust_anchor
49
+
50
+ field(parsed, :jwks) || {}
51
+ end
52
+
53
+ def entity_issuer(parsed)
54
+ parsed[:issuer] || parsed[:iss] || parsed["issuer"] || parsed["iss"]
55
+ end
56
+
57
+ def field(hash, key)
58
+ hash[key] || hash[key.to_s]
59
+ end
60
+ end
61
+ end
62
+ end
@@ -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,200 @@ 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 enforce_body_limit!(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.enforce_body_limit!(response)
72
+ max_bytes = Constants::MAX_HTTP_RESPONSE_BODY_BYTES
73
+ content_length = response.headers&.[]("Content-Length") || response.headers&.[]("content-length")
74
+ if content_length && content_length.to_i > max_bytes
75
+ raise OmniauthOpenidFederation::NetworkError,
76
+ "HTTP response Content-Length exceeds limit (#{max_bytes} bytes)"
77
+ end
78
+
79
+ response = buffer_streamed_response(response, max_bytes) if response.body.is_a?(HTTP::Response::Body)
80
+ body = response.body
81
+ size = if body.respond_to?(:bytesize)
82
+ body.bytesize
83
+ else
84
+ body.to_s.bytesize
85
+ end
86
+
87
+ if size > max_bytes
88
+ raise OmniauthOpenidFederation::NetworkError,
89
+ "HTTP response body exceeds limit (#{max_bytes} bytes)"
90
+ end
91
+ response
92
+ end
93
+
94
+ def self.buffer_streamed_response(response, max_bytes)
95
+ contents = String.new(encoding: response.body.encoding)
96
+ response.body.each do |chunk|
97
+ if contents.bytesize + chunk.bytesize > max_bytes
98
+ response.connection.close
99
+ raise OmniauthOpenidFederation::NetworkError, "HTTP response body exceeds limit (#{max_bytes} bytes)"
100
+ end
101
+ contents << chunk
102
+ end
103
+ HTTP::Response.new(status: response.status.code, version: response.version,
104
+ headers: response.headers, proxy_headers: response.proxy_headers,
105
+ request: response.request, body: contents)
106
+ end
107
+
108
+ def self.perform_request(http_client, headers, method, uri, form)
109
+ client = http_client
110
+ client = client.headers(headers) unless headers.empty?
111
+
112
+ case method
113
+ when :get
114
+ client.get(uri)
115
+ when :post
116
+ client.post(uri, form: form || {})
117
+ else
118
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
119
+ end
120
+ end
121
+
122
+ def self.should_retry_for_status?(method, response, retries, max_retries)
123
+ method == :get && retryable_http_status?(response.status.code) && retries < max_retries
124
+ end
125
+
126
+ def self.retry_after_status(response, retries, max_attempts, retry_delay)
127
+ retries += 1
128
+ delay = retry_delay_for(retries, retry_delay)
129
+ OmniauthOpenidFederation::Logger.warn(
130
+ "[HttpClient] HTTP #{response.status.code} on attempt #{retries}/#{max_attempts}, retrying in #{delay}s"
131
+ )
132
+ sleep(delay)
133
+ retries
134
+ end
135
+
136
+ def self.handle_network_error(method, uri, error, retries, max_retries, max_attempts, retry_delay)
137
+ retries += 1
138
+ if retries > max_retries
139
+ error_message = "Failed to #{method.to_s.upcase} #{uri} after #{max_attempts} attempts: #{error.class} - #{error.message}"
140
+ OmniauthOpenidFederation::Logger.error("[HttpClient] #{error_message}")
141
+ raise OmniauthOpenidFederation::NetworkError, error_message, error.backtrace
142
+ end
143
+
144
+ delay = retry_delay_for(retries, retry_delay)
145
+ OmniauthOpenidFederation::Logger.warn(
146
+ "[HttpClient] Request failed on attempt #{retries}/#{max_attempts}, retrying in #{delay}s: #{error.message}"
147
+ )
148
+ sleep(delay)
149
+ retries
150
+ end
151
+
152
+ def self.build_http_client(timeout_config)
153
+ http_options = HTTP::Options.new(build_http_options_hash)
154
+ HTTP::Client.new(http_options).timeout(timeout_config)
48
155
  end
49
156
 
50
- # Build HTTP options hash from configuration
51
- #
52
- # @return [Hash, nil] HTTP options hash or nil
53
157
  def self.build_http_options_hash
54
158
  config = Configuration.config
55
-
56
- # If http_options is configured, use it (can be Hash or Proc)
57
- if config.http_options
159
+ user_options = if config.http_options
58
160
  if config.http_options.is_a?(Proc)
59
161
  config.http_options.call
60
162
  else
61
163
  config.http_options
62
164
  end
63
165
  end
166
+
167
+ options = (user_options || {}).dup
168
+ options[:ssl] = (options[:ssl] || {}).dup
169
+ options[:ssl][:verify_mode] ||= ssl_verify_mode(config.verify_ssl)
170
+ if options[:ssl][:verify_mode] == OpenSSL::SSL::VERIFY_PEER && !options[:ssl][:ca_file]
171
+ ca_file = ssl_ca_file
172
+ options[:ssl][:ca_file] = ca_file if ca_file
173
+ end
174
+ options
175
+ end
176
+
177
+ def self.ssl_verify_mode(verify_ssl)
178
+ verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
179
+ end
180
+
181
+ def self.ssl_ca_file
182
+ if ENV["SSL_CERT_FILE"] && File.file?(ENV["SSL_CERT_FILE"])
183
+ ENV["SSL_CERT_FILE"]
184
+ elsif File.exist?(OpenSSL::X509::DEFAULT_CERT_FILE)
185
+ OpenSSL::X509::DEFAULT_CERT_FILE
186
+ end
187
+ end
188
+
189
+ def self.max_retries_for(options, method)
190
+ return options[:max_retries] if options.key?(:max_retries)
191
+
192
+ (method == :post) ? 0 : Configuration.config.max_retries
193
+ end
194
+
195
+ def self.resolve_timeout(options)
196
+ default_timeout = Configuration.config.http_timeout
197
+
198
+ if options[:connect_timeout] || options[:read_timeout]
199
+ {
200
+ connect: options[:connect_timeout] || default_timeout,
201
+ read: options[:read_timeout] || default_timeout
202
+ }
203
+ else
204
+ options[:timeout] || default_timeout
205
+ end
64
206
  end
65
207
 
66
- private_class_method :build_http_options_hash
208
+ def self.retryable_http_status?(status_code)
209
+ RETRYABLE_HTTP_STATUS_CODES.include?(status_code)
210
+ end
211
+
212
+ def self.retry_delay_for(retry_count, base_delay)
213
+ [base_delay * retry_count, Constants::MAX_RETRY_DELAY_SECONDS].min
214
+ end
67
215
 
68
- private_class_method :build_http_client
216
+ private_class_method :request, :perform_request, :should_retry_for_status?, :retry_after_status,
217
+ :handle_network_error, :build_http_client, :build_http_options_hash, :ssl_verify_mode, :ssl_ca_file,
218
+ :max_retries_for, :resolve_timeout, :retryable_http_status?, :retry_delay_for, :enforce_body_limit!, :buffer_streamed_response
69
219
  end
70
220
  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