omniauth_openid_federation 1.3.0 → 1.3.2

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.
@@ -4,6 +4,7 @@ require "json"
4
4
  require_relative "../logger"
5
5
  require_relative "../errors"
6
6
  require_relative "../configuration"
7
+ require_relative "../string_helpers"
7
8
 
8
9
  # Entity Statement Validator for OpenID Federation 1.0
9
10
  # @see https://openid.net/specs/openid-federation-1_0.html#section-3.2.1 Section 3.2.1: Entity Statement Validation
@@ -53,70 +54,27 @@ module OmniauthOpenidFederation
53
54
  # @return [Hash] Validated entity statement with header and claims
54
55
  # @raise [ValidationError] If validation fails at any step
55
56
  def validate!
56
- # Step 1: Entity Statement MUST be a signed JWT
57
57
  validate_jwt_format
58
-
59
- # Step 2: Validate typ header (MUST be "entity-statement+jwt")
60
58
  validate_typ_header
61
-
62
- # Step 3: Validate alg header (MUST be present and not "none")
63
59
  validate_alg_header
64
-
65
- # Step 4: Validate sub claim matches Entity Identifier
66
60
  validate_sub_claim
67
-
68
- # Step 5: Validate iss claim is valid Entity Identifier
69
61
  validate_iss_claim
70
-
71
- # Step 6: Determine Entity Configuration vs Subordinate Statement
72
62
  determine_statement_type
73
-
74
- # Step 7: Validate authority_hints for Subordinate Statements
75
63
  validate_authority_hints if @is_subordinate_statement
76
-
77
- # Step 8: Validate iat claim (issued at time)
78
64
  validate_iat_claim
79
-
80
- # Step 9: Validate exp claim (expiration time)
81
65
  validate_exp_claim
82
-
83
- # Step 10: Validate jwks claim (MUST be present and valid)
84
66
  validate_jwks_claim
85
-
86
- # Step 11: Validate kid header (MUST be non-zero length string)
87
67
  validate_kid_header
88
-
89
- # Step 12: Validate kid matches key in issuer's JWKS
90
68
  validate_kid_matching
91
-
92
- # Step 13: Validate signature (if issuer configuration provided)
93
69
  validate_signature if @issuer_entity_configuration || @is_entity_configuration
94
-
95
- # Step 14: Validate crit claim (if present)
96
70
  validate_crit_claim
97
-
98
- # Step 15: Validate authority_hints syntax (if present)
99
71
  validate_authority_hints_syntax if @header && @payload && @is_entity_configuration
100
-
101
- # Step 16: Validate metadata syntax (if present)
102
72
  validate_metadata_syntax
103
-
104
- # Step 17: Validate metadata_policy (if present, MUST be Subordinate Statement)
105
73
  validate_metadata_policy_presence
106
-
107
- # Step 18: Validate metadata_policy_crit (if present, MUST be Subordinate Statement)
108
74
  validate_metadata_policy_crit_presence
109
-
110
- # Step 19: Validate constraints (if present, MUST be Subordinate Statement)
111
75
  validate_constraints_presence
112
-
113
- # Step 20: Validate trust_marks (if present, MUST be Entity Configuration)
114
76
  validate_trust_marks_presence
115
-
116
- # Step 21: Validate trust_mark_issuers (if present, MUST be Entity Configuration)
117
77
  validate_trust_mark_issuers_presence
118
-
119
- # Step 22: Validate trust_mark_owners (if present, MUST be Entity Configuration)
120
78
  validate_trust_mark_owners_presence
121
79
 
