omniauth_openid_federation 1.2.2 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +20 -1
  3. data/README.md +210 -708
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +14 -1
  5. data/config/routes.rb +20 -10
  6. data/examples/config/initializers/devise.rb.example +44 -55
  7. data/examples/config/initializers/federation_endpoint.rb.example +2 -2
  8. data/examples/config/open_id_connect_config.rb.example +12 -15
  9. data/examples/config/routes.rb.example +9 -5
  10. data/examples/integration_test_flow.rb +4 -4
  11. data/examples/mock_op_server.rb +3 -3
  12. data/examples/mock_rp_server.rb +3 -3
  13. data/lib/omniauth_openid_federation/configuration.rb +8 -0
  14. data/lib/omniauth_openid_federation/constants.rb +5 -0
  15. data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
  16. data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
  17. data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
  18. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +6 -87
  19. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +3 -15
  20. data/lib/omniauth_openid_federation/federation_endpoint.rb +39 -193
  21. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -15
  22. data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
  23. data/lib/omniauth_openid_federation/jws.rb +23 -20
  24. data/lib/omniauth_openid_federation/rack_endpoint.rb +30 -5
  25. data/lib/omniauth_openid_federation/strategy.rb +143 -194
  26. data/lib/omniauth_openid_federation/tasks_helper.rb +501 -2
  27. data/lib/omniauth_openid_federation/time_helpers.rb +60 -0
  28. data/lib/omniauth_openid_federation/utils.rb +4 -7
  29. data/lib/omniauth_openid_federation/validators.rb +294 -8
  30. data/lib/omniauth_openid_federation/version.rb +1 -1
  31. data/lib/omniauth_openid_federation.rb +1 -0
  32. data/lib/tasks/omniauth_openid_federation.rake +301 -2
  33. data/sig/federation.rbs +0 -8
  34. data/sig/jwks.rbs +0 -6
  35. data/sig/omniauth_openid_federation.rbs +6 -1
  36. data/sig/strategy.rbs +0 -2
  37. metadata +100 -1
@@ -12,7 +12,7 @@
12
12
  require "omniauth_openid_federation/cache_adapter"
13
13
 
14
14
  module OmniauthOpenidFederation
15
- class FederationController < ActionController::Base
15
+ class FederationController < ::ApplicationController
16
16
  # Serve the entity statement
17
17
  #
18
18
  # GET /.well-known/openid-federation
@@ -50,6 +50,19 @@ module OmniauthOpenidFederation
50
50
  return
51
51
  end
52
52
 
53
+ # Security: Validate entity identifier per OpenID Federation 1.0 spec
54
+ # Entity identifiers must be valid HTTP/HTTPS URIs
55
+ begin
56
+ # Validate and get trimmed value
57
+ subject_entity_id = OmniauthOpenidFederation::Validators.validate_entity_identifier!(subject_entity_id, max_length: 2048)
58
+ rescue SecurityError => e
59
+ render json: {error: "invalid_request", error_description: "Invalid subject entity ID: #{e.message}"}, status: :bad_request
60
+ return
61
+ rescue => e
62
+ render json: {error: "invalid_request", error_description: "Subject entity ID validation failed: #{e.message}"}, status: :bad_request
63
+ return
64
+ end
65
+
53
66
  # Validate that subject is not the issuer (invalid request per spec)
54
67
  config = OmniauthOpenidFederation::FederationEndpoint.configuration
55
68
  if subject_entity_id == config.issuer
data/config/routes.rb CHANGED
@@ -1,17 +1,27 @@
1
1
  # Routes for OpenID Federation well-known endpoints
2
2
  # These routes are mounted at the root level (not namespaced) because
3
3
  # OpenID Federation spec requires specific well-known paths
4
- OmniauthOpenidFederation::Engine.routes.draw do
5
- # OpenID Federation 1.0 Section 9: Entity Configuration endpoint
6
- # MUST be at /.well-known/openid-federation
7
- get "/.well-known/openid-federation", to: "omniauth_openid_federation/federation#show", as: :openid_federation
4
+ # Guard to prevent double-loading routes (important for test isolation)
5
+ # Use a file-level instance variable that doesn't trigger class loading
6
+ @_omniauth_openid_federation_routes_loaded ||= false
7
+ unless @_omniauth_openid_federation_routes_loaded
8
+ @_omniauth_openid_federation_routes_loaded = true
9
+ begin
10
+ OmniauthOpenidFederation::Engine.routes.draw do
11
+ # OpenID Federation 1.0 Section 9: Entity Configuration endpoint
12
+ # MUST be at /.well-known/openid-federation
13
+ get "/.well-known/openid-federation", to: "omniauth_openid_federation/federation#show", as: :openid_federation
8
14
 
