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,150 +1,110 @@
1
- require "jwt"
2
- require "jwe"
3
1
  require "json"
4
- require "uri"
5
2
  require_relative "string_helpers"
6
3
  require_relative "logger"
7
4
  require_relative "validators"
8
5
  require_relative "utils"
9
- require_relative "key_extractor"
6
+ require_relative "http_errors"
7
+ require_relative "jwt_response_decoder"
10
8
 
11
- # AccessToken extension for OpenID Federation ID token decryption and signed JWKS support
12
- # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
13
- # @see https://openid.net/specs/openid-connect-core-1_0.html OpenID Connect Core 1.0
14
- #
15
- # Extends OpenIDConnect::AccessToken to support:
16
- # - ID Token decryption (RSA-OAEP + A128CBC-HS256) - Required for token security
17
- # - Signed JWKS validation - Required for key rotation compliance
18
- # - Entity statement key loading for signed JWKS validation
19
- #
20
- # This extension is automatically loaded when the omniauth_openid_federation library is required.
21
- module OpenIDConnect
9
+ module OmniauthOpenidFederation
22
10
  class AccessToken
23
- def resource_request
24
- res = yield
25
- status_code = if res.status.is_a?(Integer)
26
- res.status
27
- else
28
- (res.status.respond_to?(:code) ? res.status.code : res.status)
11
+ attr_reader :access_token, :refresh_token, :expires_in, :id_token, :client
12
+
13
+ def initialize(access_token:, client:, refresh_token: nil, expires_in: nil, id_token: nil,
14
+ strategy_options: nil, oauth_token: nil)
15
+ @access_token = access_token
16
+ @refresh_token = refresh_token
17
+ @expires_in = expires_in
18
+ @id_token = id_token
19
+ @client = client
20
+ @strategy_options = strategy_options
21
+ @oauth_token = oauth_token
22
+ end
23
+
24
+ def self.from_oauth2_token(oauth_token, strategy_options: nil)
25
+ token_params = oauth_token.params || {}
26
+ id_token_value = token_params["id_token"] || token_params[:id_token]
27
+
28
+ new(
29
+ access_token: oauth_token.token,
30
+ refresh_token: oauth_token.refresh_token,
31
+ expires_in: oauth_token.expires_in,
32
+ id_token: id_token_value,
33
+ client: oauth_token.client,
34
+ strategy_options: strategy_options,
35
+ oauth_token: oauth_token
36
+ )
37
+ end
38
+
39
+ def userinfo!(params = {}, http_method: :get, headers: {})
40
+ unless [:get, :post].include?(http_method)
41
+ raise ArgumentError, "http_method must be :get or :post"
29
42
  end
30
- case status_code
31
- when 200
32
- # Simple check if the response looks like a JWT string (could be ID token or encrypted userinfo)
33
- if /\A[\w\-.]+\z/.match?(res.body)
34
- # Check if it's encrypted (JWE format has 5 parts separated by dots)
35
- is_encrypted = res.body.split(".").length == 5
36
43
 
37
- if is_encrypted
38
- # Decrypt if encrypted (ID token or userinfo encryption)
39
- # Use encryption key from JWKS if available, fallback to private_key
40
- encryption_key = extract_encryption_key_for_decryption
41
- # Decrypt using JWE gem
42
- plain_text = JWE.decrypt(res.body, encryption_key)
44
+ endpoint = client.userinfo_endpoint
45
+ unless OmniauthOpenidFederation::StringHelpers.present?(endpoint)
46
+ raise ConfigurationError, "Userinfo endpoint not configured"
47
+ end
43
48
 