122
80
  {
@@ -129,14 +87,12 @@ module OmniauthOpenidFederation
129
87
 
130
88
  private
131
89
 
132
- # Step 1: Entity Statement MUST be a signed JWT
133
90
  def validate_jwt_format
134
91
  jwt_parts = @jwt_string.split(".")
135
92
  if jwt_parts.length != JWT_PARTS_COUNT
136
93
  raise ValidationError, "Invalid JWT format: expected #{JWT_PARTS_COUNT} parts (header.payload.signature), got #{jwt_parts.length}"
137
94
  end
138
95
 
139
- # Decode header and payload
140
96
  begin
141
97
  @header = JSON.parse(Base64.urlsafe_decode64(jwt_parts[0]))
142
98
  @payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
@@ -147,7 +103,6 @@ module OmniauthOpenidFederation
147
103
  end
148
104
  end
149
105
 
150
- # Step 2: Entity Statement MUST have typ header with value "entity-statement+jwt"
151
106
  def validate_typ_header
152
107
  typ = @header["typ"] || @header[:typ]
153
108
  unless typ == REQUIRED_TYP
@@ -155,10 +110,9 @@ module OmniauthOpenidFederation
155
110
  end
156
111
  end
157
112
 
158
- # Step 3: Entity Statement MUST have alg header that is present and not "none"
159
113
  def validate_alg_header
160
114
  alg = @header["alg"] || @header[:alg]
161
- if alg.nil? || alg.empty?
115
+ if StringHelpers.blank?(alg)
162
116
  raise ValidationError, "Entity statement MUST have an alg (algorithm) header parameter"
163
117
  end
164
118
  if alg == "none"
@@ -170,33 +124,28 @@ module OmniauthOpenidFederation
170
124
  end
171
125
  end
172
126
 
173
- # Step 4: Entity Identifier MUST match sub claim
174
127
  # Note: This is a structural check. The actual Entity Identifier validation
175
128
  # would require knowing the expected Entity Identifier, which is context-dependent.
176
129
  def validate_sub_claim
177
130
  sub = @payload["sub"]
178
- if sub.nil? || sub.empty?
131
+ if StringHelpers.blank?(sub)
179
132
  raise ValidationError, "Entity statement MUST have a sub (subject) claim with a valid Entity Identifier"
180
133
  end
181
- # Basic Entity Identifier format validation (should be a URI)
182
134
  unless sub.is_a?(String) && sub.start_with?("http://", "https://")
183
135
  raise ValidationError, "Entity statement sub claim MUST be a valid Entity Identifier (URI)"
184
136
  end
185
137
  end
186
138
 
187
- # Step 5: Entity Statement MUST have iss claim with valid Entity Identifier
188
139
  def validate_iss_claim
189
140
  iss = @payload["iss"]
190
- if iss.nil? || iss.empty?
141
+ if StringHelpers.blank?(iss)
191
142
  raise ValidationError, "Entity statement MUST have an iss (issuer) claim with a valid Entity Identifier"
192
143
  end
193
- # Basic Entity Identifier format validation (should be a URI)
194
144
  unless iss.is_a?(String) && iss.start_with?("http://", "https://")
195
145
  raise ValidationError, "Entity statement iss claim MUST be a valid Entity Identifier (URI)"
196
146
  end
197
147
  end
198
148
 
199
- # Step 6: Determine Entity Configuration vs Subordinate Statement
200
149
  def determine_statement_type
201
150
  iss = @payload["iss"]
202
151
  sub = @payload["sub"]
@@ -213,7 +162,6 @@ module OmniauthOpenidFederation
213
162
  return
214
163
  end
215
164
 
216
- # Extract authority_hints from issuer's Entity Configuration
217
165
  issuer_config = if @issuer_entity_configuration.is_a?(Hash)
218
166
  @issuer_entity_configuration
219
167
  elsif @issuer_entity_configuration.respond_to?(:parse)
@@ -231,7 +179,6 @@ module OmniauthOpenidFederation
231
179
  end
232
180
  end
233
181
 
234
- # Step 8: Validate iat claim (issued at time)
235
182
  def validate_iat_claim
236
183
  iat = @payload["iat"]
237
184
  if iat.nil?
@@ -243,13 +190,11 @@ module OmniauthOpenidFederation
243
190
  end
244
191
 
245
192
  current_time = Time.now.to_i
246
- # Allow clock skew: iat can be slightly in the future
247
193
  if iat > (current_time + @clock_skew_tolerance)
248
194
  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
195
  end
250
196
  end
251
197
 
252
- # Step 9: Validate exp claim (expiration time)
253
198
  def validate_exp_claim
254
199
  exp = @payload["exp"]
255
200
  if exp.nil?
@@ -261,19 +206,16 @@ module OmniauthOpenidFederation
261
206
  end
262
207
 
263
208
  current_time = Time.now.to_i
264
- # Allow clock skew: exp can be slightly in the past
265
209
  if exp < (current_time - @clock_skew_tolerance)
266
210
  raise ValidationError, "Entity statement has expired. Current time: #{current_time}, exp: #{exp}, tolerance: #{@clock_skew_tolerance}s"
267
211
  end
268
212
  end
269
213
 
270
- # Step 10: Validate jwks claim (MUST be present and valid)
271
214
  def validate_jwks_claim
272
215
  jwks = @payload["jwks"]
273
216
  if jwks.nil?
274
217
  # jwks is OPTIONAL only for Entity Statement returned from OP during Explicit Registration
275
218
  # 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
219
  raise ValidationError, "Entity statement MUST have a jwks (JWK Set) claim"
278
220
  end
279
221
 
@@ -286,14 +228,12 @@ module OmniauthOpenidFederation
286
228
  raise ValidationError, "Entity statement jwks claim MUST contain a 'keys' array"
287
229
  end
288
230
 
289
- # Validate that each key has a unique kid
290
231
  kids = keys.map { |key| key["kid"] || key[:kid] }.compact
291
232
  if kids.length != kids.uniq.length
292
233
  raise ValidationError, "Entity statement jwks keys MUST have unique kid (Key ID) values"
293
234
  end
294
235
  end
295
236
 
296
- # Step 11: Validate kid header (MUST be non-zero length string)
297
237
  def validate_kid_header
298
238
  kid = @header["kid"] || @header[:kid]
299
239
  if kid.nil?
@@ -307,17 +247,13 @@ module OmniauthOpenidFederation
307
247
  end
308
248
  end
309
249
 
310
- # Step 12: Validate kid matches key in issuer's JWKS
311
250
  def validate_kid_matching
312
251
  kid = @header["kid"] || @header[:kid]
313
252
  jwks = @payload["jwks"] || {}
314
253
 
315
- # Get issuer's JWKS
316
254
  issuer_jwks = if @is_entity_configuration
317
- # For Entity Configuration, use its own JWKS
318
255
  jwks
319
256
  elsif @issuer_entity_configuration
320
- # For Subordinate Statement, use issuer's Entity Configuration JWKS
321
257
  issuer_config = if @issuer_entity_configuration.is_a?(Hash)
322
258
  @issuer_entity_configuration
323
259
  elsif @issuer_entity_configuration.respond_to?(:parse)
@@ -328,7 +264,6 @@ module OmniauthOpenidFederation
328
264
 
329
265
  issuer_config[:jwks] || issuer_config["jwks"] || issuer_config[:claims]&.fetch("jwks", {}) || issuer_config["claims"]&.fetch("jwks", {})
330
266
  else
331
- # Cannot validate kid matching without issuer configuration
332
267
  OmniauthOpenidFederation::Logger.warn("[EntityStatementValidator] Cannot validate kid matching: issuer entity configuration not provided")
333
268
  return
334
269
  end
@@ -341,12 +276,9 @@ module OmniauthOpenidFederation
341
276
  end
342
277
  end
343
278
 
344
- # Step 13: Validate signature
279
+ # Signature validation is typically done separately using JWT.decode
280
+ # This step validates that we have the necessary information to validate the signature
345
281
  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
282
  kid = @header["kid"] || @header[:kid]
351
283
  jwks = @payload["jwks"] || {}
352
284
 
@@ -372,12 +304,8 @@ module OmniauthOpenidFederation
372
304
  unless matching_key
373
305
  raise ValidationError, "Cannot validate signature: signing key with kid '#{kid}' not found in issuer's JWKS"
374
306
  end
375
-
376
- # Note: Actual cryptographic signature verification should be done by the caller
377
- # using JWT.decode with the matching key
378
307
  end
379
308
 
380
- # Step 14: Validate crit claim (if present)
381
309
  def validate_crit_claim
382
310
  crit = @payload["crit"] || @payload[:crit]
383
311
  return unless crit
@@ -398,7 +326,6 @@ module OmniauthOpenidFederation
398
326
  end
399
327
  end
400
328
 
401
- # Step 15: Validate authority_hints syntax (if present)
402
329
  def validate_authority_hints_syntax
403
330
  authority_hints = @payload["authority_hints"] || @payload[:authority_hints]
404
331
  return unless authority_hints
@@ -418,7 +345,6 @@ module OmniauthOpenidFederation
418
345
  end
419
346
  end
420
347
 
421
- # Step 16: Validate metadata syntax (if present)
422
348
  def validate_metadata_syntax
423
349
  metadata = @payload["metadata"] || @payload[:metadata]
424
350
  return unless metadata
@@ -427,7 +353,6 @@ module OmniauthOpenidFederation
427
353
  raise ValidationError, "Entity statement metadata claim MUST be a JSON object"
428
354
  end
429
355
 
430
- # Validate that metadata values are not null
431
356
  metadata.each do |entity_type, entity_metadata|
432
357
  if entity_metadata.nil?
433
358
  raise ValidationError, "Entity statement metadata claim MUST NOT use null as metadata values"
@@ -438,7 +363,6 @@ module OmniauthOpenidFederation
438
363
  end
439
364
  end
440
365
 
441
- # Step 17: Validate metadata_policy presence (MUST be Subordinate Statement)
442
366
  def validate_metadata_policy_presence
443
367
  metadata_policy = @payload["metadata_policy"] || @payload[:metadata_policy]
444
368
  return unless metadata_policy
@@ -448,7 +372,6 @@ module OmniauthOpenidFederation
448
372
  end
449
373
  end
450
374
 
451
- # Step 18: Validate metadata_policy_crit presence (MUST be Subordinate Statement)
452
375
  def validate_metadata_policy_crit_presence
453
376
  metadata_policy_crit = @payload["metadata_policy_crit"] || @payload[:metadata_policy_crit]
454
377
  return unless metadata_policy_crit
@@ -458,7 +381,6 @@ module OmniauthOpenidFederation
458
381
  end
459
382
  end
460
383
 
461
- # Step 19: Validate constraints presence (MUST be Subordinate Statement)
462
384
  def validate_constraints_presence
463
385
  constraints = @payload["constraints"] || @payload[:constraints]
464
386
  return unless constraints
@@ -468,7 +390,6 @@ module OmniauthOpenidFederation
468
390
  end
469
391
  end
470
392
 
471
- # Step 20: Validate trust_marks presence (MUST be Entity Configuration)
472
393
  def validate_trust_marks_presence
473
394
  trust_marks = @payload["trust_marks"] || @payload[:trust_marks]
474
395
  return unless trust_marks
@@ -478,7 +399,6 @@ module OmniauthOpenidFederation
478
399
  end
479
400
  end
480
401
 
481
- # Step 21: Validate trust_mark_issuers presence (MUST be Entity Configuration)
482
402
  def validate_trust_mark_issuers_presence
483
403
  trust_mark_issuers = @payload["trust_mark_issuers"] || @payload[:trust_mark_issuers]
484
404
  return unless trust_mark_issuers
@@ -488,7 +408,6 @@ module OmniauthOpenidFederation
488
408
  end
489
409
  end
490
410
 
491
- # Step 22: Validate trust_mark_owners presence (MUST be Entity Configuration)
492
411
  def validate_trust_mark_owners_presence
493
412
  trust_mark_owners = @payload["trust_mark_owners"] || @payload[:trust_mark_owners]
494
413
  return unless trust_mark_owners
@@ -4,7 +4,7 @@ require_relative "../http_client"
4
4
  require_relative "../logger"
5
5
  require_relative "../errors"
6
6
  require_relative "../utils"
7
- require "set"
7
+ require_relative "../string_helpers"
8
8
  require "cgi"
9
9
 
10
10
  # Trust Chain Resolver for OpenID Federation 1.0
@@ -59,24 +59,21 @@ module OmniauthOpenidFederation
59
59
  def resolve!
60
60
  OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Starting trust chain resolution for: #{@leaf_entity_id}")
61
61
 
62
- # Step 1: Fetch Leaf Entity's Entity Configuration
63
62
  leaf_config = fetch_entity_configuration(@leaf_entity_id)
64
- validate_entity_statement(leaf_config, nil) # No issuer for Entity Configuration
63
+ validate_entity_statement(leaf_config, nil)
65
64
  @resolved_statements << leaf_config
66
65
  @visited_entities.add(@leaf_entity_id)
67
66
 
68
- # Step 2: Follow authority_hints to build the chain
69
67
  current_entity_id = @leaf_entity_id
70
68
  current_config = leaf_config
71
69
 
72
70
  while current_config && !is_trust_anchor?(current_config)
73
71
  authority_hints = extract_authority_hints(current_config)
74
72
 
75
- if authority_hints.nil? || authority_hints.empty?
73
+ if StringHelpers.blank?(authority_hints)
76
74
  raise ValidationError, "Entity #{current_entity_id} has no authority_hints and is not a Trust Anchor"
77
75
  end
78
76
 
79
- # Try each authority hint until we find a valid chain
80
77
  found_next = false
81
78
  authority_hints.each do |authority_id|
82
79
  next if @visited_entities.include?(authority_id)
@@ -86,28 +83,23 @@ module OmniauthOpenidFederation
86
83
  end
87
84
 
88
85
  begin
89
- # Fetch Subordinate Statement from authority
90
86
  subordinate_statement = fetch_subordinate_statement(
91
87
  issuer: authority_id,
92
88
  subject: current_entity_id
93
89
  )
94
90
 
95
- # Validate Subordinate Statement
96
91
  issuer_config = fetch_entity_configuration(authority_id)
97
92
  validate_entity_statement(subordinate_statement, issuer_config)
98
93
 
99
- # Add to chain
100
94
  @resolved_statements << subordinate_statement
101
95
  @visited_entities.add(authority_id)
102
96
 
103
- # Continue with issuer as next entity
104
97
  current_entity_id = authority_id
105
98
  current_config = issuer_config
106
99
  found_next = true
107
100
  break
108
101
  rescue ValidationError, FetchError => e
109
102
  OmniauthOpenidFederation::Logger.warn("[TrustChainResolver] Failed to resolve via #{authority_id}: #{e.message}")
110
- # Instrument trust chain validation failure
111
103
  OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
112
104
  entity_id: current_entity_id,
113
105
  trust_anchor: authority_id,
@@ -124,10 +116,8 @@ module OmniauthOpenidFederation
124
116
  end
125
117
  end
126
118
 
127
- # Step 3: Verify we reached a Trust Anchor
128
119
  unless is_trust_anchor?(current_config)
129
120
  error_msg = "Trust chain did not terminate at a configured Trust Anchor"
130
- # Instrument trust chain validation failure
131
121
  OmniauthOpenidFederation::Instrumentation.notify_trust_chain_validation_failed(
132
122
  entity_id: @leaf_entity_id,
133
123
  trust_anchor: current_entity_id,
@@ -164,7 +154,6 @@ module OmniauthOpenidFederation
164
154
  end
165
155
 
166
156
  def fetch_subordinate_statement(issuer:, subject:)
167
- # Try to get fetch endpoint from issuer's Entity Configuration
168
157
  issuer_config = fetch_entity_configuration(issuer)
169
158
  fetch_endpoint = extract_fetch_endpoint(issuer_config)
170
159
 
@@ -172,7 +161,6 @@ module OmniauthOpenidFederation
172
161
  raise FetchError, "Issuer #{issuer} does not provide a fetch endpoint"
173
162
  end
174
163
 
175
- # Build fetch URL with iss and sub parameters
176
164
  fetch_url = "#{fetch_endpoint}?iss=#{CGI.escape(issuer)}&sub=#{CGI.escape(subject)}"
177
165
  OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Fetching Subordinate Statement from: #{fetch_url}")
178
166