omniauth_openid_federation 1.3.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -1
- data/README.md +120 -14
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +3 -3
- data/config/polyrun_coverage.yml +15 -0
- data/config/polyrun_spec_quality.yml +29 -0
- data/config/routes.rb +20 -10
- data/examples/README_INTEGRATION_TESTING.md +3 -0
- data/examples/integration_test_flow.rb +10 -8
- data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
- data/examples/jobs/federation_files_generation_job.rb.example +3 -2
- data/examples/mock_op_server.rb +4 -5
- data/examples/mock_rp_server.rb +3 -3
- data/examples/standalone_multiple_endpoints_example.rb +494 -0
- data/lib/omniauth_openid_federation/access_token.rb +91 -450
- data/lib/omniauth_openid_federation/cache.rb +16 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
- data/lib/omniauth_openid_federation/constants.rb +3 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +39 -14
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
- 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 +8 -91
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +54 -21
- data/lib/omniauth_openid_federation/federation_endpoint.rb +40 -172
- data/lib/omniauth_openid_federation/http_client.rb +145 -32
- data/lib/omniauth_openid_federation/http_errors.rb +14 -0
- data/lib/omniauth_openid_federation/id_token.rb +19 -0
- data/lib/omniauth_openid_federation/jwe.rb +26 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
- data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
- data/lib/omniauth_openid_federation/jwks/rotate.rb +45 -20
- data/lib/omniauth_openid_federation/jws.rb +3 -11
- data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
- data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
- data/lib/omniauth_openid_federation/rack.rb +2 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +21 -9
- data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
- data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
- data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
- data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
- data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
- data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
- data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
- data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
- data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
- data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
- data/lib/omniauth_openid_federation/strategy.rb +27 -1910
- data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
- data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
- data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
- data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
- data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +28 -790
- data/lib/omniauth_openid_federation/time_helpers.rb +67 -0
- data/lib/omniauth_openid_federation/user_info.rb +15 -0
- data/lib/omniauth_openid_federation/utils.rb +14 -9
- data/lib/omniauth_openid_federation/validators.rb +55 -44
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +10 -3
- data/lib/tasks/omniauth_openid_federation.rake +5 -5
- data/sig/omniauth_openid_federation.rbs +8 -1
- data/sig/strategy.rbs +6 -6
- metadata +252 -43
|
@@ -1,8 +1,19 @@
|
|
|
1
|
+
require "http"
|
|
2
|
+
require "openssl"
|
|
1
3
|
require_relative "constants"
|
|
2
4
|
|
|
3
5
|
# HTTP client with retry logic and SSL configuration
|
|
4
6
|
module OmniauthOpenidFederation
|
|
5
7
|
class HttpClient
|
|
8
|
+
RETRYABLE_ERRORS = [
|
|
9
|
+
HTTP::Error,
|
|
10
|
+
Timeout::Error,
|
|
11
|
+
Errno::ECONNREFUSED,
|
|
12
|
+
Errno::ETIMEDOUT
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
15
|
+
RETRYABLE_HTTP_STATUS_CODES = [429, 502, 503].freeze
|
|
16
|
+
|
|
6
17
|
# Execute an HTTP GET request with retry logic
|
|
7
18
|
#
|
|
8
19
|
# @param uri [String, URI] The URI to fetch
|
|
@@ -10,61 +21,163 @@ module OmniauthOpenidFederation
|
|
|
10
21
|
# @option options [Integer] :max_retries Maximum number of retries (default: from config)
|
|
11
22
|
# @option options [Integer] :retry_delay Base retry delay in seconds (default: from config)
|
|
12
23
|
# @option options [Integer] :timeout Request timeout in seconds (default: from config)
|
|
24
|
+
# @option options [Integer] :connect_timeout Connect timeout in seconds
|
|
25
|
+
# @option options [Integer] :read_timeout Read timeout in seconds
|
|
26
|
+
# @option options [Hash] :headers Request headers
|
|
13
27
|
# @return [HTTP::Response] The HTTP response
|
|
14
28
|
# @raise [NetworkError] If the request fails after all retries
|
|
15
29
|
def self.get(uri, options = {})
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
30
|
+
request(:get, uri, options)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Execute an HTTP POST request with retry logic
|
|
34
|
+
#
|
|
35
|
+
# POST defaults to max_retries: 0 because retries are not safe for non-idempotent requests.
|
|
36
|
+
# Pass max_retries explicitly when duplicate POST attempts are acceptable.
|
|
37
|
+
#
|
|
38
|
+
# @param uri [String, URI] The URI to post to
|
|
39
|
+
# @param options [Hash] Request options (same as get, plus :form)
|
|
40
|
+
# @option options [Hash] :form Form parameters
|
|
41
|
+
# @return [HTTP::Response] The HTTP response
|
|
42
|
+
# @raise [NetworkError] If the request fails after all retries
|
|
43
|
+
def self.post(uri, options = {})
|
|
44
|
+
request(:post, uri, options)
|
|
45
|
+
end
|
|
19
46
|
|
|
20
|
-
|
|
47
|
+
def self.request(method, uri, options = {})
|
|
48
|
+
max_retries = max_retries_for(options, method)
|
|
49
|
+
retry_delay = options[:retry_delay] || Configuration.config.retry_delay
|
|
50
|
+
http_client = build_http_client(resolve_timeout(options))
|
|
51
|
+
headers = options[:headers] || {}
|
|
52
|
+
form = options[:form]
|
|
53
|
+
max_attempts = max_retries + 1
|
|
21
54
|
|
|
22
55
|
retries = 0
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if retries
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
raise OmniauthOpenidFederation::NetworkError, error_msg, e.backtrace
|
|
56
|
+
|
|
57
|
+
loop do
|
|
58
|
+
response = perform_request(http_client, headers, method, uri, form)
|
|
59
|
+
|
|
60
|
+
if should_retry_for_status?(method, response, retries, max_retries)
|
|
61
|
+
retries = retry_after_status(response, retries, max_attempts, retry_delay)
|
|
62
|
+
next
|
|
31
63
|
end
|
|
32
64
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
retry
|
|
65
|
+
return response
|
|
66
|
+
rescue *RETRYABLE_ERRORS => error
|
|
67
|
+
retries = handle_network_error(method, uri, error, retries, max_retries, max_attempts, retry_delay)
|
|
37
68
|
end
|
|
38
69
|
end
|
|
39
70
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
71
|
+
def self.perform_request(http_client, headers, method, uri, form)
|
|
72
|
+
client = http_client
|
|
73
|
+
client = client.headers(headers) unless headers.empty?
|
|
74
|
+
|
|
75
|
+
case method
|
|
76
|
+
when :get
|
|
77
|
+
client.get(uri)
|
|
78
|
+
when :post
|
|
79
|
+
client.post(uri, form: form || {})
|
|
80
|
+
else
|
|
81
|
+
raise ArgumentError, "Unsupported HTTP method: #{method}"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.should_retry_for_status?(method, response, retries, max_retries)
|
|
86
|
+
method == :get && retryable_http_status?(response.status.code) && retries < max_retries
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.retry_after_status(response, retries, max_attempts, retry_delay)
|
|
90
|
+
retries += 1
|
|
91
|
+
delay = retry_delay_for(retries, retry_delay)
|
|
92
|
+
OmniauthOpenidFederation::Logger.warn(
|
|
93
|
+
"[HttpClient] HTTP #{response.status.code} on attempt #{retries}/#{max_attempts}, retrying in #{delay}s"
|
|
94
|
+
)
|
|
95
|
+
sleep(delay)
|
|
96
|
+
retries
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.handle_network_error(method, uri, error, retries, max_retries, max_attempts, retry_delay)
|
|
100
|
+
retries += 1
|
|
101
|
+
if retries > max_retries
|
|
102
|
+
error_message = "Failed to #{method.to_s.upcase} #{uri} after #{max_attempts} attempts: #{error.class} - #{error.message}"
|
|
103
|
+
OmniauthOpenidFederation::Logger.error("[HttpClient] #{error_message}")
|
|
104
|
+
raise OmniauthOpenidFederation::NetworkError, error_message, error.backtrace
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
delay = retry_delay_for(retries, retry_delay)
|
|
108
|
+
OmniauthOpenidFederation::Logger.warn(
|
|
109
|
+
"[HttpClient] Request failed on attempt #{retries}/#{max_attempts}, retrying in #{delay}s: #{error.message}"
|
|
110
|
+
)
|
|
111
|
+
sleep(delay)
|
|
112
|
+
retries
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def self.build_http_client(timeout_config)
|
|
116
|
+
http_options = HTTP::Options.new(build_http_options_hash)
|
|
117
|
+
HTTP::Client.new(http_options).timeout(timeout_config)
|
|
48
118
|
end
|
|
49
119
|
|
|
50
|
-
# Build HTTP options hash from configuration
|
|
51
|
-
#
|
|
52
|
-
# @return [Hash, nil] HTTP options hash or nil
|
|
53
120
|
def self.build_http_options_hash
|
|
54
121
|
config = Configuration.config
|
|
55
|
-
|
|
56
|
-
# If http_options is configured, use it (can be Hash or Proc)
|
|
57
|
-
if config.http_options
|
|
122
|
+
user_options = if config.http_options
|
|
58
123
|
if config.http_options.is_a?(Proc)
|
|
59
124
|
config.http_options.call
|
|
60
125
|
else
|
|
61
126
|
config.http_options
|
|
62
127
|
end
|
|
63
128
|
end
|
|
129
|
+
|
|
130
|
+
options = (user_options || {}).dup
|
|
131
|
+
options[:ssl] = (options[:ssl] || {}).dup
|
|
132
|
+
options[:ssl][:verify_mode] ||= ssl_verify_mode(config.verify_ssl)
|
|
133
|
+
if options[:ssl][:verify_mode] == OpenSSL::SSL::VERIFY_PEER && !options[:ssl][:ca_file]
|
|
134
|
+
ca_file = ssl_ca_file
|
|
135
|
+
options[:ssl][:ca_file] = ca_file if ca_file
|
|
136
|
+
end
|
|
137
|
+
options
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def self.ssl_verify_mode(verify_ssl)
|
|
141
|
+
verify_ssl ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def self.ssl_ca_file
|
|
145
|
+
if ENV["SSL_CERT_FILE"] && File.file?(ENV["SSL_CERT_FILE"])
|
|
146
|
+
ENV["SSL_CERT_FILE"]
|
|
147
|
+
elsif File.exist?(OpenSSL::X509::DEFAULT_CERT_FILE)
|
|
148
|
+
OpenSSL::X509::DEFAULT_CERT_FILE
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def self.max_retries_for(options, method)
|
|
153
|
+
return options[:max_retries] if options.key?(:max_retries)
|
|
154
|
+
|
|
155
|
+
(method == :post) ? 0 : Configuration.config.max_retries
|
|
64
156
|
end
|
|
65
157
|
|
|
66
|
-
|
|
158
|
+
def self.resolve_timeout(options)
|
|
159
|
+
default_timeout = Configuration.config.http_timeout
|
|
160
|
+
|
|
161
|
+
if options[:connect_timeout] || options[:read_timeout]
|
|
162
|
+
{
|
|
163
|
+
connect: options[:connect_timeout] || default_timeout,
|
|
164
|
+
read: options[:read_timeout] || default_timeout
|
|
165
|
+
}
|
|
166
|
+
else
|
|
167
|
+
options[:timeout] || default_timeout
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def self.retryable_http_status?(status_code)
|
|
172
|
+
RETRYABLE_HTTP_STATUS_CODES.include?(status_code)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def self.retry_delay_for(retry_count, base_delay)
|
|
176
|
+
[base_delay * retry_count, Constants::MAX_RETRY_DELAY_SECONDS].min
|
|
177
|
+
end
|
|
67
178
|
|
|
68
|
-
private_class_method :
|
|
179
|
+
private_class_method :request, :perform_request, :should_retry_for_status?, :retry_after_status,
|
|
180
|
+
:handle_network_error, :build_http_client, :build_http_options_hash, :ssl_verify_mode, :ssl_ca_file,
|
|
181
|
+
:max_retries_for, :resolve_timeout, :retryable_http_status?, :retry_delay_for
|
|
69
182
|
end
|
|
70
183
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
module OmniauthOpenidFederation
|
|
2
|
+
class HttpError < Error
|
|
3
|
+
attr_reader :response
|
|
4
|
+
|
|
5
|
+
def initialize(status, message = nil, response = nil)
|
|
6
|
+
@response = response
|
|
7
|
+
super(message || "HTTP error #{status}")
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class BadRequest < HttpError; end
|
|
12
|
+
class Unauthorized < HttpError; end
|
|
13
|
+
class Forbidden < HttpError; end
|
|
14
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module OmniauthOpenidFederation
|
|
2
|
+
class IdToken
|
|
3
|
+
CLAIM_READERS = %i[iss sub aud exp iat nonce acr auth_time amr].freeze
|
|
4
|
+
|
|
5
|
+
attr_reader :raw_attributes
|
|
6
|
+
|
|
7
|
+
def initialize(raw_attributes)
|
|
8
|
+
@raw_attributes = raw_attributes.each_with_object({}) do |(key, value), claims|
|
|
9
|
+
claims[key.to_sym] = value
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
CLAIM_READERS.each do |claim_name|
|
|
14
|
+
define_method(claim_name) do
|
|
15
|
+
raw_attributes[claim_name]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
require "jwe"
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module OmniauthOpenidFederation
|
|
6
|
+
# Compact-serialization JWE helpers for nested JWTs (sign then encrypt).
|
|
7
|
+
module Jwe
|
|
8
|
+
PARTS_COUNT = 5
|
|
9
|
+
|
|
10
|
+
def self.encrypted?(token)
|
|
11
|
+
token.to_s.count(".") + 1 == PARTS_COUNT
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def self.encrypt(plaintext, public_key, alg:, enc:)
|
|
15
|
+
::JWE.encrypt(plaintext.to_s, public_key, alg: alg.to_s, enc: enc.to_s)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def self.decrypt(ciphertext, private_key)
|
|
19
|
+
::JWE.decrypt(ciphertext.to_s, private_key)
|
|
20
|
+
rescue ::JWE::DecodeError, ::JWE::InvalidData, ::JWE::BadCEK, ::JWE::NotImplementedError, ArgumentError => error
|
|
21
|
+
raise OmniauthOpenidFederation::DecryptionError,
|
|
22
|
+
"Failed to decrypt JWE: #{error.message}",
|
|
23
|
+
error.backtrace
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -113,19 +113,6 @@ module OmniauthOpenidFederation
|
|
|
113
113
|
else
|
|
114
114
|
raise e
|
|
115
115
|
end
|
|
116
|
-
rescue => e
|
|
117
|
-
# Other errors might be due to key rotation
|
|
118
|
-
if retried
|
|
119
|
-
# If already re-tried to fetch, raise error
|
|
120
|
-
error_msg = "JWT decode failed after cache refresh: #{e.class} - #{e.message}"
|
|
121
|
-
OmniauthOpenidFederation::Logger.error("[Jwks::Decode] #{error_msg}")
|
|
122
|
-
raise ValidationError, error_msg, e.backtrace
|
|
123
|
-
else
|
|
124
|
-
OmniauthOpenidFederation::Logger.warn("[Jwks::Decode] JWT decode error (clearing cache and retrying - possible key rotation): #{e.class} - #{e.message}")
|
|
125
|
-
# Reset cache to force re-fetching of keys
|
|
126
|
-
OmniauthOpenidFederation::Cache.delete_jwks(jwks_uri)
|
|
127
|
-
run(encoded_jwt, jwks_uri, retried: true, entity_statement_keys: entity_statement_keys, &block)
|
|
128
|
-
end
|
|
129
116
|
end
|
|
130
117
|
end
|
|
131
118
|
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
require "http"
|
|
2
1
|
require "jwt"
|
|
3
2
|
require "openssl"
|
|
4
3
|
require_relative "../logger"
|
|
@@ -44,52 +43,30 @@ module OmniauthOpenidFederation
|
|
|
44
43
|
cache_ttl ||= config.cache_ttl
|
|
45
44
|
rotate_on_errors = config.rotate_on_errors
|
|
46
45
|
|
|
47
|
-
# Use cache adapter if available, otherwise fetch directly
|
|
48
46
|
if CacheAdapter.available?
|
|
49
|
-
if force_refresh
|
|
50
|
-
# Force refresh: clear cache and fetch fresh
|
|
51
|
-
CacheAdapter.delete(cache_key)
|
|
52
|
-
end
|
|
47
|
+
CacheAdapter.delete(cache_key) if force_refresh
|
|
53
48
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
begin
|
|
57
|
-
CacheAdapter.fetch(cache_key, expires_in: nil) do
|
|
58
|
-
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
59
|
-
end
|
|
60
|
-
rescue KeyRelatedError => e
|
|
61
|
-
# Rotate on key-related errors if configured
|
|
62
|
-
if rotate_on_errors
|
|
63
|
-
OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
|
|
64
|
-
CacheAdapter.delete(cache_key)
|
|
65
|
-
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
66
|
-
else
|
|
67
|
-
raise
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
else
|
|
71
|
-
# TTL-based cache: expires after cache_ttl seconds
|
|
72
|
-
# Rotate on errors if configured
|
|
73
|
-
begin
|
|
74
|
-
CacheAdapter.fetch(cache_key, expires_in: cache_ttl) do
|
|
75
|
-
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
76
|
-
end
|
|
77
|
-
rescue KeyRelatedError => e
|
|
78
|
-
# Rotate on key-related errors if configured
|
|
79
|
-
if rotate_on_errors
|
|
80
|
-
OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{e.message}")
|
|
81
|
-
CacheAdapter.delete(cache_key)
|
|
82
|
-
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
83
|
-
else
|
|
84
|
-
raise
|
|
85
|
-
end
|
|
86
|
-
end
|
|
49
|
+
fetch_from_cache(cache_key, cache_ttl: cache_ttl, rotate_on_errors: rotate_on_errors) do
|
|
50
|
+
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
87
51
|
end
|
|
88
52
|
else
|
|
89
53
|
fetch_jwks(jwks_uri, entity_statement_keys)
|
|
90
54
|
end
|
|
91
55
|
end
|
|
92
56
|
|
|
57
|
+
def self.fetch_from_cache(cache_key, cache_ttl:, rotate_on_errors:)
|
|
58
|
+
CacheAdapter.fetch(cache_key, expires_in: cache_ttl) { yield }
|
|
59
|
+
rescue KeyRelatedError => error
|
|
60
|
+
if rotate_on_errors
|
|
61
|
+
OmniauthOpenidFederation::Logger.warn("[Jwks::Fetch] Key-related error detected, rotating cache: #{error.message}")
|
|
62
|
+
CacheAdapter.delete(cache_key)
|
|
63
|
+
yield
|
|
64
|
+
else
|
|
65
|
+
raise
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
private_class_method :fetch_from_cache
|
|
69
|
+
|
|
93
70
|
def self.fetch_jwks(jwks_uri, entity_statement_keys)
|
|
94
71
|
# Rate limiting to prevent DoS
|
|
95
72
|
unless RateLimiter.allow?(jwks_uri)
|
|
@@ -38,28 +38,53 @@ module OmniauthOpenidFederation
|
|
|
38
38
|
def self.run(jwks_uri, entity_statement_path: nil)
|
|
39
39
|
if entity_statement_path
|
|
40
40
|
# Validate file path to prevent path traversal
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
[Rails.root.join("config").to_s]
|
|
46
|
-
elsif config.root_path
|
|
47
|
-
[File.join(config.root_path, "config")]
|
|
48
|
-
end
|
|
41
|
+
# Allow absolute paths that exist (for temp files in tests) to skip directory validation
|
|
42
|
+
# For absolute paths that don't exist, still validate they're not path traversal, then check existence
|
|
43
|
+
path_str = entity_statement_path.to_s
|
|
44
|
+
is_absolute = path_str.start_with?("/", "~")
|
|
49
45
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
46
|
+
if is_absolute && File.exist?(entity_statement_path)
|
|
47
|
+
validated_path = entity_statement_path
|
|
48
|
+
else
|
|
49
|
+
# For absolute paths, validate path traversal but allow outside allowed_dirs
|
|
50
|
+
# For relative paths, validate against allowed directories
|
|
51
|
+
if is_absolute
|
|
52
|
+
# Validate path traversal for absolute paths, but don't require it to be in allowed_dirs
|
|
53
|
+
begin
|
|
54
|
+
validated_path = Utils.validate_file_path!(
|
|
55
|
+
entity_statement_path,
|
|
56
|
+
allowed_dirs: nil # Allow absolute paths outside config directory
|
|
57
|
+
)
|
|
58
|
+
rescue SecurityError => e
|
|
59
|
+
# Path traversal attempt - raise SecurityError
|
|
60
|
+
Logger.error("[Jwks::Rotate] #{e.message}")
|
|
61
|
+
raise SecurityError, e.message
|
|
62
|
+
end
|
|
63
|
+
else
|
|
64
|
+
# Relative path - must be in allowed directories
|
|
65
|
+
begin
|
|
66
|
+
config = Configuration.config
|
|
67
|
+
allowed_dirs = if defined?(Rails) && Rails.root
|
|
68
|
+
[Rails.root.join("config").to_s]
|
|
69
|
+
elsif config.root_path
|
|
70
|
+
[File.join(config.root_path, "config")]
|
|
71
|
+
end
|
|
58
72
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
73
|
+
validated_path = Utils.validate_file_path!(
|
|
74
|
+
entity_statement_path,
|
|
75
|
+
allowed_dirs: allowed_dirs
|
|
76
|
+
)
|
|
77
|
+
rescue SecurityError => e
|
|
78
|
+
Logger.error("[Jwks::Rotate] #{e.message}")
|
|
79
|
+
raise SecurityError, e.message
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
unless File.exist?(validated_path)
|
|
84
|
+
sanitized_path = Utils.sanitize_path(validated_path)
|
|
85
|
+
Logger.warn("[Jwks::Rotate] Entity statement file not found: #{sanitized_path}")
|
|
86
|
+
raise ConfigurationError, "Entity statement file not found: #{sanitized_path}"
|
|
87
|
+
end
|
|
63
88
|
end
|
|
64
89
|
|
|
65
90
|
# Try to use signed JWKS if entity statement is available
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
require "jwt"
|
|
2
|
-
require "jwe"
|
|
3
2
|
require "securerandom"
|
|
4
3
|
require "base64"
|
|
5
4
|
require_relative "string_helpers"
|
|
5
|
+
require_relative "time_helpers"
|
|
6
6
|
require_relative "logger"
|
|
7
7
|
require_relative "errors"
|
|
8
8
|
require_relative "validators"
|
|
@@ -55,10 +55,6 @@ module OmniauthOpenidFederation
|
|
|
55
55
|
# )
|
|
56
56
|
# signed_jwt = jws.sign
|
|
57
57
|
class Jws
|
|
58
|
-
# Request object expiration constants
|
|
59
|
-
REQUEST_OBJECT_EXPIRATION_SECONDS = 600 # 10 minutes in seconds
|
|
60
|
-
REQUEST_OBJECT_EXPIRATION_MINUTES = 10
|
|
61
|
-
|
|
62
58
|
# State generation constants
|
|
63
59
|
STATE_BYTES = 16 # Number of hex bytes for state parameter
|
|
64
60
|
|
|
@@ -222,7 +218,7 @@ module OmniauthOpenidFederation
|
|
|
222
218
|
response_type: @response_type,
|
|
223
219
|
scope: @scope,
|
|
224
220
|
state: state,
|
|
225
|
-
exp: (
|
|
221
|
+
exp: (TimeHelpers.now + TimeHelpers.request_object_expiration_offset).to_i,
|
|
226
222
|
jti: SecureRandom.uuid # JWT ID to prevent replay
|
|
227
223
|
}
|
|
228
224
|
|
|
@@ -392,11 +388,7 @@ module OmniauthOpenidFederation
|
|
|
392
388
|
# Convert JWK to OpenSSL public key
|
|
393
389
|
public_key = KeyExtractor.jwk_to_openssl_key(encryption_key_data)
|
|
394
390
|
|
|
395
|
-
|
|
396
|
-
# For JWE, we encrypt the signed JWT string as plaintext
|
|
397
|
-
# The pattern is: sign first, then encrypt (nested JWT)
|
|
398
|
-
# JWE.encrypt(plaintext, key, alg: "RSA-OAEP", enc: "A128CBC-HS256")
|
|
399
|
-
JWE.encrypt(
|
|
391
|
+
OmniauthOpenidFederation::Jwe.encrypt(
|
|
400
392
|
signed_jwt,
|
|
401
393
|
public_key,
|
|
402
394
|
alg: encryption_alg,
|