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,276 @@
1
+ require_relative "../logger"
2
+ require_relative "../errors"
3
+
4
+ # Metadata Policy Merger for OpenID Federation 1.0
5
+ # @see https://openid.net/specs/openid-federation-1_0.html#section-5.1 Section 5.1: Metadata Policy
6
+ #
7
+ # Merges metadata policies from a Trust Chain and applies them to entity metadata.
8
+ # Policies are merged from Trust Anchor down to the immediate issuer, then applied
9
+ # to the leaf entity's metadata.
10
+ #
11
+ # @example Merge and apply metadata policies
12
+ # merger = MetadataPolicyMerger.new(trust_chain: trust_chain_statements)
13
+ # effective_metadata = merger.merge_and_apply(leaf_metadata)
14
+ module OmniauthOpenidFederation
15
+ module Federation
16
+ # Metadata Policy Merger for OpenID Federation 1.0
17
+ #
18
+ # Merges metadata policies from Subordinate Statements in a Trust Chain
19
+ # and applies them to entity metadata.
20
+ class MetadataPolicyMerger
21
+ # Initialize merger
22
+ #
23
+ # @param trust_chain [Array<EntityStatement, Hash>] Array of entity statements in trust chain
24
+ # (from Leaf to Trust Anchor)
25
+ def initialize(trust_chain:)
26
+ @trust_chain = trust_chain
27
+ @merged_policies = nil
28
+ end
29
+
30
+ # Merge all metadata policies from the trust chain
31
+ #
32
+ # @return [Hash] Merged metadata policies by entity type and parameter
33
+ # @raise [ValidationError] If policy merging fails due to conflicts
34
+ def merge_policies
35
+ return @merged_policies if @merged_policies
36
+
37
+ @merged_policies = {}
38
+
39
+ # Extract policies from Subordinate Statements (skip Entity Configurations)
40
+ subordinate_statements = @trust_chain.select do |statement|
41
+ parsed = statement.is_a?(Hash) ? statement : statement.parse
42
+ parsed[:is_subordinate_statement] || parsed["is_subordinate_statement"]
43
+ end
44
+
45
+ # Merge policies from Trust Anchor down to immediate issuer
46
+ # (reverse order: Trust Anchor first, then intermediates, then immediate issuer)
47
+ subordinate_statements.reverse_each do |statement|
48
+ parsed = statement.is_a?(Hash) ? statement : statement.parse
49
+ metadata_policy = parsed[:metadata_policy] || parsed["metadata_policy"]
50
+ next unless metadata_policy
51
+
52
+ merge_single_policy(metadata_policy)
53
+ end
54
+
55
+ @merged_policies
56
+ end
57
+
58
+ # Apply merged policies to entity metadata
59
+ #
60
+ # @param entity_metadata [Hash] Original entity metadata
61
+ # @return [Hash] Effective metadata after applying policies
62
+ # @raise [ValidationError] If metadata does not comply with policies
63
+ def apply_policies(entity_metadata)
64
+ merged = merge_policies
65
+ effective_metadata = deep_dup(entity_metadata)
66
+
67
+ # Apply policies for each entity type
68
+ merged.each do |entity_type, type_policies|
69
+ entity_type_metadata = effective_metadata[entity_type.to_sym] || effective_metadata[entity_type.to_s] || {}
70
+
71
+ # Apply policies for each metadata parameter
72
+ type_policies.each do |param_name, param_policy|
73
+ apply_parameter_policy(entity_type_metadata, param_name, param_policy)
74
+ end
75
+
76
+ # Store back to effective metadata
77
+ effective_metadata[entity_type.to_sym] = entity_type_metadata
78
+ end
79
+
80
+ # Validate final metadata against policies
81
+ validate_metadata_compliance(effective_metadata, merged)
82
+
83
+ effective_metadata
84
+ end
85
+
86
+ # Merge and apply policies in one step
87
+ #
88
+ # @param entity_metadata [Hash] Original entity metadata
89
+ # @return [Hash] Effective metadata after applying policies
90
+ # @raise [ValidationError] If merging or application fails
91
+ def merge_and_apply(entity_metadata)
92
+ apply_policies(entity_metadata)
93
+ end
94
+
95
+ private
96
+
97
+ def merge_single_policy(metadata_policy)
98
+ metadata_policy.each do |entity_type, type_policies|
99
+ entity_type_str = entity_type.to_s
100
+ @merged_policies[entity_type_str] ||= {}
101
+
102
+ type_policies.each do |param_name, param_policy|
103
+ param_name_str = param_name.to_s
104
+ existing_policy = @merged_policies[entity_type_str][param_name_str]
105
+
106
+ @merged_policies[entity_type_str][param_name_str] = if existing_policy
107
+ merge_parameter_policies(
108
+ existing_policy,
109
+ param_policy
110
+ )
111
+ else
112
+ normalize_keys_to_strings(deep_dup(param_policy))
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ def merge_parameter_policies(existing_policy, new_policy)
119
+ merged = deep_dup(existing_policy)
120
+ # Normalize merged to use string keys for consistency
121
+ merged = normalize_keys_to_strings(merged)
122
+
123
+ new_policy.each do |operator, value|
124
+ operator_str = operator.to_s
125
+
126
+ case operator_str
127
+ when "value"
128
+ # value operator: values must be equal, or this is an error
129
+ if merged["value"] && merged["value"] != value
130
+ raise ValidationError, "Conflicting 'value' operators in metadata policy: #{merged["value"]} vs #{value}"
131
+ end
132
+ merged["value"] = value
133
+
134
+ when "add"
135
+ # add operator: union of values
136
+ existing_add = merged["add"] || []
137
+ new_add = value.is_a?(Array) ? value : [value]
138
+ merged["add"] = (existing_add + new_add).uniq
139
+
140
+ when "one_of"
141
+ # one_of operator: intersection of values
142
+ existing_one_of = merged["one_of"] || []
143
+ new_one_of = value.is_a?(Array) ? value : [value]
144
+ intersection = existing_one_of & new_one_of
145
+ if intersection.empty? && !existing_one_of.empty? && !new_one_of.empty?
146
+ raise ValidationError, "Conflicting 'one_of' operators: no intersection between #{existing_one_of} and #{new_one_of}"
147
+ end
148
+ merged["one_of"] = intersection.empty? ? new_one_of : intersection
149
+
150
+ when "subset_of"
151
+ # subset_of operator: intersection of values
152
+ existing_subset = merged["subset_of"] || []
153
+ new_subset = value.is_a?(Array) ? value : [value]
154
+ intersection = existing_subset & new_subset
155
+ merged["subset_of"] = intersection.empty? ? new_subset : intersection
156
+
157
+ when "default"
158
+ # default operator: values must be equal
159
+ if merged["default"] && merged["default"] != value
160
+ raise ValidationError, "Conflicting 'default' operators in metadata policy: #{merged["default"]} vs #{value}"
161
+ end
162
+ merged["default"] = value
163
+
164
+ when "superset_of", "essential"
165
+ # These operators are preserved as-is (validation only, no merging needed)
166
+ merged[operator_str] = value
167
+
168
+ else
169
+ # Unknown operator - preserve it
170
+ OmniauthOpenidFederation::Logger.warn("[MetadataPolicyMerger] Unknown operator: #{operator_str}")
171
+ merged[operator_str] = value
172
+ end
173
+ end
174
+
175
+ merged
176
+ end
177
+
178
+ def apply_parameter_policy(metadata, param_name, policy)
179
+ param_value = metadata[param_name.to_sym] || metadata[param_name.to_s]
180
+
181
+ # Apply operators in order: value -> add -> default -> one_of/subset_of/superset_of
182
+ if policy.key?("value")
183
+ # value operator: set to specific value (or remove if null)
184
+ if policy["value"].nil?
185
+ metadata.delete(param_name.to_sym)
186
+ metadata.delete(param_name.to_s)
187
+ param_value = nil
188
+ else
189
+ metadata[param_name.to_sym] = policy["value"]
190
+ param_value = policy["value"]
191
+ end
192
+ end
193
+
194
+ if policy["add"] && param_value.is_a?(Array)
195
+ # add operator: add values to array
196
+ add_values = policy["add"]
197
+ param_value = (param_value + add_values).uniq
198
+ metadata[param_name.to_sym] = param_value
199
+ end
200
+
201
+ if policy["default"] && (param_value.nil? || (param_value.is_a?(Array) && param_value.empty?))
202
+ # default operator: set default if absent
203
+ metadata[param_name.to_sym] = policy["default"]
204
+ param_value = policy["default"]
205
+ end
206
+
207
+ # Validation operators (check but don't modify)
208
+ if policy["one_of"] && param_value
209
+ unless policy["one_of"].include?(param_value)
210
+ raise ValidationError, "Metadata parameter '#{param_name}' value '#{param_value}' is not in one_of list: #{policy["one_of"]}"
211
+ end
212
+ end
213
+
214
+ if policy["subset_of"] && param_value.is_a?(Array)
215
+ subset = param_value & policy["subset_of"]
216
+ if subset != param_value
217
+ raise ValidationError, "Metadata parameter '#{param_name}' values are not a subset of allowed values: #{policy["subset_of"]}"
218
+ end
219
+ # subset_of also modifies: set to intersection
220
+ metadata[param_name.to_sym] = subset
221
+ end
222
+
223
+ if policy["superset_of"] && param_value.is_a?(Array)
224
+ unless (policy["superset_of"] - param_value).empty?
225
+ raise ValidationError, "Metadata parameter '#{param_name}' does not contain all required values: #{policy["superset_of"]}"
226
+ end
227
+ end
228
+
229
+ if policy["essential"] && param_value.nil?
230
+ raise ValidationError, "Metadata parameter '#{param_name}' is marked as essential but is absent"
231
+ end
232
+ end
233
+
234
+ def validate_metadata_compliance(effective_metadata, merged_policies)
235
+ merged_policies.each do |entity_type, type_policies|
236
+ entity_type_metadata = effective_metadata[entity_type.to_sym] || effective_metadata[entity_type.to_s] || {}
237
+
238
+ type_policies.each do |param_name, param_policy|
239
+ param_value = entity_type_metadata[param_name.to_sym] || entity_type_metadata[param_name.to_s]
240
+
241
+ # Final validation checks
242
+ if param_policy["essential"] && param_value.nil?
243
+ raise ValidationError, "Essential metadata parameter '#{param_name}' for entity type '#{entity_type}' is missing"
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ def deep_dup(obj)
250
+ case obj
251
+ when Hash
252
+ obj.each_with_object({}) do |(k, v), h|
253
+ h[k] = deep_dup(v)
254
+ end
255
+ when Array
256
+ obj.map { |e| deep_dup(e) }
257
+ else
258
+ obj
259
+ end
260
+ end
261
+
262
+ def normalize_keys_to_strings(obj)
263
+ case obj
264
+ when Hash
265
+ obj.each_with_object({}) do |(k, v), h|
266
+ h[k.to_s] = normalize_keys_to_strings(v)
267
+ end
268
+ when Array
269
+ obj.map { |e| normalize_keys_to_strings(e) }
270
+ else
271
+ obj
272
+ end
273
+ end
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,210 @@
1
+ require "http"
2
+ require "jwt"
3
+ require "base64"
4
+ require "openssl"
5
+ require "digest"
6
+ require "json"
7
+ require_relative "../key_extractor"
8
+ require_relative "../logger"
9
+ require_relative "../errors"
10
+ require_relative "../http_client"
11
+ require_relative "../validators"
12
+ require_relative "../cache"
13
+ require_relative "../cache_adapter"
14
+ require_relative "../utils"
15
+ require_relative "../constants"
16
+ require_relative "../rate_limiter"
17
+ require_relative "../jwks/normalizer"
18
+
19
+ # Signed JWKS implementation for OpenID Federation 1.0
20
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
21
+ # @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
22
+ #
23
+ # Signed JWKS are JWTs containing a JWKS (JSON Web Key Set) that are signed using
24
+ # keys from the entity statement. This provides secure key rotation/updates as
25
+ # required by OpenID Federation 1.0 specification.
26
+ #
27
+ # The signed_jwks_uri endpoint returns a JWT that:
28
+ # - Contains the provider's current JWKS in the payload
29
+ # - Is signed using a key from the entity statement's JWKS
30
+ # - Can be validated to ensure keys haven't been tampered with
31
+ #
32
+ # This implementation:
33
+ # - Fetches signed JWKS from the signed_jwks_uri endpoint
34
+ # - Validates the signature using entity statement JWKS
35
+ # - Caches the result for performance
36
+ # - Handles errors gracefully with fallback to standard JWKS
37
+ module OmniauthOpenidFederation
38
+ module Federation
39
+ # Signed JWKS implementation for OpenID Federation 1.0
40
+ #
41
+ # @example Fetch and validate signed JWKS
42
+ # signed_jwks = SignedJWKS.fetch!(
43
+ # "https://provider.example.com/.well-known/signed-jwks",
44
+ # entity_jwks
45
+ # )
46
+ class SignedJWKS
47
+ # Compatibility aliases for backward compatibility
48
+ FetchError = OmniauthOpenidFederation::FetchError
49
+ ValidationError = OmniauthOpenidFederation::ValidationError
50
+
51
+ # Fetch and validate signed JWKS
52
+ #
53
+ # @param signed_jwks_uri [String] The URI to fetch signed JWKS from
54
+ # @param entity_jwks [Hash, Array] Entity statement JWKS for validation
55
+ # @param cache_key [String, nil] Custom cache key (default: auto-generated)
56
+ # @param cache_ttl [Integer, nil] Cache TTL in seconds (default: from configuration)
57
+ # - nil: Use configuration default (manual rotation if not set, or configured TTL)
58
+ # - positive integer: Cache expires after this many seconds
59
+ # @param force_refresh [Boolean] Force refresh even if cached (default: false)
60
+ # @return [Hash] The validated JWKS hash
61
+ # @raise [FetchError] If fetching fails
62
+ # @raise [ValidationError] If validation fails
63
+ def self.fetch!(signed_jwks_uri, entity_jwks, cache_key: nil, cache_ttl: nil, force_refresh: false)
64
+ cache_key ||= OmniauthOpenidFederation::Cache.key_for_signed_jwks(signed_jwks_uri)
65
+ config = OmniauthOpenidFederation::Configuration.config
66
+ cache_ttl ||= config.cache_ttl
67
+ rotate_on_errors = config.rotate_on_errors
68
+
69
+ # Use cache adapter if available, otherwise fetch directly
70
+ if CacheAdapter.available?
71
+ if force_refresh
72
+ # Force refresh: clear cache and fetch fresh
73
+ CacheAdapter.delete(cache_key)
74
+ end
75
+
76
+ if cache_ttl.nil?
77
+ # Manual rotation: cache forever, only rotate on errors if rotate_on_errors is enabled
78
+ begin
79
+ CacheAdapter.fetch(cache_key, expires_in: nil) do
80
+ new(signed_jwks_uri, entity_jwks).fetch_and_validate
81
+ end
82
+ rescue KeyRelatedError, KeyRelatedValidationError => e
83
+ # Rotate on key-related errors if configured
84
+ if rotate_on_errors
85
+ OmniauthOpenidFederation::Logger.warn("[SignedJWKS] Key-related error detected, rotating cache: #{e.message}")
86
+ CacheAdapter.delete(cache_key)
87
+ new(signed_jwks_uri, entity_jwks).fetch_and_validate
88
+ else
89
+ raise
90
+ end
91
+ end
92
+ else
93
+ # TTL-based cache: expires after cache_ttl seconds
94
+ # Rotate on errors if configured
95
+ begin
96
+ CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
97
+ new(signed_jwks_uri, entity_jwks).fetch_and_validate
98
+ end
99
+ rescue KeyRelatedError, KeyRelatedValidationError => e
100
+ # Rotate on key-related errors if configured
101
+ if rotate_on_errors
102
+ OmniauthOpenidFederation::Logger.warn("[SignedJWKS] Key-related error detected, rotating cache: #{e.message}")
103
+ CacheAdapter.delete(cache_key)
104
+ new(signed_jwks_uri, entity_jwks).fetch_and_validate
105
+ else
106
+ raise
107
+ end
108
+ end
109
+ end
110
+ else
111
+ new(signed_jwks_uri, entity_jwks).fetch_and_validate
112
+ end
113
+ end
114
+
115
+ # Initialize signed JWKS fetcher
116
+ #
117
+ # @param signed_jwks_uri [String] The URI to fetch signed JWKS from
118
+ # @param entity_jwks [Hash, Array] Entity statement JWKS for validation
119
+ def initialize(signed_jwks_uri, entity_jwks)
120
+ @signed_jwks_uri = signed_jwks_uri
121
+ @entity_jwks = entity_jwks
122
+ end
123
+
124
+ # Fetch and validate signed JWKS
125
+ #
126
+ # @return [Hash] The validated JWKS hash
127
+ # @raise [FetchError] If fetching fails
128
+ # @raise [ValidationError] If validation fails
129
+ def fetch_and_validate
130
+ # Rate limiting to prevent DoS
131
+ unless RateLimiter.allow?(@signed_jwks_uri)
132
+ raise FetchError, "Rate limit exceeded for signed JWKS fetching"
133
+ end
134
+
135
+ # Fetch signed JWKS using HttpClient with retry logic
136
+ begin
137
+ response = HttpClient.get(@signed_jwks_uri)
138
+ rescue OmniauthOpenidFederation::NetworkError => e
139
+ sanitized_uri = Utils.sanitize_uri(@signed_jwks_uri)
140
+ OmniauthOpenidFederation::Logger.error("[SignedJWKS] Failed to fetch signed JWKS from #{sanitized_uri}")
141
+ raise FetchError, "Failed to fetch signed JWKS: #{e.message}", e.backtrace
142
+ end
143
+
144
+ unless response.status.success?
145
+ error_msg = "Failed to fetch signed JWKS: HTTP #{response.status}"
146
+ OmniauthOpenidFederation::Logger.error("[SignedJWKS] #{error_msg}")
147
+ # If it's a key-related error (401, 403, 404), this might indicate key rotation
148
+ if Constants::KEY_ROTATION_HTTP_CODES.include?(response.status.code)
149
+ raise KeyRelatedError, error_msg
150
+ else
151
+ raise FetchError, error_msg
152
+ end
153
+ end
154
+
155
+ signed_jwks_jwt = response.body.to_s
156
+
157
+ # Validate it's a JWT (must have exactly 3 parts: header.payload.signature)
158
+ unless Utils.valid_jwt_format?(signed_jwks_jwt)
159
+ raise ValidationError, "Signed JWKS is not in JWT format"
160
+ end
161
+
162
+ # Convert entity JWKS to format expected by JWT gem
163
+ jwks_hash = Jwks::Normalizer.to_jwks_hash(@entity_jwks)
164
+
165
+ # Decode and validate signed JWKS
166
+ begin
167
+ OmniauthOpenidFederation::Logger.debug("[SignedJWKS] Validating signed JWKS signature")
168
+ decoded = ::JWT.decode(
169
+ signed_jwks_jwt,
170
+ nil,
171
+ true,
172
+ {algorithms: ["RS256"], jwks: jwks_hash}
173
+ )
174
+
175
+ # Extract JWKS from decoded JWT payload
176
+ # The JWT payload can be in two formats:
177
+ # 1. OpenID Federation format: { iss, sub, iat, exp, jwks: { keys: [...] } }
178
+ # 2. Legacy format: { keys: [...] } (direct JWKS)
179
+ full_payload = decoded.first
180
+
181
+ # Check if payload has 'jwks' field (OpenID Federation format)
182
+ if full_payload.key?("jwks") || full_payload.key?(:jwks)
183
+ jwks_payload = full_payload["jwks"] || full_payload[:jwks]
184
+ elsif full_payload.key?("keys") || full_payload.key?(:keys)
185
+ # Legacy format: payload is the JWKS directly
186
+ jwks_payload = full_payload
187
+ else
188
+ error_msg = "Signed JWKS payload does not contain 'jwks' or 'keys' field"
189
+ OmniauthOpenidFederation::Logger.error("[SignedJWKS] #{error_msg}")
190
+ raise ValidationError, error_msg
191
+ end
192
+
193
+ OmniauthOpenidFederation::Logger.debug("[SignedJWKS] Successfully validated signed JWKS")
194
+
195
+ # Ensure it's a HashWithIndifferentAccess if available
196
+ Utils.to_indifferent_hash(jwks_payload)
197
+ rescue JWT::VerificationError => e
198
+ # More specific exception must be rescued first
199
+ error_msg = "Signed JWKS signature validation failed: #{e.class} - #{e.message}"
200
+ OmniauthOpenidFederation::Logger.error("[SignedJWKS] #{error_msg}")
201
+ raise KeyRelatedValidationError, error_msg
202
+ rescue JWT::DecodeError => e
203
+ error_msg = "Failed to decode signed JWKS: #{e.class} - #{e.message}"
204
+ OmniauthOpenidFederation::Logger.error("[SignedJWKS] #{error_msg}")
205
+ raise KeyRelatedValidationError, error_msg
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end