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,30 @@
|
|
|
1
|
+
# String helper utilities for compatibility with ActiveSupport
|
|
2
|
+
# Provides present? and blank? methods without monkey patching core classes
|
|
3
|
+
module OmniauthOpenidFederation
|
|
4
|
+
module StringHelpers
|
|
5
|
+
# Check if a value is present (not nil, not empty string, not blank)
|
|
6
|
+
#
|
|
7
|
+
# @param value [Object] The value to check
|
|
8
|
+
# @return [Boolean] true if value is present, false otherwise
|
|
9
|
+
def self.present?(value)
|
|
10
|
+
case value
|
|
11
|
+
when String
|
|
12
|
+
!value.empty? && !value.strip.empty?
|
|
13
|
+
when NilClass
|
|
14
|
+
false
|
|
15
|
+
when Array, Hash
|
|
16
|
+
!value.empty?
|
|
17
|
+
else
|
|
18
|
+
!value.nil?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Check if a value is blank (nil, empty string, or whitespace-only string)
|
|
23
|
+
#
|
|
24
|
+
# @param value [Object] The value to check
|
|
25
|
+
# @return [Boolean] true if value is blank, false otherwise
|
|
26
|
+
def self.blank?(value)
|
|
27
|
+
!present?(value)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# Tasks helper module for rake tasks
|
|
2
|
+
# Contains all business logic that can be tested independently
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "net/http"
|
|
6
|
+
require "uri"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require_relative "utils"
|
|
9
|
+
require_relative "configuration"
|
|
10
|
+
require_relative "errors"
|
|
11
|
+
require_relative "http_client"
|
|
12
|
+
require_relative "federation/entity_statement"
|
|
13
|
+
require_relative "entity_statement_reader"
|
|
14
|
+
require_relative "jwks/fetch"
|
|
15
|
+
require_relative "federation/signed_jwks"
|
|
16
|
+
|
|
17
|
+
module OmniauthOpenidFederation
|
|
18
|
+
module TasksHelper
|
|
19
|
+
# Resolve file path using configuration
|
|
20
|
+
#
|
|
21
|
+
# @param file_path [String] Relative or absolute file path
|
|
22
|
+
# @return [String] Resolved absolute path
|
|
23
|
+
def self.resolve_path(file_path)
|
|
24
|
+
return file_path if file_path.start_with?("/")
|
|
25
|
+
|
|
26
|
+
config = Configuration.config
|
|
27
|
+
if defined?(Rails) && Rails.root
|
|
28
|
+
Rails.root.join(file_path).to_s
|
|
29
|
+
elsif config.root_path
|
|
30
|
+
File.join(config.root_path, file_path)
|
|
31
|
+
else
|
|
32
|
+
File.expand_path(file_path)
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Fetch entity statement and save to file
|
|
37
|
+
#
|
|
38
|
+
# @param url [String] Entity statement URL
|
|
39
|
+
# @param fingerprint [String, nil] Expected fingerprint
|
|
40
|
+
# @param output_file [String] Output file path
|
|
41
|
+
# @return [Hash] Result hash with :success, :entity_statement, :output_path, :metadata
|
|
42
|
+
# @raise [Federation::EntityStatement::FetchError] If fetching fails
|
|
43
|
+
# @raise [Federation::EntityStatement::ValidationError] If validation fails
|
|
44
|
+
def self.fetch_entity_statement(url:, output_file:, fingerprint: nil)
|
|
45
|
+
output_path = resolve_path(output_file)
|
|
46
|
+
|
|
47
|
+
entity_statement = Federation::EntityStatement.fetch!(
|
|
48
|
+
url,
|
|
49
|
+
fingerprint: fingerprint
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
entity_statement.save_to_file(output_path)
|
|
53
|
+
|
|
54
|
+
metadata = entity_statement.parse
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
success: true,
|
|
58
|
+
entity_statement: entity_statement,
|
|
59
|
+
output_path: output_path,
|
|
60
|
+
fingerprint: entity_statement.fingerprint,
|
|
61
|
+
metadata: metadata
|
|
62
|
+
}
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Validate entity statement file
|
|
66
|
+
#
|
|
67
|
+
# @param file_path [String] Path to entity statement file
|
|
68
|
+
# @param expected_fingerprint [String, nil] Expected fingerprint
|
|
69
|
+
# @return [Hash] Result hash with :success, :fingerprint, :metadata
|
|
70
|
+
# @raise [Federation::EntityStatement::ValidationError] If validation fails
|
|
71
|
+
def self.validate_entity_statement(file_path:, expected_fingerprint: nil)
|
|
72
|
+
resolved_path = resolve_path(file_path)
|
|
73
|
+
|
|
74
|
+
unless File.exist?(resolved_path)
|
|
75
|
+
raise ConfigurationError, "Entity statement file not found: #{resolved_path}"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
entity_statement_content = File.read(resolved_path)
|
|
79
|
+
entity_statement = Federation::EntityStatement.new(
|
|
80
|
+
entity_statement_content,
|
|
81
|
+
fingerprint: expected_fingerprint
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
if expected_fingerprint
|
|
85
|
+
unless entity_statement.validate_fingerprint(expected_fingerprint)
|
|
86
|
+
raise Federation::EntityStatement::ValidationError, "Fingerprint mismatch: expected #{expected_fingerprint}, got #{entity_statement.fingerprint}"
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
metadata = entity_statement.parse
|
|
91
|
+
|
|
92
|
+
{
|
|
93
|
+
success: true,
|
|
94
|
+
fingerprint: entity_statement.fingerprint,
|
|
95
|
+
metadata: metadata
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Fetch JWKS and save to file
|
|
100
|
+
#
|
|
101
|
+
# @param jwks_uri [String] JWKS URI
|
|
102
|
+
# @param output_file [String] Output file path
|
|
103
|
+
# @return [Hash] Result hash with :success, :jwks, :output_path
|
|
104
|
+
# @raise [FetchError] If fetching fails
|
|
105
|
+
def self.fetch_jwks(jwks_uri:, output_file:)
|
|
106
|
+
output_path = resolve_path(output_file)
|
|
107
|
+
|
|
108
|
+
jwks = Jwks::Fetch.run(jwks_uri)
|
|
109
|
+
|
|
110
|
+
File.write(output_path, JSON.pretty_generate(jwks))
|
|
111
|
+
|
|
112
|
+
{
|
|
113
|
+
success: true,
|
|
114
|
+
jwks: jwks,
|
|
115
|
+
output_path: output_path
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Parse entity statement and return metadata
|
|
120
|
+
#
|
|
121
|
+
# @param file_path [String] Path to entity statement file
|
|
122
|
+
# @return [Hash] Metadata hash
|
|
123
|
+
# @raise [ConfigurationError] If file not found
|
|
124
|
+
# @raise [ValidationError] If parsing fails
|
|
125
|
+
def self.parse_entity_statement(file_path:)
|
|
126
|
+
resolved_path = resolve_path(file_path)
|
|
127
|
+
|
|
128
|
+
unless File.exist?(resolved_path)
|
|
129
|
+
raise ConfigurationError, "Entity statement file not found: #{resolved_path}"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
metadata = EntityStatementReader.parse_metadata(
|
|
133
|
+
entity_statement_path: resolved_path
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
unless metadata
|
|
137
|
+
raise Federation::EntityStatement::ValidationError, "Failed to parse entity statement"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
metadata
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Generate client keys
|
|
144
|
+
#
|
|
145
|
+
# @param key_type [String] "single" or "separate"
|
|
146
|
+
# @param output_dir [String] Output directory
|
|
147
|
+
# @return [Hash] Result hash with :success, :keys, :jwks, :output_path
|
|
148
|
+
# @raise [ArgumentError] If key_type is invalid
|
|
149
|
+
def self.prepare_client_keys(key_type:, output_dir:)
|
|
150
|
+
unless %w[single separate].include?(key_type)
|
|
151
|
+
raise ArgumentError, "Invalid key_type: #{key_type}. Valid options: 'single' or 'separate'"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
output_path = resolve_path(output_dir)
|
|
155
|
+
|
|
156
|
+
# Create output directory if it doesn't exist
|
|
157
|
+
FileUtils.mkdir_p(output_path) unless File.directory?(output_path)
|
|
158
|
+
|
|
159
|
+
result = if key_type == "single"
|
|
160
|
+
generate_single_key(output_path)
|
|
161
|
+
else
|
|
162
|
+
generate_separate_keys(output_path)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
{
|
|
166
|
+
success: true,
|
|
167
|
+
output_path: output_path,
|
|
168
|
+
**result
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Test local entity statement endpoint
|
|
173
|
+
#
|
|
174
|
+
# @param base_url [String] Base URL of the local server
|
|
175
|
+
# @return [Hash] Result hash with :success, :results, :entity_statement, :key_status, :validation_warnings
|
|
176
|
+
# @raise [Federation::EntityStatement::FetchError] If fetching fails
|
|
177
|
+
def self.test_local_endpoint(base_url:)
|
|
178
|
+
entity_statement_url = "#{base_url}/.well-known/openid-federation"
|
|
179
|
+
validation_warnings = []
|
|
180
|
+
|
|
181
|
+
# Fetch and parse entity statement
|
|
182
|
+
begin
|
|
183
|
+
entity_statement = Federation::EntityStatement.fetch!(
|
|
184
|
+
entity_statement_url,
|
|
185
|
+
fingerprint: nil # Don't validate fingerprint for local testing
|
|
186
|
+
)
|
|
187
|
+
rescue ValidationError => e
|
|
188
|
+
# Don't block execution - treat validation errors as warnings
|
|
189
|
+
validation_warnings << e.message
|
|
190
|
+
# Try to parse anyway for diagnostic purposes
|
|
191
|
+
begin
|
|
192
|
+
require "json"
|
|
193
|
+
require "base64"
|
|
194
|
+
response = HttpClient.get(entity_statement_url)
|
|
195
|
+
entity_statement = Federation::EntityStatement.new(response.body.to_s)
|
|
196
|
+
rescue
|
|
197
|
+
raise FetchError, "Failed to fetch entity statement: #{e.message}"
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
begin
|
|
202
|
+
metadata = entity_statement.parse
|
|
203
|
+
rescue ValidationError => e
|
|
204
|
+
validation_warnings << e.message
|
|
205
|
+
# Try to extract basic info even if validation fails
|
|
206
|
+
begin
|
|
207
|
+
require "json"
|
|
208
|
+
require "base64"
|
|
209
|
+
jwt_parts = entity_statement.entity_statement.split(".")
|
|
210
|
+
payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
211
|
+
# Preserve original structure (string keys from JSON)
|
|
212
|
+
metadata = {
|
|
213
|
+
issuer: payload["iss"],
|
|
214
|
+
sub: payload["sub"],
|
|
215
|
+
exp: payload["exp"],
|
|
216
|
+
iat: payload["iat"],
|
|
217
|
+
jwks: payload["jwks"],
|
|
218
|
+
metadata: payload["metadata"] || {}
|
|
219
|
+
}
|
|
220
|
+
rescue
|
|
221
|
+
raise FetchError, "Failed to parse entity statement: #{e.message}"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Extract endpoints - handle both provider and relying party entity types
|
|
226
|
+
metadata_section = metadata[:metadata] || {}
|
|
227
|
+
provider_metadata = metadata_section[:openid_provider] || metadata_section["openid_provider"] || {}
|
|
228
|
+
rp_metadata = metadata_section[:openid_relying_party] || metadata_section["openid_relying_party"] || {}
|
|
229
|
+
|
|
230
|
+
endpoints = {}
|
|
231
|
+
|
|
232
|
+
# Provider endpoints
|
|
233
|
+
if provider_metadata.any?
|
|
234
|
+
endpoints["Authorization Endpoint"] = provider_metadata[:authorization_endpoint] || provider_metadata["authorization_endpoint"]
|
|
235
|
+
endpoints["Token Endpoint"] = provider_metadata[:token_endpoint] || provider_metadata["token_endpoint"]
|
|
236
|
+
endpoints["UserInfo Endpoint"] = provider_metadata[:userinfo_endpoint] || provider_metadata["userinfo_endpoint"]
|
|
237
|
+
endpoints["JWKS URI"] = provider_metadata[:jwks_uri] || provider_metadata["jwks_uri"]
|
|
238
|
+
endpoints["Signed JWKS URI"] = provider_metadata[:signed_jwks_uri] || provider_metadata["signed_jwks_uri"]
|
|
239
|
+
endpoints["End Session Endpoint"] = provider_metadata[:end_session_endpoint] || provider_metadata["end_session_endpoint"]
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Relying Party endpoints (JWKS only)
|
|
243
|
+
if rp_metadata.any?
|
|
244
|
+
endpoints["JWKS URI"] ||= rp_metadata[:jwks_uri] || rp_metadata["jwks_uri"]
|
|
245
|
+
endpoints["Signed JWKS URI"] ||= rp_metadata[:signed_jwks_uri] || rp_metadata["signed_jwks_uri"]
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
endpoints.compact!
|
|
249
|
+
|
|
250
|
+
# Detect key configuration status
|
|
251
|
+
key_status = detect_key_status(metadata[:jwks])
|
|
252
|
+
|
|
253
|
+
# Test endpoints
|
|
254
|
+
results = {}
|
|
255
|
+
entity_jwks = metadata[:jwks]
|
|
256
|
+
|
|
257
|
+
endpoints.each do |name, url|
|
|
258
|
+
next unless url
|
|
259
|
+
|
|
260
|
+
begin
|
|
261
|
+
case name
|
|
262
|
+
when "JWKS URI"
|
|
263
|
+
jwks = Jwks::Fetch.run(url)
|
|
264
|
+
key_count = jwks["keys"]&.length || 0
|
|
265
|
+
results[name] = {status: :success, keys: key_count}
|
|
266
|
+
|
|
267
|
+
when "Signed JWKS URI"
|
|
268
|
+
signed_jwks = Federation::SignedJWKS.fetch!(
|
|
269
|
+
url,
|
|
270
|
+
entity_jwks,
|
|
271
|
+
force_refresh: true
|
|
272
|
+
)
|
|
273
|
+
key_count = signed_jwks["keys"]&.length || 0
|
|
274
|
+
results[name] = {status: :success, keys: key_count}
|
|
275
|
+
|
|
276
|
+
else
|
|
277
|
+
# Test other endpoints with simple HTTP GET
|
|
278
|
+
uri = URI(url)
|
|
279
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
280
|
+
http.use_ssl = (uri.scheme == "https")
|
|
281
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_NONE if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.development?
|
|
282
|
+
|
|
283
|
+
request_path = uri.path
|
|
284
|
+
request_path += "?#{uri.query}" if uri.query
|
|
285
|
+
request = Net::HTTP::Get.new(request_path)
|
|
286
|
+
response = http.request(request)
|
|
287
|
+
|
|
288
|
+
results[name] = if response.code.to_i < 400
|
|
289
|
+
{status: :success, code: response.code}
|
|
290
|
+
else
|
|
291
|
+
{status: :warning, code: response.code, body: response.body}
|
|
292
|
+
end
|
|
293
|
+
end
|
|
294
|
+
rescue FetchError, Federation::SignedJWKS::FetchError => e
|
|
295
|
+
results[name] = {status: :error, message: e.message}
|
|
296
|
+
rescue Federation::SignedJWKS::ValidationError => e
|
|
297
|
+
results[name] = {status: :error, message: e.message}
|
|
298
|
+
rescue => e
|
|
299
|
+
results[name] = {status: :error, message: e.message}
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
{
|
|
304
|
+
success: true,
|
|
305
|
+
entity_statement: entity_statement,
|
|
306
|
+
metadata: metadata,
|
|
307
|
+
results: results,
|
|
308
|
+
key_status: key_status,
|
|
309
|
+
validation_warnings: validation_warnings
|
|
310
|
+
}
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# Detect key configuration (single vs separate keys)
|
|
314
|
+
#
|
|
315
|
+
# @param jwks [Hash, nil] JWKS hash with keys array
|
|
316
|
+
# @return [Hash] Hash with :type, :count, :recommendation
|
|
317
|
+
def self.detect_key_status(jwks)
|
|
318
|
+
return {type: :unknown, count: 0, recommendation: "No keys found in entity statement"} unless jwks
|
|
319
|
+
|
|
320
|
+
keys = jwks.is_a?(Hash) ? (jwks["keys"] || jwks[:keys] || []) : []
|
|
321
|
+
return {type: :unknown, count: 0, recommendation: "No keys found in entity statement"} if keys.empty?
|
|
322
|
+
|
|
323
|
+
# Check for duplicate kids (indicates single key used for both signing and encryption)
|
|
324
|
+
kids = keys.map { |k| k["kid"] || k[:kid] }.compact
|
|
325
|
+
duplicate_kids = kids.length != kids.uniq.length
|
|
326
|
+
|
|
327
|
+
# Check use fields
|
|
328
|
+
uses = keys.map { |k| k["use"] || k[:use] }.compact.uniq
|
|
329
|
+
has_sig = uses.include?("sig")
|
|
330
|
+
has_enc = uses.include?("enc")
|
|
331
|
+
has_both_uses = has_sig && has_enc
|
|
332
|
+
|
|
333
|
+
if duplicate_kids
|
|
334
|
+
{
|
|
335
|
+
type: :single,
|
|
336
|
+
count: keys.length,
|
|
337
|
+
recommendation: "⚠️ Single key detected (duplicate Key IDs). This is NOT RECOMMENDED for production. Use separate signing and encryption keys for better security. Generate with: rake openid_federation:prepare_client_keys[separate]"
|
|
338
|
+
}
|
|
339
|
+
elsif has_both_uses && keys.length >= 2
|
|
340
|
+
{
|
|
341
|
+
type: :separate,
|
|
342
|
+
count: keys.length,
|
|
343
|
+
recommendation: "✅ Separate keys detected (recommended for production)"
|
|
344
|
+
}
|
|
345
|
+
elsif keys.length == 1
|
|
346
|
+
{
|
|
347
|
+
type: :single,
|
|
348
|
+
count: 1,
|
|
349
|
+
recommendation: "⚠️ Single key detected. This is NOT RECOMMENDED for production. Use separate signing and encryption keys for better security. Generate with: rake openid_federation:prepare_client_keys[separate]"
|
|
350
|
+
}
|
|
351
|
+
else
|
|
352
|
+
{
|
|
353
|
+
type: :unknown,
|
|
354
|
+
count: keys.length,
|
|
355
|
+
recommendation: "Key configuration unclear. Ensure keys have unique Key IDs and proper 'use' fields ('sig' for signing, 'enc' for encryption)"
|
|
356
|
+
}
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
# Generate single key for both signing and encryption
|
|
361
|
+
#
|
|
362
|
+
# @param output_path [String] Output directory path
|
|
363
|
+
# @return [Hash] Result with :private_key_path, :public_jwks_path, :jwks
|
|
364
|
+
def self.generate_single_key(output_path)
|
|
365
|
+
private_key = OpenSSL::PKey::RSA.new(2048)
|
|
366
|
+
jwk_hash = Utils.rsa_key_to_jwk(private_key, use: "sig")
|
|
367
|
+
|
|
368
|
+
# Remove private key components and 'use' field for backward compatibility
|
|
369
|
+
public_jwk = jwk_hash.reject { |k, _v| %w[d p q dp dq qi use].include?(k.to_s) }
|
|
370
|
+
jwks = {keys: [public_jwk]}
|
|
371
|
+
|
|
372
|
+
# Save private key
|
|
373
|
+
private_key_path = File.join(output_path, "client-private-key.pem")
|
|
374
|
+
File.write(private_key_path, private_key.to_pem)
|
|
375
|
+
File.chmod(0o600, private_key_path)
|
|
376
|
+
|
|
377
|
+
# Save public JWKS
|
|
378
|
+
public_jwks_path = File.join(output_path, "client-jwks.json")
|
|
379
|
+
File.write(public_jwks_path, JSON.pretty_generate(jwks))
|
|
380
|
+
|
|
381
|
+
{
|
|
382
|
+
private_key_path: private_key_path,
|
|
383
|
+
public_jwks_path: public_jwks_path,
|
|
384
|
+
jwks: jwks
|
|
385
|
+
}
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
# Generate separate keys for signing and encryption
|
|
389
|
+
#
|
|
390
|
+
# @param output_path [String] Output directory path
|
|
391
|
+
# @return [Hash] Result with :signing_key_path, :encryption_key_path, :public_jwks_path, :jwks
|
|
392
|
+
def self.generate_separate_keys(output_path)
|
|
393
|
+
signing_private_key = OpenSSL::PKey::RSA.new(2048)
|
|
394
|
+
encryption_private_key = OpenSSL::PKey::RSA.new(2048)
|
|
395
|
+
|
|
396
|
+
signing_jwk_hash = Utils.rsa_key_to_jwk(signing_private_key, use: "sig")
|
|
397
|
+
encryption_jwk_hash = Utils.rsa_key_to_jwk(encryption_private_key, use: "enc")
|
|
398
|
+
|
|
399
|
+
# Remove private key components and add 'use' field
|
|
400
|
+
signing_public_jwk = signing_jwk_hash.reject { |k, _v| %w[d p q dp dq qi].include?(k.to_s) }.merge("use" => "sig")
|
|
401
|
+
encryption_public_jwk = encryption_jwk_hash.reject { |k, _v| %w[d p q dp dq qi].include?(k.to_s) }.merge("use" => "enc")
|
|
402
|
+
|
|
403
|
+
jwks = {keys: [signing_public_jwk, encryption_public_jwk]}
|
|
404
|
+
|
|
405
|
+
# Save private keys
|
|
406
|
+
signing_key_path = File.join(output_path, "client-signing-private-key.pem")
|
|
407
|
+
encryption_key_path = File.join(output_path, "client-encryption-private-key.pem")
|
|
408
|
+
|
|
409
|
+
File.write(signing_key_path, signing_private_key.to_pem)
|
|
410
|
+
File.write(encryption_key_path, encryption_private_key.to_pem)
|
|
411
|
+
File.chmod(0o600, signing_key_path)
|
|
412
|
+
File.chmod(0o600, encryption_key_path)
|
|
413
|
+
|
|
414
|
+
# Save public JWKS
|
|
415
|
+
public_jwks_path = File.join(output_path, "client-jwks.json")
|
|
416
|
+
File.write(public_jwks_path, JSON.pretty_generate(jwks))
|
|
417
|
+
|
|
418
|
+
{
|
|
419
|
+
signing_key_path: signing_key_path,
|
|
420
|
+
encryption_key_path: encryption_key_path,
|
|
421
|
+
public_jwks_path: public_jwks_path,
|
|
422
|
+
jwks: jwks
|
|
423
|
+
}
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
private_class_method :generate_single_key, :generate_separate_keys
|
|
427
|
+
end
|
|
428
|
+
end
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# Utility functions for omniauth_openid_federation
|
|
2
|
+
module OmniauthOpenidFederation
|
|
3
|
+
module Utils
|
|
4
|
+
# Convert hash to HashWithIndifferentAccess if available
|
|
5
|
+
#
|
|
6
|
+
# @param hash [Hash, Object] The hash to convert
|
|
7
|
+
# @return [Hash, HashWithIndifferentAccess] Converted hash
|
|
8
|
+
def self.to_indifferent_hash(hash)
|
|
9
|
+
if defined?(ActiveSupport::HashWithIndifferentAccess)
|
|
10
|
+
ActiveSupport::HashWithIndifferentAccess.new(hash)
|
|
11
|
+
else
|
|
12
|
+
hash.is_a?(Hash) ? hash : hash.to_h
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Sanitize file path for error messages (only show filename, not full path)
|
|
17
|
+
#
|
|
18
|
+
# @param path [String, nil] The file path
|
|
19
|
+
# @return [String] Sanitized path (filename only)
|
|
20
|
+
def self.sanitize_path(path)
|
|
21
|
+
return "[REDACTED]" if path.nil? || path.empty?
|
|
22
|
+
File.basename(path)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Sanitize URI for error messages (only show scheme and host)
|
|
26
|
+
#
|
|
27
|
+
# @param uri [String, nil] The URI
|
|
28
|
+
# @return [String] Sanitized URI
|
|
29
|
+
def self.sanitize_uri(uri)
|
|
30
|
+
return "[REDACTED]" if uri.nil? || uri.empty?
|
|
31
|
+
begin
|
|
32
|
+
parsed = URI.parse(uri)
|
|
33
|
+
"#{parsed.scheme}://#{parsed.host}/[REDACTED]"
|
|
34
|
+
rescue URI::InvalidURIError
|
|
35
|
+
"[REDACTED]"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Build full endpoint URL from issuer and endpoint path
|
|
40
|
+
#
|
|
41
|
+
# @param issuer_uri [String, URI] Issuer URI (e.g., "https://provider.example.com")
|
|
42
|
+
# @param endpoint_path [String] Endpoint path (e.g., "/oauth2/authorize")
|
|
43
|
+
# @return [String] Full endpoint URL
|
|
44
|
+
def self.build_endpoint_url(issuer_uri, endpoint_path)
|
|
45
|
+
return endpoint_path if endpoint_path.to_s.start_with?("http://", "https://")
|
|
46
|
+
|
|
47
|
+
issuer_str = issuer_uri.to_s
|
|
48
|
+
issuer_str = issuer_str.chomp("/")
|
|
49
|
+
path = endpoint_path.to_s
|
|
50
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
51
|
+
"#{issuer_str}#{path}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Build full entity statement URL from issuer and endpoint path
|
|
55
|
+
#
|
|
56
|
+
# @param issuer_uri [String, URI] Issuer URI (e.g., "https://provider.example.com")
|
|
57
|
+
# @param entity_statement_endpoint [String, nil] Entity statement endpoint path (e.g., "/.well-known/openid-federation")
|
|
58
|
+
# @return [String] Full entity statement URL
|
|
59
|
+
def self.build_entity_statement_url(issuer_uri, entity_statement_endpoint: nil)
|
|
60
|
+
endpoint = entity_statement_endpoint || "/.well-known/openid-federation"
|
|
61
|
+
build_endpoint_url(issuer_uri, endpoint)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Validate file path to prevent path traversal attacks
|
|
65
|
+
#
|
|
66
|
+
# @param path [String, Pathname] The file path to validate
|
|
67
|
+
# @param allowed_dirs [Array<String>, nil] Allowed base directories (optional)
|
|
68
|
+
# @return [String] Resolved absolute path
|
|
69
|
+
# @raise [SecurityError] If path traversal is detected or path is outside allowed directories
|
|
70
|
+
def self.validate_file_path!(path, allowed_dirs: nil)
|
|
71
|
+
raise SecurityError, "File path cannot be nil" if path.nil?
|
|
72
|
+
|
|
73
|
+
# Convert Pathname to string if needed
|
|
74
|
+
path_str = path.to_s
|
|
75
|
+
raise SecurityError, "File path cannot be empty" if path_str.empty?
|
|
76
|
+
|
|
77
|
+
# Check for path traversal attempts
|
|
78
|
+
if path_str.include?("..") || path_str.include?("~")
|
|
79
|
+
raise SecurityError, "Path traversal detected in: #{sanitize_path(path_str)}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Resolve to absolute path
|
|
83
|
+
resolved = File.expand_path(path_str)
|
|
84
|
+
|
|
85
|
+
# Validate it's within allowed directories if specified
|
|
86
|
+
if allowed_dirs && !allowed_dirs.empty?
|
|
87
|
+
allowed = allowed_dirs.any? do |dir|
|
|
88
|
+
expanded_dir = File.expand_path(dir)
|
|
89
|
+
resolved.start_with?(expanded_dir)
|
|
90
|
+
end
|
|
91
|
+
unless allowed
|
|
92
|
+
raise SecurityError, "File path outside allowed directories: #{sanitize_path(path)}"
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
resolved
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Validate JWT format (must have exactly 3 parts separated by dots)
|
|
100
|
+
#
|
|
101
|
+
# @param str [String] The string to validate
|
|
102
|
+
# @return [Boolean] true if valid JWT format, false otherwise
|
|
103
|
+
def self.valid_jwt_format?(str)
|
|
104
|
+
return false unless str.is_a?(String)
|
|
105
|
+
parts = str.split(".")
|
|
106
|
+
parts.length == 3 && parts.all? { |p| p.length > 0 }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Convert RSA key to JWK format
|
|
110
|
+
#
|
|
111
|
+
# @param key [OpenSSL::PKey::RSA] RSA private or public key
|
|
112
|
+
# @param use [String, nil] Key use ("sig" for signing, "enc" for encryption, nil for both)
|
|
113
|
+
# @return [Hash] JWK hash with kty, kid, n, e, and optionally use
|
|
114
|
+
def self.rsa_key_to_jwk(key, use: "sig")
|
|
115
|
+
require "digest"
|
|
116
|
+
require "base64"
|
|
117
|
+
|
|
118
|
+
n = Base64.urlsafe_encode64(key.n.to_s(2), padding: false)
|
|
119
|
+
e = Base64.urlsafe_encode64(key.e.to_s(2), padding: false)
|
|
120
|
+
|
|
121
|
+
# Generate kid (key ID) from public key
|
|
122
|
+
public_key_pem = key.public_key.to_pem
|
|
123
|
+
kid = Digest::SHA256.hexdigest(public_key_pem)[0, 16]
|
|
124
|
+
|
|
125
|
+
jwk = {
|
|
126
|
+
kty: "RSA",
|
|
127
|
+
kid: kid,
|
|
128
|
+
n: n,
|
|
129
|
+
e: e
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
# Add use field if specified
|
|
133
|
+
jwk[:use] = use if use
|
|
134
|
+
|
|
135
|
+
jwk
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Extract JWKS from entity statement JWT
|
|
139
|
+
#
|
|
140
|
+
# @param entity_statement_content [String] Entity statement JWT string
|
|
141
|
+
# @return [Hash, nil] JWKS hash with "keys" array, or nil if extraction fails
|
|
142
|
+
def self.extract_jwks_from_entity_statement(entity_statement_content)
|
|
143
|
+
require "json"
|
|
144
|
+
require "base64"
|
|
145
|
+
|
|
146
|
+
return nil unless valid_jwt_format?(entity_statement_content)
|
|
147
|
+
|
|
148
|
+
jwt_parts = entity_statement_content.split(".")
|
|
149
|
+
return nil unless jwt_parts.length == 3
|
|
150
|
+
|
|
151
|
+
begin
|
|
152
|
+
payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
153
|
+
entity_jwks = payload["jwks"] || payload[:jwks] || {}
|
|
154
|
+
return nil if entity_jwks.empty?
|
|
155
|
+
|
|
156
|
+
keys = entity_jwks["keys"] || entity_jwks[:keys] || []
|
|
157
|
+
return nil if keys.empty?
|
|
158
|
+
|
|
159
|
+
{keys: Array(keys)}
|
|
160
|
+
rescue => e
|
|
161
|
+
OmniauthOpenidFederation::Logger.warn("[Utils] Failed to extract JWKS from entity statement: #{e.message}")
|
|
162
|
+
nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|