9
- # Fetch endpoint for Subordinate Statements (Section 6.1)
10
- get "/.well-known/openid-federation/fetch", to: "omniauth_openid_federation/federation#fetch", as: :openid_federation_fetch
15
+ # Fetch endpoint for Subordinate Statements (Section 6.1)
16
+ get "/.well-known/openid-federation/fetch", to: "omniauth_openid_federation/federation#fetch", as: :openid_federation_fetch
11
17
 
12
- # Standard JWKS endpoint
13
- get "/.well-known/jwks.json", to: "omniauth_openid_federation/federation#jwks", as: :openid_federation_jwks
18
+ # Standard JWKS endpoint
19
+ get "/.well-known/jwks.json", to: "omniauth_openid_federation/federation#jwks", as: :openid_federation_jwks
14
20
 
15
- # Signed JWKS endpoint (OpenID Federation requirement)
16
- get "/.well-known/signed-jwks.json", to: "omniauth_openid_federation/federation#signed_jwks", as: :openid_federation_signed_jwks
21
+ # Signed JWKS endpoint (OpenID Federation requirement)
22
+ get "/.well-known/signed-jwks.json", to: "omniauth_openid_federation/federation#signed_jwks", as: :openid_federation_signed_jwks
23
+ end
24
+ rescue NameError, LoadError
25
+ # Rails not available or not fully initialized - routes will be loaded when Engine initializes
26
+ end
17
27
  end
@@ -1,10 +1,23 @@
1
1
  # Example Devise configuration for OmniAuth OpenID Federation
2
2
  # Copy this to config/initializers/devise.rb and customize for your provider
3
+ #
4
+ # This example uses a configuration class pattern (OpenIdConnectConfig)
5
+ # See examples/config/open_id_connect_config.rb.example for the config class
3
6
 
4
7
  require "omniauth_openid_federation"
5
8
 
6
9
  # Configure global settings (optional but recommended)
7
10
  OmniauthOpenidFederation.configure do |config|
11
+ if Rails.env.development?
12
+ config.http_options = { ssl: { verify_mode: OpenSSL::SSL::VERIFY_PEER, ca_file: (OpenSSL::X509::DEFAULT_CERT_FILE if File.exist?(OpenSSL::X509::DEFAULT_CERT_FILE)) } }
13
+ end
14
+
15
+ config.cache_ttl = 24 * 60 * 60
16
+ config.rotate_on_errors = true
17
+ config.http_timeout = 10
18
+ config.max_retries = 3
19
+ config.verify_ssl = true
20
+
8
21
  # Security instrumentation - get notified about security events, MITM attacks, etc.
9
22
  # Example with Sentry:
10
23
  # config.instrumentation = ->(event, data) do
@@ -24,51 +37,12 @@ OmniauthOpenidFederation.configure do |config|
24
37
  # config.instrumentation = ->(event, data) do
25
38
  # Rails.logger.warn("[Security] #{event}: #{data.inspect}")
26
39
  # end
27
-
28
- # Cache configuration (optional)
29
- # config.cache_ttl = 3600 # Refresh provider keys every hour
30
- # config.rotate_on_errors = true # Auto-handle provider key rotation
31
- end
32
-
33
- # Provider configuration
34
- provider_issuer = ENV["OPENID_PROVIDER_ISSUER"] || "https://provider.example.com"
35
- client_id = ENV["OPENID_CLIENT_ID"] || "your-client-id"
36
- redirect_uri = "#{ENV["APP_URL"] || "https://your-app.com"}/users/auth/openid_federation/callback"
37
-
38
- # File paths
39
- private_key_path = Rails.root.join("config", "client-private-key.pem")
40
- entity_statement_path = Rails.root.join("config", "provider-entity-statement.jwt")
41
-
42
- # Load private key
43
- unless File.exist?(private_key_path)
44
- raise "Private key not found at #{private_key_path}. Generate it first using the example in README."
45
- end
46
- private_key = OpenSSL::PKey::RSA.new(File.read(private_key_path))
47
-
48
- # Resolve endpoints from entity statement or manual configuration
49
- endpoints = if File.exist?(entity_statement_path)
50
- # Use entity statement if available (recommended for OpenID Federation)
51
- OmniauthOpenidFederation::EndpointResolver.resolve(
52
- entity_statement_path: entity_statement_path.to_s,
53
- config: {}
54
- )
55
- else
56
- # Fallback to manual configuration
57
- {
58
- authorization_endpoint: ENV["OPENID_AUTHORIZATION_ENDPOINT"] || "/oauth2/authorize",
59
- token_endpoint: ENV["OPENID_TOKEN_ENDPOINT"] || "/oauth2/token",
60
- userinfo_endpoint: ENV["OPENID_USERINFO_ENDPOINT"] || "/oauth2/userinfo",
61
- jwks_uri: ENV["OPENID_JWKS_URI"] || "/.well-known/jwks.json",
62
- audience: provider_issuer
63
- }
64
40
  end
