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,502 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "base64"
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../logger"
|
|
5
|
+
require_relative "../errors"
|
|
6
|
+
require_relative "../configuration"
|
|
7
|
+
|
|
8
|
+
# Entity Statement Validator for OpenID Federation 1.0
|
|
9
|
+
# @see https://openid.net/specs/openid-federation-1_0.html#section-3.2.1 Section 3.2.1: Entity Statement Validation
|
|
10
|
+
#
|
|
11
|
+
# Implements all required validation steps from OpenID Federation 1.0 Section 3.2.1.
|
|
12
|
+
# Entity Statements MUST be validated in the following manner per the specification.
|
|
13
|
+
#
|
|
14
|
+
# @example Validate an entity statement
|
|
15
|
+
# validator = EntityStatementValidator.new(
|
|
16
|
+
# jwt_string: entity_statement_jwt,
|
|
17
|
+
# issuer_entity_configuration: issuer_config # Optional, for Subordinate Statement validation
|
|
18
|
+
# )
|
|
19
|
+
# validator.validate!
|
|
20
|
+
module OmniauthOpenidFederation
|
|
21
|
+
module Federation
|
|
22
|
+
# Entity Statement Validator for OpenID Federation 1.0
|
|
23
|
+
#
|
|
24
|
+
# Validates entity statements according to Section 3.2.1 of the OpenID Federation 1.0 specification.
|
|
25
|
+
class EntityStatementValidator
|
|
26
|
+
# Standard JWT has 3 parts: header.payload.signature
|
|
27
|
+
JWT_PARTS_COUNT = 3
|
|
28
|
+
|
|
29
|
+
# Required typ header value for entity statements
|
|
30
|
+
REQUIRED_TYP = "entity-statement+jwt"
|
|
31
|
+
|
|
32
|
+
# Supported signing algorithms (per spec, RS256 is required by OpenID Connect Core)
|
|
33
|
+
SUPPORTED_ALGORITHMS = %w[RS256 PS256 ES256 ES384 ES512].freeze
|
|
34
|
+
|
|
35
|
+
# Initialize validator
|
|
36
|
+
#
|
|
37
|
+
# @param jwt_string [String] The entity statement JWT string to validate
|
|
38
|
+
# @param issuer_entity_configuration [Hash, EntityStatement, nil] Optional: Entity Configuration of the issuer
|
|
39
|
+
# Required for validating Subordinate Statements (when iss != sub)
|
|
40
|
+
# @param clock_skew_tolerance [Integer, nil] Clock skew tolerance in seconds (default: from config)
|
|
41
|
+
def initialize(jwt_string:, issuer_entity_configuration: nil, clock_skew_tolerance: nil)
|
|
42
|
+
@jwt_string = jwt_string
|
|
43
|
+
@issuer_entity_configuration = issuer_entity_configuration
|
|
44
|
+
@clock_skew_tolerance = clock_skew_tolerance || OmniauthOpenidFederation.config.clock_skew_tolerance
|
|
45
|
+
@header = nil
|
|
46
|
+
@payload = nil
|
|
47
|
+
@is_entity_configuration = nil
|
|
48
|
+
@is_subordinate_statement = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Validate the entity statement
|
|
52
|
+
#
|
|
53
|
+
# @return [Hash] Validated entity statement with header and claims
|
|
54
|
+
# @raise [ValidationError] If validation fails at any step
|
|
55
|
+
def validate!
|
|
56
|
+
# Step 1: Entity Statement MUST be a signed JWT
|
|
57
|
+
validate_jwt_format
|
|
58
|
+
|
|
59
|
+
# Step 2: Validate typ header (MUST be "entity-statement+jwt")
|
|
60
|
+
validate_typ_header
|
|
61
|
+
|
|
62
|
+
# Step 3: Validate alg header (MUST be present and not "none")
|
|
63
|
+
validate_alg_header
|
|
64
|
+
|
|
65
|
+
# Step 4: Validate sub claim matches Entity Identifier
|
|
66
|
+
validate_sub_claim
|
|
67
|
+
|
|
68
|
+
# Step 5: Validate iss claim is valid Entity Identifier
|
|
69
|
+
validate_iss_claim
|
|
70
|
+
|
|
71
|
+
# Step 6: Determine Entity Configuration vs Subordinate Statement
|
|
72
|
+
determine_statement_type
|
|
73
|
+
|
|
74
|
+
# Step 7: Validate authority_hints for Subordinate Statements
|
|
75
|
+
validate_authority_hints if @is_subordinate_statement
|
|
76
|
+
|
|
77
|
+
# Step 8: Validate iat claim (issued at time)
|
|
78
|
+
validate_iat_claim
|
|
79
|
+
|
|
80
|
+
# Step 9: Validate exp claim (expiration time)
|
|
81
|
+
validate_exp_claim
|
|
82
|
+
|
|
83
|
+
# Step 10: Validate jwks claim (MUST be present and valid)
|
|
84
|
+
validate_jwks_claim
|
|
85
|
+
|
|
86
|
+
# Step 11: Validate kid header (MUST be non-zero length string)
|
|
87
|
+
validate_kid_header
|
|
88
|
+
|
|
89
|
+
# Step 12: Validate kid matches key in issuer's JWKS
|
|
90
|
+
validate_kid_matching
|
|
91
|
+
|
|
92
|
+
# Step 13: Validate signature (if issuer configuration provided)
|
|
93
|
+
validate_signature if @issuer_entity_configuration || @is_entity_configuration
|
|
94
|
+
|
|
95
|
+
# Step 14: Validate crit claim (if present)
|
|
96
|
+
validate_crit_claim
|
|
97
|
+
|
|
98
|
+
# Step 15: Validate authority_hints syntax (if present)
|
|
99
|
+
validate_authority_hints_syntax if @header && @payload && @is_entity_configuration
|
|
100
|
+
|
|
101
|
+
# Step 16: Validate metadata syntax (if present)
|
|
102
|
+
validate_metadata_syntax
|
|
103
|
+
|
|
104
|
+
# Step 17: Validate metadata_policy (if present, MUST be Subordinate Statement)
|
|
105
|
+
validate_metadata_policy_presence
|
|
106
|
+
|
|
107
|
+
# Step 18: Validate metadata_policy_crit (if present, MUST be Subordinate Statement)
|
|
108
|
+
validate_metadata_policy_crit_presence
|
|
109
|
+
|
|
110
|
+
# Step 19: Validate constraints (if present, MUST be Subordinate Statement)
|
|
111
|
+
validate_constraints_presence
|
|
112
|
+
|
|
113
|
+
# Step 20: Validate trust_marks (if present, MUST be Entity Configuration)
|
|
114
|
+
validate_trust_marks_presence
|
|
115
|
+
|
|
116
|
+
# Step 21: Validate trust_mark_issuers (if present, MUST be Entity Configuration)
|
|
117
|
+
validate_trust_mark_issuers_presence
|
|
118
|
+
|
|
119
|
+
# Step 22: Validate trust_mark_owners (if present, MUST be Entity Configuration)
|
|
120
|
+
validate_trust_mark_owners_presence
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
header: @header,
|
|
124
|
+
claims: @payload,
|
|
125
|
+
is_entity_configuration: @is_entity_configuration,
|
|
126
|
+
is_subordinate_statement: @is_subordinate_statement
|
|
127
|
+
}
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
private
|
|
131
|
+
|
|
132
|
+
# Step 1: Entity Statement MUST be a signed JWT
|
|
133
|
+
def validate_jwt_format
|
|
134
|
+
jwt_parts = @jwt_string.split(".")
|
|
135
|
+
if jwt_parts.length != JWT_PARTS_COUNT
|
|
136
|
+
raise ValidationError, "Invalid JWT format: expected #{JWT_PARTS_COUNT} parts (header.payload.signature), got #{jwt_parts.length}"
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Decode header and payload
|
|
140
|
+
begin
|
|
141
|
+
@header = JSON.parse(Base64.urlsafe_decode64(jwt_parts[0]))
|
|
142
|
+
@payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
143
|
+
rescue JSON::ParserError => e
|
|
144
|
+
raise ValidationError, "Failed to parse entity statement JWT: #{e.message}"
|
|
145
|
+
rescue ArgumentError => e
|
|
146
|
+
raise ValidationError, "Failed to decode entity statement JWT: #{e.message}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Step 2: Entity Statement MUST have typ header with value "entity-statement+jwt"
|
|
151
|
+
def validate_typ_header
|
|
152
|
+
typ = @header["typ"] || @header[:typ]
|
|
153
|
+
unless typ == REQUIRED_TYP
|
|
154
|
+
raise ValidationError, "Invalid entity statement type: expected '#{REQUIRED_TYP}', got '#{typ}'. Entity statements without the correct typ header MUST be rejected per OpenID Federation 1.0 Section 3.1."
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# Step 3: Entity Statement MUST have alg header that is present and not "none"
|
|
159
|
+
def validate_alg_header
|
|
160
|
+
alg = @header["alg"] || @header[:alg]
|
|
161
|
+
if alg.nil? || alg.empty?
|
|
162
|
+
raise ValidationError, "Entity statement MUST have an alg (algorithm) header parameter"
|
|
163
|
+
end
|
|
164
|
+
if alg == "none"
|
|
165
|
+
raise ValidationError, "Entity statement alg header MUST NOT be 'none'"
|
|
166
|
+
end
|
|
167
|
+
# Note: We don't reject unsupported algorithms here, but log a warning
|
|
168
|
+
unless SUPPORTED_ALGORITHMS.include?(alg)
|
|
169
|
+
OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Unsupported algorithm: #{alg}. Supported: #{SUPPORTED_ALGORITHMS.join(", ")}")
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Step 4: Entity Identifier MUST match sub claim
|
|
174
|
+
# Note: This is a structural check. The actual Entity Identifier validation
|
|
175
|
+
# would require knowing the expected Entity Identifier, which is context-dependent.
|
|
176
|
+
def validate_sub_claim
|
|
177
|
+
sub = @payload["sub"]
|
|
178
|
+
if sub.nil? || sub.empty?
|
|
179
|
+
raise ValidationError, "Entity statement MUST have a sub (subject) claim with a valid Entity Identifier"
|
|
180
|
+
end
|
|
181
|
+
# Basic Entity Identifier format validation (should be a URI)
|
|
182
|
+
unless sub.is_a?(String) && sub.start_with?("http://", "https://")
|
|
183
|
+
raise ValidationError, "Entity statement sub claim MUST be a valid Entity Identifier (URI)"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Step 5: Entity Statement MUST have iss claim with valid Entity Identifier
|
|
188
|
+
def validate_iss_claim
|
|
189
|
+
iss = @payload["iss"]
|
|
190
|
+
if iss.nil? || iss.empty?
|
|
191
|
+
raise ValidationError, "Entity statement MUST have an iss (issuer) claim with a valid Entity Identifier"
|
|
192
|
+
end
|
|
193
|
+
# Basic Entity Identifier format validation (should be a URI)
|
|
194
|
+
unless iss.is_a?(String) && iss.start_with?("http://", "https://")
|
|
195
|
+
raise ValidationError, "Entity statement iss claim MUST be a valid Entity Identifier (URI)"
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Step 6: Determine Entity Configuration vs Subordinate Statement
|
|
200
|
+
def determine_statement_type
|
|
201
|
+
iss = @payload["iss"]
|
|
202
|
+
sub = @payload["sub"]
|
|
203
|
+
@is_entity_configuration = (iss == sub)
|
|
204
|
+
@is_subordinate_statement = !@is_entity_configuration
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
# Step 7: For Subordinate Statements, validate iss matches authority_hints
|
|
208
|
+
def validate_authority_hints
|
|
209
|
+
unless @issuer_entity_configuration
|
|
210
|
+
# If issuer configuration not provided, we can't validate authority_hints
|
|
211
|
+
# This is acceptable for basic validation, but should be done for full validation
|
|
212
|
+
OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Cannot validate authority_hints: issuer entity configuration not provided")
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Extract authority_hints from issuer's Entity Configuration
|
|
217
|
+
issuer_config = if @issuer_entity_configuration.is_a?(Hash)
|
|
218
|
+
@issuer_entity_configuration
|
|
219
|
+
elsif @issuer_entity_configuration.respond_to?(:parse)
|
|
220
|
+
@issuer_entity_configuration.parse
|
|
221
|
+
else
|
|
222
|
+
raise ValidationError, "Invalid issuer entity configuration format"
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
authority_hints = issuer_config[:claims]&.fetch("authority_hints", []) ||
|
|
226
|
+
issuer_config["claims"]&.fetch("authority_hints", []) ||
|
|
227
|
+
issuer_config.fetch("authority_hints", [])
|
|
228
|
+
|
|
229
|
+
unless authority_hints.is_a?(Array) && authority_hints.include?(@payload["iss"])
|
|
230
|
+
raise ValidationError, "Subordinate Statement issuer '#{@payload["iss"]}' MUST be listed in the authority_hints array of the subject's Entity Configuration"
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Step 8: Validate iat claim (issued at time)
|
|
235
|
+
def validate_iat_claim
|
|
236
|
+
iat = @payload["iat"]
|
|
237
|
+
if iat.nil?
|
|
238
|
+
raise ValidationError, "Entity statement MUST have an iat (issued at) claim"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
unless iat.is_a?(Integer) || iat.is_a?(Numeric)
|
|
242
|
+
raise ValidationError, "Entity statement iat claim MUST be a number (Seconds Since the Epoch)"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
current_time = Time.now.to_i
|
|
246
|
+
# Allow clock skew: iat can be slightly in the future
|
|
247
|
+
if iat > (current_time + @clock_skew_tolerance)
|
|
248
|
+
raise ValidationError, "Entity statement iat (issued at) claim is too far in the future. Current time: #{current_time}, iat: #{iat}, tolerance: #{@clock_skew_tolerance}s"
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Step 9: Validate exp claim (expiration time)
|
|
253
|
+
def validate_exp_claim
|
|
254
|
+
exp = @payload["exp"]
|
|
255
|
+
if exp.nil?
|
|
256
|
+
raise ValidationError, "Entity statement MUST have an exp (expiration) claim"
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
unless exp.is_a?(Integer) || exp.is_a?(Numeric)
|
|
260
|
+
raise ValidationError, "Entity statement exp claim MUST be a number (Seconds Since the Epoch)"
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
current_time = Time.now.to_i
|
|
264
|
+
# Allow clock skew: exp can be slightly in the past
|
|
265
|
+
if exp < (current_time - @clock_skew_tolerance)
|
|
266
|
+
raise ValidationError, "Entity statement has expired. Current time: #{current_time}, exp: #{exp}, tolerance: #{@clock_skew_tolerance}s"
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Step 10: Validate jwks claim (MUST be present and valid)
|
|
271
|
+
def validate_jwks_claim
|
|
272
|
+
jwks = @payload["jwks"]
|
|
273
|
+
if jwks.nil?
|
|
274
|
+
# jwks is OPTIONAL only for Entity Statement returned from OP during Explicit Registration
|
|
275
|
+
# For all other cases, it is REQUIRED
|
|
276
|
+
# We'll be strict and require it unless we have context that this is an Explicit Registration response
|
|
277
|
+
raise ValidationError, "Entity statement MUST have a jwks (JWK Set) claim"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
unless jwks.is_a?(Hash)
|
|
281
|
+
raise ValidationError, "Entity statement jwks claim MUST be a JSON object (JWK Set)"
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
keys = jwks["keys"] || jwks[:keys]
|
|
285
|
+
unless keys.is_a?(Array)
|
|
286
|
+
raise ValidationError, "Entity statement jwks claim MUST contain a 'keys' array"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Validate that each key has a unique kid
|
|
290
|
+
kids = keys.map { |key| key["kid"] || key[:kid] }.compact
|
|
291
|
+
if kids.length != kids.uniq.length
|
|
292
|
+
raise ValidationError, "Entity statement jwks keys MUST have unique kid (Key ID) values"
|
|
293
|
+
end
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Step 11: Validate kid header (MUST be non-zero length string)
|
|
297
|
+
def validate_kid_header
|
|
298
|
+
kid = @header["kid"] || @header[:kid]
|
|
299
|
+
if kid.nil?
|
|
300
|
+
raise ValidationError, "Entity statement MUST have a kid (Key ID) header parameter with a non-zero length string value"
|
|
301
|
+
end
|
|
302
|
+
unless kid.is_a?(String)
|
|
303
|
+
raise ValidationError, "Entity statement kid header parameter MUST be a string"
|
|
304
|
+
end
|
|
305
|
+
if kid.empty?
|
|
306
|
+
raise ValidationError, "Entity statement MUST have a kid (Key ID) header parameter with a non-zero length string value"
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
# Step 12: Validate kid matches key in issuer's JWKS
|
|
311
|
+
def validate_kid_matching
|
|
312
|
+
kid = @header["kid"] || @header[:kid]
|
|
313
|
+
jwks = @payload["jwks"] || {}
|
|
314
|
+
|
|
315
|
+
# Get issuer's JWKS
|
|
316
|
+
issuer_jwks = if @is_entity_configuration
|
|
317
|
+
# For Entity Configuration, use its own JWKS
|
|
318
|
+
jwks
|
|
319
|
+
elsif @issuer_entity_configuration
|
|
320
|
+
# For Subordinate Statement, use issuer's Entity Configuration JWKS
|
|
321
|
+
issuer_config = if @issuer_entity_configuration.is_a?(Hash)
|
|
322
|
+
@issuer_entity_configuration
|
|
323
|
+
elsif @issuer_entity_configuration.respond_to?(:parse)
|
|
324
|
+
@issuer_entity_configuration.parse
|
|
325
|
+
else
|
|
326
|
+
raise ValidationError, "Invalid issuer entity configuration format"
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
issuer_config[:jwks] || issuer_config["jwks"] || issuer_config[:claims]&.fetch("jwks", {}) || issuer_config["claims"]&.fetch("jwks", {})
|
|
330
|
+
else
|
|
331
|
+
# Cannot validate kid matching without issuer configuration
|
|
332
|
+
OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Cannot validate kid matching: issuer entity configuration not provided")
|
|
333
|
+
return
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
issuer_keys = issuer_jwks["keys"] || issuer_jwks[:keys] || []
|
|
337
|
+
matching_key = issuer_keys.find { |key| (key["kid"] || key[:kid]) == kid }
|
|
338
|
+
|
|
339
|
+
unless matching_key
|
|
340
|
+
raise ValidationError, "Entity statement kid '#{kid}' MUST exactly match a kid value for a key in the issuer's jwks (JWK Set) claim"
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Step 13: Validate signature
|
|
345
|
+
def validate_signature
|
|
346
|
+
# Signature validation is typically done separately using JWT.decode
|
|
347
|
+
# This step is a placeholder - actual signature validation should be done
|
|
348
|
+
# by the caller using the issuer's public key
|
|
349
|
+
# We validate that we have the necessary information to validate the signature
|
|
350
|
+
kid = @header["kid"] || @header[:kid]
|
|
351
|
+
jwks = @payload["jwks"] || {}
|
|
352
|
+
|
|
353
|
+
issuer_jwks = if @is_entity_configuration
|
|
354
|
+
jwks
|
|
355
|
+
elsif @issuer_entity_configuration
|
|
356
|
+
issuer_config = if @issuer_entity_configuration.is_a?(Hash)
|
|
357
|
+
@issuer_entity_configuration
|
|
358
|
+
elsif @issuer_entity_configuration.respond_to?(:parse)
|
|
359
|
+
@issuer_entity_configuration.parse
|
|
360
|
+
else
|
|
361
|
+
raise ValidationError, "Invalid issuer entity configuration format"
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
issuer_config[:jwks] || issuer_config["jwks"] || issuer_config[:claims]&.fetch("jwks", {}) || issuer_config["claims"]&.fetch("jwks", {})
|
|
365
|
+
else
|
|
366
|
+
return # Cannot validate without issuer configuration
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
issuer_keys = issuer_jwks["keys"] || issuer_jwks[:keys] || []
|
|
370
|
+
matching_key = issuer_keys.find { |key| (key["kid"] || key[:kid]) == kid }
|
|
371
|
+
|
|
372
|
+
unless matching_key
|
|
373
|
+
raise ValidationError, "Cannot validate signature: signing key with kid '#{kid}' not found in issuer's JWKS"
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
# Note: Actual cryptographic signature verification should be done by the caller
|
|
377
|
+
# using JWT.decode with the matching key
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Step 14: Validate crit claim (if present)
|
|
381
|
+
def validate_crit_claim
|
|
382
|
+
crit = @payload["crit"] || @payload[:crit]
|
|
383
|
+
return unless crit
|
|
384
|
+
|
|
385
|
+
unless crit.is_a?(Array)
|
|
386
|
+
raise ValidationError, "Entity statement crit claim MUST be an array of strings"
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# Standard claims that MUST NOT be in crit (per spec)
|
|
390
|
+
standard_claims = %w[iss sub iat exp jwks metadata authority_hints trust_marks trust_mark_issuers trust_mark_owners constraints metadata_policy metadata_policy_crit source_endpoint crit]
|
|
391
|
+
unknown_claims = crit - standard_claims
|
|
392
|
+
|
|
393
|
+
if unknown_claims.any?
|
|
394
|
+
# For now, we'll log a warning but not reject
|
|
395
|
+
# In a strict implementation, we should reject if we don't understand the claims
|
|
396
|
+
OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Entity statement contains crit claim with unknown claims: #{unknown_claims.join(", ")}. These claims MUST be understood and processed.")
|
|
397
|
+
# TODO: Make this configurable - strict mode should reject unknown crit claims
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Step 15: Validate authority_hints syntax (if present)
|
|
402
|
+
def validate_authority_hints_syntax
|
|
403
|
+
authority_hints = @payload["authority_hints"] || @payload[:authority_hints]
|
|
404
|
+
return unless authority_hints
|
|
405
|
+
|
|
406
|
+
unless authority_hints.is_a?(Array)
|
|
407
|
+
raise ValidationError, "Entity statement authority_hints claim MUST be an array of strings"
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
if authority_hints.empty?
|
|
411
|
+
raise ValidationError, "Entity statement authority_hints claim MUST NOT be an empty array (unless this is a Trust Anchor with no Superiors)"
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
authority_hints.each do |hint|
|
|
415
|
+
unless hint.is_a?(String) && hint.start_with?("http://", "https://")
|
|
416
|
+
raise ValidationError, "Entity statement authority_hints claim MUST contain valid Entity Identifiers (URIs)"
|
|
417
|
+
end
|
|
418
|
+
end
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
# Step 16: Validate metadata syntax (if present)
|
|
422
|
+
def validate_metadata_syntax
|
|
423
|
+
metadata = @payload["metadata"] || @payload[:metadata]
|
|
424
|
+
return unless metadata
|
|
425
|
+
|
|
426
|
+
unless metadata.is_a?(Hash)
|
|
427
|
+
raise ValidationError, "Entity statement metadata claim MUST be a JSON object"
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
# Validate that metadata values are not null
|
|
431
|
+
metadata.each do |entity_type, entity_metadata|
|
|
432
|
+
if entity_metadata.nil?
|
|
433
|
+
raise ValidationError, "Entity statement metadata claim MUST NOT use null as metadata values"
|
|
434
|
+
end
|
|
435
|
+
unless entity_metadata.is_a?(Hash)
|
|
436
|
+
raise ValidationError, "Entity statement metadata claim values MUST be JSON objects"
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
# Step 17: Validate metadata_policy presence (MUST be Subordinate Statement)
|
|
442
|
+
def validate_metadata_policy_presence
|
|
443
|
+
metadata_policy = @payload["metadata_policy"] || @payload[:metadata_policy]
|
|
444
|
+
return unless metadata_policy
|
|
445
|
+
|
|
446
|
+
unless @is_subordinate_statement
|
|
447
|
+
raise ValidationError, "Entity statement metadata_policy claim MUST only appear in Subordinate Statements"
|
|
448
|
+
end
|
|
449
|
+
end
|
|
450
|
+
|
|
451
|
+
# Step 18: Validate metadata_policy_crit presence (MUST be Subordinate Statement)
|
|
452
|
+
def validate_metadata_policy_crit_presence
|
|
453
|
+
metadata_policy_crit = @payload["metadata_policy_crit"] || @payload[:metadata_policy_crit]
|
|
454
|
+
return unless metadata_policy_crit
|
|
455
|
+
|
|
456
|
+
unless @is_subordinate_statement
|
|
457
|
+
raise ValidationError, "Entity statement metadata_policy_crit claim MUST only appear in Subordinate Statements"
|
|
458
|
+
end
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
# Step 19: Validate constraints presence (MUST be Subordinate Statement)
|
|
462
|
+
def validate_constraints_presence
|
|
463
|
+
constraints = @payload["constraints"] || @payload[:constraints]
|
|
464
|
+
return unless constraints
|
|
465
|
+
|
|
466
|
+
unless @is_subordinate_statement
|
|
467
|
+
raise ValidationError, "Entity statement constraints claim MUST only appear in Subordinate Statements"
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
|
|
471
|
+
# Step 20: Validate trust_marks presence (MUST be Entity Configuration)
|
|
472
|
+
def validate_trust_marks_presence
|
|
473
|
+
trust_marks = @payload["trust_marks"] || @payload[:trust_marks]
|
|
474
|
+
return unless trust_marks
|
|
475
|
+
|
|
476
|
+
unless @is_entity_configuration
|
|
477
|
+
raise ValidationError, "Entity statement trust_marks claim MUST only appear in Entity Configurations"
|
|
478
|
+
end
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# Step 21: Validate trust_mark_issuers presence (MUST be Entity Configuration)
|
|
482
|
+
def validate_trust_mark_issuers_presence
|
|
483
|
+
trust_mark_issuers = @payload["trust_mark_issuers"] || @payload[:trust_mark_issuers]
|
|
484
|
+
return unless trust_mark_issuers
|
|
485
|
+
|
|
486
|
+
unless @is_entity_configuration
|
|
487
|
+
raise ValidationError, "Entity statement trust_mark_issuers claim MUST only appear in Entity Configurations"
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
# Step 22: Validate trust_mark_owners presence (MUST be Entity Configuration)
|
|
492
|
+
def validate_trust_mark_owners_presence
|
|
493
|
+
trust_mark_owners = @payload["trust_mark_owners"] || @payload[:trust_mark_owners]
|
|
494
|
+
return unless trust_mark_owners
|
|
495
|
+
|
|
496
|
+
unless @is_entity_configuration
|
|
497
|
+
raise ValidationError, "Entity statement trust_mark_owners claim MUST only appear in Entity Configurations"
|
|
498
|
+
end
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
end
|
|
502
|
+
end
|