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.
@@ -2,6 +2,8 @@ require_relative "federation/entity_statement_builder"
2
2
  require_relative "logger"
3
3
  require_relative "errors"
4
4
  require_relative "utils"
5
+ require_relative "string_helpers"
6
+ require_relative "time_helpers"
5
7
  require "jwt"
6
8
  require "base64"
7
9
  require "digest"
@@ -123,16 +125,13 @@ module OmniauthOpenidFederation
123
125
  auto_provision_keys: true,
124
126
  key_rotation_period: nil
125
127
  )
126
- raise ConfigurationError, "Issuer is required" if issuer.nil? || issuer.empty?
128
+ raise ConfigurationError, "Issuer is required" if StringHelpers.blank?(issuer)
127
129
 
128
- # Priority 1: Validate key configuration - exception if all three are set
129
130
  if signing_key && encryption_key && private_key
130
131
  raise ConfigurationError, "Cannot specify signing_key, encryption_key, and private_key simultaneously. " \
131
132
  "Use either (signing_key + encryption_key) OR private_key, not both."
132
133
  end
133
134
 
134
- # If auto_provision_keys is enabled and no keys are provided, allow automatic generation
135
- # Keys will be generated in provision_jwks if needed
136
135
  unless auto_provision_keys
137
136
  if signing_key.nil? && encryption_key.nil? && private_key.nil? && jwks.nil?
138
137
  raise ConfigurationError, "At least one key source is required: signing_key, encryption_key, private_key, or jwks"
@@ -149,11 +148,9 @@ module OmniauthOpenidFederation
149
148
 
150
149
  config = configuration
151
150
 
152
- # Set issuer and subject
153
151
  config.issuer = issuer
154
152
  config.subject = subject || issuer
155
153
 
156
- # Automatic key provisioning
157
154
  if auto_provision_keys && jwks.nil?
158
155
  jwks = provision_jwks(
159
156
  signing_key: signing_key,
@@ -166,85 +163,60 @@ module OmniauthOpenidFederation
166
163
  entity_statement_path_provided: !entity_statement_path.nil?
167
164
  )
168
165
 
169
- # After provisioning, check if we have keys available
170
- # If provisioning failed and no keys were provided, raise appropriate error
171
166
  if jwks.nil? && signing_key.nil? && encryption_key.nil? && private_key.nil? && config.signing_key.nil?
172
167
  raise ConfigurationError, "Signing key is required. Provide signing_key, encryption_key, or private_key, or enable auto_provision_keys with entity_statement_path."
173
168
  end
174
169
  end
175
170
 
176
- # Use provided jwks if available, otherwise use provisioned jwks
177
171
  config.jwks = jwks || raise(ConfigurationError, "JWKS is required. Provide jwks parameter or enable auto_provision_keys.")
178
172
 
179
- # Set keys in configuration following priority:
180
- # 1. Use provided keys (signing_key + encryption_key, or private_key)
181
- # 2. Use keys loaded from disk (if entity statement was loaded)
182
- # 3. Use keys from generated keys (if auto-generated)
183
173
  if signing_key && encryption_key
184
- # Priority 1: Use provided separate keys
185
174
  config.signing_key = signing_key
186
175
  config.encryption_key = encryption_key
187
176
  config.private_key = signing_key
188
177
  elsif signing_key
189
- # Priority 1b: Use provided signing_key for both signing and encryption
190
178
  config.signing_key = signing_key
191
179
  config.encryption_key = signing_key
192
180
  config.private_key = signing_key
193
181
  elsif private_key
194
- # Priority 2: Use provided single private_key
195
182
  config.private_key = private_key
196
183
  config.signing_key = private_key
197
184
  config.encryption_key = private_key
198
185
  elsif config.signing_key && config.encryption_key
199
- # Priority 3: Keys were loaded from disk in provision_jwks
200
186
  config.private_key = config.signing_key
201
187
  elsif config.signing_key
202
- # Priority 4: Only signing_key was set (from auto-generation or fallback)
203
- # Use it for both signing and encryption
204
188
  config.private_key = config.signing_key
205
189
  config.encryption_key = config.signing_key
206
190
  else
207
191
  raise ConfigurationError, "Signing key is required. Provide signing_key, encryption_key, or private_key, or enable auto_provision_keys with entity_statement_path."