65
41
 
66
- # Validate endpoints
67
- OmniauthOpenidFederation::EndpointResolver.validate_and_build_audience(
68
- endpoints,
69
- issuer_uri: URI.parse(provider_issuer)
70
- )
42
+ # Load configuration from config class
43
+ open_id_config = OpenIdConnectConfig.new
71
44
 
45
+ if open_id_config.enabled?
72
46
  Devise.setup do |config|
73
47
  # ... your other Devise configuration ...
74
48
 
@@ -107,25 +81,40 @@ Devise.setup do |config|
107
81
  end
108
82
 
109
83
  config.omniauth :openid_federation,
84
+ strategy_class: OmniAuth::Strategies::OpenIDFederation,
110
85
  name: :openid_federation,
111
86
  scope: [:openid],
112
87
  response_type: "code",
113
88
  discovery: true,
114
- issuer: provider_issuer,
115
89
  client_auth_method: :jwt_bearer,
116
90
  client_signing_alg: :RS256,
117
- audience: endpoints[:audience],
118
- entity_statement_path: entity_statement_path.to_s,
91
+ entity_statement_path: open_id_config.entity_statement_absolute_path,
92
+ always_encrypt_request_object: true,
93
+ # Allow-list of custom parameter names to include in signed request object
94
+ # Parameters must be present in request.params and listed here to be included
95
+ request_object_params: [:ftn_spname], # Example: include ftn_spname if present
96
+ # Proc to modify params before adding to signed request object
97
+ # Useful for combining config values with form values, adding config-based params, etc.
98
+ prepare_request_object_params: proc do |params|
99
+ # Example: Combine config acr_values with form acr_values
100
+ form_acr_values = params["acr_values"]&.to_s&.strip
101
+ config_acr_values = open_id_config.acr_values.to_s.strip
102
+
103
+ if config_acr_values.present? && form_acr_values.present?
104
+ params["acr_values"] = "#{config_acr_values} #{form_acr_values}".strip
105
+ elsif config_acr_values.present?
106
+ params["acr_values"] = config_acr_values
107
+ end
108
+
109
+ # Example: Add custom parameter from config
110
+ params["ftn_spname"] = open_id_config.ftn_spname if open_id_config.ftn_spname.present?
111
+
112
+ params
113
+ end,
119
114
  client_options: {
120
- identifier: client_id,
121
- redirect_uri: redirect_uri,
122
- private_key: private_key,
123
- scheme: URI.parse(provider_issuer).scheme,
124
- host: URI.parse(provider_issuer).host,
125
- authorization_endpoint: endpoints[:authorization_endpoint],
126
- token_endpoint: endpoints[:token_endpoint],
127
- userinfo_endpoint: endpoints[:userinfo_endpoint],
128
- jwks_uri: endpoints[:jwks_uri]
115
+ identifier: open_id_config.client_id,
116
+ redirect_uri: open_id_config.redirect_uri,
117
+ private_key: open_id_config.private_key
129
118
  }
130
119
  end
131
-
120
+ end
@@ -187,7 +187,7 @@ end
187
187
  # ============================================================================
188
188
  # Add to config/routes.rb:
189
189
  #
190
- # OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
190
+ # mount OmniauthOpenidFederation::Engine => "/"
191
191
  #
192
192
  # This mounts all endpoints:
193
193
  # - GET /.well-known/openid-federation (entity statement)
