omniauth_openid_federation 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +16 -0
  3. data/LICENSE.md +22 -0
  4. data/README.md +822 -0
  5. data/SECURITY.md +129 -0
  6. data/examples/README_INTEGRATION_TESTING.md +399 -0
  7. data/examples/README_MOCK_OP.md +243 -0
  8. data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
  9. data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
  10. data/examples/app/models/user.rb.example +39 -0
  11. data/examples/config/initializers/devise.rb.example +97 -0
  12. data/examples/config/initializers/federation_endpoint.rb.example +206 -0
  13. data/examples/config/mock_op.yml.example +83 -0
  14. data/examples/config/open_id_connect_config.rb.example +210 -0
  15. data/examples/config/routes.rb.example +12 -0
  16. data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
  17. data/examples/integration_test_flow.rb +1334 -0
  18. data/examples/jobs/README.md +194 -0
  19. data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
  20. data/examples/jobs/federation_files_generation_job.rb.example +87 -0
  21. data/examples/mock_op_server.rb +775 -0
  22. data/examples/mock_rp_server.rb +435 -0
  23. data/lib/omniauth_openid_federation/access_token.rb +504 -0
  24. data/lib/omniauth_openid_federation/cache.rb +39 -0
  25. data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
  26. data/lib/omniauth_openid_federation/configuration.rb +135 -0
  27. data/lib/omniauth_openid_federation/constants.rb +13 -0
  28. data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
  29. data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
  30. data/lib/omniauth_openid_federation/errors.rb +52 -0
  31. data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
  32. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
  33. data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
  34. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
  35. data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
  36. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
  37. data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
  38. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
  39. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
  40. data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
  41. data/lib/omniauth_openid_federation/http_client.rb +70 -0
  42. data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
  43. data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
  44. data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
  45. data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
  46. data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
  47. data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
  48. data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
  49. data/lib/omniauth_openid_federation/jws.rb +416 -0
  50. data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
  51. data/lib/omniauth_openid_federation/logger.rb +99 -0
  52. data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
  53. data/lib/omniauth_openid_federation/railtie.rb +29 -0
  54. data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
  55. data/lib/omniauth_openid_federation/strategy.rb +2029 -0
  56. data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
  57. data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
  58. data/lib/omniauth_openid_federation/utils.rb +166 -0
  59. data/lib/omniauth_openid_federation/validators.rb +126 -0
  60. data/lib/omniauth_openid_federation/version.rb +3 -0
  61. data/lib/omniauth_openid_federation.rb +98 -0
  62. data/lib/tasks/omniauth_openid_federation.rake +376 -0
  63. data/sig/federation.rbs +218 -0
  64. data/sig/jwks.rbs +63 -0
  65. data/sig/omniauth_openid_federation.rbs +254 -0
  66. data/sig/strategy.rbs +60 -0
  67. metadata +352 -0
