omniauth_openid_federation 1.2.2 → 1.3.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.
@@ -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
@@ -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
 
@@ -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
@@ -64,8 +64,6 @@ module OmniauthOpenidFederation
64
64
  # # In config/routes.rb (Rails)
65
65
  # get "/.well-known/openid-federation", to: "omniauth_openid_federation/federation#show"
66
66
  #
67
- # # Or use the provided route helper
68
- # OmniauthOpenidFederation::FederationEndpoint.mount_routes
69
67
  class FederationEndpoint
70
68
  class << self
71
69
  # Configure the federation endpoint
@@ -558,26 +556,6 @@ module OmniauthOpenidFederation
558
556
  # - GET /.well-known/jwks.json (standard JWKS)
559
557
  # - GET /.well-known/signed-jwks.json (signed JWKS)
560
558
  #
561
- # ALTERNATIVE: Use mount_routes helper (for backward compatibility or custom paths):
562
- # Rails.application.routes.draw do
563
- # OmniauthOpenidFederation::FederationEndpoint.mount_routes(self)
564
- # end
565
- #
566
- # @param router [ActionDispatch::Routing::Mapper] The routes mapper (pass `self` from routes.rb)
567
- # @param entity_statement_path [String] Path for entity statement endpoint (default: "/.well-known/openid-federation")
568
- # @param fetch_path [String] Path for fetch endpoint (default: "/.well-known/openid-federation/fetch")
569
- # @param jwks_path [String] Path for standard JWKS endpoint (default: "/.well-known/jwks.json")
570
- # @param signed_jwks_path [String] Path for signed JWKS endpoint (default: "/.well-known/signed-jwks.json")
571
- # @param as [String, Symbol] Route name prefix (default: :openid_federation)
572
- # @deprecated Use `mount OmniauthOpenidFederation::Engine => "/"` instead (Rails-idiomatic way)
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
559
 
582
560
  # Generate fresh signing and encryption keys and write entity statement to file
583
561
  #
@@ -155,21 +155,6 @@ module OmniauthOpenidFederation
155
155
  )
156
156
  end
157
157
  end
158
-
159
- # Decode JWT using jwt gem (legacy method name kept for backward compatibility)
160
- #
161
- # @param encoded_jwt [String] The JWT to decode
162
- # @param jwks_uri [String] The JWKS URI for key lookup
163
- # @param retried [Boolean] Internal flag for retry logic (default: false)
164
- # @param entity_statement_keys [Hash, Array, nil] Entity statement keys for validation
165
- # @return [Array<Hash>] Array with [payload, header]
166
- # @raise [ValidationError] If JWT validation fails
167
- # @raise [SignatureError] If signature verification fails
168
- # @deprecated Use jwt() method instead. This method will be removed in a future version.
169
- def self.json_jwt(encoded_jwt, jwks_uri, retried: false, entity_statement_keys: nil)
170
- OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] json_jwt is deprecated. Use jwt() method instead.")
171
- jwt(encoded_jwt, jwks_uri, retried: retried, entity_statement_keys: entity_statement_keys)
172
- end
173
158
  end
174
159
  end
175
160
  end
@@ -63,10 +63,6 @@ module OmniauthOpenidFederation
63
63
  STATE_BYTES = 16 # Number of hex bytes for state parameter
64
64
 
65
65
  attr_accessor :private_key, :state, :nonce
66
- # Provider-specific extension parameters (outside JWT)
67
- # Some providers may require additional parameters that are not part of the JWT
68
- # @deprecated Use request_object_params option in strategy instead (adds params to the JWT request object)
69
- attr_accessor :ftn_spname
70
66
 
71
67
  # Initialize JWT request object builder
72
68
  #
@@ -114,21 +110,27 @@ module OmniauthOpenidFederation
114
110
  key_source: :local,
115
111
  client_entity_statement: nil
116
112
  )
117
- @client_id = client_id
118
- @redirect_uri = redirect_uri
119
- @scope = scope
120
- @issuer = issuer
121
- @audience = audience
122
- @state = state || SecureRandom.hex(STATE_BYTES)
123
- @nonce = nonce
124
- @response_type = response_type
125
- @response_mode = response_mode
126
- @login_hint = login_hint
127
- @ui_locales = ui_locales
128
- @claims_locales = claims_locales
129
- @prompt = prompt
130
- @hd = hd
131
- @acr_values = acr_values
113
+ # Security: User input parameters are validated and sanitized before reaching here
114
+ # Configuration parameters are trusted and only trimmed for consistency
115
+ # Store trimmed values to ensure consistency
116
+ @client_id = client_id.to_s.strip
117
+ @redirect_uri = redirect_uri.to_s.strip
118
+ @scope = scope.to_s.strip
119
+ @issuer = issuer&.to_s&.strip
120
+ @audience = audience&.to_s&.strip
121
+ @state = state&.to_s&.strip || SecureRandom.hex(STATE_BYTES)
122
+ @nonce = nonce&.to_s&.strip
123
+ @response_type = response_type.to_s.strip
124
+ @response_mode = response_mode&.to_s&.strip
125
+ # User input parameters (already sanitized)
126
+ @login_hint = login_hint&.to_s&.strip
127
+ @ui_locales = ui_locales&.to_s&.strip
128
+ @claims_locales = claims_locales&.to_s&.strip
129
+ # Configuration parameters (trusted, only trimmed)
130
+ @prompt = prompt&.to_s&.strip
131
+ @hd = hd&.to_s&.strip
132
+ # User input parameter (already sanitized)
133
+ @acr_values = acr_values&.to_s&.strip
132
134
  @extra_params = extra_params
133
135
  @jwks = jwks
134
136
  @entity_statement_path = entity_statement_path
@@ -25,9 +25,11 @@
25
25
  require "rack"
26
26
  require "json"
27
27
  require "digest"
28
+ require "uri"
28
29
  require_relative "cache_adapter"
29
30
  require_relative "federation_endpoint"
30
31
  require_relative "logger"
32
+ require_relative "constants"
31
33
 
32
34
  module OmniauthOpenidFederation
33
35
  class RackEndpoint
@@ -117,6 +119,17 @@ module OmniauthOpenidFederation
117
119
  return error_response(400, {error: "invalid_request", error_description: "Missing required parameter: sub"}.to_json)
118
120
  end
119
121
 
122
+ # Security: Validate entity identifier per OpenID Federation 1.0 spec
123
+ # Entity identifiers must be valid HTTP/HTTPS URIs
124
+ begin
125
+ # Validate and get trimmed value
126
+ subject_entity_id = OmniauthOpenidFederation::Validators.validate_entity_identifier!(subject_entity_id)
127
+ rescue SecurityError => e
128
+ return error_response(400, {error: "invalid_request", error_description: "Invalid subject entity ID: #{e.message}"}.to_json)
129
+ rescue => e
130
+ return error_response(400, {error: "invalid_request", error_description: "Subject entity ID validation failed: #{e.message}"}.to_json)
131
+ end
132
+
120
133
  # Validate that subject is not the issuer (invalid request per spec)
121
134
  config = OmniauthOpenidFederation::FederationEndpoint.configuration
122
135
  if subject_entity_id == config.issuer