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,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