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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.md +22 -0
- data/README.md +822 -0
- data/SECURITY.md +129 -0
- data/examples/README_INTEGRATION_TESTING.md +399 -0
- data/examples/README_MOCK_OP.md +243 -0
- data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
- data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
- data/examples/app/models/user.rb.example +39 -0
- data/examples/config/initializers/devise.rb.example +97 -0
- data/examples/config/initializers/federation_endpoint.rb.example +206 -0
- data/examples/config/mock_op.yml.example +83 -0
- data/examples/config/open_id_connect_config.rb.example +210 -0
- data/examples/config/routes.rb.example +12 -0
- data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
- data/examples/integration_test_flow.rb +1334 -0
- data/examples/jobs/README.md +194 -0
- data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
- data/examples/jobs/federation_files_generation_job.rb.example +87 -0
- data/examples/mock_op_server.rb +775 -0
- data/examples/mock_rp_server.rb +435 -0
- data/lib/omniauth_openid_federation/access_token.rb +504 -0
- data/lib/omniauth_openid_federation/cache.rb +39 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
- data/lib/omniauth_openid_federation/configuration.rb +135 -0
- data/lib/omniauth_openid_federation/constants.rb +13 -0
- data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
- data/lib/omniauth_openid_federation/errors.rb +52 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
- data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
- data/lib/omniauth_openid_federation/http_client.rb +70 -0
- data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
- data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
- data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
- data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
- data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
- data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
- data/lib/omniauth_openid_federation/jws.rb +416 -0
- data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
- data/lib/omniauth_openid_federation/logger.rb +99 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
- data/lib/omniauth_openid_federation/railtie.rb +29 -0
- data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
- data/lib/omniauth_openid_federation/strategy.rb +2029 -0
- data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
- data/lib/omniauth_openid_federation/utils.rb +166 -0
- data/lib/omniauth_openid_federation/validators.rb +126 -0
- data/lib/omniauth_openid_federation/version.rb +3 -0
- data/lib/omniauth_openid_federation.rb +98 -0
- data/lib/tasks/omniauth_openid_federation.rake +376 -0
- data/sig/federation.rbs +218 -0
- data/sig/jwks.rbs +63 -0
- data/sig/omniauth_openid_federation.rbs +254 -0
- data/sig/strategy.rbs +60 -0
- 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
|