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,70 @@
1
+ require_relative "constants"
2
+
3
+ # HTTP client with retry logic and SSL configuration
4
+ module OmniauthOpenidFederation
5
+ class HttpClient
6
+ # Execute an HTTP GET request with retry logic
7
+ #
8
+ # @param uri [String, URI] The URI to fetch
9
+ # @param options [Hash] Request options
10
+ # @option options [Integer] :max_retries Maximum number of retries (default: from config)
11
+ # @option options [Integer] :retry_delay Base retry delay in seconds (default: from config)
12
+ # @option options [Integer] :timeout Request timeout in seconds (default: from config)
13
+ # @return [HTTP::Response] The HTTP response
14
+ # @raise [NetworkError] If the request fails after all retries
15
+ def self.get(uri, options = {})
16
+ max_retries = options[:max_retries] || Configuration.config.max_retries
17
+ retry_delay = options[:retry_delay] || Configuration.config.retry_delay
18
+ timeout = options[:timeout] || Configuration.config.http_timeout
19
+
20
+ http_client = build_http_client(timeout)
21
+
22
+ retries = 0
23
+ begin
24
+ http_client.get(uri)
25
+ rescue HTTP::Error, Timeout::Error, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
26
+ retries += 1
27
+ if retries > max_retries
28
+ error_msg = "Failed to fetch #{uri} after #{max_retries} retries: #{e.class} - #{e.message}"
29
+ OmniauthOpenidFederation::Logger.error("[HttpClient] #{error_msg}")
30
+ raise OmniauthOpenidFederation::NetworkError, error_msg, e.backtrace
31
+ end
32
+
33
+ delay = [retry_delay * retries, Constants::MAX_RETRY_DELAY_SECONDS].min
34
+ OmniauthOpenidFederation::Logger.warn("[HttpClient] Request failed (attempt #{retries}/#{max_retries}), retrying in #{delay}s: #{e.message}")
35
+ sleep(delay)
36
+ retry
37
+ end
38
+ end
39
+
40
+ # Build HTTP client with SSL configuration
41
+ #
42
+ # @param timeout [Integer] Request timeout in seconds
43
+ # @return [HTTP::Client] Configured HTTP client
44
+ def self.build_http_client(timeout)
45
+ http_options_hash = build_http_options_hash || {}
46
+ http_options = HTTP::Options.new(http_options_hash)
47
+ HTTP::Client.new(http_options).timeout(timeout)
48
+ end
49
+
50
+ # Build HTTP options hash from configuration
51
+ #
52
+ # @return [Hash, nil] HTTP options hash or nil
53
+ def self.build_http_options_hash
54
+ config = Configuration.config
55
+
56
+ # If http_options is configured, use it (can be Hash or Proc)
57
+ if config.http_options
58
+ if config.http_options.is_a?(Proc)
59
+ config.http_options.call
60
+ else
61
+ config.http_options
62
+ end
63
+ end
64
+ end
65
+
66
+ private_class_method :build_http_options_hash
67
+
68
+ private_class_method :build_http_client
69
+ end
70
+ end
@@ -0,0 +1,383 @@
1
+ # Instrumentation for omniauth_openid_federation
2
+ # Provides configurable notifications for security events, MITM attacks, and authentication mismatches
3
+ #
4
+ # @example Configure with Sentry
5
+ # OmniauthOpenidFederation.configure do |config|
6
+ # config.instrumentation = ->(event, data) do
7
+ # Sentry.capture_message("OpenID Federation: #{event}", level: :warning, extra: data)
8
+ # end
9
+ # end
10
+ #
11
+ # @example Configure with Honeybadger
12
+ # OmniauthOpenidFederation.configure do |config|
13
+ # config.instrumentation = ->(event, data) do
14
+ # Honeybadger.notify("OpenID Federation: #{event}", context: data)
15
+ # end
16
+ # end
17
+ #
18
+ # @example Configure with custom logger
19
+ # OmniauthOpenidFederation.configure do |config|
20
+ # config.instrumentation = ->(event, data) do
21
+ # Rails.logger.warn("[Security] #{event}: #{data.inspect}")
22
+ # end
23
+ # end
24
+ #
25
+ # @example Disable instrumentation
26
+ # OmniauthOpenidFederation.configure do |config|
27
+ # config.instrumentation = nil
28
+ # end
29
+ module OmniauthOpenidFederation
30
+ module Instrumentation
31
+ # Security event types
32
+ EVENT_CSRF_DETECTED = "csrf_detected"
33
+ EVENT_SIGNATURE_VERIFICATION_FAILED = "signature_verification_failed"
34
+ EVENT_DECRYPTION_FAILED = "decryption_failed"
35
+ EVENT_TOKEN_VALIDATION_FAILED = "token_validation_failed"
36
+ EVENT_KEY_ROTATION_DETECTED = "key_rotation_detected"
37
+ EVENT_KID_NOT_FOUND = "kid_not_found"
38
+ EVENT_ENTITY_STATEMENT_VALIDATION_FAILED = "entity_statement_validation_failed"
39
+ EVENT_FINGERPRINT_MISMATCH = "fingerprint_mismatch"
40
+ EVENT_TRUST_CHAIN_VALIDATION_FAILED = "trust_chain_validation_failed"
41
+ EVENT_ENDPOINT_MISMATCH = "endpoint_mismatch"
42
+ EVENT_UNEXPECTED_AUTHENTICATION_BREAK = "unexpected_authentication_break"
43
+ EVENT_STATE_MISMATCH = "state_mismatch"
44
+ EVENT_MISSING_REQUIRED_CLAIMS = "missing_required_claims"
45
+ EVENT_AUDIENCE_MISMATCH = "audience_mismatch"
46
+ EVENT_ISSUER_MISMATCH = "issuer_mismatch"
47
+ EVENT_EXPIRED_TOKEN = "expired_token"
48
+ EVENT_INVALID_NONCE = "invalid_nonce"
49
+
50
+ class << self
51
+ # Notify about a security event
52
+ #
53
+ # @param event [String] Event type (use constants from this module)
54
+ # @param data [Hash] Event data (will be sanitized to remove sensitive information)
55
+ # @param severity [Symbol] Event severity (:info, :warning, :error)
56
+ # @return [void]
57
+ def notify(event, data: {}, severity: :warning)
58
+ config = Configuration.config
59
+ return unless config.instrumentation
60
+
61
+ # Sanitize data to remove sensitive information
62
+ sanitized_data = sanitize_data(data)
63
+
64
+ # Build notification payload
65
+ payload = {
66
+ event: event,
67
+ severity: severity,
68
+ timestamp: Time.now.utc.iso8601,
69
+ data: sanitized_data
70
+ }
71
+
72
+ # Call the configured instrumentation callback
73
+ begin
74
+ if config.instrumentation.respond_to?(:call)
75
+ config.instrumentation.call(event, payload)
76
+ elsif config.instrumentation.respond_to?(:notify)
77
+ config.instrumentation.notify(event, payload)
78
+ else
79
+ # Assume it's a logger-like object
80
+ log_message = "[OpenID Federation Security] #{event}: #{sanitized_data.inspect}"
81
+ case severity
82
+ when :error
83
+ config.instrumentation.error(log_message)
84
+ when :warning
85
+ config.instrumentation.warn(log_message)
86
+ else
87
+ config.instrumentation.info(log_message)
88
+ end
89
+ end
90
+ rescue => e
91
+ # Don't let instrumentation failures break the authentication flow
92
+ Logger.warn("[Instrumentation] Failed to notify about #{event}: #{e.message}")
93
+ end
94
+ end
95
+
96
+ # Notify about CSRF detection
97
+ #
98
+ # @param data [Hash] Additional context (state_param, state_session, request_info)
99
+ # @return [void]
100
+ def notify_csrf_detected(data = {})
101
+ notify(
102
+ EVENT_CSRF_DETECTED,
103
+ data: {
104
+ reason: "State parameter mismatch - possible CSRF attack",
105
+ **data
106
+ },
107
+ severity: :error
108
+ )
109
+ end
110
+
111
+ # Notify about signature verification failure
112
+ #
113
+ # @param data [Hash] Additional context (token_type, kid, jwks_uri, error_message)
114
+ # @return [void]
115
+ def notify_signature_verification_failed(data = {})
116
+ notify(
117
+ EVENT_SIGNATURE_VERIFICATION_FAILED,
118
+ data: {
119
+ reason: "JWT signature verification failed - possible MITM attack or key rotation",
120
+ **data
121
+ },
122
+ severity: :error
123
+ )
124
+ end
125
+
126
+ # Notify about decryption failure
127
+ #
128
+ # @param data [Hash] Additional context (token_type, error_message)
129
+ # @return [void]
130
+ def notify_decryption_failed(data = {})
131
+ notify(
132
+ EVENT_DECRYPTION_FAILED,
133
+ data: {
134
+ reason: "Token decryption failed - possible MITM attack or key mismatch",
135
+ **data
136
+ },
137
+ severity: :error
138
+ )
139
+ end
140
+
141
+ # Notify about token validation failure
142
+ #
143
+ # @param data [Hash] Additional context (validation_type, missing_claims, error_message)
144
+ # @return [void]
145
+ def notify_token_validation_failed(data = {})
146
+ notify(
147
+ EVENT_TOKEN_VALIDATION_FAILED,
148
+ data: {
149
+ reason: "Token validation failed - possible tampering or configuration mismatch",
150
+ **data
151
+ },
152
+ severity: :error
153
+ )
154
+ end
155
+
156
+ # Notify about key rotation detection
157
+ #
158
+ # @param data [Hash] Additional context (jwks_uri, kid, available_kids)
159
+ # @return [void]
160
+ def notify_key_rotation_detected(data = {})
161
+ notify(
162
+ EVENT_KEY_ROTATION_DETECTED,
163
+ data: {
164
+ reason: "Key rotation detected - kid not found in current JWKS",
165
+ **data
166
+ },
167
+ severity: :warning
168
+ )
169
+ end
170
+
171
+ # Notify about kid not found
172
+ #
173
+ # @param data [Hash] Additional context (kid, jwks_uri, available_kids)
174
+ # @return [void]
175
+ def notify_kid_not_found(data = {})
176
+ notify(
177
+ EVENT_KID_NOT_FOUND,
178
+ data: {
179
+ reason: "Key ID not found in JWKS - possible key rotation or MITM attack",
180
+ **data
181
+ },
182
+ severity: :error
183
+ )
184
+ end
185
+
186
+ # Notify about entity statement validation failure
187
+ #
188
+ # @param data [Hash] Additional context (entity_id, validation_step, error_message)
189
+ # @return [void]
190
+ def notify_entity_statement_validation_failed(data = {})
191
+ notify(
192
+ EVENT_ENTITY_STATEMENT_VALIDATION_FAILED,
193
+ data: {
194
+ reason: "Entity statement validation failed - possible tampering or MITM attack",
195
+ **data
196
+ },
197
+ severity: :error
198
+ )
199
+ end
200
+
201
+ # Notify about fingerprint mismatch
202
+ #
203
+ # @param data [Hash] Additional context (expected_fingerprint, calculated_fingerprint, entity_statement_url)
204
+ # @return [void]
205
+ def notify_fingerprint_mismatch(data = {})
206
+ notify(
207
+ EVENT_FINGERPRINT_MISMATCH,
208
+ data: {
209
+ reason: "Entity statement fingerprint mismatch - possible MITM attack or tampering",
210
+ **data
211
+ },
212
+ severity: :error
213
+ )
214
+ end
215
+
216
+ # Notify about trust chain validation failure
217
+ #
218
+ # @param data [Hash] Additional context (entity_id, trust_anchor, validation_step, error_message)
219
+ # @return [void]
220
+ def notify_trust_chain_validation_failed(data = {})
221
+ notify(
222
+ EVENT_TRUST_CHAIN_VALIDATION_FAILED,
223
+ data: {
224
+ reason: "Trust chain validation failed - possible MITM attack or configuration issue",
225
+ **data
226
+ },
227
+ severity: :error
228
+ )
229
+ end
230
+
231
+ # Notify about endpoint mismatch
232
+ #
233
+ # @param data [Hash] Additional context (endpoint_type, expected, actual, source)
234
+ # @return [void]
235
+ def notify_endpoint_mismatch(data = {})
236
+ notify(
237
+ EVENT_ENDPOINT_MISMATCH,
238
+ data: {
239
+ reason: "Endpoint mismatch detected - possible MITM attack or configuration issue",
240
+ **data
241
+ },
242
+ severity: :warning
243
+ )
244
+ end
245
+
246
+ # Notify about unexpected authentication break
247
+ #
248
+ # @param data [Hash] Additional context (stage, error_message, error_class)
249
+ # @return [void]
250
+ def notify_unexpected_authentication_break(data = {})
251
+ notify(
252
+ EVENT_UNEXPECTED_AUTHENTICATION_BREAK,
253
+ data: {
254
+ reason: "Unexpected authentication break - something that should not fail has failed",
255
+ **data
256
+ },
257
+ severity: :error
258
+ )
259
+ end
260
+
261
+ # Notify about state mismatch
262
+ #
263
+ # @param data [Hash] Additional context (state_param, state_session)
264
+ # @return [void]
265
+ def notify_state_mismatch(data = {})
266
+ notify(
267
+ EVENT_STATE_MISMATCH,
268
+ data: {
269
+ reason: "State parameter mismatch - possible CSRF attack or session issue",
270
+ **data
271
+ },
272
+ severity: :error
273
+ )
274
+ end
275
+
276
+ # Notify about missing required claims
277
+ #
278
+ # @param data [Hash] Additional context (missing_claims, available_claims, token_type)
279
+ # @return [void]
280
+ def notify_missing_required_claims(data = {})
281
+ notify(
282
+ EVENT_MISSING_REQUIRED_CLAIMS,
283
+ data: {
284
+ reason: "Token missing required claims - possible tampering or provider issue",
285
+ **data
286
+ },
287
+ severity: :error
288
+ )
289
+ end
290
+
291
+ # Notify about audience mismatch
292
+ #
293
+ # @param data [Hash] Additional context (expected_audience, actual_audience, token_type)
294
+ # @return [void]
295
+ def notify_audience_mismatch(data = {})
296
+ notify(
297
+ EVENT_AUDIENCE_MISMATCH,
298
+ data: {
299
+ reason: "Token audience mismatch - possible MITM attack or configuration issue",
300
+ **data
301
+ },
302
+ severity: :error
303
+ )
304
+ end
305
+
306
+ # Notify about issuer mismatch
307
+ #
308
+ # @param data [Hash] Additional context (expected_issuer, actual_issuer, token_type)
309
+ # @return [void]
310
+ def notify_issuer_mismatch(data = {})
311
+ notify(
312
+ EVENT_ISSUER_MISMATCH,
313
+ data: {
314
+ reason: "Token issuer mismatch - possible MITM attack or configuration issue",
315
+ **data
316
+ },
317
+ severity: :error
318
+ )
319
+ end
320
+
321
+ # Notify about expired token
322
+ #
323
+ # @param data [Hash] Additional context (exp, current_time, token_type)
324
+ # @return [void]
325
+ def notify_expired_token(data = {})
326
+ notify(
327
+ EVENT_EXPIRED_TOKEN,
328
+ data: {
329
+ reason: "Token expired - possible clock skew or replay attack",
330
+ **data
331
+ },
332
+ severity: :warning
333
+ )
334
+ end
335
+
336
+ # Notify about invalid nonce
337
+ #
338
+ # @param data [Hash] Additional context (expected_nonce, actual_nonce)
339
+ # @return [void]
340
+ def notify_invalid_nonce(data = {})
341
+ notify(
342
+ EVENT_INVALID_NONCE,
343
+ data: {
344
+ reason: "Nonce mismatch - possible replay attack",
345
+ **data
346
+ },
347
+ severity: :error
348
+ )
349
+ end
350
+
351
+ private
352
+
353
+ # Sanitize data to remove sensitive information
354
+ #
355
+ # @param data [Hash] Raw data
356
+ # @return [Hash] Sanitized data
357
+ def sanitize_data(data)
358
+ return {} unless data.is_a?(Hash)
359
+
360
+ sensitive_keys = [
361
+ :token, :access_token, :id_token, :refresh_token,
362
+ :private_key, :key, :secret, :password,
363
+ :authorization_code, :code,
364
+ :state, :nonce, :state_param, :state_session,
365
+ :fingerprint, :calculated_fingerprint, :expected_fingerprint
366
+ ]
367
+
368
+ data.each_with_object({}) do |(key, value), result|
369
+ key_sym = key.to_sym
370
+ result[key] = if sensitive_keys.include?(key_sym)
371
+ "[REDACTED]"
372
+ elsif value.is_a?(Hash)
373
+ sanitize_data(value)
374
+ elsif value.is_a?(Array)
375
+ value.map { |v| v.is_a?(Hash) ? sanitize_data(v) : v }
376
+ else
377
+ value
378
+ end
379
+ end
380
+ end
381
+ end
382
+ end
383
+ end
@@ -0,0 +1,76 @@
1
+ require_relative "../logger"
2
+ require_relative "../cache"
3
+ require_relative "../cache_adapter"
4
+ require_relative "fetch"
5
+
6
+ # JWKS Cache with automatic invalidation on kid_not_found
7
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
8
+ #
9
+ # Provides intelligent caching for JWKS with automatic cache invalidation when a key ID (kid)
10
+ # is not found. This handles provider key rotation gracefully by:
11
+ # - Caching JWKS for performance
12
+ # - Invalidating cache when kid_not_found error occurs (after timeout)
13
+ # - Automatically reloading JWKS source on cache miss
14
+ #
15
+ # This prevents malicious requests from triggering cache invalidations by enforcing
16
+ # a grace period (timeout) between invalidations.
17
+ module OmniauthOpenidFederation
18
+ module Jwks
19
+ class Cache
20
+ attr_reader :jwks_source, :timeout_sec, :cache_last_update
21
+
22
+ # Initialize JWKS cache
23
+ #
24
+ # @param jwks_source [Object] The JWKS source (must respond to #jwks and optionally #reload!)
25
+ # @param timeout_sec [Integer] Minimum seconds between cache invalidations (default: 300 = 5 minutes)
26
+ def initialize(jwks_source, timeout_sec = 300)
27
+ @jwks_source = jwks_source
28
+ @timeout_sec = timeout_sec
29
+ @cache_last_update = 0
30
+ @cached_keys = nil
31
+ end
32
+
33
+ # Get JWKS with automatic cache invalidation on kid_not_found
34
+ #
35
+ # @param options [Hash] Options hash
36
+ # @option options [Boolean] :kid_not_found Whether kid was not found (triggers invalidation if timeout passed)
37
+ # @option options [String] :kid The key ID that was not found
38
+ # @return [Array<Hash>] Array of signing keys (filtered from JWKS)
39
+ def call(options = {})
40
+ # Check if we should invalidate cache due to kid_not_found
41
+ if options[:kid_not_found] && @cache_last_update < Time.now.to_i - @timeout_sec
42
+ kid = options[:kid]
43
+ OmniauthOpenidFederation::Logger.info("[Jwks::Cache] Invalidating JWK cache. Kid '#{kid}' not found from previous cache")
44
+ @cached_keys = nil
45
+ @jwks_source.reload! if @jwks_source.respond_to?(:reload!)
46
+ end
47
+
48
+ # Return cached keys or fetch fresh
49
+ @cached_keys ||= begin
50
+ @cache_last_update = Time.now.to_i
51
+ jwks = @jwks_source.jwks
52
+
53
+ # Ensure jwks is in the expected format
54
+ keys = if jwks.is_a?(Hash) && (jwks.key?("keys") || jwks.key?(:keys))
55
+ jwks["keys"] || jwks[:keys] || []
56
+ elsif jwks.is_a?(Array)
57
+ jwks
58
+ else
59
+ []
60
+ end
61
+
62
+ # Filter to signing keys only (for JWT verification)
63
+ keys.select { |key| (key[:use] || key["use"]) == "sig" }
64
+ end
65
+ end
66
+
67
+ # Clear the cache
68
+ #
69
+ # @return [void]
70
+ def clear!
71
+ @cached_keys = nil
72
+ @cache_last_update = 0
73
+ end
74
+ end
75
+ end
76
+ end