208
192
  end
209
193
 
210
- # Set kid from first signing key in JWKS
211
194
  keys = config.jwks[:keys] || config.jwks["keys"] || []
212
195
  signing_key_jwk = keys.find { |k| (k[:use] || k["use"]) == "sig" } || keys.first
213
196
  config.kid = signing_key_jwk&.dig(:kid) || signing_key_jwk&.dig("kid")
214
197
 
215
- # Set metadata
216
- # Detect entity type from metadata or default based on provided keys
217
198
  entity_type = detect_entity_type(metadata)
218
199
 
219
200
  if metadata
220
- # Automatically add required jwks_uri and signed_jwks_uri if not present
221
- # These are required by OpenID Federation 1.0 spec and the library provides these endpoints
222
201
  metadata = ensure_jwks_endpoints(metadata, issuer, entity_type)
223
202
  config.metadata = metadata
224
- # Ensure entity type is consistent
225
203
  entity_type = detect_entity_type(config.metadata)
226
204
  else
227
- # Auto-generate minimal metadata with only standard well-known endpoints
228
- # Default to openid_relying_party (RP) entity type for clients
229
205
  base_metadata = {
230
206
  issuer: issuer
231
207
  }
232
208
 
233
- # Only add federation_fetch_endpoint for openid_provider (OP) entities
234
- # RPs typically don't have subordinates, so they don't need fetch endpoint
235
209
  if entity_type == :openid_provider
236
210
  base_metadata[:federation_fetch_endpoint] = "#{issuer}/.well-known/openid-federation/fetch"
237
211
  config.metadata = {
238
212
  openid_provider: base_metadata
239
213
  }
240
214
  else
241
- # Default to openid_relying_party (RP)
242
215
  config.metadata = {
243
216
  openid_relying_party: base_metadata
244
217
  }
245
218
  end
246
219
 
247
- # Ensure jwks_uri and signed_jwks_uri are added (same as when metadata is provided)
248
220
  config.metadata = ensure_jwks_endpoints(config.metadata, issuer, entity_type)
249
221
 
250
222
  OmniauthOpenidFederation::Logger.warn(
@@ -255,25 +227,19 @@ module OmniauthOpenidFederation
255
227
  )
256
228
  end
257
229
 
258
- # Store entity type for later use
259
230
  config.entity_type = entity_type
260
231
 
261
- # Set optional configuration
262
232
  config.expiration_seconds = expiration_seconds if expiration_seconds
263
233
  config.jwks_cache_ttl = jwks_cache_ttl if jwks_cache_ttl
264
234
  config.key_rotation_period = key_rotation_period if key_rotation_period
265
235
  config.entity_statement_path = entity_statement_path if entity_statement_path
266
236
 
267
- # If keys were provided in config, regenerate entity statement and save keys to disk
268
- # This ensures the entity statement signature matches the provided keys
269
237
  if entity_statement_path && (signing_key || private_key)
270
238
  begin
271
- # Save provided keys to disk for persistence
272
239
  keys_dir = File.dirname(entity_statement_path)
273
240
  FileUtils.mkdir_p(keys_dir) unless File.directory?(keys_dir)
274
241
 
275
242
  if signing_key && encryption_key
276
- # Save separate keys
277
243
  signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
278
244
  encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
279
245
  File.write(signing_key_path, signing_key.to_pem)
@@ -282,7 +248,6 @@ module OmniauthOpenidFederation
282
248
  File.chmod(0o600, encryption_key_path)
283
249
  OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Saved provided signing and encryption keys to disk")
284
250
  elsif private_key
285
- # Save single key (for transition period / dev/testing)
286
251
  signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
287
252
  encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
288
253
  File.write(signing_key_path, private_key.to_pem)
@@ -292,7 +257,6 @@ module OmniauthOpenidFederation
292
257
  OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Saved provided private_key to disk (used for both signing and encryption)")
293
258
  end
294
259
 
295
- # Regenerate entity statement with provided keys to ensure signature matches
296
260
  entity_statement = generate_entity_statement
297
261
  FileUtils.mkdir_p(File.dirname(entity_statement_path)) if File.dirname(entity_statement_path) != "."
298
262
  File.write(entity_statement_path, entity_statement)
