omniauth_openid_federation 1.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 (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +822 -0
  5. data/SECURITY.md +129 -0
  6. data/examples/README_INTEGRATION_TESTING.md +399 -0
  7. data/examples/README_MOCK_OP.md +243 -0
  8. data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
  9. data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
  10. data/examples/app/models/user.rb.example +39 -0
  11. data/examples/config/initializers/devise.rb.example +97 -0
  12. data/examples/config/initializers/federation_endpoint.rb.example +206 -0
  13. data/examples/config/mock_op.yml.example +83 -0
  14. data/examples/config/open_id_connect_config.rb.example +210 -0
  15. data/examples/config/routes.rb.example +12 -0
  16. data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
  17. data/examples/integration_test_flow.rb +1334 -0
  18. data/examples/jobs/README.md +194 -0
  19. data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
  20. data/examples/jobs/federation_files_generation_job.rb.example +87 -0
  21. data/examples/mock_op_server.rb +775 -0
  22. data/examples/mock_rp_server.rb +435 -0
  23. data/lib/omniauth_openid_federation/access_token.rb +504 -0
  24. data/lib/omniauth_openid_federation/cache.rb +39 -0
  25. data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
  26. data/lib/omniauth_openid_federation/configuration.rb +135 -0
  27. data/lib/omniauth_openid_federation/constants.rb +13 -0
  28. data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
  29. data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
  30. data/lib/omniauth_openid_federation/errors.rb +52 -0
  31. data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
  32. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
  33. data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
  34. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
  35. data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
  36. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
  37. data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
  38. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
  39. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
  40. data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
  41. data/lib/omniauth_openid_federation/http_client.rb +70 -0
  42. data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
  43. data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
  44. data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
  45. data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
  46. data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
  47. data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
  48. data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
  49. data/lib/omniauth_openid_federation/jws.rb +416 -0
  50. data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
  51. data/lib/omniauth_openid_federation/logger.rb +99 -0
  52. data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
  53. data/lib/omniauth_openid_federation/railtie.rb +29 -0
  54. data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
  55. data/lib/omniauth_openid_federation/strategy.rb +2029 -0
  56. data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
  57. data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
  58. data/lib/omniauth_openid_federation/utils.rb +166 -0
  59. data/lib/omniauth_openid_federation/validators.rb +126 -0
  60. data/lib/omniauth_openid_federation/version.rb +3 -0
  61. data/lib/omniauth_openid_federation.rb +98 -0
  62. data/lib/tasks/omniauth_openid_federation.rake +376 -0
  63. data/sig/federation.rbs +218 -0
  64. data/sig/jwks.rbs +63 -0
  65. data/sig/omniauth_openid_federation.rbs +254 -0
  66. data/sig/strategy.rbs +60 -0
  67. metadata +352 -0
@@ -0,0 +1,504 @@
1
+ require "jwt"
2
+ require "jwe"
3
+ require "json"
4
+ require "uri"
5
+ require_relative "string_helpers"
6
+ require_relative "logger"
7
+ require_relative "validators"
8
+ require_relative "utils"
9
+ require_relative "key_extractor"
10
+
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
22
+ 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)
29
+ 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
+
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)
43
+
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
63
+
64
+ # Try to get options from strategy configuration
65
+ strategy_options = get_strategy_options
66
+
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)
70
+
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)
73
+
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
86
+
87
+ # Always use federation features (signed JWKS preferred, fallback to standard JWKS)
88
+ normalized_strategy_options = OmniauthOpenidFederation::Validators.normalize_hash(strategy_options)
89
+
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]
96
+
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
123
+
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
129
+
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
143
+
144
+ jwt.first.with_indifferent_access
145
+ else
146
+ JSON.parse(res.body).with_indifferent_access
147
+ end
148
+ when 400
149
+ raise BadRequest.new("API Access Faild", res)
150
+ when 401
151
+ raise Unauthorized.new("Access Token Invalid or Expired", res)
152
+ when 403
153
+ raise Forbidden.new("Insufficient Scope", res)
154
+ else
155
+ raise HttpError.new(res.status, "Unknown HttpError", res)
156
+ end
157
+ end
158
+
159
+ private
160
+
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
+ def get_strategy_options
166
+ # Try to get strategy options stored on client by the strategy
167
+ if respond_to?(:client) && client
168
+ strategy_options = client.instance_variable_get(:@strategy_options)
169
+ return strategy_options if strategy_options&.is_a?(Hash)
170
+ end
171
+
172
+ # Fallback: try to extract from client attributes if available
173
+ if respond_to?(:client) && client
174
+ # Build minimal options from client
175
+ client_options = {}
176
+ client_options[:jwks_uri] = client.jwks_uri.to_s if client.respond_to?(:jwks_uri) && client.jwks_uri
177
+ client_options[:private_key] = client.private_key if client.respond_to?(:private_key) && client.private_key
178
+
179
+ return {
180
+ client_options: client_options
181
+ }
182
+ end
183
+
184
+ # Last resort: return empty hash (will cause errors later, but at least won't crash immediately)
185
+ OmniauthOpenidFederation::Logger.warn("[AccessToken] Could not determine strategy options from client. Some features may not work correctly.")
186
+ {}
187
+ 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
+ end
504
+ end
@@ -0,0 +1,39 @@
1
+ require "digest"
2
+ require_relative "cache_adapter"
3
+
4
+ # Cache utilities for JWKS caching
5
+ module OmniauthOpenidFederation
6
+ module Cache
7
+ # Generate cache key for JWKS
8
+ #
9
+ # @param jwks_uri [String] The JWKS URI
10
+ # @return [String] Cache key
11
+ def self.key_for_jwks(jwks_uri)
12
+ "omniauth_openid_federation:jwks:#{Digest::SHA256.hexdigest(jwks_uri)}"
13
+ end
14
+
15
+ # Generate cache key for signed JWKS
16
+ #
17
+ # @param signed_jwks_uri [String] The signed JWKS URI
18
+ # @return [String] Cache key
19
+ def self.key_for_signed_jwks(signed_jwks_uri)
20
+ "omniauth_openid_federation:signed_jwks:#{Digest::SHA256.hexdigest(signed_jwks_uri)}"
21
+ end
22
+
23
+ # Delete JWKS cache
24
+ #
25
+ # @param jwks_uri [String] The JWKS URI
26
+ def self.delete_jwks(jwks_uri)
27
+ return unless CacheAdapter.available?
28
+ CacheAdapter.delete(key_for_jwks(jwks_uri))
29
+ end
30
+
31
+ # Delete signed JWKS cache
32
+ #
33
+ # @param signed_jwks_uri [String] The signed JWKS URI
34
+ def self.delete_signed_jwks(signed_jwks_uri)
35
+ return unless CacheAdapter.available?
36
+ CacheAdapter.delete(key_for_signed_jwks(signed_jwks_uri))
37
+ end
38
+ end
39
+ end