@@ -202,5 +202,5 @@ end
202
202
  # get "/.well-known/signed-jwks.json", to: "omniauth_openid_federation/federation#signed_jwks"
203
203
 
204
204
  Rails.logger.info "[FederationEndpoint] Configured. Add the route in config/routes.rb:"
205
- Rails.logger.info " OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)"
205
+ Rails.logger.info " mount OmniauthOpenidFederation::Engine => \"/\""
206
206
 
@@ -62,8 +62,6 @@ class OpenIdConnectConfig < ApplicationConfig
62
62
  :client_entity_statement_path,
63
63
  :client_entity_statement_url,
64
64
  :client_entity_identifier,
65
- :ftn_spname,
66
- :acr_values,
67
65
  :organization_name
68
66
 
69
67
  # Load private key from file or base64 encoded string
@@ -128,8 +126,8 @@ class OpenIdConnectConfig < ApplicationConfig
128
126
  # Entity statements MUST be available at /.well-known/openid-federation
129
127
  # URL is the source of truth per OpenID Federation spec
130
128
  def entity_statement_absolute_path
131
- return nil if entity_statement_path.blank?
132
- @entity_statement_absolute_path ||= Rails.root.join(entity_statement_path)
129
+ return Rails.root.join("config", ".federation-entity-statement.jwt").to_s if entity_statement_path.blank?
130
+ @entity_statement_absolute_path ||= Rails.root.join(entity_statement_path).to_s
133
131
  end
134
132
 
135
133
  # Get provider entity statement URL
@@ -143,7 +141,7 @@ class OpenIdConnectConfig < ApplicationConfig
143
141
  # Get client entity statement absolute path
144
142
  def client_entity_statement_absolute_path
145
143
  return nil if client_entity_statement_path.blank?
146
- @client_entity_statement_absolute_path ||= Rails.root.join(client_entity_statement_path)
144
+ @client_entity_statement_absolute_path ||= Rails.root.join(client_entity_statement_path).to_s
147
145
  end
148
146
 
149
147
  # Get client entity statement path for strategy configuration
@@ -165,7 +163,6 @@ class OpenIdConnectConfig < ApplicationConfig
165
163
  # Uses standard path /.well-known/openid-federation (mounted by library)
166
164
  def client_entity_statement_url
167
165
  return values[:client_entity_statement_url] if values[:client_entity_statement_url].present?
168
- # Default to standard federation endpoint if not explicitly configured
169
166
  app_url = ENV["APP_URL"] || "https://your-app.example.com"
170
167
  "#{app_url}/.well-known/openid-federation"
171
168
  end
@@ -174,16 +171,15 @@ end
174
171
  # Usage in initializer (config/initializers/omniauth_openid_federation.rb):
175
172
  #
176
173
  # open_id_config = OpenIdConnectConfig.new
177
- # if open_id_config.enabled? && open_id_config.private_key.present?
174
+ # if open_id_config.enabled?
178
175
  # app_url = ENV["APP_URL"] || "https://your-app.example.com"
179
176
  #
180
177
  # OmniauthOpenidFederation::FederationEndpoint.auto_configure(
181
178
  # issuer: app_url,
182
- # # Production (RECOMMENDED): Use separate keys
183
- # signing_key: open_id_config.signing_key,
184
- # encryption_key: open_id_config.encryption_key,
185
- # # Development/Testing (NOT RECOMMENDED FOR PRODUCTION): Single key
186
- # # private_key: open_id_config.private_key,
179
+ # # RECOMMENDED: Use separate signing_key and encryption_key for production
180
+ # # signing_key: open_id_config.signing_key,
181
+ # # encryption_key: open_id_config.encryption_key,
182
+ # private_key: open_id_config.private_key, # DEV/TESTING ONLY - not recommended for production
187
183
  # entity_statement_path: open_id_config.client_entity_statement_absolute_path,
188
184
  # metadata: {
189
185
  # openid_relying_party: {
@@ -202,9 +198,10 @@ end
202
198
  # organization_name: open_id_config.organization_name
203
199
  # }
204
200
  # },
205
- # expiration_seconds: (ENV["FEDERATION_EXPIRATION_SECONDS"] || 86400).to_i,
206
- # jwks_cache_ttl: (ENV["FEDERATION_JWKS_CACHE_TTL"] || 3600).to_i,
207
- # auto_provision_keys: true
201
+ # expiration_seconds: 86400,
202
+ # jwks_cache_ttl: 3600,
203
+ # auto_provision_keys: true,
204
+ # key_rotation_period: 90.days.to_i
208
205
  # )
