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,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