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,174 @@
1
+ require "jwt"
2
+ require "digest"
3
+ require "base64"
4
+ require_relative "../logger"
5
+ require_relative "../errors"
6
+ require_relative "../cache"
7
+ require_relative "fetch"
8
+
9
+ # JWT decoding service with automatic key rotation handling
10
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
11
+ # @see https://openid.net/specs/openid-federation-1_0.html#section-11.1 Section 11.1: Protocol Key Rollover
12
+ #
13
+ # Decodes and validates JWTs using JWKS from the provider. Implements automatic
14
+ # key rotation handling by:
15
+ # - Detecting signature verification failures (possible key rotation)
16
+ # - Clearing JWKS cache and re-fetching fresh keys
17
+ # - Retrying JWT validation with updated keys
18
+ #
19
+ # This handles provider key rotation automatically without manual intervention.
20
+ # Providers will notify before key rotation, and this implementation handles it gracefully.
21
+ module OmniauthOpenidFederation
22
+ module Jwks
23
+ class Decode
24
+ # Decode JWT with automatic key rotation handling
25
+ #
26
+ # @param encoded_jwt [String] The JWT to decode
27
+ # @param jwks_uri [String] The JWKS URI for key lookup
28
+ # @param retried [Boolean] Internal flag for retry logic (default: false)
29
+ # @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validation
30
+ # @yield [jwks] Optional block to process JWKS before decoding
31
+ # @yieldparam jwks [Hash] The JWKS hash
32
+ # @return [Object] Result from block or JWKS hash
33
+ # @raise [ValidationError] If JWT validation fails
34
+ # @raise [SignatureError] If signature verification fails after retry
35
+ def self.run(encoded_jwt, jwks_uri, retried: false, entity_statement_keys: nil, &block)
36
+ # Fetch JWKS
37
+ jwks = OmniauthOpenidFederation::Jwks::Fetch.run(jwks_uri, entity_statement_keys: entity_statement_keys)
38
+
39
+ begin
40
+ # Try to decode
41
+ if block_given?
42
+ yield(jwks)
43
+ else
44
+ jwks
45
+ end
46
+ rescue JWT::ExpiredSignature, JWT::InvalidJtiError, JWT::InvalidIatError, JWT::InvalidAudError, JWT::InvalidIssuerError => e
47
+ # These are JWT validation errors that shouldn't trigger retry
48
+ # They indicate the token itself is invalid (expired, wrong audience, etc.), not that the keys are wrong
49
+ OmniauthOpenidFederation::Logger.error("[Jwks::Decode] JWT validation failed: #{e.class} - #{e.message}")
50
+ raise ValidationError, e.message, e.backtrace
51
+ rescue JWT::DecodeError => e
52
+ # JWT::DecodeError can be either:
53
+ # - Format errors (invalid JWT structure) - shouldn't retry
54
+ # - Verification errors (signature mismatch) - should retry (might be key rotation)
55
+ # - Key not found errors (kid not found) - should raise ValidationError
56
+ if e.message.include?("Could not find public key for kid") || e.message.include?("Key with kid")
57
+ # Key not found error - might indicate key rotation, so clear cache and retry once
58
+ if retried
59
+ # Already retried, raise ValidationError
60
+ error_msg = "Key with kid not found in JWKS after cache refresh: #{e.message}"
61
+ OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
62
+ raise ValidationError, error_msg, e.backtrace
63
+ else
64
+ # First attempt - clear cache and retry (might be key rotation)
65
+ OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] Key with kid not found (clearing cache and retrying - possible key rotation): #{e.message}")
66
+ # Instrument key rotation detection
67
+ OmniauthOpenidFederation::Instrumentation.notify_key_rotation_detected(
68
+ jwks_uri: jwks_uri,
69
+ error_message: e.message,
70
+ error_class: e.class.name,
71
+ reason: "kid_not_found"
72
+ )
73
+ OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
74
+ run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
75
+ end
76
+ elsif e.is_a?(JWT::VerificationError) || e.message.include?("verification") || e.message.include?("signature")
77
+ # Verification error - might be key rotation, so retry
78
+ if retried
79
+ error_msg = "JWT signature verification failed after cache refresh (possible key rotation issue): #{e.class} - #{e.message}"
80
+ OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
81
+ # Instrument signature verification failure after retry
82
+ OmniauthOpenidFederation::Instrumentation.notify_signature_verification_failed(
83
+ token_type: "jwt",
84
+ jwks_uri: jwks_uri,
85
+ error_message: e.message,
86
+ error_class: e.class.name,
87
+ retried: true
88
+ )
89
+ raise SignatureError, error_msg, e.backtrace
90
+ else
91
+ OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] JWT signature verification failed (clearing cache and retrying - possible key rotation): #{e.class} - #{e.message}")
92
+ # Instrument key rotation detection
93
+ OmniauthOpenidFederation::Instrumentation.notify_key_rotation_detected(
94
+ jwks_uri: jwks_uri,
95
+ error_message: e.message,
96
+ error_class: e.class.name
97
+ )
98
+ OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
99
+ run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
100
+ end
101
+ else
102
+ # Format error - don't retry
103
+ error_msg = "JWT format error: #{e.class} - #{e.message}"
104
+ OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
105
+ raise ValidationError, error_msg, e.backtrace
106
+ end
107
+ rescue ArgumentError => e
108
+ # Argument errors from invalid JWT format
109
+ if e.message.include?("Invalid")
110
+ error_msg = "JWT decode failed due to invalid format: #{e.class} - #{e.message}"
111
+ OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
112
+ raise ValidationError, error_msg, e.backtrace
113
+ else
114
+ raise e
115
+ end
116
+ rescue => e
117
+ # Other errors might be due to key rotation
118
+ if retried
119
+ # If already re-tried to fetch, raise error
120
+ error_msg = "JWT decode failed after cache refresh: #{e.class} - #{e.message}"
121
+ OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
122
+ raise ValidationError, error_msg, e.backtrace
123
+ else
124
+ OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] JWT decode error (clearing cache and retrying - possible key rotation): #{e.class} - #{e.message}")
125
+ # Reset cache to force re-fetching of keys
126
+ OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
127
+ run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
128
+ end
129
+ end
130
+ end
131
+
132
+ # Decode JWT using JWT gem
133
+ #
134
+ # @param encoded_jwt [String] The JWT to decode
135
+ # @param jwks_uri [String] The JWKS URI for key lookup
136
+ # @param retried [Boolean] Internal flag for retry logic (default: false)
137
+ # @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validation
138
+ # @return [Array<Hash>] Array with [payload, header]
139
+ # @raise [ValidationError] If JWT validation fails
140
+ # @raise [SignatureError] If signature verification fails
141
+ def self.jwt(encoded_jwt, jwks_uri, retried: false, entity_statement_keys: nil)
142
+ run(encoded_jwt, jwks_uri, retried: retried, entity_statement_keys: entity_statement_keys) do |jwks|
143
+ # jwks should be a HashWithIndifferentAccess with "keys" array
144
+ jwks_for_decode = if jwks.is_a?(Hash) && jwks.key?("keys")
145
+ jwks
146
+ else
147
+ {keys: jwks}
148
+ end
149
+
150
+ ::JWT.decode(
151
+ encoded_jwt,
152
+ nil,
153
+ true,
154
+ {algorithms: ["RS256"], jwks: jwks_for_decode}
155
+ )
156
+ end
157
+ end
158
+
159
+ # Decode JWT using jwt gem (legacy method name kept for backward compatibility)
160
+ #
161
+ # @param encoded_jwt [String] The JWT to decode
162
+ # @param jwks_uri [String] The JWKS URI for key lookup
163
+ # @param retried [Boolean] Internal flag for retry logic (default: false)
164
+ # @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validation
165
+ # @return [Array<Hash>] Array with [payload, header]
166
+ # @raise [ValidationError] If JWT validation fails
167
+ # @raise [SignatureError] If signature verification fails
168
+ # @deprecated Use jwt() method instead
169
+ def self.json_jwt(encoded_jwt, jwks_uri, retried: false, entity_statement_keys: nil)
170
+ jwt(encoded_jwt, jwks_uri, retried: retried, entity_statement_keys: entity_statement_keys)
171
+ end
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,153 @@
1
+ require "http"
2
+ require "jwt"
3
+ require "openssl"
4
+ require_relative "../logger"
5
+ require_relative "../errors"
6
+ require_relative "../http_client"
7
+ require_relative "../cache"
8
+ require_relative "../cache_adapter"
9
+ require_relative "../utils"
10
+ require_relative "../constants"
11
+ require_relative "../rate_limiter"
12
+ require_relative "normalizer"
13
+
14
+ # JWKS fetching service with support for both standard and signed JWKS
15
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
16
+ # @see https://openid.net/specs/openid-federation-1_0.html#section-5.2.1.1 Section 5.2.1.1: Usage of jwks, jwks_uri, and signed_jwks_uri
17
+ #
18
+ # Fetches JWKS from either:
19
+ # - Standard JWKS endpoint (jwks_uri) - Returns JSON directly
20
+ # - Signed JWKS endpoint (signed_jwks_uri) - Returns a JWT containing JWKS, validated using entity statement keys
21
+ #
22
+ # Supports caching for performance (24 hour TTL by default) and handles both federation and standard OIDC scenarios.
23
+ # According to RFC 7517 and OpenID Connect best practices, JWKS should be cached to reduce latency and avoid
24
+ # unnecessary network requests. The cache TTL balances performance with key rotation needs.
25
+ #
26
+ # Key rotation is handled automatically via retry logic in Jwks::Decode which clears the cache
27
+ # on signature verification failures, allowing fresh keys to be fetched when providers rotate keys.
28
+ module OmniauthOpenidFederation
29
+ module Jwks
30
+ class Fetch
31
+ # Fetch JWKS from provider with caching support
32
+ #
33
+ # @param jwks_uri [String] The JWKS URI to fetch from
34
+ # @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validating signed JWKS
35
+ # @param cache_ttl [Integer, nil] Cache TTL in seconds (default: from configuration)
36
+ # - nil: Use configuration default (manual rotation if not set, or configured TTL)
37
+ # - positive integer: Cache expires after this many seconds
38
+ # @param force_refresh [Boolean] Force refresh even if cached (default: false)
39
+ # @return [Hash] JWKS hash with "keys" array
40
+ # @raise [FetchError] If fetching fails
41
+ def self.run(jwks_uri, entity_statement_keys: nil, cache_ttl: nil, force_refresh: false)
42
+ cache_key = OmniauthOpenidFederation::Cache.key_for_jwks(jwks_uri)
43
+ config = OmniauthOpenidFederation::Configuration.config
44
+ cache_ttl ||= config.cache_ttl
45
+ rotate_on_errors = config.rotate_on_errors
46
+
47
+ # Use cache adapter if available, otherwise fetch directly
48
+ if CacheAdapter.available?
49
+ if force_refresh
50
+ # Force refresh: clear cache and fetch fresh
51
+ CacheAdapter.delete(cache_key)
52
+ end
53
+
54
+ if cache_ttl.nil?
55
+ # Manual rotation: cache forever, only rotate on errors if rotate_on_errors is enabled
56
+ begin
57
+ CacheAdapter.fetch(cache_key, expires_in: nil) do
58
+ fetch_jwks(jwks_uri, entity_statement_keys)
59
+ end
60
+ rescue KeyRelatedError => e
61
+ # Rotate on key-related errors if configured
62
+ if rotate_on_errors
63
+ OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
64
+ CacheAdapter.delete(cache_key)
65
+ fetch_jwks(jwks_uri, entity_statement_keys)
66
+ else
67
+ raise
68
+ end
69
+ end
70
+ else
71
+ # TTL-based cache: expires after cache_ttl seconds
72
+ # Rotate on errors if configured
73
+ begin
74
+ CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
75
+ fetch_jwks(jwks_uri, entity_statement_keys)
76
+ end
77
+ rescue KeyRelatedError => e
78
+ # Rotate on key-related errors if configured
79
+ if rotate_on_errors
80
+ OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
81
+ CacheAdapter.delete(cache_key)
82
+ fetch_jwks(jwks_uri, entity_statement_keys)
83
+ else
84
+ raise
85
+ end
86
+ end
87
+ end
88
+ else
89
+ fetch_jwks(jwks_uri, entity_statement_keys)
90
+ end
91
+ end
92
+
93
+ def self.fetch_jwks(jwks_uri, entity_statement_keys)
94
+ # Rate limiting to prevent DoS
95
+ unless RateLimiter.allow?(jwks_uri)
96
+ raise FetchError, "Rate limit exceeded for JWKS fetching"
97
+ end
98
+
99
+ # Use HTTP client with retry logic and configurable SSL verification
100
+ begin
101
+ response = HttpClient.get(jwks_uri)
102
+ rescue OmniauthOpenidFederation::NetworkError => e
103
+ sanitized_uri = Utils.sanitize_uri(jwks_uri)
104
+ OmniauthOpenidFederation::Logger.error("[Jwks::Fetch] Failed to fetch JWKS from #{sanitized_uri}: #{e.message}")
105
+ raise FetchError, "Failed to fetch JWKS: #{e.message}", e.backtrace
106
+ end
107
+
108
+ unless response.status.success?
109
+ sanitized_uri = Utils.sanitize_uri(jwks_uri)
110
+ error_msg = "Failed to fetch JWKS: HTTP #{response.status}"
111
+ OmniauthOpenidFederation::Logger.error("[Jwks::Fetch] #{error_msg} from #{sanitized_uri}")
112
+ # If it's a key-related error (401, 403, 404), this might indicate key rotation
113
+ if Constants::KEY_ROTATION_HTTP_CODES.include?(response.status.code)
114
+ raise KeyRelatedError, error_msg
115
+ else
116
+ raise FetchError, error_msg
117
+ end
118
+ end
119
+
120
+ if entity_statement_keys
121
+ # Validate signed JWKS using entity statement keys
122
+ # If jwks_uri returns a JWT (signed JWKS), validate it
123
+ if Utils.valid_jwt_format?(response.body.to_s)
124
+ # It's a signed JWKS (JWT format)
125
+ jwks_jwt = response.body.to_s
126
+
127
+ # Convert entity statement keys to format expected by JWT gem
128
+ jwks_hash = Normalizer.to_jwks_hash(entity_statement_keys)
129
+
130
+ jwks_array = ::JWT.decode(
131
+ jwks_jwt,
132
+ nil,
133
+ true,
134
+ {algorithms: ["RS256"], jwks: jwks_hash}
135
+ )
136
+
137
+ # Extract JWKS from the decoded JWT payload
138
+ jwks_payload = jwks_array.first
139
+ Utils.to_indifferent_hash(jwks_payload)
140
+ else
141
+ # Standard JWKS JSON
142
+ json = response.parse(:json)
143
+ Utils.to_indifferent_hash(json)
144
+ end
145
+ else
146
+ # Standard JWKS without validation
147
+ json = response.parse(:json)
148
+ Utils.to_indifferent_hash(json)
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,49 @@
1
+ require "json"
2
+ require_relative "../validators"
3
+
4
+ # JWKS normalization utilities
5
+ # Converts various JWKS formats to the format expected by JWT gem
6
+ module OmniauthOpenidFederation
7
+ module Jwks
8
+ class Normalizer
9
+ # Convert JWKS to format expected by JWT gem
10
+ # Handles various input formats: Hash with "keys" or :keys, Array of keys, etc.
11
+ #
12
+ # @param jwks [Hash, Array, Object] JWKS in various formats
13
+ # @return [Hash] Normalized JWKS hash with "keys" array (string keys)
14
+ def self.to_jwks_hash(jwks)
15
+ if jwks.is_a?(Hash) && (jwks.key?("keys") || jwks.key?(:keys))
16
+ # Hash with keys array
17
+ keys = jwks["keys"] || jwks[:keys]
18
+ normalize_keys_array(keys)
19
+ elsif jwks.is_a?(Array)
20
+ # Array of keys
21
+ normalize_keys_array(jwks)
22
+ else
23
+ # Fallback: try to convert to hash
24
+ normalized = Validators.normalize_hash(jwks || {})
25
+ keys = normalized[:keys] || []
26
+ normalize_keys_array(keys)
27
+ end
28
+ end
29
+
30
+ # Normalize an array of JWK objects to hash format with string keys
31
+ #
32
+ # @param keys [Array] Array of JWK objects (Hash, etc.)
33
+ # @return [Hash] Hash with "keys" array containing normalized JWKs
34
+ def self.normalize_keys_array(keys)
35
+ {
36
+ "keys" => Array(keys).map do |jwk|
37
+ if jwk.is_a?(Hash)
38
+ jwk.stringify_keys
39
+ else
40
+ JSON.parse(jwk.to_json)
41
+ end
42
+ end
43
+ }
44
+ end
45
+
46
+ private_class_method :normalize_keys_array
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,97 @@
1
+ require_relative "../utils"
2
+ require_relative "../logger"
3
+ require_relative "../errors"
4
+ require_relative "../configuration"
5
+ require_relative "../string_helpers"
6
+ require_relative "../entity_statement_reader"
7
+ require_relative "fetch"
8
+ require_relative "../federation/entity_statement_helper"
9
+ require_relative "../federation/signed_jwks"
10
+
11
+ # JWKS rotation service for OpenID Federation 1.0
12
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
13
+ #
14
+ # Provides functionality to proactively refresh JWKS cache for providers.
15
+ # This is useful for background jobs to refresh keys before they expire.
16
+ #
17
+ # Supports both standard JWKS and signed JWKS (OpenID Federation).
18
+ module OmniauthOpenidFederation
19
+ module Jwks
20
+ # JWKS rotation service
21
+ #
22
+ # @example Rotate JWKS for a provider
23
+ # OmniauthOpenidFederation::Jwks::Rotate.run(
24
+ # "https://provider.example.com/.well-known/jwks.json",
25
+ # entity_statement_path: "config/provider-entity-statement.jwt"
26
+ # )
27
+ class Rotate
28
+ # Rotate JWKS cache for a provider
29
+ # This is useful for background jobs to proactively refresh keys
30
+ #
31
+ # @param jwks_uri [String] The JWKS URI to refresh
32
+ # @param entity_statement_path [String, nil] Path to entity statement file (optional)
33
+ # @return [Hash] The refreshed JWKS hash
34
+ # @raise [FetchError] If fetching fails
35
+ # @raise [ValidationError] If validation fails
36
+ # @raise [SecurityError] If path validation fails
37
+ # @raise [ConfigurationError] If entity statement file not found
38
+ def self.run(jwks_uri, entity_statement_path: nil)
39
+ if entity_statement_path
40
+ # Validate file path to prevent path traversal
41
+ begin
42
+ # Determine allowed directories for file path validation
43
+ config = Configuration.config
44
+ allowed_dirs = if defined?(Rails) && Rails.root
45
+ [Rails.root.join("config").to_s]
46
+ elsif config.root_path
47
+ [File.join(config.root_path, "config")]
48
+ end
49
+
50
+ validated_path = Utils.validate_file_path!(
51
+ entity_statement_path,
52
+ allowed_dirs: allowed_dirs
53
+ )
54
+ rescue SecurityError => e
55
+ Logger.error("[Jwks::Rotate] #{e.message}")
56
+ raise SecurityError, e.message
57
+ end
58
+
59
+ unless File.exist?(validated_path)
60
+ sanitized_path = Utils.sanitize_path(validated_path)
61
+ Logger.warn("[Jwks::Rotate] Entity statement file not found: #{sanitized_path}")
62
+ raise ConfigurationError, "Entity statement file not found: #{sanitized_path}"
63
+ end
64
+
65
+ # Try to use signed JWKS if entity statement is available
66
+ begin
67
+ parsed = Federation::EntityStatementHelper.parse_for_signed_jwks(validated_path)
68
+ if parsed && StringHelpers.present?(parsed[:signed_jwks_uri])
69
+ return Federation::SignedJWKS.fetch!(
70
+ parsed[:signed_jwks_uri],
71
+ parsed[:entity_jwks],
72
+ force_refresh: true
73
+ )
74
+ end
75
+ rescue SecurityError
76
+ raise
77
+ rescue
78
+ Logger.warn("[Jwks::Rotate] Failed to use signed JWKS, falling back to standard JWKS")
79
+ end
80
+
81
+ # Fallback to standard JWKS with entity statement keys
82
+ entity_statement_keys = EntityStatementReader.fetch_keys(
83
+ entity_statement_path: validated_path
84
+ )
85
+ return Fetch.run(
86
+ jwks_uri,
87
+ entity_statement_keys: entity_statement_keys,
88
+ force_refresh: true
89
+ )
90
+ end
91
+
92
+ # Use standard JWKS
93
+ Fetch.run(jwks_uri, force_refresh: true)
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,101 @@
1
+ require_relative "../logger"
2
+
3
+ # JWKS Selector utilities for filtering keys
4
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
5
+ #
6
+ # Provides utilities for selecting specific keys from a JWKS set, such as:
7
+ # - Current keys (first signing and encryption keys)
8
+ # - All keys (including previous keys for rotation support)
9
+ # - Keys by use (signing vs encryption)
10
+ # - Keys by kid (key ID)
11
+ module OmniauthOpenidFederation
12
+ module Jwks
13
+ class Selector
14
+ # Get current keys from JWKS (first signing and encryption keys)
15
+ # This is useful for operations that only need the current keys, not all keys
16
+ # including previous keys from rotation.
17
+ #
18
+ # @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
19
+ # @return [Hash] JWKS hash with only current keys (one signing, one encryption)
20
+ def self.current_keys(jwks)
21
+ keys = extract_keys_array(jwks)
22
+ return {"keys" => []} if keys.empty?
23
+
24
+ taken_uses = []
25
+ current_keys = keys.select do |key|
26
+ use = key[:use] || key["use"]
27
+ if use == "sig" && !taken_uses.include?("sig")
28
+ taken_uses << "sig"
29
+ true
30
+ elsif use == "enc" && !taken_uses.include?("enc")
31
+ taken_uses << "enc"
32
+ true
33
+ else
34
+ false
35
+ end
36
+ end
37
+
38
+ {"keys" => current_keys}
39
+ end
40
+
41
+ # Get all keys from JWKS (including previous keys from rotation)
42
+ #
43
+ # @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
44
+ # @return [Hash] JWKS hash with all keys
45
+ def self.all_keys(jwks)
46
+ keys = extract_keys_array(jwks)
47
+ {"keys" => keys}
48
+ end
49
+
50
+ # Get signing keys only
51
+ #
52
+ # @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
53
+ # @return [Array<Hash>] Array of signing keys
54
+ def self.signing_keys(jwks)
55
+ keys = extract_keys_array(jwks)
56
+ keys.select { |key| (key[:use] || key["use"]) == "sig" }
57
+ end
58
+
59
+ # Get encryption keys only
60
+ #
61
+ # @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
62
+ # @return [Array<Hash>] Array of encryption keys
63
+ def self.encryption_keys(jwks)
64
+ keys = extract_keys_array(jwks)
65
+ keys.select { |key| (key[:use] || key["use"]) == "enc" }
66
+ end
67
+
68
+ # Get key by kid (key ID)
69
+ #
70
+ # @param jwks [Hash, Array] JWKS hash with "keys" array or array of keys
71
+ # @param kid [String] The key ID to find
72
+ # @return [Hash, nil] The key with matching kid, or nil if not found
73
+ def self.key_by_kid(jwks, kid)
74
+ keys = extract_keys_array(jwks)
75
+ keys.find { |key| (key[:kid] || key["kid"]) == kid }
76
+ end
77
+
78
+ # Extract keys array from various JWKS formats
79
+ #
80
+ # @param jwks [Hash, Array] JWKS in various formats
81
+ # @return [Array<Hash>] Array of key hashes
82
+ def self.extract_keys_array(jwks)
83
+ if jwks.is_a?(Hash)
84
+ if jwks.key?("keys")
85
+ jwks["keys"] || []
86
+ elsif jwks.key?(:keys)
87
+ jwks[:keys] || []
88
+ else
89
+ []
90
+ end
91
+ elsif jwks.is_a?(Array)
92
+ jwks
93
+ else
94
+ []
95
+ end
96
+ end
97
+
98
+ private_class_method :extract_keys_array
99
+ end
100
+ end
101
+ end