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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.md +22 -0
- data/README.md +822 -0
- data/SECURITY.md +129 -0
- data/examples/README_INTEGRATION_TESTING.md +399 -0
- data/examples/README_MOCK_OP.md +243 -0
- data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
- data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
- data/examples/app/models/user.rb.example +39 -0
- data/examples/config/initializers/devise.rb.example +97 -0
- data/examples/config/initializers/federation_endpoint.rb.example +206 -0
- data/examples/config/mock_op.yml.example +83 -0
- data/examples/config/open_id_connect_config.rb.example +210 -0
- data/examples/config/routes.rb.example +12 -0
- data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
- data/examples/integration_test_flow.rb +1334 -0
- data/examples/jobs/README.md +194 -0
- data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
- data/examples/jobs/federation_files_generation_job.rb.example +87 -0
- data/examples/mock_op_server.rb +775 -0
- data/examples/mock_rp_server.rb +435 -0
- data/lib/omniauth_openid_federation/access_token.rb +504 -0
- data/lib/omniauth_openid_federation/cache.rb +39 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
- data/lib/omniauth_openid_federation/configuration.rb +135 -0
- data/lib/omniauth_openid_federation/constants.rb +13 -0
- data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
- data/lib/omniauth_openid_federation/errors.rb +52 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
- data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
- data/lib/omniauth_openid_federation/http_client.rb +70 -0
- data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
- data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
- data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
- data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
- data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
- data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
- data/lib/omniauth_openid_federation/jws.rb +416 -0
- data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
- data/lib/omniauth_openid_federation/logger.rb +99 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
- data/lib/omniauth_openid_federation/railtie.rb +29 -0
- data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
- data/lib/omniauth_openid_federation/strategy.rb +2029 -0
- data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
- data/lib/omniauth_openid_federation/utils.rb +166 -0
- data/lib/omniauth_openid_federation/validators.rb +126 -0
- data/lib/omniauth_openid_federation/version.rb +3 -0
- data/lib/omniauth_openid_federation.rb +98 -0
- data/lib/tasks/omniauth_openid_federation.rake +376 -0
- data/sig/federation.rbs +218 -0
- data/sig/jwks.rbs +63 -0
- data/sig/omniauth_openid_federation.rbs +254 -0
- data/sig/strategy.rbs +60 -0
- 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
|