44
- # Check if plain_text is a JWT (3 parts) or JSON payload
45
- # For nested JWTs: encrypted JWT contains signed JWT as plaintext
46
- # For direct encryption: encrypted JWT may contain JWT representation of payload
47
- if plain_text.split(".").length == 3
48
- # It's a JWT (signed or unsigned) - decode it
49
- signed_jwt = plain_text
50
- else
51
- # Try to parse as JSON, if that fails, it might be a malformed JWT
52
- begin
53
- return JSON.parse(plain_text).with_indifferent_access
54
- rescue JSON::ParserError
55
- # If parsing fails, treat as JWT and try to decode
56
- signed_jwt = plain_text
57
- end
58
- end
59
- else
60
- # Not encrypted, use body directly (signed JWT)
61
- signed_jwt = res.body
62
- end
49
+ unless @oauth_token
50
+ raise ConfigurationError, "OAuth token reference not available for userinfo request"
51
+ end
63
52
 
64
- # Try to get options from strategy configuration
65
- strategy_options = get_strategy_options
53
+ response = case http_method
54
+ when :get
55
+ @oauth_token.get(endpoint.to_s, params: params, headers: headers)
56
+ when :post
57
+ @oauth_token.post(
58
+ endpoint.to_s,
59
+ body: params,
60
+ headers: headers.merge("Content-Type" => "application/x-www-form-urlencoded")
61
+ )
62
+ end
66
63
 
67
- # Access client_options from the options hash and normalize keys
68
- raw_client_options = strategy_options[:client_options] || strategy_options["client_options"] || {}
69
- client_options = OmniauthOpenidFederation::Validators.normalize_hash(raw_client_options)
64
+ claims = parse_userinfo_response(response)
65
+ OmniauthOpenidFederation::UserInfo.new(claims)
66
+ end
70
67
 
71
- # Get jwks_uri from client_options or fallback to client
72
- jwks_uri_value = client_options[:jwks_uri] || ((respond_to?(:client) && client&.respond_to?(:jwks_uri)) ? client.jwks_uri : nil)
68
+ def decode_response_body(body)
69
+ JwtResponseDecoder.new(strategy_options: get_strategy_options, client: client).decode(body)
70
+ end
73
71
 