@@ -303,7 +267,6 @@ module OmniauthOpenidFederation
303
267
  end
304
268
  end
305
269
 
306
- # Handle automatic key rotation if enabled
307
270
  if auto_provision_keys && entity_statement_path && config.key_rotation_period
308
271
  rotate_keys_if_needed(config)
309
272
  end
@@ -314,12 +277,6 @@ module OmniauthOpenidFederation
314
277
 
315
278
  # Automatic key provisioning: Extract or generate JWKS from available sources
316
279
  #
317
- # Priority order:
318
- # 1. Extract from entity_statement_path (cached, supports key rotation)
319
- # 2. Generate from separate signing_key and encryption_key (RECOMMENDED)
320
- # 3. Generate from single private_key (DEV/TESTING ONLY)
321
- # 4. Auto-generate new keys if no keys provided and auto_provision_keys is enabled
322
- #
323
280
  # @param signing_key [OpenSSL::PKey::RSA, nil] Signing private key
324
281
  # @param encryption_key [OpenSSL::PKey::RSA, nil] Encryption private key
325
282
  # @param private_key [OpenSSL::PKey::RSA, nil] Single private key (dev/testing only)
@@ -330,37 +287,27 @@ module OmniauthOpenidFederation
330
287
  # @param entity_statement_path_provided [Boolean] Whether entity_statement_path was provided as parameter (not auto-generated)
331
288
  # @return [Hash, nil] JWKS hash with keys array, or nil if provisioning fails
332
289
  def provision_jwks(signing_key: nil, encryption_key: nil, private_key: nil, entity_statement_path: nil, issuer: nil, subject: nil, metadata: nil, entity_statement_path_provided: false)
333
- # Priority 1-3: Use provided keys from config (highest priority)
334
290
  if encryption_key
335
- # Generate from separate signing_key and encryption_key (RECOMMENDED for production)
336
291
  signing_key_for_jwk = signing_key || private_key
337
292
  raise ConfigurationError, "Signing key is required when encryption_key is provided. Provide signing_key or private_key." unless signing_key_for_jwk
338
293
 
339
- # Check if signing and encryption keys are the same (compare public key PEM)
340
294
  # If same, generate single JWK to avoid duplicate kid values
341
295
  if signing_key_for_jwk.public_key.to_pem == encryption_key.public_key.to_pem
342
- # Same key used for both signing and encryption - generate single JWK
343
296
  single_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(signing_key_for_jwk, use: nil)
344
297
  return {keys: [single_jwk]}
345
298
  else
346
- # Different keys - generate separate JWKs
347
299
  signing_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(signing_key_for_jwk, use: "sig")
348
300
  encryption_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(encryption_key, use: "enc")
349
301
  return {keys: [signing_jwk, encryption_jwk]}
350
302
  end
351
303
  elsif private_key || signing_key
352
- # Use single key (private_key or signing_key) for both signing and encryption
353
- # When using a single key, include only ONE JWK (not two with duplicate kid)
354
304
  single_key = private_key || signing_key
355
305
 
356
- # Generate JWK without 'use' field to indicate it can be used for both purposes
357
- # This avoids duplicate kid values which violate the spec
306
+ # Generate JWK without 'use' field to avoid duplicate kid values which violate the spec
358
307
  single_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(single_key, use: nil)
359
308
  return {keys: [single_jwk]}
360
309
  end
361
310
 
362
- # Priority 4: Extract from entity statement file (cached, supports automatic key rotation)
363
- # Only if no keys were provided in config (keys from config take priority)
364
311
  extraction_failed = false
365
312
  if entity_statement_path&.then { |path| File.exist?(path) }
366
313
  begin
@@ -369,8 +316,6 @@ module OmniauthOpenidFederation
369
316
  if jwks&.dig(:keys)&.any?
370
317
  OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Extracted JWKS from entity statement file: #{entity_statement_path}")
371
318
 
372
- # Only load private keys from disk if no keys were provided in config
373
- # This ensures provided keys take priority over cached keys
374
319
  keys_dir = File.dirname(entity_statement_path)
375
320
  signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
376
321
  encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
@@ -409,16 +354,11 @@ module OmniauthOpenidFederation
409
354
  end
410
355
  end
411
356
 