209
206
  # end
210
207
 
@@ -1,12 +1,16 @@
1
1
  # Example routes.rb showing how to mount the federation endpoint
2
2
  Rails.application.routes.draw do
3
- # Mount the federation endpoint (required for serving entity statements)
3
+ # Mount OpenID Federation engine early to ensure well-known routes are registered before catch-all routes
4
4
  # This enables the /.well-known/openid-federation endpoint
5
- OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
6
-
7
- # Or mount manually:
8
- # get "/.well-known/openid-federation", to: "omniauth_openid_federation/federation#show", as: :openid_federation
5
+ if OpenIdConnectConfig.enabled?
6
+ mount OmniauthOpenidFederation::Engine => "/"
7
+ end
9
8
 
10
9
  # Your other routes...
10
+ devise_for :users, controllers: {
11
+ omniauth_callbacks: "users/omniauth_callbacks"
12
+ }
13
+
14
+ # ... rest of your routes ...
11
15
  end
12
16
 
@@ -319,7 +319,7 @@ class IntegrationTestFlow
319
319
  attempt = 0
320
320
  ready = false
321
321
  server_name = url.include?("9292") ? "OP" : "RP"
322
- start_time = Time.now
322
+ start_time = Time.zone.now
323
323
 
324
324
  while attempt < max_attempts && !ready
325
325
  begin
@@ -330,7 +330,7 @@ class IntegrationTestFlow
330
330
  response = http.get(uri.path)
331
331
 
332
332
  if response.code == "200"
333
- elapsed = (Time.now - start_time).round(1)
333
+ elapsed = (Time.zone.now - start_time).round(1)
334
334
  puts " ✓ #{url} is ready (#{elapsed}s)"
335
335
  ready = true
336
336
  end
@@ -342,7 +342,7 @@ class IntegrationTestFlow
342
342
  attempt += 1
343
343
  # Show progress every 5 attempts (1 second)
344
344
  if attempt % 5 == 0
345
- elapsed = (Time.now - start_time).round(1)
345
+ elapsed = (Time.zone.now - start_time).round(1)
346
346
  print "."
347
347
  end
348
348
  sleep check_interval
@@ -350,7 +350,7 @@ class IntegrationTestFlow
350
350
  end
351
351
 
352
352
  unless ready
353
- elapsed = (Time.now - start_time).round(1)
353
+ elapsed = (Time.zone.now - start_time).round(1)
354
354
  puts "\n ✗ #{server_name} server at #{url} did not become ready in time (#{elapsed}s)"
355
355
  err_log = File.join(@tmp_dir, "#{server_name.downcase}_server_error.log")
356
356
  log_file = File.join(@tmp_dir, "#{server_name.downcase}_server.log")
@@ -111,7 +111,7 @@ class MockOPServer
111
111
  end
112
112
 
113
113
  def self.load_signing_key(key_data)
114
- if key_data.nil? || key_data.empty?
114
+ if key_data.blank?
115
115
  # Generate a new key for testing
116
116
  OpenSSL::PKey::RSA.new(2048)
117
117
  elsif key_data.is_a?(String)
@@ -126,7 +126,7 @@ class MockOPServer
126
126
  end
127
127
 
128
128
  def self.load_encryption_key(key_data)
129
- return nil if key_data.nil? || key_data.empty?
129
+ return nil if key_data.blank?
130
130
  load_signing_key(key_data)
131
131
  end
132
132
 
@@ -478,7 +478,7 @@ class MockOPServer
478
478
  redirect_uri: redirect_uri,
479
479
  state: state,
480
480
  nonce: nonce,
481
- created_at: Time.now
481
+ created_at: Time.zone.now
482
482
  }
483
483
 
484
484
  # Redirect back to RP with authorization code
@@ -66,7 +66,7 @@ class MockRPServer
66
66
  end
67
67
 
68
68
  def self.load_signing_key(key_data)
69
- if key_data.nil? || key_data.empty?
69
+ if key_data.blank?
70
70
  OpenSSL::PKey::RSA.new(2048)
71
71
  elsif key_data.is_a?(String)
72
72
  if key_data.include?("BEGIN")
@@ -80,7 +80,7 @@ class MockRPServer
80
80
  end
