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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +20 -1
- data/README.md +210 -708
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +14 -1
- data/config/routes.rb +20 -10
- data/examples/config/initializers/devise.rb.example +44 -55
- data/examples/config/initializers/federation_endpoint.rb.example +2 -2
- data/examples/config/open_id_connect_config.rb.example +12 -15
- data/examples/config/routes.rb.example +9 -5
- data/examples/integration_test_flow.rb +4 -4
- data/examples/mock_op_server.rb +3 -3
- data/examples/mock_rp_server.rb +3 -3
- data/lib/omniauth_openid_federation/configuration.rb +8 -0
- data/lib/omniauth_openid_federation/constants.rb +5 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +7 -14
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +40 -11
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +6 -87
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +3 -15
- data/lib/omniauth_openid_federation/federation_endpoint.rb +39 -193
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -15
- data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
- data/lib/omniauth_openid_federation/jws.rb +23 -20
- data/lib/omniauth_openid_federation/rack_endpoint.rb +30 -5
- data/lib/omniauth_openid_federation/strategy.rb +143 -194
- data/lib/omniauth_openid_federation/tasks_helper.rb +501 -2
- data/lib/omniauth_openid_federation/time_helpers.rb +60 -0
- data/lib/omniauth_openid_federation/utils.rb +4 -7
- data/lib/omniauth_openid_federation/validators.rb +294 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +1 -0
- data/lib/tasks/omniauth_openid_federation.rake +301 -2
- data/sig/federation.rbs +0 -8
- data/sig/jwks.rbs +0 -6
- data/sig/omniauth_openid_federation.rbs +6 -1
- data/sig/strategy.rbs +0 -2
- metadata +100 -1
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
require "omniauth_openid_federation/cache_adapter"
|
|
13
13
|
|
|
14
14
|
module OmniauthOpenidFederation
|
|
15
|
-
class FederationController <
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
13
|
-
|
|
18
|
+
# Standard JWKS endpoint
|
|
19
|
+
get "/.well-known/jwks.json", to: "omniauth_openid_federation/federation#jwks", as: :openid_federation_jwks
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
#
|
|
67
|
-
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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::
|
|
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::
|
|
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
|
|
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?
|
|
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
|
-
# #
|
|
183
|
-
# signing_key: open_id_config.signing_key,
|
|
184
|
-
# encryption_key: open_id_config.encryption_key,
|
|
185
|
-
# #
|
|
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:
|
|
206
|
-
# jwks_cache_ttl:
|
|
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
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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")
|
data/examples/mock_op_server.rb
CHANGED
|
@@ -111,7 +111,7 @@ class MockOPServer
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def self.load_signing_key(key_data)
|
|
114
|
-
if key_data.
|
|
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.
|
|
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
|
data/examples/mock_rp_server.rb
CHANGED
|
@@ -66,7 +66,7 @@ class MockRPServer
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
def self.load_signing_key(key_data)
|
|
69
|
-
if key_data.
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
#
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
106
|
-
raise ConfigurationError, "Subject is required" if
|
|
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
|
|
109
|
-
raise ConfigurationError, "Metadata is required" if
|
|
110
|
-
raise ConfigurationError, "JWKS must contain at least one key" if
|
|
111
|
-
raise ConfigurationError, "Key ID (kid) is required" if
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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)
|