74
- jwks_uri =
75
- if jwks_uri_value && %r{https?://.+}.match?(jwks_uri_value.to_s)
76
- URI.parse(jwks_uri_value.to_s)
77
- elsif jwks_uri_value
78
- URI::HTTPS.build(
79
- host: client_options[:host] || ((respond_to?(:client) && client&.respond_to?(:host)) ? client.host : nil),
80
- path: jwks_uri_value.to_s
81
- )
82
- else
83
- # If we can't determine jwks_uri, we'll need to handle this in the JWT decode
84
- nil
85
- end
72
+ private
86
73
 
87
- # Always use federation features (signed JWKS preferred, fallback to standard JWKS)
88
- normalized_strategy_options = OmniauthOpenidFederation::Validators.normalize_hash(strategy_options)
74
+ def parse_userinfo_response(response)
75
+ body = response.respond_to?(:parsed) ? response.parsed : nil
76
+ if body.is_a?(Hash)
77
+ return body
78
+ end
89
79
 
90
- # Check if JWT is signed or unsigned
91
- # Decode header to check algorithm
92
- begin
93
- header_part = signed_jwt.split(".").first
94
- header = JSON.parse(Base64.urlsafe_decode64(header_part))
95
- algorithm = header["alg"] || header[:alg]
80
+ body_text =
81
+ if body.is_a?(String) && OmniauthOpenidFederation::StringHelpers.present?(body)
82
+ body
83
+ elsif response.respond_to?(:body)
84
+ response.body.to_s
85
+ else
86
+ ""
87
+ end
96
88
 
97
- if algorithm == "none" || algorithm.nil?
98
- # Unsigned JWT - decode without verification
99
- jwt = ::JWT.decode(signed_jwt, nil, false)
100
- else
101
- # Signed JWT - decode with verification
102
- signed_jwks = fetch_signed_jwks(normalized_strategy_options)
103
- if signed_jwks
104
- # Decode using signed JWKS
105
- jwt = ::JWT.decode(
106
- signed_jwt,
107
- nil,
108
- true,
109
- {algorithms: [algorithm], jwks: signed_jwks}
110
- )
111
- else
112
- # Fallback to standard JWKS
113
- # Try to resolve JWKS URI from entity statement if not in client_options
114
- unless jwks_uri
115
- OmniauthOpenidFederation::Logger.debug("[AccessToken] JWKS URI not in client_options, trying to resolve from entity statement")
116
- jwks_uri = resolve_jwks_uri_from_entity_statement(normalized_strategy_options)
117
- if jwks_uri
118
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Successfully resolved JWKS URI from entity statement")
119
- # Convert to URI object if it's a string
120
- jwks_uri = URI.parse(jwks_uri) if jwks_uri.is_a?(String) && !jwks_uri.is_a?(URI)
121
- end
122
- end
89
+ return {} if OmniauthOpenidFederation::StringHelpers.blank?(body_text)
123
90
 
124
- unless jwks_uri
125
- error_msg = "JWKS URI not available. Cannot verify JWT signature. Provide jwks_uri in client_options or entity statement."
126
- OmniauthOpenidFederation::Logger.error("[AccessToken] #{error_msg}")
127
- raise OmniauthOpenidFederation::ConfigurationError, error_msg
128
- end
91
+ decode_response_body(body_text)
92
+ rescue JSON::ParserError => error
93
+ raise ValidationError, "Failed to parse userinfo response: #{error.message}", error.backtrace
94
+ end
129
95
 
130
- entity_statement_keys = load_entity_statement_keys_for_jwks_validation(normalized_strategy_options)
131
- jwt = OmniauthOpenidFederation::Jwks::Decode.jwt(
132
- signed_jwt,
133
- jwks_uri.to_s,
134
- entity_statement_keys: entity_statement_keys
135
- )
136
- end
137
- end
138
- rescue => e
139
- # If header parsing fails, try to decode as unsigned
140
- OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to parse JWT header, trying unsigned decode: #{e.message}")
141
- jwt = ::JWT.decode(signed_jwt, nil, false)
142
- end
96
+ public
143
97
 
144
- jwt.first.with_indifferent_access
145
- else
146
- JSON.parse(res.body).with_indifferent_access
147
- end
98
+ def resource_request
99
+ res = yield
100
+ status_code = if res.status.is_a?(Integer)
101
+ res.status
102
+ else
103
+ (res.status.respond_to?(:code) ? res.status.code : res.status)
104
+ end
105
+ case status_code
106
+ when 200
107
+ decode_response_body(res.body)
148
108
  when 400
149
109
  raise BadRequest.new("API Access Faild", res)
150
110
  when 401
@@ -158,20 +118,17 @@ module OpenIDConnect
158
118
 
159
119
  private
160
120
 
161
- # Get strategy options from client (stored by strategy when client was created)
162
- # Falls back to extracting from client attributes if strategy options not available
163
- #
164
- # @return [Hash] Strategy options hash
165
121
  def get_strategy_options
166
- # Try to get strategy options stored on client by the strategy
122
+ if @strategy_options.is_a?(Hash)
123
+ return @strategy_options
124
+ end
125
+
167
126
  if respond_to?(:client) && client
168
127
  strategy_options = client.instance_variable_get(:@strategy_options)
169
128
  return strategy_options if strategy_options&.is_a?(Hash)
170
129
  end
171
130
 
172
- # Fallback: try to extract from client attributes if available
173
131
  if respond_to?(:client) && client
174
- # Build minimal options from client
175
132
  client_options = {}
176
133
  client_options[:jwks_uri] = client.jwks_uri.to_s if client.respond_to?(:jwks_uri) && client.jwks_uri
177
134
  client_options[:private_key] = client.private_key if client.respond_to?(:private_key) && client.private_key
@@ -181,324 +138,8 @@ module OpenIDConnect
181
138
  }
