omniauth_openid_federation 1.3.2 → 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 +32 -1
- data/README.md +119 -13
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
- data/config/polyrun_coverage.yml +15 -0
- data/config/polyrun_spec_quality.yml +29 -0
- 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/federation/entity_statement.rb +0 -4
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +2 -4
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +51 -6
- data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
- 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/jws.rb +2 -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 +2 -2
- 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 -808
- data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
- data/lib/omniauth_openid_federation/user_info.rb +15 -0
- data/lib/omniauth_openid_federation/utils.rb +10 -2
- data/lib/omniauth_openid_federation/validators.rb +43 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +9 -3
- data/lib/tasks/omniauth_openid_federation.rake +1 -2
- data/sig/omniauth_openid_federation.rbs +2 -1
- data/sig/strategy.rbs +6 -6
- metadata +181 -71
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
require "cgi"
|
|
2
|
+
require "json"
|
|
3
|
+
require "base64"
|
|
4
|
+
require "jwt"
|
|
1
5
|
require_relative "entity_statement"
|
|
2
6
|
require_relative "entity_statement_validator"
|
|
3
7
|
require_relative "../http_client"
|
|
@@ -5,7 +9,7 @@ require_relative "../logger"
|
|
|
5
9
|
require_relative "../errors"
|
|
6
10
|
require_relative "../utils"
|
|
7
11
|
require_relative "../string_helpers"
|
|
8
|
-
|
|
12
|
+
require_relative "../key_extractor"
|
|
9
13
|
|
|
10
14
|
# Trust Chain Resolver for OpenID Federation 1.0
|
|
11
15
|
# @see https://openid.net/specs/openid-federation-1_0.html#section-10 Section 10: Trust Chain Resolution
|
|
@@ -49,6 +53,7 @@ module OmniauthOpenidFederation
|
|
|
49
53
|
@timeout = timeout
|
|
50
54
|
@resolved_statements = []
|
|
51
55
|
@visited_entities = Set.new
|
|
56
|
+
@entity_configurations = {}
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
# Resolve the trust chain
|
|
@@ -83,12 +88,13 @@ module OmniauthOpenidFederation
|
|
|
83
88
|
end
|
|
84
89
|
|
|
85
90
|
begin
|
|
91
|
+
issuer_config = fetch_entity_configuration(authority_id)
|
|
86
92
|
subordinate_statement = fetch_subordinate_statement(
|
|
87
93
|
issuer: authority_id,
|
|
88
|
-
subject: current_entity_id
|
|
94
|
+
subject: current_entity_id,
|
|
95
|
+
issuer_config: issuer_config
|
|
89
96
|
)
|
|
90
97
|
|
|
91
|
-
issuer_config = fetch_entity_configuration(authority_id)
|
|
92
98
|
validate_entity_statement(subordinate_statement, issuer_config)
|
|
93
99
|
|
|
94
100
|
@resolved_statements << subordinate_statement
|
|
@@ -143,18 +149,20 @@ module OmniauthOpenidFederation
|
|
|
143
149
|
end
|
|
144
150
|
|
|
145
151
|
def fetch_entity_configuration(entity_id)
|
|
152
|
+
return @entity_configurations[entity_id] if @entity_configurations.key?(entity_id)
|
|
153
|
+
|
|
146
154
|
entity_statement_url = OmniauthOpenidFederation::Utils.build_entity_statement_url(entity_id)
|
|
147
155
|
OmniauthOpenidFederation::Logger.debug("[TrustChainResolver] Fetching Entity Configuration from: #{entity_statement_url}")
|
|
148
156
|
|
|
149
157
|
begin
|
|
150
|
-
EntityStatement.fetch!(entity_statement_url, timeout: @timeout)
|
|
158
|
+
config = EntityStatement.fetch!(entity_statement_url, timeout: @timeout)
|
|
159
|
+
@entity_configurations[entity_id] = config
|
|
151
160
|
rescue OmniauthOpenidFederation::NetworkError => e
|
|
152
161
|
raise FetchError, "Failed to fetch entity configuration from #{entity_statement_url}: #{e.message}"
|
|
153
162
|
end
|
|
154
163
|
end
|
|
155
164
|
|
|
156
|
-
def fetch_subordinate_statement(issuer:, subject:)
|
|
157
|
-
issuer_config = fetch_entity_configuration(issuer)
|
|
165
|
+
def fetch_subordinate_statement(issuer:, subject:, issuer_config:)
|
|
158
166
|
fetch_endpoint = extract_fetch_endpoint(issuer_config)
|
|
159
167
|
|
|
160
168
|
unless fetch_endpoint
|
|
@@ -198,6 +206,43 @@ module OmniauthOpenidFederation
|
|
|
198
206
|
issuer_entity_configuration: issuer_config
|
|
199
207
|
)
|
|
200
208
|
validator.validate!
|
|
209
|
+
verify_entity_statement_signature!(statement, issuer_config)
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def verify_entity_statement_signature!(statement, issuer_config)
|
|
213
|
+
jwt_string = statement.entity_statement
|
|
214
|
+
parts = jwt_string.split(".")
|
|
215
|
+
if parts.length != EntityStatementValidator::JWT_PARTS_COUNT
|
|
216
|
+
raise ValidationError, "Invalid JWT format: expected #{EntityStatementValidator::JWT_PARTS_COUNT} parts, got #{parts.length}"
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
header = JSON.parse(Base64.urlsafe_decode64(parts[0]))
|
|
220
|
+
kid = header["kid"] || header[:kid]
|
|
221
|
+
|
|
222
|
+
signing_keys = issuer_signing_keys(statement, issuer_config)
|
|
223
|
+
keys = signing_keys["keys"] || signing_keys[:keys] || []
|
|
224
|
+
signing_key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
|
|
225
|
+
|
|
226
|
+
unless signing_key_data
|
|
227
|
+
raise ValidationError, "Signing key with kid '#{kid}' not found in issuer JWKS for signature verification"
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(signing_key_data)
|
|
231
|
+
::JWT.decode(jwt_string, public_key, true, {algorithm: "RS256"})
|
|
232
|
+
rescue ::JWT::DecodeError, ::JWT::VerificationError => e
|
|
233
|
+
error_msg = "Entity statement signature validation failed: #{e.class} - #{e.message}"
|
|
234
|
+
OmniauthOpenidFederation::Logger.error("[TrustChainResolver] #{error_msg}")
|
|
235
|
+
raise SignatureError, error_msg, e.backtrace
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def issuer_signing_keys(statement, issuer_config)
|
|
239
|
+
if issuer_config.nil?
|
|
240
|
+
parsed = statement.parse
|
|
241
|
+
parsed[:jwks] || parsed["jwks"] || {}
|
|
242
|
+
else
|
|
243
|
+
issuer_parsed = issuer_config.parse
|
|
244
|
+
issuer_parsed[:jwks] || issuer_parsed["jwks"] || {}
|
|
245
|
+
end
|
|
201
246
|
end
|
|
202
247
|
|
|
203
248
|
def is_trust_anchor?(entity_config)
|
|
@@ -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)
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
require "jwt"
|
|
2
|
-
require "jwe"
|
|
3
2
|
require "securerandom"
|
|
4
3
|
require "base64"
|
|
5
4
|
require_relative "string_helpers"
|
|
@@ -56,10 +55,6 @@ module OmniauthOpenidFederation
|
|
|
56
55
|
# )
|
|
57
56
|
# signed_jwt = jws.sign
|
|
58
57
|
class Jws
|
|
59
|
-
# Request object expiration constants
|
|
60
|
-
REQUEST_OBJECT_EXPIRATION_SECONDS = 600 # 10 minutes in seconds
|
|
61
|
-
REQUEST_OBJECT_EXPIRATION_MINUTES = 10
|
|
62
|
-
|
|
63
58
|
# State generation constants
|
|
64
59
|
STATE_BYTES = 16 # Number of hex bytes for state parameter
|
|
65
60
|
|
|
@@ -223,7 +218,7 @@ module OmniauthOpenidFederation
|
|
|
223
218
|
response_type: @response_type,
|
|
224
219
|
scope: @scope,
|
|
225
220
|
state: state,
|
|
226
|
-
exp: (TimeHelpers.now +
|
|
221
|
+
exp: (TimeHelpers.now + TimeHelpers.request_object_expiration_offset).to_i,
|
|
227
222
|
jti: SecureRandom.uuid # JWT ID to prevent replay
|
|
228
223
|
}
|
|
229
224
|
|
|
@@ -393,11 +388,7 @@ module OmniauthOpenidFederation
|
|
|
393
388
|
# Convert JWK to OpenSSL public key
|
|
394
389
|
public_key = KeyExtractor.jwk_to_openssl_key(encryption_key_data)
|
|
395
390
|
|
|
396
|
-
|
|
397
|
-
# For JWE, we encrypt the signed JWT string as plaintext
|
|
398
|
-
# The pattern is: sign first, then encrypt (nested JWT)
|
|
399
|
-
# JWE.encrypt(plaintext, key, alg: "RSA-OAEP", enc: "A128CBC-HS256")
|
|
400
|
-
JWE.encrypt(
|
|
391
|
+
OmniauthOpenidFederation::Jwe.encrypt(
|
|
401
392
|
signed_jwt,
|
|
402
393
|
public_key,
|
|
403
394
|
alg: encryption_alg,
|