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,150 +1,110 @@
|
|
|
1
|
-
require "jwt"
|
|
2
|
-
require "jwe"
|
|
3
1
|
require "json"
|
|
4
|
-
require "uri"
|
|
5
2
|
require_relative "string_helpers"
|
|
6
3
|
require_relative "logger"
|
|
7
4
|
require_relative "validators"
|
|
8
5
|
require_relative "utils"
|
|
9
|
-
require_relative "
|
|
6
|
+
require_relative "http_errors"
|
|
7
|
+
require_relative "jwt_response_decoder"
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
13
|
-
# @see https://openid.net/specs/openid-connect-core-1_0.html OpenID Connect Core 1.0
|
|
14
|
-
#
|
|
15
|
-
# Extends OpenIDConnect::AccessToken to support:
|
|
16
|
-
# - ID Token decryption (RSA-OAEP + A128CBC-HS256) - Required for token security
|
|
17
|
-
# - Signed JWKS validation - Required for key rotation compliance
|
|
18
|
-
# - Entity statement key loading for signed JWKS validation
|
|
19
|
-
#
|
|
20
|
-
# This extension is automatically loaded when the omniauth_openid_federation library is required.
|
|
21
|
-
module OpenIDConnect
|
|
9
|
+
module OmniauthOpenidFederation
|
|
22
10
|
class AccessToken
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
11
|
+
attr_reader :access_token, :refresh_token, :expires_in, :id_token, :client
|
|
12
|
+
|
|
13
|
+
def initialize(access_token:, client:, refresh_token: nil, expires_in: nil, id_token: nil,
|
|
14
|
+
strategy_options: nil, oauth_token: nil)
|
|
15
|
+
@access_token = access_token
|
|
16
|
+
@refresh_token = refresh_token
|
|
17
|
+
@expires_in = expires_in
|
|
18
|
+
@id_token = id_token
|
|
19
|
+
@client = client
|
|
20
|
+
@strategy_options = strategy_options
|
|
21
|
+
@oauth_token = oauth_token
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.from_oauth2_token(oauth_token, strategy_options: nil)
|
|
25
|
+
token_params = oauth_token.params || {}
|
|
26
|
+
id_token_value = token_params["id_token"] || token_params[:id_token]
|
|
27
|
+
|
|
28
|
+
new(
|
|
29
|
+
access_token: oauth_token.token,
|
|
30
|
+
refresh_token: oauth_token.refresh_token,
|
|
31
|
+
expires_in: oauth_token.expires_in,
|
|
32
|
+
id_token: id_token_value,
|
|
33
|
+
client: oauth_token.client,
|
|
34
|
+
strategy_options: strategy_options,
|
|
35
|
+
oauth_token: oauth_token
|
|
36
|
+
)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def userinfo!(params = {}, http_method: :get, headers: {})
|
|
40
|
+
unless [:get, :post].include?(http_method)
|
|
41
|
+
raise ArgumentError, "http_method must be :get or :post"
|
|
29
42
|
end
|
|
30
|
-
case status_code
|
|
31
|
-
when 200
|
|
32
|
-
# Simple check if the response looks like a JWT string (could be ID token or encrypted userinfo)
|
|
33
|
-
if /\A[\w\-.]+\z/.match?(res.body)
|
|
34
|
-
# Check if it's encrypted (JWE format has 5 parts separated by dots)
|
|
35
|
-
is_encrypted = res.body.split(".").length == 5
|
|
36
43
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
# Decrypt using JWE gem
|
|
42
|
-
plain_text = JWE.decrypt(res.body, encryption_key)
|
|
44
|
+
endpoint = client.userinfo_endpoint
|
|
45
|
+
unless OmniauthOpenidFederation::StringHelpers.present?(endpoint)
|
|
46
|
+
raise ConfigurationError, "Userinfo endpoint not configured"
|
|
47
|
+
end
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if plain_text.split(".").length == 3
|
|
48
|
-
# It's a JWT (signed or unsigned) - decode it
|
|
49
|
-
signed_jwt = plain_text
|
|
50
|
-
else
|
|
51
|
-
# Try to parse as JSON, if that fails, it might be a malformed JWT
|
|
52
|
-
begin
|
|
53
|
-
return JSON.parse(plain_text).with_indifferent_access
|
|
54
|
-
rescue JSON::ParserError
|
|
55
|
-
# If parsing fails, treat as JWT and try to decode
|
|
56
|
-
signed_jwt = plain_text
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
else
|
|
60
|
-
# Not encrypted, use body directly (signed JWT)
|
|
61
|
-
signed_jwt = res.body
|
|
62
|
-
end
|
|
49
|
+
unless @oauth_token
|
|
50
|
+
raise ConfigurationError, "OAuth token reference not available for userinfo request"
|
|
51
|
+
end
|
|
63
52
|
|
|
64
|
-
|
|
65
|
-
|
|
53
|
+
response = case http_method
|
|
54
|
+
when :get
|
|
55
|
+
@oauth_token.get(endpoint.to_s, params: params, headers: headers)
|
|
56
|
+
when :post
|
|
57
|
+
@oauth_token.post(
|
|
58
|
+
endpoint.to_s,
|
|
59
|
+
body: params,
|
|
60
|
+
headers: headers.merge("Content-Type" => "application/x-www-form-urlencoded")
|
|
61
|
+
)
|
|
62
|
+
end
|
|
66
63
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
64
|
+
claims = parse_userinfo_response(response)
|
|
65
|
+
OmniauthOpenidFederation::UserInfo.new(claims)
|
|
66
|
+
end
|
|
70
67
|
|
|
71
|
-
|
|
72
|
-
|
|
68
|
+
def decode_response_body(body)
|
|
69
|
+
JwtResponseDecoder.new(strategy_options: get_strategy_options, client: client).decode(body)
|
|
70
|
+
end
|
|
73
71
|
|
|
74
|
-
|
|
75
|
-
if jwks_uri_value && %r{https?://.+}.match?(jwks_uri_value.to_s)
|
|
76
|
-
URI.parse(jwks_uri_value.to_s)
|
|
77
|
-
elsif jwks_uri_value
|
|
78
|
-
URI::HTTPS.build(
|
|
79
|
-
host: client_options[:host] || ((respond_to?(:client) && client&.respond_to?(:host)) ? client.host : nil),
|
|
80
|
-
path: jwks_uri_value.to_s
|
|
81
|
-
)
|
|
82
|
-
else
|
|
83
|
-
# If we can't determine jwks_uri, we'll need to handle this in the JWT decode
|
|
84
|
-
nil
|
|
85
|
-
end
|
|
72
|
+
private
|
|
86
73
|
|
|
87
|
-
|
|
88
|
-
|
|
74
|
+
def parse_userinfo_response(response)
|
|
75
|
+
body = response.respond_to?(:parsed) ? response.parsed : nil
|
|
76
|
+
if body.is_a?(Hash)
|
|
77
|
+
return body
|
|
78
|
+
end
|
|
89
79
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
80
|
+
body_text =
|
|
81
|
+
if body.is_a?(String) && OmniauthOpenidFederation::StringHelpers.present?(body)
|
|
82
|
+
body
|
|
83
|
+
elsif response.respond_to?(:body)
|
|
84
|
+
response.body.to_s
|
|
85
|
+
else
|
|
86
|
+
""
|
|
87
|
+
end
|
|
96
88
|
|
|
97
|
-
|
|
98
|
-
# Unsigned JWT - decode without verification
|
|
99
|
-
jwt = ::JWT.decode(signed_jwt, nil, false)
|
|
100
|
-
else
|
|
101
|
-
# Signed JWT - decode with verification
|
|
102
|
-
signed_jwks = fetch_signed_jwks(normalized_strategy_options)
|
|
103
|
-
if signed_jwks
|
|
104
|
-
# Decode using signed JWKS
|
|
105
|
-
jwt = ::JWT.decode(
|
|
106
|
-
signed_jwt,
|
|
107
|
-
nil,
|
|
108
|
-
true,
|
|
109
|
-
{algorithms: [algorithm], jwks: signed_jwks}
|
|
110
|
-
)
|
|
111
|
-
else
|
|
112
|
-
# Fallback to standard JWKS
|
|
113
|
-
# Try to resolve JWKS URI from entity statement if not in client_options
|
|
114
|
-
unless jwks_uri
|
|
115
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] JWKS URI not in client_options, trying to resolve from entity statement")
|
|
116
|
-
jwks_uri = resolve_jwks_uri_from_entity_statement(normalized_strategy_options)
|
|
117
|
-
if jwks_uri
|
|
118
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Successfully resolved JWKS URI from entity statement")
|
|
119
|
-
# Convert to URI object if it's a string
|
|
120
|
-
jwks_uri = URI.parse(jwks_uri) if jwks_uri.is_a?(String) && !jwks_uri.is_a?(URI)
|
|
121
|
-
end
|
|
122
|
-
end
|
|
89
|
+
return {} if OmniauthOpenidFederation::StringHelpers.blank?(body_text)
|
|
123
90
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
end
|
|
91
|
+
decode_response_body(body_text)
|
|
92
|
+
rescue JSON::ParserError => error
|
|
93
|
+
raise ValidationError, "Failed to parse userinfo response: #{error.message}", error.backtrace
|
|
94
|
+
end
|
|
129
95
|
|
|
130
|
-
|
|
131
|
-
jwt = OmniauthOpenidFederation::Jwks::Decode.jwt(
|
|
132
|
-
signed_jwt,
|
|
133
|
-
jwks_uri.to_s,
|
|
134
|
-
entity_statement_keys: entity_statement_keys
|
|
135
|
-
)
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
|
-
rescue => e
|
|
139
|
-
# If header parsing fails, try to decode as unsigned
|
|
140
|
-
OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to parse JWT header, trying unsigned decode: #{e.message}")
|
|
141
|
-
jwt = ::JWT.decode(signed_jwt, nil, false)
|
|
142
|
-
end
|
|
96
|
+
public
|
|
143
97
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
98
|
+
def resource_request
|
|
99
|
+
res = yield
|
|
100
|
+
status_code = if res.status.is_a?(Integer)
|
|
101
|
+
res.status
|
|
102
|
+
else
|
|
103
|
+
(res.status.respond_to?(:code) ? res.status.code : res.status)
|
|
104
|
+
end
|
|
105
|
+
case status_code
|
|
106
|
+
when 200
|
|
107
|
+
decode_response_body(res.body)
|
|
148
108
|
when 400
|
|
149
109
|
raise BadRequest.new("API Access Faild", res)
|
|
150
110
|
when 401
|
|
@@ -158,20 +118,17 @@ module OpenIDConnect
|
|
|
158
118
|
|
|
159
119
|
private
|
|
160
120
|
|
|
161
|
-
# Get strategy options from client (stored by strategy when client was created)
|
|
162
|
-
# Falls back to extracting from client attributes if strategy options not available
|
|
163
|
-
#
|
|
164
|
-
# @return [Hash] Strategy options hash
|
|
165
121
|
def get_strategy_options
|
|
166
|
-
|
|
122
|
+
if @strategy_options.is_a?(Hash)
|
|
123
|
+
return @strategy_options
|
|
124
|
+
end
|
|
125
|
+
|
|
167
126
|
if respond_to?(:client) && client
|
|
168
127
|
strategy_options = client.instance_variable_get(:@strategy_options)
|
|
169
128
|
return strategy_options if strategy_options&.is_a?(Hash)
|
|
170
129
|
end
|
|
171
130
|
|
|
172
|
-
# Fallback: try to extract from client attributes if available
|
|
173
131
|
if respond_to?(:client) && client
|
|
174
|
-
# Build minimal options from client
|
|
175
132
|
client_options = {}
|
|
176
133
|
client_options[:jwks_uri] = client.jwks_uri.to_s if client.respond_to?(:jwks_uri) && client.jwks_uri
|
|
177
134
|
client_options[:private_key] = client.private_key if client.respond_to?(:private_key) && client.private_key
|
|
@@ -181,324 +138,8 @@ module OpenIDConnect
|
|
|
181
138
|
}
|
|
182
139
|
end
|
|
183
140
|
|
|
184
|
-
# Last resort: return empty hash (will cause errors later, but at least won't crash immediately)
|
|
185
141
|
OmniauthOpenidFederation::Logger.warn("[AccessToken] Could not determine strategy options from client. Some features may not work correctly.")
|
|
186
142
|
{}
|
|
187
143
|
end
|
|
188
|
-
|
|
189
|
-
# Extract encryption key for decrypting ID tokens or userinfo responses
|
|
190
|
-
# Uses KeyExtractor to support separate signing/encryption keys per OpenID Federation spec
|
|
191
|
-
#
|
|
192
|
-
# @return [OpenSSL::PKey::RSA] Encryption key
|
|
193
|
-
def extract_encryption_key_for_decryption
|
|
194
|
-
# Try to get strategy options
|
|
195
|
-
strategy_options = get_strategy_options
|
|
196
|
-
raw_client_options = strategy_options[:client_options] || strategy_options["client_options"]
|
|
197
|
-
client_options = OmniauthOpenidFederation::Validators.normalize_hash(raw_client_options)
|
|
198
|
-
|
|
199
|
-
private_key = client_options[:private_key] || ((respond_to?(:client) && client&.respond_to?(:private_key)) ? client.private_key : nil)
|
|
200
|
-
jwks = client_options[:jwks] || client_options["jwks"]
|
|
201
|
-
|
|
202
|
-
# Try to load metadata for key extraction
|
|
203
|
-
metadata = nil
|
|
204
|
-
entity_statement_path = strategy_options[:entity_statement_path]
|
|
205
|
-
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
|
|
206
|
-
begin
|
|
207
|
-
validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
|
|
208
|
-
entity_statement_path,
|
|
209
|
-
allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
|
|
210
|
-
)
|
|
211
|
-
if File.exist?(validated_path)
|
|
212
|
-
metadata = JSON.parse(File.read(validated_path))
|
|
213
|
-
end
|
|
214
|
-
rescue => e
|
|
215
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not load metadata for key extraction: #{e.message}")
|
|
216
|
-
end
|
|
217
|
-
end
|
|
218
|
-
|
|
219
|
-
# Extract encryption key from JWKS or use provided private_key (backward compatibility)
|
|
220
|
-
encryption_key = OmniauthOpenidFederation::KeyExtractor.extract_encryption_key(
|
|
221
|
-
jwks: jwks,
|
|
222
|
-
metadata: metadata,
|
|
223
|
-
private_key: private_key
|
|
224
|
-
) || private_key
|
|
225
|
-
|
|
226
|
-
OmniauthOpenidFederation::Validators.validate_private_key!(encryption_key)
|
|
227
|
-
encryption_key
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def fetch_signed_jwks(strategy_options)
|
|
231
|
-
# Support entity_statement_path, entity_statement_url, or issuer (like strategy does)
|
|
232
|
-
entity_statement_path = strategy_options[:entity_statement_path] || strategy_options["entity_statement_path"]
|
|
233
|
-
entity_statement_url = strategy_options[:entity_statement_url] || strategy_options["entity_statement_url"]
|
|
234
|
-
issuer = strategy_options[:issuer] || strategy_options["issuer"]
|
|
235
|
-
entity_statement_fingerprint = strategy_options[:entity_statement_fingerprint] || strategy_options["entity_statement_fingerprint"]
|
|
236
|
-
|
|
237
|
-
# Load entity statement from path, URL, or issuer
|
|
238
|
-
entity_statement_content = nil
|
|
239
|
-
|
|
240
|
-
# Priority 1: File path (if provided)
|
|
241
|
-
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
|
|
242
|
-
begin
|
|
243
|
-
validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
|
|
244
|
-
entity_statement_path,
|
|
245
|
-
allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
|
|
246
|
-
)
|
|
247
|
-
if File.exist?(validated_path)
|
|
248
|
-
entity_statement_content = File.read(validated_path)
|
|
249
|
-
else
|
|
250
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Entity statement file not found: #{OmniauthOpenidFederation::Utils.sanitize_path(validated_path)}")
|
|
251
|
-
end
|
|
252
|
-
rescue SecurityError => e
|
|
253
|
-
OmniauthOpenidFederation::Logger.error("[AccessToken] #{e.message}")
|
|
254
|
-
end
|
|
255
|
-
end
|
|
256
|
-
|
|
257
|
-
# Priority 2: Fetch from URL (if provided)
|
|
258
|
-
if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
|
|
259
|
-
begin
|
|
260
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Fetching entity statement from URL for signed JWKS")
|
|
261
|
-
entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
|
|
262
|
-
entity_statement_url,
|
|
263
|
-
fingerprint: entity_statement_fingerprint
|
|
264
|
-
)
|
|
265
|
-
# fetch! returns EntityStatement instance, extract JWT string from it
|
|
266
|
-
entity_statement_content = entity_statement_instance.entity_statement
|
|
267
|
-
rescue => e
|
|
268
|
-
OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch entity statement from URL: #{e.message}")
|
|
269
|
-
end
|
|
270
|
-
end
|
|
271
|
-
|
|
272
|
-
# Priority 3: Fetch from issuer (if provided)
|
|
273
|
-
if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(issuer)
|
|
274
|
-
begin
|
|
275
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Fetching entity statement from issuer for signed JWKS")
|
|
276
|
-
entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
|
|
277
|
-
issuer,
|
|
278
|
-
fingerprint: entity_statement_fingerprint
|
|
279
|
-
)
|
|
280
|
-
# fetch_from_issuer! returns EntityStatement instance, extract JWT string from it
|
|
281
|
-
entity_statement_content = entity_statement_instance.entity_statement
|
|
282
|
-
rescue => e
|
|
283
|
-
OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch entity statement from issuer: #{e.message}")
|
|
284
|
-
end
|
|
285
|
-
end
|
|
286
|
-
|
|
287
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
|
|
288
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Entity statement not available (path, URL, or issuer not configured), skipping signed JWKS")
|
|
289
|
-
return nil
|
|
290
|
-
end
|
|
291
|
-
|
|
292
|
-
begin
|
|
293
|
-
parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
|
|
294
|
-
entity_statement_content
|
|
295
|
-
)
|
|
296
|
-
if parsed.nil?
|
|
297
|
-
return nil
|
|
298
|
-
end
|
|
299
|
-
|
|
300
|
-
signed_jwks_uri = parsed[:signed_jwks_uri]
|
|
301
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(signed_jwks_uri)
|
|
302
|
-
OmniauthOpenidFederation::Logger.warn("[AccessToken] signed_jwks_uri not found in entity statement metadata")
|
|
303
|
-
return nil
|
|
304
|
-
end
|
|
305
|
-
|
|
306
|
-
# Get entity JWKS for validation
|
|
307
|
-
entity_jwks = parsed[:entity_jwks]
|
|
308
|
-
|
|
309
|
-
# Fetch and validate signed JWKS
|
|
310
|
-
sanitized_uri = OmniauthOpenidFederation::Utils.sanitize_uri(signed_jwks_uri)
|
|
311
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Fetching signed JWKS from #{sanitized_uri}")
|
|
312
|
-
signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(signed_jwks_uri, entity_jwks)
|
|
313
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Successfully fetched and validated signed JWKS")
|
|
314
|
-
signed_jwks
|
|
315
|
-
rescue SecurityError => e
|
|
316
|
-
# Security errors should not be silently ignored
|
|
317
|
-
OmniauthOpenidFederation::Logger.error("[AccessToken] Security error: #{e.message}")
|
|
318
|
-
nil
|
|
319
|
-
rescue
|
|
320
|
-
OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch signed JWKS, falling back to standard JWKS")
|
|
321
|
-
# Return nil to allow fallback to standard JWKS, but log the error
|
|
322
|
-
nil
|
|
323
|
-
end
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
def load_entity_statement_keys_for_jwks_validation(strategy_options)
|
|
327
|
-
# Support entity_statement_path, entity_statement_url, or issuer (like strategy does)
|
|
328
|
-
entity_statement_path = strategy_options[:entity_statement_path] || strategy_options["entity_statement_path"]
|
|
329
|
-
entity_statement_url = strategy_options[:entity_statement_url] || strategy_options["entity_statement_url"]
|
|
330
|
-
issuer = strategy_options[:issuer] || strategy_options["issuer"]
|
|
331
|
-
entity_statement_fingerprint = strategy_options[:entity_statement_fingerprint] || strategy_options["entity_statement_fingerprint"]
|
|
332
|
-
|
|
333
|
-
# Load entity statement from path, URL, or issuer
|
|
334
|
-
entity_statement_content = nil
|
|
335
|
-
|
|
336
|
-
# Priority 1: File path (if provided)
|
|
337
|
-
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
|
|
338
|
-
begin
|
|
339
|
-
validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
|
|
340
|
-
entity_statement_path,
|
|
341
|
-
allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
|
|
342
|
-
)
|
|
343
|
-
if File.exist?(validated_path)
|
|
344
|
-
entity_statement_content = File.read(validated_path)
|
|
345
|
-
end
|
|
346
|
-
rescue SecurityError => e
|
|
347
|
-
OmniauthOpenidFederation::Logger.error("[AccessToken] #{e.message}")
|
|
348
|
-
end
|
|
349
|
-
end
|
|
350
|
-
|
|
351
|
-
# Priority 2: Fetch from URL (if provided)
|
|
352
|
-
if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
|
|
353
|
-
begin
|
|
354
|
-
entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
|
|
355
|
-
entity_statement_url,
|
|
356
|
-
fingerprint: entity_statement_fingerprint
|
|
357
|
-
)
|
|
358
|
-
# fetch! returns EntityStatement instance, extract JWT string from it
|
|
359
|
-
entity_statement_content = entity_statement_instance.entity_statement
|
|
360
|
-
rescue => e
|
|
361
|
-
OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch entity statement from URL: #{e.message}")
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
|
-
|
|
365
|
-
# Priority 3: Fetch from issuer (if provided)
|
|
366
|
-
if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(issuer)
|
|
367
|
-
begin
|
|
368
|
-
entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
|
|
369
|
-
issuer,
|
|
370
|
-
fingerprint: entity_statement_fingerprint
|
|
371
|
-
)
|
|
372
|
-
# fetch_from_issuer! returns EntityStatement instance, extract JWT string from it
|
|
373
|
-
entity_statement_content = entity_statement_instance.entity_statement
|
|
374
|
-
rescue => e
|
|
375
|
-
OmniauthOpenidFederation::Logger.warn("[AccessToken] Failed to fetch entity statement from issuer: #{e.message}")
|
|
376
|
-
end
|
|
377
|
-
end
|
|
378
|
-
|
|
379
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
|
|
380
|
-
OmniauthOpenidFederation::Logger.warn("[AccessToken] Entity statement not available for federation")
|
|
381
|
-
return nil
|
|
382
|
-
end
|
|
383
|
-
|
|
384
|
-
begin
|
|
385
|
-
# Parse entity statement to extract keys
|
|
386
|
-
# entity_statement_content is now always a string (JWT)
|
|
387
|
-
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
388
|
-
parsed = entity_statement.parse
|
|
389
|
-
entity_jwks = parsed[:jwks] if parsed
|
|
390
|
-
|
|
391
|
-
# Extract keys from entity JWKS
|
|
392
|
-
keys = if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
|
|
393
|
-
entity_jwks["keys"]
|
|
394
|
-
elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
|
|
395
|
-
entity_jwks[:keys]
|
|
396
|
-
elsif entity_jwks.is_a?(Array)
|
|
397
|
-
entity_jwks
|
|
398
|
-
else
|
|
399
|
-
[]
|
|
400
|
-
end
|
|
401
|
-
if keys.empty?
|
|
402
|
-
OmniauthOpenidFederation::Logger.warn("[AccessToken] No keys found in entity statement")
|
|
403
|
-
return nil
|
|
404
|
-
end
|
|
405
|
-
|
|
406
|
-
# Convert to format expected by JWT gem (HashWithIndifferentAccess with keys array)
|
|
407
|
-
jwks_hash = {
|
|
408
|
-
keys: keys.map { |jwk| jwk.is_a?(Hash) ? jwk : JSON.parse(jwk.to_json) }
|
|
409
|
-
}
|
|
410
|
-
OmniauthOpenidFederation::Utils.to_indifferent_hash(jwks_hash)
|
|
411
|
-
rescue => e
|
|
412
|
-
error_msg = "Failed to load entity statement keys for JWKS validation: #{e.class} - #{e.message}"
|
|
413
|
-
OmniauthOpenidFederation::Logger.error("[AccessToken] #{error_msg}")
|
|
414
|
-
# Return nil to allow fallback, but log the error
|
|
415
|
-
nil
|
|
416
|
-
end
|
|
417
|
-
end
|
|
418
|
-
|
|
419
|
-
# Resolve JWKS URI from entity statement if not in client_options
|
|
420
|
-
#
|
|
421
|
-
# @param strategy_options [Hash] Strategy options hash
|
|
422
|
-
# @return [String, nil] JWKS URI or nil if not available
|
|
423
|
-
def resolve_jwks_uri_from_entity_statement(strategy_options)
|
|
424
|
-
# Try both symbol and string keys (OmniAuth options can be either)
|
|
425
|
-
entity_statement_path = strategy_options[:entity_statement_path] || strategy_options["entity_statement_path"]
|
|
426
|
-
entity_statement_url = strategy_options[:entity_statement_url] || strategy_options["entity_statement_url"]
|
|
427
|
-
issuer = strategy_options[:issuer] || strategy_options["issuer"]
|
|
428
|
-
entity_statement_fingerprint = strategy_options[:entity_statement_fingerprint] || strategy_options["entity_statement_fingerprint"]
|
|
429
|
-
|
|
430
|
-
# Debug logging to help diagnose issues
|
|
431
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_path) &&
|
|
432
|
-
OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_url) &&
|
|
433
|
-
OmniauthOpenidFederation::StringHelpers.blank?(issuer)
|
|
434
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] No entity statement source configured (path, URL, or issuer) in strategy options. Available keys: #{strategy_options.keys.join(", ")}")
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
# Load entity statement from path, URL, or issuer
|
|
438
|
-
entity_statement_content = nil
|
|
439
|
-
|
|
440
|
-
# Priority 1: File path (if provided)
|
|
441
|
-
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
|
|
442
|
-
begin
|
|
443
|
-
validated_path = OmniauthOpenidFederation::Utils.validate_file_path!(
|
|
444
|
-
entity_statement_path,
|
|
445
|
-
allowed_dirs: defined?(Rails) ? [Rails.root.join("config").to_s] : nil
|
|
446
|
-
)
|
|
447
|
-
if File.exist?(validated_path)
|
|
448
|
-
entity_statement_content = File.read(validated_path)
|
|
449
|
-
end
|
|
450
|
-
rescue SecurityError => e
|
|
451
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not load entity statement from path: #{e.message}")
|
|
452
|
-
end
|
|
453
|
-
end
|
|
454
|
-
|
|
455
|
-
# Priority 2: Fetch from URL (if provided)
|
|
456
|
-
if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(entity_statement_url)
|
|
457
|
-
begin
|
|
458
|
-
entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
|
|
459
|
-
entity_statement_url,
|
|
460
|
-
fingerprint: entity_statement_fingerprint
|
|
461
|
-
)
|
|
462
|
-
# fetch! returns EntityStatement instance, extract JWT string from it
|
|
463
|
-
entity_statement_content = entity_statement_instance.entity_statement
|
|
464
|
-
rescue => e
|
|
465
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not fetch entity statement from URL: #{e.message}")
|
|
466
|
-
end
|
|
467
|
-
end
|
|
468
|
-
|
|
469
|
-
# Priority 3: Fetch from issuer (if provided)
|
|
470
|
-
if entity_statement_content.nil? && OmniauthOpenidFederation::StringHelpers.present?(issuer)
|
|
471
|
-
begin
|
|
472
|
-
entity_statement_instance = OmniauthOpenidFederation::Federation::EntityStatement.fetch_from_issuer!(
|
|
473
|
-
issuer,
|
|
474
|
-
fingerprint: entity_statement_fingerprint
|
|
475
|
-
)
|
|
476
|
-
# fetch_from_issuer! returns EntityStatement instance, extract JWT string from it
|
|
477
|
-
entity_statement_content = entity_statement_instance.entity_statement
|
|
478
|
-
rescue => e
|
|
479
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not fetch entity statement from issuer: #{e.message}")
|
|
480
|
-
end
|
|
481
|
-
end
|
|
482
|
-
|
|
483
|
-
return nil if OmniauthOpenidFederation::StringHelpers.blank?(entity_statement_content)
|
|
484
|
-
|
|
485
|
-
begin
|
|
486
|
-
# Parse entity statement to extract JWKS URI
|
|
487
|
-
# entity_statement_content is now always a string (JWT)
|
|
488
|
-
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
489
|
-
parsed = entity_statement.parse
|
|
490
|
-
return nil unless parsed
|
|
491
|
-
|
|
492
|
-
# Extract JWKS URI from provider metadata
|
|
493
|
-
jwks_uri = parsed.dig(:metadata, :openid_provider, :jwks_uri) ||
|
|
494
|
-
parsed.dig("metadata", "openid_provider", "jwks_uri")
|
|
495
|
-
|
|
496
|
-
return jwks_uri if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
|
|
497
|
-
rescue => e
|
|
498
|
-
OmniauthOpenidFederation::Logger.debug("[AccessToken] Could not extract JWKS URI from entity statement: #{e.message}")
|
|
499
|
-
end
|
|
500
|
-
|
|
501
|
-
nil
|
|
502
|
-
end
|
|
503
144
|
end
|
|
504
145
|
end
|
|
@@ -20,6 +20,22 @@ module OmniauthOpenidFederation
|
|
|
20
20
|
"omniauth_openid_federation:signed_jwks:#{Digest::SHA256.hexdigest(signed_jwks_uri)}"
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
# Generate cache key for locally served JWKS (federation endpoint)
|
|
24
|
+
#
|
|
25
|
+
# @param issuer [String] Federation endpoint issuer entity identifier
|
|
26
|
+
# @return [String] Cache key
|
|
27
|
+
def self.key_for_served_jwks(issuer)
|
|
28
|
+
"federation:jwks:#{Digest::SHA256.hexdigest(issuer)}"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Generate cache key for locally served signed JWKS (federation endpoint)
|
|
32
|
+
#
|
|
33
|
+
# @param issuer [String] Federation endpoint issuer entity identifier
|
|
34
|
+
# @return [String] Cache key
|
|
35
|
+
def self.key_for_served_signed_jwks(issuer)
|
|
36
|
+
"federation:signed_jwks:#{Digest::SHA256.hexdigest(issuer)}"
|
|
37
|
+
end
|
|
38
|
+
|
|
23
39
|
# Delete JWKS cache
|
|
24
40
|
#
|
|
25
41
|
# @param jwks_uri [String] The JWKS URI
|
|
@@ -116,9 +116,12 @@ module OmniauthOpenidFederation
|
|
|
116
116
|
return config.cache_adapter
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
-
# Try Rails cache
|
|
120
|
-
if defined?(Rails) && Rails.respond_to?(:cache)
|
|
121
|
-
|
|
119
|
+
# Try Rails cache when a booted app exposes a real store
|
|
120
|
+
if defined?(Rails) && Rails.respond_to?(:cache)
|
|
121
|
+
cache = Rails.cache
|
|
122
|
+
if cache && cache.respond_to?(:read)
|
|
123
|
+
return RailsCacheAdapter.new(cache)
|
|
124
|
+
end
|
|
122
125
|
end
|
|
123
126
|
|
|
124
127
|
# Try ActiveSupport::Cache if available (without Rails)
|
|
@@ -14,5 +14,8 @@ module OmniauthOpenidFederation
|
|
|
14
14
|
# Prevents DoS attacks while allowing legitimate use cases (e.g., encrypted JWT authorization codes)
|
|
15
15
|
# Use Configuration.config.max_string_length for runtime configuration instead of patching this constant
|
|
16
16
|
MAX_STRING_LENGTH = 8192
|
|
17
|
+
|
|
18
|
+
# Minimum RSA modulus size for OIDC signing and encryption keys (FTN and common federation profiles)
|
|
19
|
+
MIN_RSA_KEY_BITS = 2048
|
|
17
20
|
end
|
|
18
21
|
end
|