182
139
  end
183
140
 
184
- # Last resort: return empty hash (will cause errors later, but at least won't crash immediately)
185
141
  OmniauthOpenidFederation::Logger.warn("[AccessToken] Could not determine strategy options from client. Some features may not work correctly.")
186
142
  {}
187
143
  end
188
-
189
- # Extract encryption key for decrypting ID tokens or userinfo responses
190
- # Uses KeyExtractor to support separate signing/encryption keys per OpenID Federation spec
191
- #
192
- # @return [OpenSSL::PKey::RSA] Encryption key
193
- def extract_encryption_key_for_decryption
194
- # Try to get strategy options
195
- strategy_options = get_strategy_options
196
- raw_client_options = strategy_options[:client_options] || strategy_options["client_options"]
197
- client_options = OmniauthOpenidFederation::Validators.normalize_hash(raw_client_options)
198
-
199
- private_key = client_options[:private_key] || ((respond_to?(:client) && client&.respond_to?(:private_key)) ? client.private_key : nil)
200
- jwks = client_options[:jwks] || client_options["jwks"]
201
-
202
- # Try to load metadata for key extraction
203
- metadata = nil
204
- entity_statement_path = strategy_options[:entity_statement_path]
205
- if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
206
- begin
207
- validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
208
- entity_statement_path,
209
- allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
210
- )
211
- if File.exist?(validated_path)
212
- metadata = JSON.parse(File.read(validated_path))
213
- end
214
- rescue => e
215
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not load metadata for key extraction: #{e.message}")
216
- end
217
- end
218
-
219
- # Extract encryption key from JWKS or use provided private_key (backward compatibility)
220
- encryption_key = OmniauthOpenidFederation::KeyExtractor.extract_encryption_key(
221
- jwks: jwks,
222
- metadata: metadata,
223
- private_key: private_key
224
- ) || private_key
225
-
226
- OmniauthOpenidFederation::Validators.validate_private_key!(encryption_key)
227
- encryption_key
228
- end
229
-
230
- def fetch_signed_jwks(strategy_options)
231
- # Support entity_statement_path, entity_statement_url, or issuer (like strategy does)
232
- entity_statement_path = strategy_options[:entity_statement_path] || strategy_options["entity_statement_path"]
233
- entity_statement_url = strategy_options[:entity_statement_url] || strategy_options["entity_statement_url"]
234
- issuer = strategy_options[:issuer] || strategy_options["issuer"]
235
- entity_statement_fingerprint = strategy_options[:entity_statement_fingerprint] || strategy_options["entity_statement_fingerprint"]
236
-
237
- # Load entity statement from path, URL, or issuer
238
- entity_statement_content = nil
239
-
240
- # Priority 1: File path (if provided)
241
- if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
242
- begin
243
- validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
244
- entity_statement_path,
245
- allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
246
- )
247
- if File.exist?(validated_path)
248
- entity_statement_content = File.read(validated_path)
249
- else
250
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Entity statement file not found: #{OmniauthOpenidFederation::Utils.sanitize_path(validated_path)}")
251
- end
252
- rescue SecurityError => e
253
- OmniauthOpenidFederation::Logger.error("[AccessToken] #{e.message}")
254
- end
255
- end
256
-
257
- # Priority 2: Fetch from URL (if provided)
258
- if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
259
- begin
260
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Fetching entity statement from URL for signed JWKS")
261
- entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
262
- entity_statement_url,
263
- fingerprint: entity_statement_fingerprint
264
- )
265
- # fetch! returns EntityStatement instance, extract JWT string from it
266
- entity_statement_content = entity_statement_instance.entity_statement
267
- rescue => e
268
- OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch entity statement from URL: #{e.message}")
269
- end
270
- end
271
-
272
- # Priority 3: Fetch from issuer (if provided)
273
- if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(issuer)
274
- begin
275
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Fetching entity statement from issuer for signed JWKS")
276
- entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
277
- issuer,
278
- fingerprint: entity_statement_fingerprint
279
- )
280
- # fetch_from_issuer! returns EntityStatement instance, extract JWT string from it
281
- entity_statement_content = entity_statement_instance.entity_statement
282
- rescue => e
283
- OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch entity statement from issuer: #{e.message}")
284
- end
285
- end
286
-
287
- if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
288
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Entity statement not available (path, URL, or issuer not configured), skipping signed JWKS")
289
- return nil
290
- end
291
-
292
- begin
293
- parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
294
- entity_statement_content
295
- )
296
- if parsed.nil?
297
- return nil
298
- end
299
-
300
- signed_jwks_uri = parsed[:signed_jwks_uri]
301
- if OmniauthOpenidFederation::StringHelpers.blank?(signed_jwks_uri)
302
- OmniauthOpenidFederation::Logger.warn("[AccessToken] signed_jwks_uri not found in entity statement metadata")
303
- return nil
304
- end
305
-
306
- # Get entity JWKS for validation
307
- entity_jwks = parsed[:entity_jwks]
308
-
309
- # Fetch and validate signed JWKS
310
- sanitized_uri = OmniauthOpenidFederation::Utils.sanitize_uri(signed_jwks_uri)
311
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Fetching signed JWKS from #{sanitized_uri}")
312
- signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(signed_jwks_uri, entity_jwks)
313
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Successfully fetched and validated signed JWKS")
314
- signed_jwks
315
- rescue SecurityError => e
316
- # Security errors should not be silently ignored
317
- OmniauthOpenidFederation::Logger.error("[AccessToken] Security error: #{e.message}")
318
- nil
319
- rescue
320
- OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch signed JWKS, falling back to standard JWKS")
321
- # Return nil to allow fallback to standard JWKS, but log the error
322
- nil
323
- end
324
- end
325
-
326
- def load_entity_statement_keys_for_jwks_validation(strategy_options)
327
- # Support entity_statement_path, entity_statement_url, or issuer (like strategy does)
328
- entity_statement_path = strategy_options[:entity_statement_path] || strategy_options["entity_statement_path"]
329
- entity_statement_url = strategy_options[:entity_statement_url] || strategy_options["entity_statement_url"]
330
- issuer = strategy_options[:issuer] || strategy_options["issuer"]
331
- entity_statement_fingerprint = strategy_options[:entity_statement_fingerprint] || strategy_options["entity_statement_fingerprint"]
332
-
333
- # Load entity statement from path, URL, or issuer
334
- entity_statement_content = nil
335
-
336
- # Priority 1: File path (if provided)
337
- if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
338
- begin
339
- validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
340
- entity_statement_path,
341
- allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
342
- )
343
- if File.exist?(validated_path)
344
- entity_statement_content = File.read(validated_path)
345
- end
346
- rescue SecurityError => e
347
- OmniauthOpenidFederation::Logger.error("[AccessToken] #{e.message}")
348
- end
349
- end
350
-
351
- # Priority 2: Fetch from URL (if provided)
352
- if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
353
- begin
354
- entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
355
- entity_statement_url,
356
- fingerprint: entity_statement_fingerprint
357
- )
358
- # fetch! returns EntityStatement instance, extract JWT string from it
359
- entity_statement_content = entity_statement_instance.entity_statement
360
- rescue => e
361
- OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch entity statement from URL: #{e.message}")
362
- end
363
- end
364
-
365
- # Priority 3: Fetch from issuer (if provided)
366
- if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(issuer)
367
- begin
368
- entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
369
- issuer,
370
- fingerprint: entity_statement_fingerprint
371
- )
372
- # fetch_from_issuer! returns EntityStatement instance, extract JWT string from it
373
- entity_statement_content = entity_statement_instance.entity_statement
374
- rescue => e
375
- OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch entity statement from issuer: #{e.message}")
376
- end
377
- end
378
-
379
- if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
380
- OmniauthOpenidFederation::Logger.warn("[AccessToken] Entity statement not available for federation")
381
- return nil
382
- end
383
-
384
- begin
385
- # Parse entity statement to extract keys
386
- # entity_statement_content is now always a string (JWT)
387
- entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
388
- parsed = entity_statement.parse
389
- entity_jwks = parsed[:jwks] if parsed
390
-
391
- # Extract keys from entity JWKS
392
- keys = if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
393
- entity_jwks["keys"]
394
- elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
395
- entity_jwks[:keys]
396
- elsif entity_jwks.is_a?(Array)
397
- entity_jwks
398
- else
399
- []
400
- end
401
- if keys.empty?
402
- OmniauthOpenidFederation::Logger.warn("[AccessToken] No keys found in entity statement")
403
- return nil
404
- end
405
-
406
- # Convert to format expected by JWT gem (HashWithIndifferentAccess with keys array)
407
- jwks_hash = {
408
- keys: keys.map { |jwk| jwk.is_a?(Hash) ? jwk : JSON.parse(jwk.to_json) }
409
- }
410
- OmniauthOpenidFederation::Utils.to_indifferent_hash(jwks_hash)
411
- rescue => e
412
- error_msg = "Failed to load entity statement keys for JWKS validation: #{e.class} - #{e.message}"
413
- OmniauthOpenidFederation::Logger.error("[AccessToken] #{error_msg}")
414
- # Return nil to allow fallback, but log the error
415
- nil
416
- end
417
- end
418
-
419
- # Resolve JWKS URI from entity statement if not in client_options
420
- #
421
- # @param strategy_options [Hash] Strategy options hash
422
- # @return [String, nil] JWKS URI or nil if not available
423
- def resolve_jwks_uri_from_entity_statement(strategy_options)
424
- # Try both symbol and string keys (OmniAuth options can be either)
425
- entity_statement_path = strategy_options[:entity_statement_path] || strategy_options["entity_statement_path"]
426
- entity_statement_url = strategy_options[:entity_statement_url] || strategy_options["entity_statement_url"]
427
- issuer = strategy_options[:issuer] || strategy_options["issuer"]
428
- entity_statement_fingerprint = strategy_options[:entity_statement_fingerprint] || strategy_options["entity_statement_fingerprint"]
429
-
430
- # Debug logging to help diagnose issues
431
- if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_path) &&
432
- OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_url) &&
433
- OmniauthOpenidFederation::StringHelpers.blank?(issuer)
434
- OmniauthOpenidFederation::Logger.debug("[AccessToken] No entity statement source configured (path, URL, or issuer) in strategy options. Available keys: #{strategy_options.keys.join(", ")}")
435
- end
436
-
437
- # Load entity statement from path, URL, or issuer
438
- entity_statement_content = nil
439
-
440
- # Priority 1: File path (if provided)
441
- if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
442
- begin
443
- validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
444
- entity_statement_path,
445
- allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
446
- )
447
- if File.exist?(validated_path)
448
- entity_statement_content = File.read(validated_path)
449
- end
450
- rescue SecurityError => e
451
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not load entity statement from path: #{e.message}")
452
- end
453
- end
454
-
455
- # Priority 2: Fetch from URL (if provided)
456
- if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
457
- begin
458
- entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
459
- entity_statement_url,
460
- fingerprint: entity_statement_fingerprint
461
- )
462
- # fetch! returns EntityStatement instance, extract JWT string from it
463
- entity_statement_content = entity_statement_instance.entity_statement
464
- rescue => e
465
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not fetch entity statement from URL: #{e.message}")
466
- end
467
- end
468
-
469
- # Priority 3: Fetch from issuer (if provided)
470
- if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(issuer)
471
- begin
472
- entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
473
- issuer,
474
- fingerprint: entity_statement_fingerprint
475
- )
476
- # fetch_from_issuer! returns EntityStatement instance, extract JWT string from it
477
- entity_statement_content = entity_statement_instance.entity_statement
478
- rescue => e
479
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not fetch entity statement from issuer: #{e.message}")
480
- end
481
- end
482
-
483
- return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
484
-
485
- begin
486
- # Parse entity statement to extract JWKS URI
487
- # entity_statement_content is now always a string (JWT)
488
- entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
489
- parsed = entity_statement.parse
490
- return nil unless parsed
491
-
492
- # Extract JWKS URI from provider metadata
493
- jwks_uri = parsed.dig(:metadata, :openid_provider, :jwks_uri) ||
494
- parsed.dig("metadata", "openid_provider", "jwks_uri")
495
-
496
- return jwks_uri if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
497
- rescue => e
498
- OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not extract JWKS URI from entity statement: #{e.message}")
499
- end
500
-
501
- nil
502
- end
503
144
  end
