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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +51 -1
- data/README.md +136 -15
- data/SECURITY.md +11 -14
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
- data/config/polyrun_coverage.yml +15 -0
- data/config/polyrun_spec_quality.yml +29 -0
- data/examples/README_INTEGRATION_TESTING.md +3 -0
- data/examples/integration_test_flow.rb +10 -8
- data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
- data/examples/jobs/federation_files_generation_job.rb.example +3 -2
- data/examples/mock_op_server.rb +4 -5
- data/examples/mock_rp_server.rb +3 -3
- data/examples/standalone_multiple_endpoints_example.rb +494 -0
- data/lib/omniauth_openid_federation/access_token.rb +91 -450
- data/lib/omniauth_openid_federation/cache.rb +42 -4
- data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
- data/lib/omniauth_openid_federation/constants.rb +6 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +33 -21
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +4 -2
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +85 -20
- data/lib/omniauth_openid_federation/federation/trust_chain_signature.rb +62 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
- data/lib/omniauth_openid_federation/http_client.rb +182 -32
- data/lib/omniauth_openid_federation/http_errors.rb +14 -0
- data/lib/omniauth_openid_federation/id_token.rb +19 -0
- data/lib/omniauth_openid_federation/jwe.rb +26 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
- data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
- data/lib/omniauth_openid_federation/jws.rb +2 -11
- data/lib/omniauth_openid_federation/jwt_response_decoder.rb +288 -0
- data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
- data/lib/omniauth_openid_federation/rack.rb +2 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
- data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
- data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
- data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
- data/lib/omniauth_openid_federation/strategy/callback_handling.rb +133 -0
- data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
- data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +216 -0
- data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +432 -0
- data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
- data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +262 -0
- data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +135 -0
- data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +93 -0
- data/lib/omniauth_openid_federation/strategy.rb +30 -1911
- data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
- data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
- data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
- data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
- data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
- data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
- data/lib/omniauth_openid_federation/user_info.rb +15 -0
- data/lib/omniauth_openid_federation/utils.rb +11 -3
- data/lib/omniauth_openid_federation/validators.rb +43 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +9 -3
- data/lib/tasks/omniauth_openid_federation.rake +1 -2
- data/sig/omniauth_openid_federation.rbs +2 -1
- data/sig/strategy.rbs +6 -6
- 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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
166
|
-
|
|
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
|
-
|
|
323
|
-
|
|
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(
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
92
|
-
|
|
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
|
|
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
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if retries
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
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 :
|
|
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
|