omniauth_openid_federation 1.2.2 → 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 +20 -1
- data/README.md +210 -708
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +14 -1
- data/config/routes.rb +20 -10
- data/examples/config/initializers/devise.rb.example +44 -55
- data/examples/config/initializers/federation_endpoint.rb.example +2 -2
- data/examples/config/open_id_connect_config.rb.example +12 -15
- data/examples/config/routes.rb.example +9 -5
- 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/configuration.rb +8 -0
- data/lib/omniauth_openid_federation/constants.rb +5 -0
- 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 -193
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -15
- data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
- data/lib/omniauth_openid_federation/jws.rb +23 -20
- data/lib/omniauth_openid_federation/rack_endpoint.rb +30 -5
- data/lib/omniauth_openid_federation/strategy.rb +143 -194
- data/lib/omniauth_openid_federation/tasks_helper.rb +501 -2
- 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 +294 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +1 -0
- data/lib/tasks/omniauth_openid_federation.rake +301 -2
- data/sig/federation.rbs +0 -8
- data/sig/jwks.rbs +0 -6
- data/sig/omniauth_openid_federation.rbs +6 -1
- data/sig/strategy.rbs +0 -2
- 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"
|
|
@@ -64,8 +66,6 @@ module OmniauthOpenidFederation
|
|
|
64
66
|
# # In config/routes.rb (Rails)
|
|
65
67
|
# get "/.well-known/openid-federation", to: "omniauth_openid_federation/federation#show"
|
|
66
68
|
#
|
|
67
|
-
# # Or use the provided route helper
|
|
68
|
-
# OmniauthOpenidFederation::FederationEndpoint.mount_routes
|
|
69
69
|
class FederationEndpoint
|
|
70
70
|
class << self
|
|
71
71
|
# Configure the federation endpoint
|
|
@@ -125,16 +125,13 @@ module OmniauthOpenidFederation
|
|
|
125
125
|
auto_provision_keys: true,
|
|
126
126
|
key_rotation_period: nil
|
|
127
127
|
)
|
|
128
|
-
raise ConfigurationError, "Issuer is required" if
|
|
128
|
+
raise ConfigurationError, "Issuer is required" if StringHelpers.blank?(issuer)
|
|
129
129
|
|
|
130
|
-
# Priority 1: Validate key configuration - exception if all three are set
|
|
131
130
|
if signing_key && encryption_key && private_key
|
|
132
131
|
raise ConfigurationError, "Cannot specify signing_key, encryption_key, and private_key simultaneously. " \
|
|
133
132
|
"Use either (signing_key + encryption_key) OR private_key, not both."
|
|
134
133
|
end
|
|
135
134
|
|
|
136
|
-
# If auto_provision_keys is enabled and no keys are provided, allow automatic generation
|
|
137
|
-
# Keys will be generated in provision_jwks if needed
|
|
138
135
|
unless auto_provision_keys
|
|
139
136
|
if signing_key.nil? && encryption_key.nil? && private_key.nil? && jwks.nil?
|
|
140
137
|
raise ConfigurationError, "At least one key source is required: signing_key, encryption_key, private_key, or jwks"
|
|
@@ -151,11 +148,9 @@ module OmniauthOpenidFederation
|
|
|
151
148
|
|
|
152
149
|
config = configuration
|
|
153
150
|
|
|
154
|
-
# Set issuer and subject
|
|
155
151
|
config.issuer = issuer
|
|
156
152
|
config.subject = subject || issuer
|
|
157
153
|
|
|
158
|
-
# Automatic key provisioning
|
|
159
154
|
if auto_provision_keys && jwks.nil?
|
|
160
155
|
jwks = provision_jwks(
|
|
161
156
|
signing_key: signing_key,
|
|
@@ -168,85 +163,60 @@ module OmniauthOpenidFederation
|
|
|
168
163
|
entity_statement_path_provided: !entity_statement_path.nil?
|
|
169
164
|
)
|
|
170
165
|
|
|
171
|
-
# After provisioning, check if we have keys available
|
|
172
|
-
# If provisioning failed and no keys were provided, raise appropriate error
|
|
173
166
|
if jwks.nil? && signing_key.nil? && encryption_key.nil? && private_key.nil? && config.signing_key.nil?
|
|
174
167
|
raise ConfigurationError, "Signing key is required. Provide signing_key, encryption_key, or private_key, or enable auto_provision_keys with entity_statement_path."
|
|
175
168
|
end
|
|
176
169
|
end
|
|
177
170
|
|
|
178
|
-
# Use provided jwks if available, otherwise use provisioned jwks
|
|
179
171
|
config.jwks = jwks || raise(ConfigurationError, "JWKS is required. Provide jwks parameter or enable auto_provision_keys.")
|
|
180
172
|
|
|
181
|
-
# Set keys in configuration following priority:
|
|
182
|
-
# 1. Use provided keys (signing_key + encryption_key, or private_key)
|
|
183
|
-
# 2. Use keys loaded from disk (if entity statement was loaded)
|
|
184
|
-
# 3. Use keys from generated keys (if auto-generated)
|
|
185
173
|
if signing_key && encryption_key
|
|
186
|
-
# Priority 1: Use provided separate keys
|
|
187
174
|
config.signing_key = signing_key
|
|
188
175
|
config.encryption_key = encryption_key
|
|
189
176
|
config.private_key = signing_key
|
|
190
177
|
elsif signing_key
|
|
191
|
-
# Priority 1b: Use provided signing_key for both signing and encryption
|
|
192
178
|
config.signing_key = signing_key
|
|
193
179
|
config.encryption_key = signing_key
|
|
194
180
|
config.private_key = signing_key
|
|
195
181
|
elsif private_key
|
|
196
|
-
# Priority 2: Use provided single private_key
|
|
197
182
|
config.private_key = private_key
|
|
198
183
|
config.signing_key = private_key
|
|
199
184
|
config.encryption_key = private_key
|
|
200
185
|
elsif config.signing_key && config.encryption_key
|
|
201
|
-
# Priority 3: Keys were loaded from disk in provision_jwks
|
|
202
186
|
config.private_key = config.signing_key
|
|
203
187
|
elsif config.signing_key
|
|
204
|
-
# Priority 4: Only signing_key was set (from auto-generation or fallback)
|
|
205
|
-
# Use it for both signing and encryption
|
|
206
188
|
config.private_key = config.signing_key
|
|
207
189
|
config.encryption_key = config.signing_key
|
|
208
190
|
else
|
|
209
191
|
raise ConfigurationError, "Signing key is required. Provide signing_key, encryption_key, or private_key, or enable auto_provision_keys with entity_statement_path."
|
|
210
192
|
end
|
|
211
193
|
|
|
212
|
-
# Set kid from first signing key in JWKS
|
|
213
194
|
keys = config.jwks[:keys] || config.jwks["keys"] || []
|
|
214
195
|
signing_key_jwk = keys.find { |k| (k[:use] || k["use"]) == "sig" } || keys.first
|
|
215
196
|
config.kid = signing_key_jwk&.dig(:kid) || signing_key_jwk&.dig("kid")
|
|
216
197
|
|
|
217
|
-
# Set metadata
|
|
218
|
-
# Detect entity type from metadata or default based on provided keys
|
|
219
198
|
entity_type = detect_entity_type(metadata)
|
|
220
199
|
|
|
221
200
|
if metadata
|
|
222
|
-
# Automatically add required jwks_uri and signed_jwks_uri if not present
|
|
223
|
-
# These are required by OpenID Federation 1.0 spec and the library provides these endpoints
|
|
224
201
|
metadata = ensure_jwks_endpoints(metadata, issuer, entity_type)
|
|
225
202
|
config.metadata = metadata
|
|
226
|
-
# Ensure entity type is consistent
|
|
227
203
|
entity_type = detect_entity_type(config.metadata)
|
|
228
204
|
else
|
|
229
|
-
# Auto-generate minimal metadata with only standard well-known endpoints
|
|
230
|
-
# Default to openid_relying_party (RP) entity type for clients
|
|
231
205
|
base_metadata = {
|
|
232
206
|
issuer: issuer
|
|
233
207
|
}
|
|
234
208
|
|
|
235
|
-
# Only add federation_fetch_endpoint for openid_provider (OP) entities
|
|
236
|
-
# RPs typically don't have subordinates, so they don't need fetch endpoint
|
|
237
209
|
if entity_type == :openid_provider
|
|
238
210
|
base_metadata[:federation_fetch_endpoint] = "#{issuer}/.well-known/openid-federation/fetch"
|
|
239
211
|
config.metadata = {
|
|
240
212
|
openid_provider: base_metadata
|
|
241
213
|
}
|
|
242
214
|
else
|
|
243
|
-
# Default to openid_relying_party (RP)
|
|
244
215
|
config.metadata = {
|
|
245
216
|
openid_relying_party: base_metadata
|
|
246
217
|
}
|
|
247
218
|
end
|
|
248
219
|
|
|
249
|
-
# Ensure jwks_uri and signed_jwks_uri are added (same as when metadata is provided)
|
|
250
220
|
config.metadata = ensure_jwks_endpoints(config.metadata, issuer, entity_type)
|
|
251
221
|
|
|
252
222
|
OmniauthOpenidFederation::Logger.warn(
|
|
@@ -257,25 +227,19 @@ module OmniauthOpenidFederation
|
|
|
257
227
|
)
|
|
258
228
|
end
|
|
259
229
|
|
|
260
|
-
# Store entity type for later use
|
|
261
230
|
config.entity_type = entity_type
|
|
262
231
|
|
|
263
|
-
# Set optional configuration
|
|
264
232
|
config.expiration_seconds = expiration_seconds if expiration_seconds
|
|
265
233
|
config.jwks_cache_ttl = jwks_cache_ttl if jwks_cache_ttl
|
|
266
234
|
config.key_rotation_period = key_rotation_period if key_rotation_period
|
|
267
235
|
config.entity_statement_path = entity_statement_path if entity_statement_path
|
|
268
236
|
|
|
269
|
-
# If keys were provided in config, regenerate entity statement and save keys to disk
|
|
270
|
-
# This ensures the entity statement signature matches the provided keys
|
|
271
237
|
if entity_statement_path && (signing_key || private_key)
|
|
272
238
|
begin
|
|
273
|
-
# Save provided keys to disk for persistence
|
|
274
239
|
keys_dir = File.dirname(entity_statement_path)
|
|
275
240
|
FileUtils.mkdir_p(keys_dir) unless File.directory?(keys_dir)
|
|
276
241
|
|
|
277
242
|
if signing_key && encryption_key
|
|
278
|
-
# Save separate keys
|
|
279
243
|
signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
|
|
280
244
|
encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
|
|
281
245
|
File.write(signing_key_path, signing_key.to_pem)
|
|
@@ -284,7 +248,6 @@ module OmniauthOpenidFederation
|
|
|
284
248
|
File.chmod(0o600, encryption_key_path)
|
|
285
249
|
OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Saved provided signing and encryption keys to disk")
|
|
286
250
|
elsif private_key
|
|
287
|
-
# Save single key (for transition period / dev/testing)
|
|
288
251
|
signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
|
|
289
252
|
encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
|
|
290
253
|
File.write(signing_key_path, private_key.to_pem)
|
|
@@ -294,7 +257,6 @@ module OmniauthOpenidFederation
|
|
|
294
257
|
OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Saved provided private_key to disk (used for both signing and encryption)")
|
|
295
258
|
end
|
|
296
259
|
|
|
297
|
-
# Regenerate entity statement with provided keys to ensure signature matches
|
|
298
260
|
entity_statement = generate_entity_statement
|
|
299
261
|
FileUtils.mkdir_p(File.dirname(entity_statement_path)) if File.dirname(entity_statement_path) != "."
|
|
300
262
|
File.write(entity_statement_path, entity_statement)
|
|
@@ -305,7 +267,6 @@ module OmniauthOpenidFederation
|
|
|
305
267
|
end
|
|
306
268
|
end
|
|
307
269
|
|
|
308
|
-
# Handle automatic key rotation if enabled
|
|
309
270
|
if auto_provision_keys && entity_statement_path && config.key_rotation_period
|
|
310
271
|
rotate_keys_if_needed(config)
|
|
311
272
|
end
|
|
@@ -316,12 +277,6 @@ module OmniauthOpenidFederation
|
|
|
316
277
|
|
|
317
278
|
# Automatic key provisioning: Extract or generate JWKS from available sources
|
|
318
279
|
#
|
|
319
|
-
# Priority order:
|
|
320
|
-
# 1. Extract from entity_statement_path (cached, supports key rotation)
|
|
321
|
-
# 2. Generate from separate signing_key and encryption_key (RECOMMENDED)
|
|
322
|
-
# 3. Generate from single private_key (DEV/TESTING ONLY)
|
|
323
|
-
# 4. Auto-generate new keys if no keys provided and auto_provision_keys is enabled
|
|
324
|
-
#
|
|
325
280
|
# @param signing_key [OpenSSL::PKey::RSA, nil] Signing private key
|
|
326
281
|
# @param encryption_key [OpenSSL::PKey::RSA, nil] Encryption private key
|
|
327
282
|
# @param private_key [OpenSSL::PKey::RSA, nil] Single private key (dev/testing only)
|
|
@@ -332,37 +287,27 @@ module OmniauthOpenidFederation
|
|
|
332
287
|
# @param entity_statement_path_provided [Boolean] Whether entity_statement_path was provided as parameter (not auto-generated)
|
|
333
288
|
# @return [Hash, nil] JWKS hash with keys array, or nil if provisioning fails
|
|
334
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)
|
|
335
|
-
# Priority 1-3: Use provided keys from config (highest priority)
|
|
336
290
|
if encryption_key
|
|
337
|
-
# Generate from separate signing_key and encryption_key (RECOMMENDED for production)
|
|
338
291
|
signing_key_for_jwk = signing_key || private_key
|
|
339
292
|
raise ConfigurationError, "Signing key is required when encryption_key is provided. Provide signing_key or private_key." unless signing_key_for_jwk
|
|
340
293
|
|
|
341
|
-
# Check if signing and encryption keys are the same (compare public key PEM)
|
|
342
294
|
# If same, generate single JWK to avoid duplicate kid values
|
|
343
295
|
if signing_key_for_jwk.public_key.to_pem == encryption_key.public_key.to_pem
|
|
344
|
-
# Same key used for both signing and encryption - generate single JWK
|
|
345
296
|
single_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(signing_key_for_jwk, use: nil)
|
|
346
297
|
return {keys: [single_jwk]}
|
|
347
298
|
else
|
|
348
|
-
# Different keys - generate separate JWKs
|
|
349
299
|
signing_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(signing_key_for_jwk, use: "sig")
|
|
350
300
|
encryption_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(encryption_key, use: "enc")
|
|
351
301
|
return {keys: [signing_jwk, encryption_jwk]}
|
|
352
302
|
end
|
|
353
303
|
elsif private_key || signing_key
|
|
354
|
-
# Use single key (private_key or signing_key) for both signing and encryption
|
|
355
|
-
# When using a single key, include only ONE JWK (not two with duplicate kid)
|
|
356
304
|
single_key = private_key || signing_key
|
|
357
305
|
|
|
358
|
-
# Generate JWK without 'use' field to
|
|
359
|
-
# This avoids duplicate kid values which violate the spec
|
|
306
|
+
# Generate JWK without 'use' field to avoid duplicate kid values which violate the spec
|
|
360
307
|
single_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(single_key, use: nil)
|
|
361
308
|
return {keys: [single_jwk]}
|
|
362
309
|
end
|
|
363
310
|
|
|
364
|
-
# Priority 4: Extract from entity statement file (cached, supports automatic key rotation)
|
|
365
|
-
# Only if no keys were provided in config (keys from config take priority)
|
|
366
311
|
extraction_failed = false
|
|
367
312
|
if entity_statement_path&.then { |path| File.exist?(path) }
|
|
368
313
|
begin
|
|
@@ -371,8 +316,6 @@ module OmniauthOpenidFederation
|
|
|
371
316
|
if jwks&.dig(:keys)&.any?
|
|
372
317
|
OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Extracted JWKS from entity statement file: #{entity_statement_path}")
|
|
373
318
|
|
|
374
|
-
# Only load private keys from disk if no keys were provided in config
|
|
375
|
-
# This ensures provided keys take priority over cached keys
|
|
376
319
|
keys_dir = File.dirname(entity_statement_path)
|
|
377
320
|
signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
|
|
378
321
|
encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
|
|
@@ -411,16 +354,11 @@ module OmniauthOpenidFederation
|
|
|
411
354
|
end
|
|
412
355
|
end
|
|
413
356
|
|
|
414
|
-
# Priority 5: Auto-generate new keys (when auto_provision_keys is enabled and no keys provided)
|
|
415
|
-
# This generates separate signing and encryption keys
|
|
416
|
-
# Only auto-generate if entity_statement_path was not provided as parameter (or extraction succeeded)
|
|
417
|
-
# If entity_statement_path was provided but extraction failed, don't auto-generate
|
|
418
357
|
if issuer && (!entity_statement_path_provided || !extraction_failed)
|
|
419
|
-
# Generate a default entity_statement_path if not provided
|
|
420
358
|
entity_statement_path ||= begin
|
|
421
359
|
configuration
|
|
422
360
|
if defined?(Rails) && Rails.root
|
|
423
|
-
default_path = Rails.root.join("config
|
|
361
|
+
default_path = Rails.root.join("config/.federation-entity-statement.jwt").to_s
|
|
424
362
|
OmniauthOpenidFederation::Logger.info("[FederationEndpoint] No entity_statement_path provided, using default: #{OmniauthOpenidFederation::Utils.sanitize_path(default_path)}")
|
|
425
363
|
default_path
|
|
426
364
|
end
|
|
@@ -443,17 +381,10 @@ module OmniauthOpenidFederation
|
|
|
443
381
|
nil
|
|
444
382
|
end
|
|
445
383
|
|
|
446
|
-
# Get the current configuration
|
|
447
|
-
#
|
|
448
|
-
# @return [Configuration] Current configuration
|
|
449
384
|
def configuration
|
|
450
385
|
@configuration ||= Configuration.new
|
|
451
386
|
end
|
|
452
387
|
|
|
453
|
-
# Generate the entity statement JWT
|
|
454
|
-
#
|
|
455
|
-
# @return [String] The signed entity statement JWT
|
|
456
|
-
# @raise [ConfigurationError] If configuration is incomplete
|
|
457
388
|
def generate_entity_statement
|
|
458
389
|
config = configuration
|
|
459
390
|
validate_configuration(config)
|
|
@@ -472,22 +403,15 @@ module OmniauthOpenidFederation
|
|
|
472
403
|
builder.build
|
|
473
404
|
end
|
|
474
405
|
|
|
475
|
-
# Generate signed JWKS JWT
|
|
476
|
-
#
|
|
477
|
-
# @return [String] The signed JWKS JWT
|
|
478
|
-
# @raise [ConfigurationError] If configuration is incomplete
|
|
479
406
|
def generate_signed_jwks
|
|
480
407
|
config = configuration
|
|
481
408
|
validate_configuration(config)
|
|
482
409
|
|
|
483
|
-
# Get JWKS to include in payload (current keys, not entity statement keys)
|
|
484
410
|
jwks_payload = resolve_signed_jwks_payload(config)
|
|
485
411
|
|
|
486
|
-
# Sign with entity statement key
|
|
487
412
|
signing_kid = config.signed_jwks_signing_kid || config.kid || extract_kid_from_jwks(config.jwks)
|
|
488
413
|
expiration_seconds = config.signed_jwks_expiration_seconds || 86400
|
|
489
414
|
|
|
490
|
-
# Build JWT payload with JWKS
|
|
491
415
|
now = Time.now.to_i
|
|
492
416
|
payload = {
|
|
493
417
|
iss: config.issuer,
|
|
@@ -497,7 +421,6 @@ module OmniauthOpenidFederation
|
|
|
497
421
|
jwks: jwks_payload
|
|
498
422
|
}
|
|
499
423
|
|
|
500
|
-
# Sign JWT using jwt gem
|
|
501
424
|
header = {
|
|
502
425
|
alg: "RS256",
|
|
503
426
|
typ: "JWT",
|
|
@@ -513,11 +436,9 @@ module OmniauthOpenidFederation
|
|
|
513
436
|
end
|
|
514
437
|
end
|
|
515
438
|
|
|
516
|
-
# Get current JWKS for serving
|
|
517
|
-
#
|
|
518
|
-
# @return [Hash] Current JWKS hash
|
|
519
439
|
def current_jwks
|
|
520
440
|
config = configuration
|
|
441
|
+
validate_configuration(config)
|
|
521
442
|
resolve_current_jwks(config)
|
|
522
443
|
end
|
|
523
444
|
|
|
@@ -558,26 +479,6 @@ module OmniauthOpenidFederation
|
|
|
558
479
|
# - GET /.well-known/jwks.json (standard JWKS)
|
|
559
480
|
# - GET /.well-known/signed-jwks.json (signed JWKS)
|
|
560
481
|
#
|
|
561
|
-
# ALTERNATIVE: Use mount_routes helper (for backward compatibility or custom paths):
|
|
562
|
-
# Rails.application.routes.draw do
|
|
563
|
-
# OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
|
|
564
|
-
# end
|
|
565
|
-
#
|
|
566
|
-
# @param router [ActionDispatch::Routing::Mapper] The routes mapper (pass `self` from routes.rb)
|
|
567
|
-
# @param entity_statement_path [String] Path for entity statement endpoint (default: "/.well-known/openid-federation")
|
|
568
|
-
# @param fetch_path [String] Path for fetch endpoint (default: "/.well-known/openid-federation/fetch")
|
|
569
|
-
# @param jwks_path [String] Path for standard JWKS endpoint (default: "/.well-known/jwks.json")
|
|
570
|
-
# @param signed_jwks_path [String] Path for signed JWKS endpoint (default: "/.well-known/signed-jwks.json")
|
|
571
|
-
# @param as [String, Symbol] Route name prefix (default: :openid_federation)
|
|
572
|
-
# @deprecated Use `mount OmniauthOpenidFederation::Engine => "/"` instead (Rails-idiomatic way)
|
|
573
|
-
def mount_routes(router, entity_statement_path: "/.well-known/openid-federation", fetch_path: "/.well-known/openid-federation/fetch", jwks_path: "/.well-known/jwks.json", signed_jwks_path: "/.well-known/signed-jwks.json", as: :openid_federation)
|
|
574
|
-
# Controller uses Rails-conventional naming (OmniauthOpenidFederation)
|
|
575
|
-
# which matches natural inflection from omniauth_openid_federation
|
|
576
|
-
router.get entity_statement_path, to: "omniauth_openid_federation/federation#show", as: as
|
|
577
|
-
router.get fetch_path, to: "omniauth_openid_federation/federation#fetch", as: :"#{as}_fetch"
|
|
578
|
-
router.get jwks_path, to: "omniauth_openid_federation/federation#jwks", as: :"#{as}_jwks"
|
|
579
|
-
router.get signed_jwks_path, to: "omniauth_openid_federation/federation#signed_jwks", as: :"#{as}_signed_jwks"
|
|
580
|
-
end
|
|
581
482
|
|
|
582
483
|
# Generate fresh signing and encryption keys and write entity statement to file
|
|
583
484
|
#
|
|
@@ -588,7 +489,6 @@ module OmniauthOpenidFederation
|
|
|
588
489
|
# @param keys_output_dir [String, nil] Directory to store private keys (optional, defaults to same dir as entity_statement_path)
|
|
589
490
|
# @return [Hash, nil] JWKS hash with keys array, or nil if generation fails
|
|
590
491
|
def generate_fresh_keys(entity_statement_path:, issuer: nil, subject: nil, metadata: nil, keys_output_dir: nil)
|
|
591
|
-
# Generate separate signing and encryption keys
|
|
592
492
|
signing_key = OpenSSL::PKey::RSA.new(2048)
|
|
593
493
|
encryption_key = OpenSSL::PKey::RSA.new(2048)
|
|
594
494
|
|
|
@@ -596,13 +496,11 @@ module OmniauthOpenidFederation
|
|
|
596
496
|
encryption_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(encryption_key, use: "enc")
|
|
597
497
|
jwks = {keys: [signing_jwk, encryption_jwk]}
|
|
598
498
|
|
|
599
|
-
# Get configuration for issuer, subject, and metadata
|
|
600
499
|
config = configuration
|
|
601
500
|
issuer ||= config.issuer
|
|
602
501
|
subject ||= config.subject || issuer
|
|
603
502
|
metadata ||= config.metadata
|
|
604
503
|
|
|
605
|
-
# Generate minimal metadata if none provided
|
|
606
504
|
unless metadata
|
|
607
505
|
if issuer
|
|
608
506
|
# Default to openid_relying_party (RP) entity type for clients
|
|
@@ -620,7 +518,6 @@ module OmniauthOpenidFederation
|
|
|
620
518
|
end
|
|
621
519
|
end
|
|
622
520
|
|
|
623
|
-
# Generate entity statement with new keys
|
|
624
521
|
if issuer
|
|
625
522
|
builder = Federation::EntityStatementBuilder.new(
|
|
626
523
|
issuer: issuer,
|
|
@@ -634,11 +531,9 @@ module OmniauthOpenidFederation
|
|
|
634
531
|
|
|
635
532
|
entity_statement = builder.build
|
|
636
533
|
|
|
637
|
-
# Determine keys output directory (default to same directory as entity statement)
|
|
638
534
|
keys_dir = keys_output_dir || File.dirname(entity_statement_path)
|
|
639
535
|
FileUtils.mkdir_p(keys_dir) unless File.directory?(keys_dir)
|
|
640
536
|
|
|
641
|
-
# Write private keys to disk (secure storage)
|
|
642
537
|
signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
|
|
643
538
|
encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
|
|
644
539
|
|
|
@@ -647,12 +542,10 @@ module OmniauthOpenidFederation
|
|
|
647
542
|
File.chmod(0o600, signing_key_path)
|
|
648
543
|
File.chmod(0o600, encryption_key_path)
|
|
649
544
|
|
|
650
|
-
# Write entity statement to file
|
|
651
545
|
FileUtils.mkdir_p(File.dirname(entity_statement_path)) if File.dirname(entity_statement_path) != "."
|
|
652
546
|
File.write(entity_statement_path, entity_statement)
|
|
653
547
|
File.chmod(0o600, entity_statement_path) if File.exist?(entity_statement_path)
|
|
654
548
|
|
|
655
|
-
# Update configuration with new keys
|
|
656
549
|
config.signing_key = signing_key
|
|
657
550
|
config.encryption_key = encryption_key
|
|
658
551
|
config.private_key = signing_key
|
|
@@ -673,19 +566,15 @@ module OmniauthOpenidFederation
|
|
|
673
566
|
nil
|
|
674
567
|
end
|
|
675
568
|
|
|
676
|
-
# Rotate keys if rotation period has elapsed
|
|
677
|
-
#
|
|
678
|
-
# @param config [Configuration] Configuration object
|
|
679
569
|
def rotate_keys_if_needed(config)
|
|
680
570
|
return unless config.key_rotation_period && config.entity_statement_path
|
|
681
571
|
|
|
682
572
|
entity_statement_path = config.entity_statement_path
|
|
683
573
|
return unless File.exist?(entity_statement_path)
|
|
684
574
|
|
|
685
|
-
# Check if file needs rotation based on modification time
|
|
686
575
|
file_mtime = File.mtime(entity_statement_path)
|
|
687
576
|
rotation_period_seconds = config.key_rotation_period.to_i
|
|
688
|
-
time_since_rotation =
|
|
577
|
+
time_since_rotation = TimeHelpers.now - file_mtime
|
|
689
578
|
|
|
690
579
|
if time_since_rotation >= rotation_period_seconds
|
|
691
580
|
OmniauthOpenidFederation::Logger.info(
|
|
@@ -693,7 +582,6 @@ module OmniauthOpenidFederation
|
|
|
693
582
|
"generating new keys"
|
|
694
583
|
)
|
|
695
584
|
|
|
696
|
-
# Generate fresh keys and update entity statement
|
|
697
585
|
keys_dir = File.dirname(entity_statement_path)
|
|
698
586
|
jwks = generate_fresh_keys(
|
|
699
587
|
entity_statement_path: entity_statement_path,
|
|
@@ -705,7 +593,6 @@ module OmniauthOpenidFederation
|
|
|
705
593
|
|
|
706
594
|
if jwks
|
|
707
595
|
config.jwks = jwks
|
|
708
|
-
# Update kid from new signing key
|
|
709
596
|
keys = jwks[:keys] || jwks["keys"] || []
|
|
710
597
|
signing_key_jwk = keys.find { |k| (k[:use] || k["use"]) == "sig" } || keys.first
|
|
711
598
|
config.kid = signing_key_jwk&.dig(:kid) || signing_key_jwk&.dig("kid")
|
|
@@ -722,38 +609,25 @@ module OmniauthOpenidFederation
|
|
|
722
609
|
end
|
|
723
610
|
end
|
|
724
611
|
|
|
725
|
-
# Ensure jwks_uri and signed_jwks_uri are present in metadata
|
|
726
|
-
# These are required by OpenID Federation 1.0 specification
|
|
727
|
-
# Also ensures federation_fetch_endpoint is present for openid_provider entities
|
|
728
|
-
#
|
|
729
|
-
# @param metadata [Hash] Metadata hash
|
|
730
|
-
# @param issuer [String] Issuer URL
|
|
731
|
-
# @param entity_type [Symbol] Entity type (:openid_provider or :openid_relying_party)
|
|
732
|
-
# @return [Hash] Metadata with jwks endpoints added if missing
|
|
733
612
|
def ensure_jwks_endpoints(metadata, issuer, entity_type)
|
|
734
|
-
metadata = metadata.dup
|
|
613
|
+
metadata = metadata.dup
|
|
735
614
|
entity_type ||= detect_entity_type(metadata)
|
|
736
615
|
|
|
737
|
-
# Determine which metadata section to update
|
|
738
616
|
section = if entity_type == :openid_provider
|
|
739
617
|
metadata[:openid_provider] || metadata["openid_provider"] || {}
|
|
740
618
|
else
|
|
741
619
|
metadata[:openid_relying_party] || metadata["openid_relying_party"] || {}
|
|
742
620
|
end
|
|
743
621
|
|
|
744
|
-
# Convert to symbol keys for consistency
|
|
745
622
|
section = section.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
|
|
746
623
|
|
|
747
|
-
# Add jwks_uri and signed_jwks_uri if not present
|
|
748
624
|
section[:jwks_uri] ||= "#{issuer}/.well-known/jwks.json"
|
|
749
625
|
section[:signed_jwks_uri] ||= "#{issuer}/.well-known/signed-jwks.json"
|
|
750
626
|
|
|
751
|
-
# Add federation_fetch_endpoint for openid_provider entities if not present
|
|
752
627
|
if entity_type == :openid_provider
|
|
753
628
|
section[:federation_fetch_endpoint] ||= "#{issuer}/.well-known/openid-federation/fetch"
|
|
754
629
|
end
|
|
755
630
|
|
|
756
|
-
# Update metadata with modified section
|
|
757
631
|
if entity_type == :openid_provider
|
|
758
632
|
metadata[:openid_provider] = section
|
|
759
633
|
metadata.delete("openid_provider") if metadata.key?("openid_provider")
|
|
@@ -765,34 +639,54 @@ module OmniauthOpenidFederation
|
|
|
765
639
|
metadata
|
|
766
640
|
end
|
|
767
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
|
+
|
|
768
669
|
private
|
|
769
670
|
|
|
770
|
-
# Detect entity type from metadata
|
|
771
|
-
#
|
|
772
|
-
# @param metadata [Hash, nil] Entity metadata
|
|
773
|
-
# @return [Symbol] Entity type: :openid_provider or :openid_relying_party
|
|
774
671
|
def detect_entity_type(metadata)
|
|
775
|
-
return :openid_relying_party if
|
|
672
|
+
return :openid_relying_party if StringHelpers.blank?(metadata)
|
|
776
673
|
|
|
777
|
-
# Check for openid_relying_party first (primary use case)
|
|
778
674
|
if metadata.key?(:openid_relying_party) || metadata.key?("openid_relying_party")
|
|
779
675
|
return :openid_relying_party
|
|
780
676
|
end
|
|
781
677
|
|
|
782
|
-
# Check for openid_provider
|
|
783
678
|
if metadata.key?(:openid_provider) || metadata.key?("openid_provider")
|
|
784
679
|
return :openid_provider
|
|
785
680
|
end
|
|
786
681
|
|
|
787
|
-
# Default to openid_relying_party (primary use case for clients)
|
|
788
682
|
:openid_relying_party
|
|
789
683
|
end
|
|
790
684
|
|
|
791
685
|
def validate_configuration(config)
|
|
792
|
-
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)
|
|
793
687
|
raise ConfigurationError, "Private key is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if config.private_key.nil?
|
|
794
|
-
raise ConfigurationError, "JWKS is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if
|
|
795
|
-
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)
|
|
796
690
|
end
|
|
797
691
|
|
|
798
692
|
def resolve_current_jwks(config)
|
|
@@ -814,32 +708,19 @@ module OmniauthOpenidFederation
|
|
|
814
708
|
first_key["kid"] || first_key[:kid]
|
|
815
709
|
end
|
|
816
710
|
|
|
817
|
-
# Generate Subordinate Statement for a subject entity
|
|
818
|
-
# Only available for openid_provider (OP) entities that have subordinates
|
|
819
|
-
#
|
|
820
|
-
# @param subject_entity_id [String] Entity Identifier of the subject
|
|
821
|
-
# @param subject_metadata [Hash, nil] Optional: Subject entity metadata to include
|
|
822
|
-
# @param metadata_policy [Hash, nil] Optional: Metadata policy to apply
|
|
823
|
-
# @param constraints [Hash, nil] Optional: Trust Chain constraints
|
|
824
|
-
# @param source_endpoint [String, nil] Optional: Fetch endpoint URL
|
|
825
|
-
# @return [String] The signed Subordinate Statement JWT
|
|
826
|
-
# @raise [ConfigurationError] If configuration is incomplete or entity is not an OP
|
|
827
711
|
def generate_subordinate_statement(subject_entity_id:, subject_metadata: nil, metadata_policy: nil, constraints: nil, source_endpoint: nil)
|
|
828
712
|
config = configuration
|
|
829
713
|
validate_configuration(config)
|
|
830
714
|
|
|
831
|
-
# Only OPs can generate subordinate statements
|
|
832
715
|
entity_type = detect_entity_type(config.metadata)
|
|
833
716
|
unless entity_type == :openid_provider
|
|
834
717
|
raise ConfigurationError, "Subordinate statements can only be generated by openid_provider entities. Current entity type: #{entity_type}"
|
|
835
718
|
end
|
|
836
719
|
|
|
837
|
-
# Get federation_fetch_endpoint from metadata or use default
|
|
838
720
|
op_metadata = config.metadata[:openid_provider] || config.metadata["openid_provider"] || {}
|
|
839
721
|
fetch_endpoint = op_metadata[:federation_fetch_endpoint] || op_metadata["federation_fetch_endpoint"] ||
|
|
840
722
|
"#{config.issuer}/.well-known/openid-federation/fetch"
|
|
841
723
|
|
|
842
|
-
# Build metadata for subject if provided
|
|
843
724
|
metadata = subject_metadata || {}
|
|
844
725
|
|
|
845
726
|
builder = Federation::EntityStatementBuilder.new(
|
|
@@ -858,41 +739,6 @@ module OmniauthOpenidFederation
|
|
|
858
739
|
builder.build
|
|
859
740
|
end
|
|
860
741
|
|
|
861
|
-
# Get Subordinate Statement for a subject (for Fetch Endpoint)
|
|
862
|
-
# Only available for openid_provider (OP) entities
|
|
863
|
-
#
|
|
864
|
-
# @param subject_entity_id [String] Entity Identifier of the subject
|
|
865
|
-
# @return [String, nil] The Subordinate Statement JWT or nil if not found
|
|
866
|
-
# @raise [ConfigurationError] If configuration is incomplete or entity is not an OP
|
|
867
|
-
def get_subordinate_statement(subject_entity_id)
|
|
868
|
-
config = configuration
|
|
869
|
-
|
|
870
|
-
# Only OPs can serve subordinate statements
|
|
871
|
-
entity_type = detect_entity_type(config.metadata)
|
|
872
|
-
unless entity_type == :openid_provider
|
|
873
|
-
OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Fetch endpoint called for non-OP entity (#{entity_type}), returning nil")
|
|
874
|
-
return nil
|
|
875
|
-
end
|
|
876
|
-
|
|
877
|
-
# Use subordinate_statements_proc if configured
|
|
878
|
-
if config.subordinate_statements_proc
|
|
879
|
-
return config.subordinate_statements_proc.call(subject_entity_id)
|
|
880
|
-
end
|
|
881
|
-
|
|
882
|
-
# Use subordinate_statements hash if configured
|
|
883
|
-
if config.subordinate_statements && config.subordinate_statements[subject_entity_id]
|
|
884
|
-
subordinate_config = config.subordinate_statements[subject_entity_id]
|
|
885
|
-
return generate_subordinate_statement(
|
|
886
|
-
subject_entity_id: subject_entity_id,
|
|
887
|
-
subject_metadata: subordinate_config[:metadata] || subordinate_config["metadata"],
|
|
888
|
-
metadata_policy: subordinate_config[:metadata_policy] || subordinate_config["metadata_policy"],
|
|
889
|
-
constraints: subordinate_config[:constraints] || subordinate_config["constraints"]
|
|
890
|
-
)
|
|
891
|
-
end
|
|
892
|
-
|
|
893
|
-
nil
|
|
894
|
-
end
|
|
895
|
-
|
|
896
742
|
# Configuration class for FederationEndpoint
|
|
897
743
|
# Supports automatic key provisioning with separate signing and encryption keys
|
|
898
744
|
# Supports both openid_provider (OP) and openid_relying_party (RP) entity types
|
|
@@ -155,21 +155,6 @@ module OmniauthOpenidFederation
|
|
|
155
155
|
)
|
|
156
156
|
end
|
|
157
157
|
end
|
|
158
|
-
|
|
159
|
-
# Decode JWT using jwt gem (legacy method name kept for backward compatibility)
|
|
160
|
-
#
|
|
161
|
-
# @param encoded_jwt [String] The JWT to decode
|
|
162
|
-
# @param jwks_uri [String] The JWKS URI for key lookup
|
|
163
|
-
# @param retried [Boolean] Internal flag for retry logic (default: false)
|
|
164
|
-
# @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validation
|
|
165
|
-
# @return [Array<Hash>] Array with [payload, header]
|
|
166
|
-
# @raise [ValidationError] If JWT validation fails
|
|
167
|
-
# @raise [SignatureError] If signature verification fails
|
|
168
|
-
# @deprecated Use jwt() method instead. This method will be removed in a future version.
|
|
169
|
-
def self.json_jwt(encoded_jwt, jwks_uri, retried: false, entity_statement_keys: nil)
|
|
170
|
-
OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] json_jwt is deprecated. Use jwt() method instead.")
|
|
171
|
-
jwt(encoded_jwt, jwks_uri, retried: retried, entity_statement_keys: entity_statement_keys)
|
|
172
|
-
end
|
|
173
158
|
end
|
|
174
159
|
end
|
|
175
160
|
end
|