504
145
  end
@@ -20,6 +20,22 @@ module OmniauthOpenidFederation
20
20
  "omniauth_openid_federation:signed_jwks:#{Digest::SHA256.hexdigest(signed_jwks_uri)}"
21
21
  end
22
22
 
23
+ # Generate cache key for locally served JWKS (federation endpoint)
24
+ #
25
+ # @param issuer [String] Federation endpoint issuer entity identifier
26
+ # @return [String] Cache key
27
+ def self.key_for_served_jwks(issuer)
28
+ "federation:jwks:#{Digest::SHA256.hexdigest(issuer)}"
29
+ end
30
+
31
+ # Generate cache key for locally served signed JWKS (federation endpoint)
32
+ #
33
+ # @param issuer [String] Federation endpoint issuer entity identifier
34
+ # @return [String] Cache key
35
+ def self.key_for_served_signed_jwks(issuer)
36
+ "federation:signed_jwks:#{Digest::SHA256.hexdigest(issuer)}"
37
+ end
38
+
23
39
  # Delete JWKS cache
24
40
  #
25
41
  # @param jwks_uri [String] The JWKS URI
@@ -116,9 +116,12 @@ module OmniauthOpenidFederation
116
116
  return config.cache_adapter
117
117
  end
118
118
 
119
- # Try Rails cache
120
- if defined?(Rails) && Rails.respond_to?(:cache) && Rails.cache
121
- return RailsCacheAdapter.new(Rails.cache)
119
+ # Try Rails cache when a booted app exposes a real store
120
+ if defined?(Rails) && Rails.respond_to?(:cache)
121
+ cache = Rails.cache
122
+ if cache && cache.respond_to?(:read)
123
+ return RailsCacheAdapter.new(cache)
124
+ end
122
125
  end
123
126
 
124
127
  # Try ActiveSupport::Cache if available (without Rails)
@@ -14,5 +14,8 @@ module OmniauthOpenidFederation
14
14
  # Prevents DoS attacks while allowing legitimate use cases (e.g., encrypted JWT authorization codes)
15
15
  # Use Configuration.config.max_string_length for runtime configuration instead of patching this constant
16
16
  MAX_STRING_LENGTH = 8192
17
+
18
+ # Minimum RSA modulus size for OIDC signing and encryption keys (FTN and common federation profiles)
19
+ MIN_RSA_KEY_BITS = 2048
17
20
  end
18
21
  end
@@ -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)
@@ -319,10 +319,8 @@ module OmniauthOpenidFederation
319
319
  unknown_claims = crit - standard_claims
320
320
 
321
321
  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.")
322
+ raise ValidationError,
323
+ "Entity statement crit claim contains unknown claims that MUST be understood: #{unknown_claims.join(", ")}"
326
324
  end
327
325
  end
328
326
 
@@ -1,4 +1,3 @@
1
- require "http"
2
1
  require "jwt"
3
2
  require "base64"
4
3
  require "openssl"