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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/README.md +1 -1
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +1 -1
- data/config/routes.rb +20 -10
- data/examples/integration_test_flow.rb +4 -4
- data/examples/mock_op_server.rb +3 -3
- data/examples/mock_rp_server.rb +3 -3
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +6 -87
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +3 -15
- data/lib/omniauth_openid_federation/federation_endpoint.rb +39 -171
- data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
- data/lib/omniauth_openid_federation/jws.rb +2 -1
- data/lib/omniauth_openid_federation/rack_endpoint.rb +19 -7
- data/lib/omniauth_openid_federation/tasks_helper.rb +23 -5
- data/lib/omniauth_openid_federation/time_helpers.rb +60 -0
- data/lib/omniauth_openid_federation/utils.rb +4 -7
- data/lib/omniauth_openid_federation/validators.rb +12 -36
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +1 -0
- data/lib/tasks/omniauth_openid_federation.rake +4 -3
- data/sig/omniauth_openid_federation.rbs +6 -0
- metadata +100 -1
|
@@ -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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
773
|
-
raise ConfigurationError, "Metadata is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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: (
|
|
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
|
|