@@ -0,0 +1,949 @@
1
+ require_relative "federation/entity_statement_builder"
2
+ require_relative "logger"
3
+ require_relative "errors"
4
+ require_relative "utils"
5
+ require "jwt"
6
+ require "base64"
7
+ require "digest"
8
+ require "time"
9
+ require "fileutils"
10
+
11
+ # Federation Endpoint for OpenID Federation 1.0
12
+ # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
13
+ #
14
+ # Provides a federation endpoint (/.well-known/openid-federation) that serves
15
+ # entity statements for clients to fetch provider configuration and JWKS.
16
+ #
17
+ # This enables signed JWKS support as required by OpenID Federation 1.0 specification.
18
+ module OmniauthOpenidFederation
19
+ # Federation Endpoint for serving entity statements
20
+ #
21
+ # Supports automatic key provisioning with separate signing and encryption keys.
22
+ # See AUTOMATIC_KEY_PROVISIONING.md for detailed documentation.
23
+ #
24
+ # @example Auto-configure with separate keys (RECOMMENDED for production)
25
+ # # In config/initializers/omniauth_openid_federation.rb
26
+ # OmniauthOpenidFederation::FederationEndpoint.auto_configure(
27
+ # issuer: "https://provider.example.com",
28
+ # signing_key: OpenSSL::PKey::RSA.new(File.read("config/signing-key.pem")),
29
+ # encryption_key: OpenSSL::PKey::RSA.new(File.read("config/encryption-key.pem")),
30
+ # entity_statement_path: "config/entity-statement.jwt", # Cache for automatic key rotation
31
+ # metadata: {
32
+ # openid_relying_party: {
33
+ # redirect_uris: ["https://provider.example.com/auth/callback"],
34
+ # client_registration_types: ["automatic"]
35
+ # }
36
+ # },
37
+ # auto_provision_keys: true
38
+ # )
39
+ #
40
+ # @example Manual configuration (advanced)
41
+ # OmniauthOpenidFederation::FederationEndpoint.configure do |config|
42
+ # config.issuer = "https://provider.example.com"
43
+ # config.subject = "https://provider.example.com"
44
+ # config.signing_key = OpenSSL::PKey::RSA.new(File.read("config/signing-key.pem"))
45
+ # config.encryption_key = OpenSSL::PKey::RSA.new(File.read("config/encryption-key.pem"))
46
+ # config.jwks = {
47
+ # keys: [
48
+ # { kty: "RSA", use: "sig", kid: "sig-key-id", n: "...", e: "..." },
49
+ # { kty: "RSA", use: "enc", kid: "enc-key-id", n: "...", e: "..." }
50
+ # ]
51
+ # }
52
+ # config.metadata = {
53
+ # openid_provider: {
54
+ # issuer: "https://provider.example.com",
55
+ # authorization_endpoint: "https://provider.example.com/oauth2/authorize",
56
+ # token_endpoint: "https://provider.example.com/oauth2/token",
57
+ # userinfo_endpoint: "https://provider.example.com/oauth2/userinfo",
58
+ # jwks_uri: "https://provider.example.com/.well-known/jwks.json",
59
+ # signed_jwks_uri: "https://provider.example.com/.well-known/signed-jwks.json"
60
+ # }
61
+ # }
62
+ # end
63
+ #
64
+ # # In config/routes.rb (Rails)
65
+ # get "/.well-known/openid-federation", to: "omniauth_openid_federation/federation#show"
66
+ #
67
+ # # Or use the provided route helper
68
+ # OmniauthOpenidFederation::FederationEndpoint.mount_routes
69
+ class FederationEndpoint
70
+ class << self
71
+ # Configure the federation endpoint
72
+ #
73
+ # @yield [config] Configuration block
74
+ # @yieldparam config [Configuration] Configuration object
75
+ def configure
76
+ yield(configuration) if block_given?
77
+ configuration
78
+ end
79
+
80
+ # Auto-configure the federation endpoint with automatic key provisioning
81
+ # Automatically calculates JWKS, metadata, and other settings from provided inputs
82
+ #
83
+ # Automatic Key Provisioning:
84
+ # - Extracts JWKS from entity_statement_path if provided (cached, supports key rotation)
85
+ # - Supports separate signing_key and encryption_key (RECOMMENDED for production)
86
+ # - Falls back to single private_key (DEV/TESTING ONLY - not recommended for production)
87
+ # - Automatically generates both signing and encryption keys from provided keys
88
+ #
89
+ # @param issuer [String] Entity issuer (typically the application URL)
90
+ # @param signing_key [OpenSSL::PKey::RSA, nil] Signing private key (RECOMMENDED: separate from encryption)
91
+ # @param encryption_key [OpenSSL::PKey::RSA, nil] Encryption private key (RECOMMENDED: separate from signing)
92
+ # @param private_key [OpenSSL::PKey::RSA, nil] Single private key for both signing and encryption (DEV/TESTING ONLY)
93
+ # - Only used if signing_key and encryption_key are not provided
94
+ # - NOT RECOMMENDED for production - use separate keys instead
95
+ # @param jwks [Hash, nil] Pre-configured JWKS (optional, overrides automatic provisioning)
96
+ # @param subject [String, nil] Entity subject (defaults to issuer if not provided)
97
+ # @param entity_statement_path [String, nil] Path to existing entity statement to extract JWKS from (optional)
98
+ # - Used as cache for automatic key provisioning
99
+ # - Supports automatic key rotation: update file, library uses new keys on next cache refresh
100
+ # @param entity_statement_url [String, nil] URL to existing entity statement to extract JWKS from (optional)
101
+ # @param metadata [Hash, nil] Provider metadata (auto-generated if not provided)
102
+ # @param expiration_seconds [Integer, nil] Entity statement expiration in seconds (default: 86400)
103
+ # @param jwks_cache_ttl [Integer, nil] Cache TTL for JWKS endpoints in seconds (default: 3600)
104
+ # @param auto_provision_keys [Boolean] Enable automatic key provisioning (default: true)
105
+ # - If true: Automatically extracts/generates keys from provided sources
106
+ # - If false: Requires explicit jwks parameter
107
+ # @param key_rotation_period [Integer, nil] Key rotation period in seconds (default: nil, no automatic rotation)
108
+ # - If set: Keys are automatically rotated when entity statement file age exceeds this period
109
+ # - Keys are regenerated and entity statement file is updated
110
+ # - Example: 90.days.to_i for 90-day rotation period
111
+ # @return [Configuration] The configured configuration object
112
+ # @raise [ConfigurationError] If required parameters are missing
113
+ def auto_configure(
114
+ issuer:,
115
+ signing_key: nil,
116
+ encryption_key: nil,
117
+ private_key: nil,
118
+ jwks: nil,
119
+ subject: nil,
120
+ entity_statement_path: nil,
121
+ entity_statement_url: nil,
122
+ metadata: nil,
123
+ expiration_seconds: nil,
124
+ jwks_cache_ttl: nil,
125
+ auto_provision_keys: true,
126
+ key_rotation_period: nil
127
+ )
128
+ raise ConfigurationError, "Issuer is required" if issuer.nil? || issuer.empty?
129
+
130
+ # Priority 1: Validate key configuration - exception if all three are set
131
+ if signing_key && encryption_key && private_key
132
+ raise ConfigurationError, "Cannot specify signing_key, encryption_key, and private_key simultaneously. " \
133
+ "Use either (signing_key + encryption_key) OR private_key, not both."
134
+ end
135
+
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
+ unless auto_provision_keys
139
+ if signing_key.nil? && encryption_key.nil? && private_key.nil? && jwks.nil?
140
+ raise ConfigurationError, "At least one key source is required: signing_key, encryption_key, private_key, or jwks"
141
+ end
142
+ end
143
+
144
+ # Warn if using single private_key (dev/testing only)
145
+ if private_key && signing_key.nil? && encryption_key.nil?
146
+ OmniauthOpenidFederation::Logger.warn(
147
+ "[FederationEndpoint] Using single private_key for both signing and encryption. " \
148
+ "This is DEV/TESTING ONLY. For production, use separate signing_key and encryption_key."
149
+ )
150
+ end
151
+
152
+ config = configuration
153
+
154
+ # Set issuer and subject
155
+ config.issuer = issuer
156
+ config.subject = subject || issuer
157
+
158
+ # Automatic key provisioning
159
+ if auto_provision_keys && jwks.nil?
160
+ jwks = provision_jwks(
161
+ signing_key: signing_key,
162
+ encryption_key: encryption_key,
163
+ private_key: private_key,
164
+ entity_statement_path: entity_statement_path,
165
+ issuer: issuer,
166
+ subject: subject || issuer,
167
+ metadata: metadata,
168
+ entity_statement_path_provided: !entity_statement_path.nil?
169
+ )
170
+
171
+ # After provisioning, check if we have keys available
172
+ # If provisioning failed and no keys were provided, raise appropriate error
173
+ if jwks.nil? && signing_key.nil? && encryption_key.nil? && private_key.nil? && config.signing_key.nil?
174
+ raise ConfigurationError, "Signing key is required. Provide signing_key, encryption_key, or private_key, or enable auto_provision_keys with entity_statement_path."
175
+ end
176
+ end
177
+
178
+ # Use provided jwks if available, otherwise use provisioned jwks
179
+ config.jwks = jwks || raise(ConfigurationError, "JWKS is required. Provide jwks parameter or enable auto_provision_keys.")
180
+
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
+ if signing_key && encryption_key
186
+ # Priority 1: Use provided separate keys
187
+ config.signing_key = signing_key
188
+ config.encryption_key = encryption_key
189
+ config.private_key = signing_key
190
+ elsif signing_key
191
+ # Priority 1b: Use provided signing_key for both signing and encryption
192
+ config.signing_key = signing_key
193
+ config.encryption_key = signing_key
194
+ config.private_key = signing_key
195
+ elsif private_key
196
+ # Priority 2: Use provided single private_key
197
+ config.private_key = private_key
198
+ config.signing_key = private_key
199
+ config.encryption_key = private_key
200
+ elsif config.signing_key && config.encryption_key
201
+ # Priority 3: Keys were loaded from disk in provision_jwks
202
+ config.private_key = config.signing_key
203
+ 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
+ config.private_key = config.signing_key
207
+ config.encryption_key = config.signing_key
208
+ else
209
+ raise ConfigurationError, "Signing key is required. Provide signing_key, encryption_key, or private_key, or enable auto_provision_keys with entity_statement_path."
210
+ end
211
+
212
+ # Set kid from first signing key in JWKS
213
+ keys = config.jwks[:keys] || config.jwks["keys"] || []
214
+ signing_key_jwk = keys.find { |k| (k[:use] || k["use"]) == "sig" } || keys.first
215
+ config.kid = signing_key_jwk&.dig(:kid) || signing_key_jwk&.dig("kid")
216
+
217
+ # Set metadata
218
+ # Detect entity type from metadata or default based on provided keys
219
+ entity_type = detect_entity_type(metadata)
220
+
221
+ 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
+ metadata = ensure_jwks_endpoints(metadata, issuer, entity_type)
225
+ config.metadata = metadata
226
+ # Ensure entity type is consistent
227
+ entity_type = detect_entity_type(config.metadata)
228
+ else
229
+ # Auto-generate minimal metadata with only standard well-known endpoints
230
+ # Default to openid_relying_party (RP) entity type for clients
231
+ base_metadata = {
232
+ issuer: issuer
233
+ }
234
+
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
+ if entity_type == :openid_provider
238
+ base_metadata[:federation_fetch_endpoint] = "#{issuer}/.well-known/openid-federation/fetch"
239
+ config.metadata = {
240
+ openid_provider: base_metadata
241
+ }
242
+ else
243
+ # Default to openid_relying_party (RP)
244
+ config.metadata = {
245
+ openid_relying_party: base_metadata
246
+ }
247
+ end
248
+
249
+ # Ensure jwks_uri and signed_jwks_uri are added (same as when metadata is provided)
250
+ config.metadata = ensure_jwks_endpoints(config.metadata, issuer, entity_type)
251
+
252
+ OmniauthOpenidFederation::Logger.warn(
253
+ "[FederationEndpoint] Auto-generated metadata only includes well-known endpoints. " \
254
+ "Provide custom metadata parameter for application-specific endpoints " \
255
+ "(authorization_endpoint, token_endpoint, userinfo_endpoint for OP; " \
256
+ "redirect_uris, client_registration_types for RP)."
257
+ )
258
+ end
259
+
260
+ # Store entity type for later use
261
+ config.entity_type = entity_type
262
+
263
+ # Set optional configuration
264
+ config.expiration_seconds = expiration_seconds if expiration_seconds
265
+ config.jwks_cache_ttl = jwks_cache_ttl if jwks_cache_ttl
266
+ config.key_rotation_period = key_rotation_period if key_rotation_period
267
+ config.entity_statement_path = entity_statement_path if entity_statement_path
268
+
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
+ if entity_statement_path && (signing_key || private_key)
272
+ begin
273
+ # Save provided keys to disk for persistence
274
+ keys_dir = File.dirname(entity_statement_path)
275
+ FileUtils.mkdir_p(keys_dir) unless File.directory?(keys_dir)
276
+
277
+ if signing_key && encryption_key
278
+ # Save separate keys
279
+ signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
280
+ encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
281
+ File.write(signing_key_path, signing_key.to_pem)
282
+ File.write(encryption_key_path, encryption_key.to_pem)
283
+ File.chmod(0o600, signing_key_path)
284
+ File.chmod(0o600, encryption_key_path)
285
+ OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Saved provided signing and encryption keys to disk")
286
+ elsif private_key
287
+ # Save single key (for transition period / dev/testing)
288
+ signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
289
+ encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
290
+ File.write(signing_key_path, private_key.to_pem)
291
+ File.write(encryption_key_path, private_key.to_pem)
292
+ File.chmod(0o600, signing_key_path)
293
+ File.chmod(0o600, encryption_key_path)
294
+ OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Saved provided private_key to disk (used for both signing and encryption)")
295
+ end
296
+
297
+ # Regenerate entity statement with provided keys to ensure signature matches
298
+ entity_statement = generate_entity_statement
299
+ FileUtils.mkdir_p(File.dirname(entity_statement_path)) if File.dirname(entity_statement_path) != "."
300
+ File.write(entity_statement_path, entity_statement)
301
+ File.chmod(0o600, entity_statement_path) if File.exist?(entity_statement_path)
302
+ OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Regenerated entity statement with provided keys")
303
+ rescue => e
304
+ OmniauthOpenidFederation::Logger.warn("[FederationEndpoint] Failed to save keys or regenerate entity statement: #{e.message}")
305
+ end
306
+ end
307
+
308
+ # Handle automatic key rotation if enabled
309
+ if auto_provision_keys && entity_statement_path && config.key_rotation_period
310
+ rotate_keys_if_needed(config)
311
+ end
312
+
313
+ OmniauthOpenidFederation::Logger.info("[FederationEndpoint] Auto-configured with issuer: #{issuer}")
314
+ config
315
+ end
316
+
317
+ # Automatic key provisioning: Extract or generate JWKS from available sources
318
+ #
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
+ # @param signing_key [OpenSSL::PKey::RSA, nil] Signing private key
326
+ # @param encryption_key [OpenSSL::PKey::RSA, nil] Encryption private key
327
+ # @param private_key [OpenSSL::PKey::RSA, nil] Single private key (dev/testing only)
328
+ # @param entity_statement_path [String, nil] Path to entity statement file
329
+ # @param issuer [String, nil] Issuer for entity statement (needed for key generation)
330
+ # @param subject [String, nil] Subject for entity statement (needed for key generation)
331
+ # @param metadata [Hash, nil] Metadata for entity statement (needed for key generation)
332
+ # @param entity_statement_path_provided [Boolean] Whether entity_statement_path was provided as parameter (not auto-generated)
333
+ # @return [Hash, nil] JWKS hash with keys array, or nil if provisioning fails
334
+ 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
+ if encryption_key
337
+ # Generate from separate signing_key and encryption_key (RECOMMENDED for production)
338
+ signing_key_for_jwk = signing_key || private_key
339
+ raise ConfigurationError, "Signing key is required when encryption_key is provided. Provide signing_key or private_key." unless signing_key_for_jwk
340
+
341
+ # Check if signing and encryption keys are the same (compare public key PEM)
342
+ # If same, generate single JWK to avoid duplicate kid values
343
+ 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
+ single_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(signing_key_for_jwk, use: nil)
346
+ return {keys: [single_jwk]}
347
+ else
348
+ # Different keys - generate separate JWKs
349
+ signing_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(signing_key_for_jwk, use: "sig")
350
+ encryption_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(encryption_key, use: "enc")
351
+ return {keys: [signing_jwk, encryption_jwk]}
352
+ end
353
+ 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
+ single_key = private_key || signing_key
357
+
358
+ # Generate JWK without 'use' field to indicate it can be used for both purposes
359
+ # This avoids duplicate kid values which violate the spec
360
+ single_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(single_key, use: nil)
361
+ return {keys: [single_jwk]}
362
+ end
363
+
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
+ extraction_failed = false
367
+ if entity_statement_path&.then { |path| File.exist?(path) }
368
+ begin
369
+ entity_statement_content = File.read(entity_statement_path)
370
+ jwks = OmniauthOpenidFederation::Utils.extract_jwks_from_entity_statement(entity_statement_content)
371
+ if jwks&.dig(:keys)&.any?
372
+ OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Extracted JWKS from entity statement file: #{entity_statement_path}")
373
+
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
+ keys_dir = File.dirname(entity_statement_path)
377
+ signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
378
+ encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
379
+
380
+ if File.exist?(signing_key_path) && File.exist?(encryption_key_path)
381
+ begin
382
+ config = configuration
383
+ config.signing_key = OpenSSL::PKey::RSA.new(File.read(signing_key_path))
384
+ config.encryption_key = OpenSSL::PKey::RSA.new(File.read(encryption_key_path))
385
+ config.private_key = config.signing_key
386
+ OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Loaded private keys from disk")
387
+ rescue => e
388
+ OmniauthOpenidFederation::Logger.warn("[FederationEndpoint] Failed to load private keys from disk: #{e.message}")
389
+ end
390
+ elsif File.exist?(signing_key_path)
391
+ # Single key file (backward compatibility or dev/testing)
392
+ begin
393
+ config = configuration
394
+ single_key = OpenSSL::PKey::RSA.new(File.read(signing_key_path))
395
+ config.signing_key = single_key
396
+ config.encryption_key = single_key
397
+ config.private_key = single_key
398
+ OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Loaded single private key from disk")
399
+ rescue => e
400
+ OmniauthOpenidFederation::Logger.warn("[FederationEndpoint] Failed to load private key from disk: #{e.message}")
401
+ end
402
+ end
403
+
404
+ return jwks
405
+ else
406
+ extraction_failed = true
407
+ end
408
+ rescue => e
409
+ OmniauthOpenidFederation::Logger.warn("[FederationEndpoint] Failed to extract JWKS from entity statement file: #{e.message}")
410
+ extraction_failed = true
411
+ end
412
+ end
413
+
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
+ if issuer && (!entity_statement_path_provided || !extraction_failed)
419
+ # Generate a default entity_statement_path if not provided
420
+ entity_statement_path ||= begin
421
+ configuration
422
+ if defined?(Rails) && Rails.root
423
+ default_path = Rails.root.join("config", ".federation-entity-statement.jwt").to_s
424
+ OmniauthOpenidFederation::Logger.info("[FederationEndpoint] No entity_statement_path provided, using default: #{OmniauthOpenidFederation::Utils.sanitize_path(default_path)}")
425
+ default_path
426
+ end
427
+ end
428
+
429
+ if entity_statement_path
430
+ OmniauthOpenidFederation::Logger.info("[FederationEndpoint] No keys provided, auto-generating new signing and encryption keys")
431
+ jwks = generate_fresh_keys(
432
+ entity_statement_path: entity_statement_path,
433
+ issuer: issuer,
434
+ subject: subject || issuer,
435
+ metadata: metadata # Can be nil - generate_fresh_keys will create minimal metadata
436
+ )
437
+ return jwks if jwks
438
+ else
439
+ OmniauthOpenidFederation::Logger.warn("[FederationEndpoint] Cannot auto-generate keys: entity_statement_path is required for persistence")
440
+ end
441
+ end
442
+
443
+ nil
444
+ end
445
+
446
+ # Get the current configuration
447
+ #
448
+ # @return [Configuration] Current configuration
449
+ def configuration
450
+ @configuration ||= Configuration.new
451
+ end
452
+
453
+ # Generate the entity statement JWT
454
+ #
455
+ # @return [String] The signed entity statement JWT
456
+ # @raise [ConfigurationError] If configuration is incomplete
457
+ def generate_entity_statement
458
+ config = configuration
459
+ validate_configuration(config)
460
+
461
+ builder = Federation::EntityStatementBuilder.new(
462
+ issuer: config.issuer,
463
+ subject: config.subject || config.issuer,
464
+ private_key: config.private_key,
465
+ jwks: config.jwks,
466
+ metadata: config.metadata,
467
+ expiration_seconds: config.expiration_seconds || 86400,
468
+ kid: config.kid,
469
+ authority_hints: config.authority_hints
470
+ )
471
+
472
+ builder.build
473
+ end
474
+
475
+ # Generate signed JWKS JWT
476
+ #
477
+ # @return [String] The signed JWKS JWT
478
+ # @raise [ConfigurationError] If configuration is incomplete
479
+ def generate_signed_jwks
480
+ config = configuration
481
+ validate_configuration(config)
482
+
483
+ # Get JWKS to include in payload (current keys, not entity statement keys)
484
+ jwks_payload = resolve_signed_jwks_payload(config)
485
+
486
+ # Sign with entity statement key
487
+ signing_kid = config.signed_jwks_signing_kid || config.kid || extract_kid_from_jwks(config.jwks)
488
+ expiration_seconds = config.signed_jwks_expiration_seconds || 86400
489
+
490
+ # Build JWT payload with JWKS
491
+ now = Time.now.to_i
492
+ payload = {
493
+ iss: config.issuer,
494
+ sub: config.subject || config.issuer,
495
+ iat: now,
496
+ exp: now + expiration_seconds,
497
+ jwks: jwks_payload
498
+ }
499
+
500
+ # Sign JWT using jwt gem
501
+ header = {
502
+ alg: "RS256",
503
+ typ: "JWT",
504
+ kid: signing_kid
505
+ }
506
+
507
+ begin
508
+ JWT.encode(payload, config.private_key, "RS256", header)
509
+ rescue => e
510
+ error_msg = "Failed to sign JWKS: #{e.class} - #{e.message}"
511
+ OmniauthOpenidFederation::Logger.error("[FederationEndpoint] #{error_msg}")
512
+ raise SignatureError, error_msg, e.backtrace
513
+ end
514
+ end
515
+
516
+ # Get current JWKS for serving
517
+ #
518
+ # @return [Hash] Current JWKS hash
519
+ def current_jwks
520
+ config = configuration
521
+ resolve_current_jwks(config)
522
+ end
523
+
524
+ # Get a Rack-compatible endpoint handler
525
+ # Use this for framework-agnostic routing (Sinatra, Rack, etc.)
526
+ #
527
+ # @return [RackEndpoint] Rack endpoint handler
528
+ # @example Using with Sinatra
529
+ # require "sinatra"
530
+ # require "omniauth_openid_federation"
531
+ #
532
+ # use OmniauthOpenidFederation::FederationEndpoint.rack_app
533
+ #
534
+ # @example Using with plain Rack
535
+ # require "rack"
536
+ # require "omniauth_openid_federation"
537
+ #
538
+ # app = Rack::Builder.new do
539
+ # map "/.well-known" do
540
+ # run OmniauthOpenidFederation::FederationEndpoint.rack_app
541
+ # end
542
+ # end
543
+ def rack_app
544
+ require_relative "rack_endpoint"
545
+ RackEndpoint.new
546
+ end
547
+
548
+ # Mount the federation endpoint routes in Rails routes
549
+ #
550
+ # Add this to your config/routes.rb:
551
+ # Rails.application.routes.draw do
552
+ # OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
553
+ # end
554
+ #
555
+ # This mounts all four endpoints:
556
+ # - GET /.well-known/openid-federation (entity statement)
557
+ # - GET /.well-known/openid-federation/fetch (fetch endpoint for Subordinate Statements)
558
+ # - GET /.well-known/jwks.json (standard JWKS)
559
+ # - GET /.well-known/signed-jwks.json (signed JWKS)
560
+ #
561
+ # Or manually:
562
+ # get "/.well-known/openid-federation", to: "omniauth_openid_federation/federation#show"
563
+ # get "/.well-known/openid-federation/fetch", to: "omniauth_openid_federation/federation#fetch"
564
+ # get "/.well-known/jwks.json", to: "omniauth_openid_federation/federation#jwks"
565
+ # get "/.well-known/signed-jwks.json", to: "omniauth_openid_federation/federation#signed_jwks"
566
+ #
567
+ # @param router [ActionDispatch::Routing::Mapper] The routes mapper (pass `self` from routes.rb)
568
+ # @param entity_statement_path [String] Path for entity statement endpoint (default: "/.well-known/openid-federation")
569
+ # @param fetch_path [String] Path for fetch endpoint (default: "/.well-known/openid-federation/fetch")
570
+ # @param jwks_path [String] Path for standard JWKS endpoint (default: "/.well-known/jwks.json")
571
+ # @param signed_jwks_path [String] Path for signed JWKS endpoint (default: "/.well-known/signed-jwks.json")
572
+ # @param as [String, Symbol] Route name prefix (default: :openid_federation)
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
+
582
+ # Generate fresh signing and encryption keys and write entity statement to file
583
+ #
584
+ # @param entity_statement_path [String] Path to entity statement file
585
+ # @param issuer [String, nil] Issuer for entity statement (optional, uses config if not provided)
586
+ # @param subject [String, nil] Subject for entity statement (optional, uses issuer if not provided)
587
+ # @param metadata [Hash, nil] Metadata for entity statement (optional, uses config if not provided, or generates minimal)
588
+ # @param keys_output_dir [String, nil] Directory to store private keys (optional, defaults to same dir as entity_statement_path)
589
+ # @return [Hash, nil] JWKS hash with keys array, or nil if generation fails
590
+ 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
+ signing_key = OpenSSL::PKey::RSA.new(2048)
593
+ encryption_key = OpenSSL::PKey::RSA.new(2048)
594
+
595
+ signing_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(signing_key, use: "sig")
596
+ encryption_jwk = OmniauthOpenidFederation::Utils.rsa_key_to_jwk(encryption_key, use: "enc")
597
+ jwks = {keys: [signing_jwk, encryption_jwk]}
598
+
599
+ # Get configuration for issuer, subject, and metadata
600
+ config = configuration
601
+ issuer ||= config.issuer
602
+ subject ||= config.subject || issuer
603
+ metadata ||= config.metadata
604
+
605
+ # Generate minimal metadata if none provided
606
+ unless metadata
607
+ if issuer
608
+ # Default to openid_relying_party (RP) entity type for clients
609
+ metadata = {
610
+ openid_relying_party: {
611
+ issuer: issuer,
612
+ jwks_uri: "#{issuer}/.well-known/jwks.json",
613
+ signed_jwks_uri: "#{issuer}/.well-known/signed-jwks.json"
614
+ }
615
+ }
616
+ OmniauthOpenidFederation::Logger.debug("[FederationEndpoint] Generated minimal metadata for key generation")
617
+ else
618
+ OmniauthOpenidFederation::Logger.warn("[FederationEndpoint] Cannot generate entity statement: issuer missing")
619
+ return nil
620
+ end
621
+ end
622
+
623
+ # Generate entity statement with new keys
624
+ if issuer
625
+ builder = Federation::EntityStatementBuilder.new(
626
+ issuer: issuer,
627
+ subject: subject,
628
+ private_key: signing_key, # Use signing key for entity statement signature
629
+ jwks: jwks,
630
+ metadata: metadata,
631
+ expiration_seconds: config.expiration_seconds || 86400,
632
+ kid: signing_jwk[:kid] || signing_jwk["kid"]
633
+ )
634
+
635
+ entity_statement = builder.build
636
+
637
+ # Determine keys output directory (default to same directory as entity statement)
638
+ keys_dir = keys_output_dir || File.dirname(entity_statement_path)
639
+ FileUtils.mkdir_p(keys_dir) unless File.directory?(keys_dir)
640
+
641
+ # Write private keys to disk (secure storage)
642
+ signing_key_path = File.join(keys_dir, ".federation-signing-key.pem")
643
+ encryption_key_path = File.join(keys_dir, ".federation-encryption-key.pem")
644
+
645
+ File.write(signing_key_path, signing_key.to_pem)
646
+ File.write(encryption_key_path, encryption_key.to_pem)
647
+ File.chmod(0o600, signing_key_path)
648
+ File.chmod(0o600, encryption_key_path)
649
+
650
+ # Write entity statement to file
651
+ FileUtils.mkdir_p(File.dirname(entity_statement_path)) if File.dirname(entity_statement_path) != "."
652
+ File.write(entity_statement_path, entity_statement)
653
+ File.chmod(0o600, entity_statement_path) if File.exist?(entity_statement_path)
654
+
655
+ # Update configuration with new keys
656
+ config.signing_key = signing_key
657
+ config.encryption_key = encryption_key
658
+ config.private_key = signing_key
659
+
660
+ OmniauthOpenidFederation::Logger.info(
661
+ "[FederationEndpoint] Generated fresh keys and wrote entity statement to: #{OmniauthOpenidFederation::Utils.sanitize_path(entity_statement_path)}"
662
+ )
663
+ OmniauthOpenidFederation::Logger.info(
664
+ "[FederationEndpoint] Private keys stored in: #{OmniauthOpenidFederation::Utils.sanitize_path(keys_dir)}"
665
+ )
666
+ jwks
667
+ else
668
+ OmniauthOpenidFederation::Logger.warn("[FederationEndpoint] Cannot generate entity statement: issuer missing")
669
+ nil
670
+ end
671
+ rescue => e
672
+ OmniauthOpenidFederation::Logger.error("[FederationEndpoint] Failed to generate fresh keys: #{e.message}")
673
+ nil
674
+ end
675
+
676
+ # Rotate keys if rotation period has elapsed
677
+ #
678
+ # @param config [Configuration] Configuration object
679
+ def rotate_keys_if_needed(config)
680
+ return unless config.key_rotation_period && config.entity_statement_path
681
+
682
+ entity_statement_path = config.entity_statement_path
683
+ return unless File.exist?(entity_statement_path)
684
+
685
+ # Check if file needs rotation based on modification time
686
+ file_mtime = File.mtime(entity_statement_path)
687
+ rotation_period_seconds = config.key_rotation_period.to_i
688
+ time_since_rotation = Time.now - file_mtime
689
+
690
+ if time_since_rotation >= rotation_period_seconds
691
+ OmniauthOpenidFederation::Logger.info(
692
+ "[FederationEndpoint] Key rotation period elapsed (#{time_since_rotation.to_i}s >= #{rotation_period_seconds}s), " \
693
+ "generating new keys"
694
+ )
695
+
696
+ # Generate fresh keys and update entity statement
697
+ keys_dir = File.dirname(entity_statement_path)
698
+ jwks = generate_fresh_keys(
699
+ entity_statement_path: entity_statement_path,
700
+ issuer: config.issuer,
701
+ subject: config.subject,
702
+ metadata: config.metadata,
703
+ keys_output_dir: keys_dir
704
+ )
705
+
706
+ if jwks
707
+ config.jwks = jwks
708
+ # Update kid from new signing key
709
+ keys = jwks[:keys] || jwks["keys"] || []
710
+ signing_key_jwk = keys.find { |k| (k[:use] || k["use"]) == "sig" } || keys.first
711
+ config.kid = signing_key_jwk&.dig(:kid) || signing_key_jwk&.dig("kid")
712
+
713
+ OmniauthOpenidFederation::Logger.info("[FederationEndpoint] Keys rotated successfully")
714
+ else
715
+ OmniauthOpenidFederation::Logger.warn("[FederationEndpoint] Key rotation failed, using existing keys")
716
+ end
717
+ else
718
+ OmniauthOpenidFederation::Logger.debug(
719
+ "[FederationEndpoint] Keys still valid (#{time_since_rotation.to_i}s < #{rotation_period_seconds}s), " \
720
+ "no rotation needed"
721
+ )
722
+ end
723
+ end
724
+
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
+ def ensure_jwks_endpoints(metadata, issuer, entity_type)
734
+ metadata = metadata.dup # Don't modify original
735
+ entity_type ||= detect_entity_type(metadata)
736
+
737
+ # Determine which metadata section to update
738
+ section = if entity_type == :openid_provider
739
+ metadata[:openid_provider] || metadata["openid_provider"] || {}
740
+ else
741
+ metadata[:openid_relying_party] || metadata["openid_relying_party"] || {}
742
+ end
743
+
744
+ # Convert to symbol keys for consistency
745
+ section = section.each_with_object({}) { |(k, v), h| h[k.to_sym] = v }
746
+
747
+ # Add jwks_uri and signed_jwks_uri if not present
748
+ section[:jwks_uri] ||= "#{issuer}/.well-known/jwks.json"
749
+ section[:signed_jwks_uri] ||= "#{issuer}/.well-known/signed-jwks.json"
750
+
751
+ # Add federation_fetch_endpoint for openid_provider entities if not present
752
+ if entity_type == :openid_provider
753
+ section[:federation_fetch_endpoint] ||= "#{issuer}/.well-known/openid-federation/fetch"
754
+ end
755
+
756
+ # Update metadata with modified section
757
+ if entity_type == :openid_provider
758
+ metadata[:openid_provider] = section
759
+ metadata.delete("openid_provider") if metadata.key?("openid_provider")
760
+ else
761
+ metadata[:openid_relying_party] = section
762
+ metadata.delete("openid_relying_party") if metadata.key?("openid_relying_party")
763
+ end
764
+
765
+ metadata
766
+ end
767
+
768
+ private
769
+
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
+ def detect_entity_type(metadata)
775
+ return :openid_relying_party if metadata.nil? || metadata.empty?
776
+
777
+ # Check for openid_relying_party first (primary use case)
778
+ if metadata.key?(:openid_relying_party) || metadata.key?("openid_relying_party")
779
+ return :openid_relying_party
780
+ end
781
+
782
+ # Check for openid_provider
783
+ if metadata.key?(:openid_provider) || metadata.key?("openid_provider")
784
+ return :openid_provider
785
+ end
786
+
787
+ # Default to openid_relying_party (primary use case for clients)
788
+ :openid_relying_party
789
+ end
790
+
791
+ def validate_configuration(config)
792
+ raise ConfigurationError, "Issuer is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if config.issuer.nil? || config.issuer.empty?
793
+ 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 config.jwks.nil? || config.jwks.empty?
795
+ raise ConfigurationError, "Metadata is required. Configure with OmniauthOpenidFederation::FederationEndpoint.configure" if config.metadata.nil? || config.metadata.empty?
796
+ end
797
+
798
+ def resolve_current_jwks(config)
799
+ return config.current_jwks if config.current_jwks
800
+ return config.current_jwks_proc.call if config.current_jwks_proc
801
+ config.jwks # Fall back to entity statement JWKS
802
+ end
803
+
804
+ def resolve_signed_jwks_payload(config)
805
+ return config.signed_jwks_payload if config.signed_jwks_payload
806
+ return config.signed_jwks_payload_proc.call if config.signed_jwks_payload_proc
807
+ config.jwks # Fall back to entity statement JWKS
808
+ end
809
+
810
+ def extract_kid_from_jwks(jwks)
811
+ keys = jwks["keys"] || jwks[:keys] || []
812
+ return nil if keys.empty?
813
+ first_key = keys.first
814
+ first_key["kid"] || first_key[:kid]
815
+ end
816
+
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
+ def generate_subordinate_statement(subject_entity_id:, subject_metadata: nil, metadata_policy: nil, constraints: nil, source_endpoint: nil)
828
+ config = configuration
829
+ validate_configuration(config)
830
+
831
+ # Only OPs can generate subordinate statements
832
+ entity_type = detect_entity_type(config.metadata)
833
+ unless entity_type == :openid_provider
834
+ raise ConfigurationError, "Subordinate statements can only be generated by openid_provider entities. Current entity type: #{entity_type}"
835
+ end
836
+
837
+ # Get federation_fetch_endpoint from metadata or use default
838
+ op_metadata = config.metadata[:openid_provider] || config.metadata["openid_provider"] || {}
839
+ fetch_endpoint = op_metadata[:federation_fetch_endpoint] || op_metadata["federation_fetch_endpoint"] ||
840
+ "#{config.issuer}/.well-known/openid-federation/fetch"
841
+
842
+ # Build metadata for subject if provided
843
+ metadata = subject_metadata || {}
844
+
845
+ builder = Federation::EntityStatementBuilder.new(
846
+ issuer: config.issuer,
847
+ subject: subject_entity_id,
848
+ private_key: config.private_key,
849
+ jwks: config.jwks,
850
+ metadata: metadata,
851
+ expiration_seconds: config.expiration_seconds || 86400,
852
+ kid: config.kid,
853
+ metadata_policy: metadata_policy,
854
+ constraints: constraints,
855
+ source_endpoint: source_endpoint || fetch_endpoint
856
+ )
857
+
858
+ builder.build
859
+ end
860
+
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
+ # Configuration class for FederationEndpoint
897
+ # Supports automatic key provisioning with separate signing and encryption keys
898
+ # Supports both openid_provider (OP) and openid_relying_party (RP) entity types
899
+ class Configuration
900
+ attr_accessor :issuer, :subject, :private_key, :jwks, :metadata, :expiration_seconds, :kid
901
+ # Entity type configuration
902
+ attr_accessor :entity_type # :openid_provider or :openid_relying_party
903
+ # Automatic key provisioning configuration
904
+ attr_accessor :signing_key, :encryption_key, :auto_provision_keys, :entity_statement_path, :key_rotation_period
905
+ # JWKS endpoint configuration
906
+ attr_accessor :current_jwks, :current_jwks_proc
907
+ # Signed JWKS endpoint configuration
908
+ attr_accessor :signed_jwks_payload, :signed_jwks_payload_proc, :signed_jwks_expiration_seconds, :signed_jwks_signing_kid
909
+ # Caching configuration
910
+ attr_accessor :jwks_cache_ttl
911
+ # Fetch Endpoint configuration (for serving Subordinate Statements)
912
+ attr_accessor :subordinate_statements, :subordinate_statements_proc
913
+ # Authority hints configuration (for Entity Configuration)
914
+ attr_accessor :authority_hints
915
+
916
+ def initialize
917
+ @issuer = nil
918
+ @subject = nil
919
+ @private_key = nil # Signing key (DEV/TESTING: can be same as encryption, PRODUCTION: use separate signing_key)
920
+ @jwks = nil
921
+ @metadata = nil
922
+ @expiration_seconds = 86400 # 24 hours
923
+ @kid = nil
924
+ # Entity type configuration
925
+ @entity_type = :openid_relying_party # Default to RP (primary use case)
926
+ # Automatic key provisioning defaults
927
+ @signing_key = nil # RECOMMENDED: Separate signing key for production
928
+ @encryption_key = nil # RECOMMENDED: Separate encryption key for production
929
+ @auto_provision_keys = true # Enable automatic key provisioning
930
+ @entity_statement_path = nil # Path to cached entity statement (supports automatic key rotation)
931
+ @key_rotation_period = nil # Key rotation period in seconds (nil = no automatic rotation)
932
+ # JWKS endpoint defaults
933
+ @current_jwks = nil
934
+ @current_jwks_proc = nil
935
+ # Signed JWKS endpoint defaults
936
+ @signed_jwks_payload = nil
937
+ @signed_jwks_payload_proc = nil
938
+ @signed_jwks_expiration_seconds = 86400 # 24 hours
939
+ @signed_jwks_signing_kid = nil
940
+ # Caching defaults
941
+ @jwks_cache_ttl = 3600 # 1 hour
942
+ # Fetch Endpoint defaults
943
+ @subordinate_statements = nil # Hash of subject_entity_id => {metadata, metadata_policy, constraints}
944
+ @subordinate_statements_proc = nil # Proc that takes subject_entity_id and returns Subordinate Statement JWT
945
+ end
946
+ end
947
+ end
948
+ end
949
+ end