412
- # Priority 5: Auto-generate new keys (when auto_provision_keys is enabled and no keys provided)
413
- # This generates separate signing and encryption keys
414
- # Only auto-generate if entity_statement_path was not provided as parameter (or extraction succeeded)
415
- # If entity_statement_path was provided but extraction failed, don't auto-generate
416
357
  if issuer && (!entity_statement_path_provided || !extraction_failed)
417
- # Generate a default entity_statement_path if not provided
418
358
  entity_statement_path ||= begin
419
359
  configuration
420
360
  if defined?(Rails) && Rails.root
421
- default_path = Rails.root.join("config", ".federation-entity-statement.jwt").to_s
361
+ default_path = Rails.root.join("config/.federation-entity-statement.jwt").to_s
422
362
  OmniauthOpenidFederation::Logger.info("[FederationEndpoint] No entity_statement_path provided, using default: #{OmniauthOpenidFederation::Utils.sanitize_path(default_path)}")
423
363
  default_path
424
364
  end
@@ -441,17 +381,10 @@ module OmniauthOpenidFederation
441
381
  nil
442
382
  end
443
383
 
444
- # Get the current configuration
445
- #
446
- # @return [Configuration] Current configuration
447
384
  def configuration
448
385
  @configuration ||= Configuration.new
449
386
  end
450
387
 
451
- # Generate the entity statement JWT
452
- #
453
- # @return [String] The signed entity statement JWT
454
- # @raise [ConfigurationError] If configuration is incomplete
455
388
  def generate_entity_statement
456
389
  config = configuration
457
390
  validate_configuration(config)
@@ -470,22 +403,15 @@ module OmniauthOpenidFederation
470
403
  builder.build
471
404
  end
472
405
 
473
- # Generate signed JWKS JWT
474
- #
475
- # @return [String] The signed JWKS JWT
476
- # @raise [ConfigurationError] If configuration is incomplete
477
406
  def generate_signed_jwks
478
407
  config = configuration
479
408
  validate_configuration(config)
480
409
 
481
- # Get JWKS to include in payload (current keys, not entity statement keys)
482
410
  jwks_payload = resolve_signed_jwks_payload(config)
483
411
 
484
- # Sign with entity statement key
485
412
  signing_kid = config.signed_jwks_signing_kid || config.kid || extract_kid_from_jwks(config.jwks)
486
413
  expiration_seconds = config.signed_jwks_expiration_seconds || 86400
487
414
 
488
- # Build JWT payload with JWKS
489
415
  now = Time.now.to_i
490
416
  payload = {
491
417
  iss: config.issuer,
@@ -495,7 +421,6 @@ module OmniauthOpenidFederation
495
421
  jwks: jwks_payload
496
422
  }
497
423
 