81
81
 
82
82
  def self.load_encryption_key(key_data)
83
- return nil if key_data.nil? || key_data.empty?
83
+ return nil if key_data.blank?
84
84
  load_signing_key(key_data)
85
85
  end
86
86
 
@@ -297,7 +297,7 @@ class MockRPServer
297
297
  provider_entity_id: provider_entity_id,
298
298
  redirect_uri: redirect_uri,
299
299
  nonce: nonce,
300
- created_at: Time.now
300
+ created_at: Time.zone.now
301
301
  }
302
302
 
303
303
  # Step 5: Redirect to provider
@@ -84,6 +84,13 @@ module OmniauthOpenidFederation
84
84
  # config.instrumentation = nil
85
85
  attr_accessor :instrumentation
86
86
 
87
+ # Maximum string length for request parameters (default: 8192 / 8KB)
88
+ # Prevents DoS attacks while allowing legitimate use cases (e.g., encrypted JWT authorization codes)
89
+ # @return [Integer] Maximum string length in characters
90
+ # @example
91
+ # config.max_string_length = 16384 # Increase to 16KB
92
+ attr_accessor :max_string_length
93
+
87
94
  def initialize
88
95
  @verify_ssl = true # Default to secure
89
96
  @cache_ttl = nil # Default: manual rotation (never expires)
@@ -96,6 +103,7 @@ module OmniauthOpenidFederation
96
103
  @root_path = nil
97
104
  @clock_skew_tolerance = 60 # Default: 60 seconds clock skew tolerance
98
105
  @instrumentation = nil # Default: no instrumentation
106
+ @max_string_length = 8192 # Default: 8KB - prevents DoS while allowing legitimate use cases
99
107
  end
100
108
 
101
109
  # Configure the gem
@@ -9,5 +9,10 @@ module OmniauthOpenidFederation
9
9
 
10
10
  # Maximum retry delay in seconds (prevents unbounded retry delays)
11
11
  MAX_RETRY_DELAY_SECONDS = 60
12
+
13
+ # Maximum string length for request parameters (8KB)
14
+ # Prevents DoS attacks while allowing legitimate use cases (e.g., encrypted JWT authorization codes)
15
+ # Use Configuration.config.max_string_length for runtime configuration instead of patching this constant
16
+ MAX_STRING_LENGTH = 8192
12
17
  end
13
18
  end
@@ -5,6 +5,7 @@ require_relative "key_extractor"
5
5
  require_relative "utils"
6
6
  require_relative "configuration"
7
7
  require_relative "logger"
8
+ require_relative "string_helpers"
8
9
 
9
10
  # Entity Statement Reader for OpenID Federation 1.0
10
11
  # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
@@ -30,7 +31,7 @@ module OmniauthOpenidFederation
30
31
  # @return [Array<Hash>] Array of JWK hash objects
31
32
  def fetch_keys(entity_statement_path: nil)
32
33
  entity_statement = load_entity_statement(entity_statement_path)
33
- return [] if entity_statement.nil? || entity_statement.empty?
34
+ return [] if StringHelpers.blank?(entity_statement)
34
35
 
35
36
  # Decode self-signed entity statement
36
37
  # Entity statements are self-signed, so we validate using their own JWKS
@@ -53,7 +54,7 @@ module OmniauthOpenidFederation
53
54
  # @return [Hash, nil] Hash with provider metadata or nil if not found
54
55
  def parse_metadata(entity_statement_path: nil)
55
56
  entity_statement = load_entity_statement(entity_statement_path)
56
- return nil if entity_statement.nil? || entity_statement.empty?
57
+ return nil if StringHelpers.blank?(entity_statement)
57
58
 
58
59
  # Decode JWT payload
59
60
  jwt_parts = entity_statement.split(".")
@@ -93,21 +94,45 @@ module OmniauthOpenidFederation
93
94
  def load_entity_statement(entity_statement_path)
94
95
  return nil if entity_statement_path.nil? || entity_statement_path.to_s.empty?
95
96
 