498
- # Sign JWT using jwt gem
499
424
  header = {
500
425
  alg: "RS256",
501
426
  typ: "JWT",
@@ -511,11 +436,9 @@ module OmniauthOpenidFederation
511
436
  end
512
437
  end
513
438
 
514
- # Get current JWKS for serving
515
- #
516
- # @return [Hash] Current JWKS hash
517
439
  def current_jwks
518
440
  config = configuration
441
+ validate_configuration(config)
519
442
  resolve_current_jwks(config)
520
443
  end
521
444
 
@@ -566,7 +489,6 @@ module OmniauthOpenidFederation
566
489
  # @param keys_output_dir [String, nil] Directory to store private keys (optional, defaults to same dir as entity_statement_path)
567
490
  # @return [Hash, nil] JWKS hash with keys array, or nil if generation fails
568
491
  def generate_fresh_keys(entity_statement_path:, issuer: nil, subject: nil, metadata: nil, keys_output_dir: nil)
569
- # Generate separate signing and encryption keys
570
492
  signing_key = OpenSSL::PKey::RSA.new(2048)
571
493
  encryption_key = OpenSSL::PKey::RSA.new(2048)
572
494
 
@@ -574,13 +496,11 @@ module OmniauthOpenidFederation
574
496
  encryption_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(encryption_key, use: "enc")
575
497
  jwks = {keys: [signing_jwk, encryption_jwk]}
576
498
 
577
- # Get configuration for issuer, subject, and metadata
578
499
  config = configuration
579
500
  issuer ||= config.issuer
580
501
  subject ||= config.subject || issuer
581
502
  metadata ||= config.metadata
582
503
 
583
- # Generate minimal metadata if none provided
584
504
  unless metadata
585
505
  if issuer
586
506
  # Default to openid_relying_party (RP) entity type for clients
@@ -598,7 +518,6 @@ module OmniauthOpenidFederation
598
518
  end
599
519
  end
600
520
 
601
- # Generate entity statement with new keys
602
521
  if issuer
603
522
  builder = Federation::EntityStatementBuilder.new(
604
523
  issuer: issuer,
@@ -612,11 +531,9 @@ module OmniauthOpenidFederation
612
531
 
613
532
  entity_statement = builder.build
614
533
 
615
- # Determine keys output directory (default to same directory as entity statement)
616
534
  keys_dir = keys_output_dir || File.dirname(entity_statement_path)
617
535
  FileUtils.mkdir_p(keys_dir) unless File.directory?(keys_dir)
618
536
 
619
- # Write private keys to disk (secure storage)
620
537
  signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
621
538
  encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
622
539
 
@@ -625,12 +542,10 @@ module OmniauthOpenidFederation
625
542
  File.chmod(0o600, signing_key_path)
626
543
  File.chmod(0o600, encryption_key_path)
627
544
 
628
- # Write entity statement to file
629
545
  FileUtils.mkdir_p(File.dirname(entity_statement_path)) if File.dirname(entity_statement_path) != "."
630
546
  File.write(entity_statement_path, entity_statement)
631
547
  File.chmod(0o600, entity_statement_path) if File.exist?(entity_statement_path)
632
548
 
633
- # Update configuration with new keys
634
549
  config.signing_key = signing_key
635
550
  config.encryption_key = encryption_key
636
551
  config.private_key = signing_key
@@ -651,19 +566,15 @@ module OmniauthOpenidFederation
651
566
  nil
652
567
  end
653
568
 
654
- # Rotate keys if rotation period has elapsed
655
- #
656
- # @param config [Configuration] Configuration object
657
569
  def rotate_keys_if_needed(config)
658
570
  return unless config.key_rotation_period && config.entity_statement_path
659
571
 
660
572
  entity_statement_path = config.entity_statement_path
661
573
  return unless File.exist?(entity_statement_path)
662
574
 
663
- # Check if file needs rotation based on modification time
664
575
  file_mtime = File.mtime(entity_statement_path)
665
576
  rotation_period_seconds = config.key_rotation_period.to_i
666
- time_since_rotation = Time.now - file_mtime
577
+ time_since_rotation = TimeHelpers.now - file_mtime
667
578
 
668
579
  if time_since_rotation >= rotation_period_seconds
669
580
  OmniauthOpenidFederation::Logger.info(
@@ -671,7 +582,6 @@ module OmniauthOpenidFederation
671
582
  "generating new keys"
672
583
  )
673
584
 
674
- # Generate fresh keys and update entity statement
675
585
  keys_dir = File.dirname(entity_statement_path)
676
586
  jwks = generate_fresh_keys(
677
587
  entity_statement_path: entity_statement_path,
@@ -683,7 +593,6 @@ module OmniauthOpenidFederation
683
593
 
684
594
  if jwks
685
595
  config.jwks = jwks
686
- # Update kid from new signing key
687
596
  keys = jwks[:keys] || jwks["keys"] || []
688
597
  signing_key_jwk = keys.find { |k| (k[:use] || k["use"]) == "sig" } || keys.first
689
598
  config.kid = signing_key_jwk&.dig(:kid) || signing_key_jwk&.dig("kid")
@@ -700,38 +609,25 @@ module OmniauthOpenidFederation
700
609
  end
701
610
  end
702
611
 
703
- # Ensure jwks_uri and signed_jwks_uri are present in metadata
704
- # These are required by OpenID Federation 1.0 specification
705
- # Also ensures federation_fetch_endpoint is present for openid_provider entities
706
- #
707
- # @param metadata [Hash] Metadata hash
708
- # @param issuer [String] Issuer URL
709
- # @param entity_type [Symbol] Entity type (:openid_provider or :openid_relying_party)
710
- # @return [Hash] Metadata with jwks endpoints added if missing
711
612
  def ensure_jwks_endpoints(metadata, issuer, entity_type)
712
- metadata = metadata.dup # Don't modify original
613
+ metadata = metadata.dup
713
614
  entity_type ||= detect_entity_type(metadata)
714
615
 
715
- # Determine which metadata section to update
716
616
  section = if entity_type == :openid_provider
717
617
  metadata[:openid_provider] || metadata["openid_provider"] || {}
718
618
  else
719
619
  metadata[:openid_relying_party] || metadata["openid_relying_party"] || {}
720
620
  end
721
621
 
722
- # Convert to symbol keys for consistency
723
622
  section = section.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
724
623
 
725
- # Add jwks_uri and signed_jwks_uri if not present
726
624
  section[:jwks_uri] ||= "#{issuer}/.well-known/jwks.json"
727
625
  section[:signed_jwks_uri] ||= "#{issuer}/.well-known/signed-jwks.json"
728
626
 
729
- # Add federation_fetch_endpoint for openid_provider entities if not present
730
627
  if entity_type == :openid_provider
731
628
  section[:federation_fetch_endpoint] ||= "#{issuer}/.well-known/openid-federation/fetch"
732
629
  end
733
630
 
734
- # Update metadata with modified section
735
631
  if entity_type == :openid_provider
736
632
  metadata[:openid_provider] = section
737
633
  metadata.delete("openid_provider") if metadata.key?("openid_provider")
@@ -743,34 +639,54 @@ module OmniauthOpenidFederation
743
639
  metadata
744
640
  end
745
641
 
642
+ def get_subordinate_statement(subject_entity_id)
643
+ config = configuration
644
+ validate_configuration(config)
645
+
646
+ entity_type = detect_entity_type(config.metadata)
647
+ unless entity_type == :openid_provider
648
+ OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Fetch endpoint called for non-OP entity (#{entity_type}), returning nil")
649
+ return nil
650
+ end
651
+
652
+ if config.subordinate_statements_proc
653
+ return config.subordinate_statements_proc.call(subject_entity_id)
654
+ end
655
+
656
+ if config.subordinate_statements && config.subordinate_statements[subject_entity_id]
657
+ subordinate_config = config.subordinate_statements[subject_entity_id]
658
+ return generate_subordinate_statement(
659
+ subject_entity_id: subject_entity_id,
660
+ subject_metadata: subordinate_config[:metadata] || subordinate_config["metadata"],
661
+ metadata_policy: subordinate_config[:metadata_policy] || subordinate_config["metadata_policy"],
662
+ constraints: subordinate_config[:constraints] || subordinate_config["constraints"]
663
+ )
664
+ end
665
+
666
+ nil
667
+ end
668
+
746
669
  private
747
670
 
748
- # Detect entity type from metadata
749
- #
750
- # @param metadata [Hash, nil] Entity metadata
751
- # @return [Symbol] Entity type: :openid_provider or :openid_relying_party
752
671
  def detect_entity_type(metadata)
753
- return :openid_relying_party if metadata.nil? || metadata.empty?
672
+ return :openid_relying_party if StringHelpers.blank?(metadata)
754
673
 
755
- # Check for openid_relying_party first (primary use case)
756
674
  if metadata.key?(:openid_relying_party) || metadata.key?("openid_relying_party")
757
675
  return :openid_relying_party
758
676
  end
759
677
 
760
- # Check for openid_provider
761
678
  if metadata.key?(:openid_provider) || metadata.key?("openid_provider")
762
679
  return :openid_provider
763
680
  end
764
681
 
765
- # Default to openid_relying_party (primary use case for clients)
766
682
  :openid_relying_party
767
683
  end
768
684
 
769
685
  def validate_configuration(config)
770
- raise ConfigurationError, "Issuer is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if config.issuer.nil? || config.issuer.empty?
686
+ raise ConfigurationError, "Issuer is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if StringHelpers.blank?(config.issuer)
771
687
  raise ConfigurationError, "Private key is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if config.private_key.nil?
772
- raise ConfigurationError, "JWKS is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if config.jwks.nil? || config.jwks.empty?
773
- raise ConfigurationError, "Metadata is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if config.metadata.nil? || config.metadata.empty?
688
+ raise ConfigurationError, "JWKS is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if StringHelpers.blank?(config.jwks)
689
+ raise ConfigurationError, "Metadata is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if StringHelpers.blank?(config.metadata)
774
690
  end
775
691
 
776
692
  def resolve_current_jwks(config)
@@ -792,32 +708,19 @@ module OmniauthOpenidFederation
792
708
  first_key["kid"] || first_key[:kid]
793
709
  end
794
710
 
795
- # Generate Subordinate Statement for a subject entity
796
- # Only available for openid_provider (OP) entities that have subordinates
797
- #
798
- # @param subject_entity_id [String] Entity Identifier of the subject
799
- # @param subject_metadata [Hash, nil] Optional: Subject entity metadata to include
800
- # @param metadata_policy [Hash, nil] Optional: Metadata policy to apply
801
- # @param constraints [Hash, nil] Optional: Trust Chain constraints
802
- # @param source_endpoint [String, nil] Optional: Fetch endpoint URL
803
- # @return [String] The signed Subordinate Statement JWT
804
- # @raise [ConfigurationError] If configuration is incomplete or entity is not an OP
805
711
  def generate_subordinate_statement(subject_entity_id:, subject_metadata: nil, metadata_policy: nil, constraints: nil, source_endpoint: nil)
806
712
  config = configuration
807
713
  validate_configuration(config)
808
714
 
809
- # Only OPs can generate subordinate statements
810
715
  entity_type = detect_entity_type(config.metadata)
811
716
  unless entity_type == :openid_provider
812
717
  raise ConfigurationError, "Subordinate statements can only be generated by openid_provider entities. Current entity type: #{entity_type}"
813
718
  end
814
719
 
815
- # Get federation_fetch_endpoint from metadata or use default
816
720
  op_metadata = config.metadata[:openid_provider] || config.metadata["openid_provider"] || {}
817
721
  fetch_endpoint = op_metadata[:federation_fetch_endpoint] || op_metadata["federation_fetch_endpoint"] ||
818
722
  "#{config.issuer}/.well-known/openid-federation/fetch"
819
723
 
820
- # Build metadata for subject if provided
821
724
  metadata = subject_metadata || {}
822
725
 
823
726
  builder = Federation::EntityStatementBuilder.new(
@@ -836,41 +739,6 @@ module OmniauthOpenidFederation
836
739
  builder.build
837
740
  end
838
741
 
839
- # Get Subordinate Statement for a subject (for Fetch Endpoint)
840
- # Only available for openid_provider (OP) entities
841
- #
842
- # @param subject_entity_id [String] Entity Identifier of the subject
843
- # @return [String, nil] The Subordinate Statement JWT or nil if not found
844
- # @raise [ConfigurationError] If configuration is incomplete or entity is not an OP
845
- def get_subordinate_statement(subject_entity_id)
846
- config = configuration
847
-
848
- # Only OPs can serve subordinate statements
849
- entity_type = detect_entity_type(config.metadata)
850
- unless entity_type == :openid_provider
851
- OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Fetch endpoint called for non-OP entity (#{entity_type}), returning nil")
852
- return nil
853
- end
854
-
855
- # Use subordinate_statements_proc if configured
856
- if config.subordinate_statements_proc
857
- return config.subordinate_statements_proc.call(subject_entity_id)
858
- end
859
-
860
- # Use subordinate_statements hash if configured
861
- if config.subordinate_statements && config.subordinate_statements[subject_entity_id]
862
- subordinate_config = config.subordinate_statements[subject_entity_id]
863
- return generate_subordinate_statement(
864
- subject_entity_id: subject_entity_id,
865
- subject_metadata: subordinate_config[:metadata] || subordinate_config["metadata"],
866
- metadata_policy: subordinate_config[:metadata_policy] || subordinate_config["metadata_policy"],
867
- constraints: subordinate_config[:constraints] || subordinate_config["constraints"]
868
- )
869
- end
870
-
871
- nil
872
- end
873
-
874
742
  # Configuration class for FederationEndpoint
875
743
  # Supports automatic key provisioning with separate signing and encryption keys
876
744
  # Supports both openid_provider (OP) and openid_relying_party (RP) entity types
@@ -38,28 +38,53 @@ module OmniauthOpenidFederation
38
38
  def self.run(jwks_uri, entity_statement_path: nil)
39
39
  if entity_statement_path
40
40
  # Validate file path to prevent path traversal
41
- begin
42
- # Determine allowed directories for file path validation
43
- config = Configuration.config
44
- allowed_dirs = if defined?(Rails) && Rails.root
45
- [Rails.root.join("config").to_s]
46
- elsif config.root_path
47
- [File.join(config.root_path, "config")]
48
- end
41
+ # Allow absolute paths that exist (for temp files in tests) to skip directory validation
42
+ # For absolute paths that don't exist, still validate they're not path traversal, then check existence
43
+ path_str = entity_statement_path.to_s
44
+ is_absolute = path_str.start_with?("/", "~")
49
45
 
50
- validated_path = Utils.validate_file_path!(
51
- entity_statement_path,
52
- allowed_dirs: allowed_dirs
53
- )
54
- rescue SecurityError => e
55
- Logger.error("[Jwks::Rotate] #{e.message}")
56
- raise SecurityError, e.message
57
- end
46
+ if is_absolute && File.exist?(entity_statement_path)
47
+ validated_path = entity_statement_path
48
+ else
49
+ # For absolute paths, validate path traversal but allow outside allowed_dirs
50
+ # For relative paths, validate against allowed directories
51
+ if is_absolute
52
+ # Validate path traversal for absolute paths, but don't require it to be in allowed_dirs
53
+ begin
54
+ validated_path = Utils.validate_file_path!(
55
+ entity_statement_path,
56
+ allowed_dirs: nil # Allow absolute paths outside config directory
57
+ )
58
+ rescue SecurityError => e
59
+ # Path traversal attempt - raise SecurityError
60
+ Logger.error("[Jwks::Rotate] #{e.message}")
61
+ raise SecurityError, e.message
62
+ end
63
+ else
64
+ # Relative path - must be in allowed directories
65
+ begin
66
+ config = Configuration.config
67
+ allowed_dirs = if defined?(Rails) && Rails.root
68
+ [Rails.root.join("config").to_s]
69
+ elsif config.root_path
70
+ [File.join(config.root_path, "config")]
71
+ end
58
72
 
59
- unless File.exist?(validated_path)
60
- sanitized_path = Utils.sanitize_path(validated_path)
61
- Logger.warn("[Jwks::Rotate] Entity statement file not found: #{sanitized_path}")
62
- raise ConfigurationError, "Entity statement file not found: #{sanitized_path}"
73
+ validated_path = Utils.validate_file_path!(
74
+ entity_statement_path,
75
+ allowed_dirs: allowed_dirs
76
+ )
77
+ rescue SecurityError => e
78
+ Logger.error("[Jwks::Rotate] #{e.message}")
79
+ raise SecurityError, e.message
80
+ end
81
+ end
82
+
83
+ unless File.exist?(validated_path)
84
+ sanitized_path = Utils.sanitize_path(validated_path)
85
+ Logger.warn("[Jwks::Rotate] Entity statement file not found: #{sanitized_path}")
86
+ raise ConfigurationError, "Entity statement file not found: #{sanitized_path}"
87
+ end
63
88
  end
64
89
 
65
90
  # Try to use signed JWKS if entity statement is available
@@ -3,6 +3,7 @@ require "jwe"
3
3
  require "securerandom"
4
4
  require "base64"
5
5
  require_relative "string_helpers"
6
+ require_relative "time_helpers"
6
7
  require_relative "logger"
7
8
  require_relative "errors"
8
9
  require_relative "validators"
@@ -222,7 +223,7 @@ module OmniauthOpenidFederation
222
223
  response_type: @response_type,
223
224
  scope: @scope,
224
225
  state: state,
225
- exp: (Time.now + (defined?(ActiveSupport) ? REQUEST_OBJECT_EXPIRATION_MINUTES.minutes : REQUEST_OBJECT_EXPIRATION_SECONDS)).to_i,
226
+ exp: (TimeHelpers.now + (defined?(ActiveSupport) ? REQUEST_OBJECT_EXPIRATION_MINUTES.minutes : REQUEST_OBJECT_EXPIRATION_SECONDS)).to_i,
226
227
  jti: SecureRandom.uuid # JWT ID to prevent replay
227
228
  }
228
229