96
- # Determine allowed directories for file path validation
97
- config = OmniauthOpenidFederation::Configuration.config
98
- allowed_dirs = if defined?(Rails) && Rails.root
99
- [Rails.root.join("config").to_s]
100
- elsif config.root_path
101
- [File.join(config.root_path, "config")]
97
+ # If path is absolute and exists, allow it (for temp files in tests)
98
+ # For absolute paths that don't exist, validate path traversal but allow outside allowed_dirs
99
+ # For relative paths, validate against allowed directories
100
+ path_str = entity_statement_path.to_s
101
+ is_absolute = path_str.start_with?("/", "~")
102
+
103
+ if is_absolute && File.exist?(entity_statement_path)
104
+ validated_path = entity_statement_path
105
+ elsif is_absolute
106
+ # Absolute path - validate path traversal but allow outside allowed_dirs
107
+ begin
108
+ validated_path = Utils.validate_file_path!(
109
+ entity_statement_path,
110
+ allowed_dirs: nil # Allow absolute paths outside config directory
111
+ )
112
+ rescue SecurityError
113
+ return nil
114
+ end
115
+ else
116
+ # Relative path - must be in allowed directories
117
+ config = OmniauthOpenidFederation::Configuration.config
118
+ allowed_dirs = if defined?(Rails) && Rails.root
119
+ [Rails.root.join("config").to_s]
120
+ elsif config.root_path
121
+ [File.join(config.root_path, "config")]
122
+ end
123
+
124
+ begin
125
+ # Validate file path to prevent path traversal attacks
126
+ validated_path = Utils.validate_file_path!(
127
+ entity_statement_path,
128
+ allowed_dirs: allowed_dirs
129
+ )
130
+ rescue SecurityError
131
+ return nil
132
+ end
102
133
  end
103
134
 
104
135
  begin
105
- # Validate file path to prevent path traversal attacks
106
- validated_path = Utils.validate_file_path!(
107
- entity_statement_path,
108
- allowed_dirs: allowed_dirs
109
- )
110
-
111
136
  return nil unless File.exist?(validated_path)
112
137
 
113
138
  File.read(validated_path)
@@ -4,6 +4,7 @@ require "openssl"
4
4
  require "time"
5
5
  require_relative "../logger"
6
6
  require_relative "../errors"
7
+ require_relative "../string_helpers"
7
8
 
8
9
  # Entity Statement Builder for OpenID Federation 1.0
9
10
  # @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
@@ -82,7 +83,6 @@ module OmniauthOpenidFederation
82
83
 
83
84
  payload = build_payload
84
85
 
85
- # Build JWT header
86
86
  # Per OpenID Federation 1.0 Section 3.1: typ MUST be "entity-statement+jwt"
87
87
  header = {
88
88
  alg: "RS256",
@@ -102,13 +102,13 @@ module OmniauthOpenidFederation
102
102
  private
103
103
 
104
104
  def validate_parameters
105
- raise ConfigurationError, "Issuer is required" if @issuer.nil? || @issuer.empty?
106
- raise ConfigurationError, "Subject is required" if @subject.nil? || @subject.empty?
105
+ raise ConfigurationError, "Issuer is required" if StringHelpers.blank?(@issuer)
106
+ raise ConfigurationError, "Subject is required" if StringHelpers.blank?(@subject)
107
107
  raise ConfigurationError, "Private key is required" if @private_key.nil?
108
- raise ConfigurationError, "JWKS is required" if @jwks.nil? || @jwks.empty?
109
- raise ConfigurationError, "Metadata is required" if @metadata.nil? || @metadata.empty?
110
- raise ConfigurationError, "JWKS must contain at least one key" if @jwks["keys"].nil? || @jwks["keys"].empty?
111
- raise ConfigurationError, "Key ID (kid) is required" if @kid.nil? || @kid.empty?
108
+ raise ConfigurationError, "JWKS is required" if StringHelpers.blank?(@jwks)
109
+ raise ConfigurationError, "Metadata is required" if StringHelpers.blank?(@metadata)
110
+ raise ConfigurationError, "JWKS must contain at least one key" if StringHelpers.blank?(@jwks["keys"])
111
+ raise ConfigurationError, "Key ID (kid) is required" if StringHelpers.blank?(@kid)
112
112
  end
113
113
 
114
114
  def build_payload
@@ -125,7 +125,6 @@ module OmniauthOpenidFederation
125
125
  metadata: @metadata
126
126
  }
127
127
 
128
- # Entity Configuration specific claims
129
128
  if is_entity_configuration
130
129
  payload[:authority_hints] = @authority_hints if @authority_hints
131
130
  payload[:trust_marks] = @trust_marks if @trust_marks
@@ -133,7 +132,6 @@ module OmniauthOpenidFederation
133
132
  payload[:trust_mark_owners] = @trust_mark_owners if @trust_mark_owners
134
133
  end
135
134
 
136
- # Subordinate Statement specific claims
137
135
  if is_subordinate_statement
138
136
  payload[:metadata_policy] = @metadata_policy if @metadata_policy
139
137
  payload[:metadata_policy_crit] = @metadata_policy_crit if @metadata_policy_crit
@@ -141,21 +139,17 @@ module OmniauthOpenidFederation
141
139
  payload[:source_endpoint] = @source_endpoint if @source_endpoint
142
140
  end
143
141
 
144
- # Common optional claims
145
142
  payload[:crit] = @crit if @crit
146
143
 
147
144
  payload
148
145
  end
149
146
 
150
147
  def normalize_jwks(jwks)
151
- # Ensure JWKS is a hash with "keys" array
152
148
  if jwks.is_a?(Hash)
153
- # If it has :keys or "keys", use as-is
154
149
  if jwks.key?(:keys) || jwks.key?("keys")
155
150
  keys = jwks[:keys] || jwks["keys"]
156
151
  {"keys" => normalize_keys(keys)}
157
152
  else
158
- # If it's just a hash, wrap it
159
153
  {"keys" => [jwks]}
160
154
  end
161
155
  elsif jwks.is_a?(Array)
@@ -168,7 +162,6 @@ module OmniauthOpenidFederation
168
162
  def normalize_keys(keys)
169
163
  keys.map do |key|
170
164
  if key.is_a?(Hash)
171
- # Convert symbol keys to string keys
172
165
  key.transform_keys(&:to_s)
173
166
  else
174
167
  key
@@ -17,18 +17,47 @@ module OmniauthOpenidFederation
17
17
  # @raise [ValidationError] If parsing fails
18
18
  def self.parse_for_signed_jwks(entity_statement_path)
19
19
  # Determine allowed directories for file path validation
20
- config = Configuration.config
21
- allowed_dirs = if defined?(Rails) && Rails.root
22
- [Rails.root.join("config").to_s]
23
- elsif config.root_path
24
- [File.join(config.root_path, "config")]
25
- end
20
+ # If path is absolute and exists, allow it (for temp files in tests)
21
+ # For absolute paths that don't exist, validate path traversal but allow outside allowed_dirs
22
+ # For relative paths, validate against allowed directories
23
+ path_str = entity_statement_path.to_s
24
+ is_absolute = path_str.start_with?("/", "~")
25
+
26
+ if is_absolute && File.exist?(entity_statement_path)
27
+ validated_path = entity_statement_path
28
+ elsif is_absolute
29
+ # Absolute path - validate path traversal but allow outside allowed_dirs
30
+ begin
31
+ validated_path = Utils.validate_file_path!(
32
+ entity_statement_path,
33
+ allowed_dirs: nil # Allow absolute paths outside config directory
34
+ )
35
+ rescue SecurityError => e
36
+ OmniauthOpenidFederation::Logger.warn("[EntityStatementHelper] Security error: #{e.message}")
37
+ return nil
38
+ end
39
+ else
40
+ # Relative path - must be in allowed directories
41
+ config = Configuration.config
42
+ allowed_dirs = if defined?(Rails) && Rails.root
43
+ [Rails.root.join("config").to_s]
44
+ elsif config.root_path
45
+ [File.join(config.root_path, "config")]
46
+ end
26
47
 
27
- # Validate file path to prevent path traversal
28
- validated_path = Utils.validate_file_path!(
29
- entity_statement_path,
30
- allowed_dirs: allowed_dirs
31
- )
48
+ begin
49
+ # Validate file path to prevent path traversal
50
+ validated_path = Utils.validate_file_path!(
51
+ entity_statement_path,
52
+ allowed_dirs: allowed_dirs
53
+ )
54
+ rescue SecurityError => e
55
+ # For relative paths with path traversal, raise SecurityError instead of returning nil
56
+ # This is a security violation that should be explicitly handled
57
+ OmniauthOpenidFederation::Logger.warn("[EntityStatementHelper] Security error: #{e.message}")
58
+ raise SecurityError, e.message
59
+ end
60
+ end
32
61
 
33
62
  unless File.exist?(validated_path)
34
63
  sanitized_path = Utils.sanitize_path(validated_path)