omniauth_openid_federation 1.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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.md +22 -0
- data/README.md +822 -0
- data/SECURITY.md +129 -0
- data/examples/README_INTEGRATION_TESTING.md +399 -0
- data/examples/README_MOCK_OP.md +243 -0
- data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
- data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
- data/examples/app/models/user.rb.example +39 -0
- data/examples/config/initializers/devise.rb.example +97 -0
- data/examples/config/initializers/federation_endpoint.rb.example +206 -0
- data/examples/config/mock_op.yml.example +83 -0
- data/examples/config/open_id_connect_config.rb.example +210 -0
- data/examples/config/routes.rb.example +12 -0
- data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
- data/examples/integration_test_flow.rb +1334 -0
- data/examples/jobs/README.md +194 -0
- data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
- data/examples/jobs/federation_files_generation_job.rb.example +87 -0
- data/examples/mock_op_server.rb +775 -0
- data/examples/mock_rp_server.rb +435 -0
- data/lib/omniauth_openid_federation/access_token.rb +504 -0
- data/lib/omniauth_openid_federation/cache.rb +39 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
- data/lib/omniauth_openid_federation/configuration.rb +135 -0
- data/lib/omniauth_openid_federation/constants.rb +13 -0
- data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
- data/lib/omniauth_openid_federation/errors.rb +52 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
- data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
- data/lib/omniauth_openid_federation/http_client.rb +70 -0
- data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
- data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
- data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
- data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
- data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
- data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
- data/lib/omniauth_openid_federation/jws.rb +416 -0
- data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
- data/lib/omniauth_openid_federation/logger.rb +99 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
- data/lib/omniauth_openid_federation/railtie.rb +29 -0
- data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
- data/lib/omniauth_openid_federation/strategy.rb +2029 -0
- data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
- data/lib/omniauth_openid_federation/utils.rb +166 -0
- data/lib/omniauth_openid_federation/validators.rb +126 -0
- data/lib/omniauth_openid_federation/version.rb +3 -0
- data/lib/omniauth_openid_federation.rb +98 -0
- data/lib/tasks/omniauth_openid_federation.rake +376 -0
- data/sig/federation.rbs +218 -0
- data/sig/jwks.rbs +63 -0
- data/sig/omniauth_openid_federation.rbs +254 -0
- data/sig/strategy.rbs +60 -0
- metadata +352 -0
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
require "jwt"
|
|
2
|
+
require "jwe"
|
|
3
|
+
require "json"
|
|
4
|
+
require "uri"
|
|
5
|
+
require_relative "string_helpers"
|
|
6
|
+
require_relative "logger"
|
|
7
|
+
require_relative "validators"
|
|
8
|
+
require_relative "utils"
|
|
9
|
+
require_relative "key_extractor"
|
|
10
|
+
|
|
11
|
+
# AccessToken extension for OpenID Federation ID token decryption and signed JWKS support
|
|
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
|
|
22
|
+
class AccessToken
|
|
23
|
+
def resource_request
|
|
24
|
+
res = yield
|
|
25
|
+
status_code = if res.status.is_a?(Integer)
|
|
26
|
+
res.status
|
|
27
|
+
else
|
|
28
|
+
(res.status.respond_to?(:code) ? res.status.code : res.status)
|
|
29
|
+
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
|
+
|
|
37
|
+
if is_encrypted
|
|
38
|
+
# Decrypt if encrypted (ID token or userinfo encryption)
|
|
39
|
+
# Use encryption key from JWKS if available, fallback to private_key
|
|
40
|
+
encryption_key = extract_encryption_key_for_decryption
|
|
41
|
+
# Decrypt using JWE gem
|
|
42
|
+
plain_text = JWE.decrypt(res.body, encryption_key)
|
|
43
|
+
|
|
44
|
+
# Check if plain_text is a JWT (3 parts) or JSON payload
|
|
45
|
+
# For nested JWTs: encrypted JWT contains signed JWT as plaintext
|
|
46
|
+
# For direct encryption: encrypted JWT may contain JWT representation of payload
|
|
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
|
|
63
|
+
|
|
64
|
+
# Try to get options from strategy configuration
|
|
65
|
+
strategy_options = get_strategy_options
|
|
66
|
+
|
|
67
|
+
# Access client_options from the options hash and normalize keys
|
|
68
|
+
raw_client_options = strategy_options[:client_options] || strategy_options["client_options"] || {}
|
|
69
|
+
client_options = OmniauthOpenidFederation::Validators.normalize_hash(raw_client_options)
|
|
70
|
+
|
|
71
|
+
# Get jwks_uri from client_options or fallback to client
|
|
72
|
+
jwks_uri_value = client_options[:jwks_uri] || ((respond_to?(:client) && client&.respond_to?(:jwks_uri)) ? client.jwks_uri : nil)
|
|
73
|
+
|
|
74
|
+
jwks_uri =
|
|
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
|
|
86
|
+
|
|
87
|
+
# Always use federation features (signed JWKS preferred, fallback to standard JWKS)
|
|
88
|
+
normalized_strategy_options = OmniauthOpenidFederation::Validators.normalize_hash(strategy_options)
|
|
89
|
+
|
|
90
|
+
# Check if JWT is signed or unsigned
|
|
91
|
+
# Decode header to check algorithm
|
|
92
|
+
begin
|
|
93
|
+
header_part = signed_jwt.split(".").first
|
|
94
|
+
header = JSON.parse(Base64.urlsafe_decode64(header_part))
|
|
95
|
+
algorithm = header["alg"] || header[:alg]
|
|
96
|
+
|
|
97
|
+
if algorithm == "none" || algorithm.nil?
|
|
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
|
|
123
|
+
|
|
124
|
+
unless jwks_uri
|
|
125
|
+
error_msg = "JWKS URI not available. Cannot verify JWT signature. Provide jwks_uri in client_options or entity statement."
|
|
126
|
+
OmniauthOpenidFederation::Logger.error("[AccessToken] #{error_msg}")
|
|
127
|
+
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
entity_statement_keys = load_entity_statement_keys_for_jwks_validation(normalized_strategy_options)
|
|
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
|
|
143
|
+
|
|
144
|
+
jwt.first.with_indifferent_access
|
|
145
|
+
else
|
|
146
|
+
JSON.parse(res.body).with_indifferent_access
|
|
147
|
+
end
|
|
148
|
+
when 400
|
|
149
|
+
raise BadRequest.new("API Access Faild", res)
|
|
150
|
+
when 401
|
|
151
|
+
raise Unauthorized.new("Access Token Invalid or Expired", res)
|
|
152
|
+
when 403
|
|
153
|
+
raise Forbidden.new("Insufficient Scope", res)
|
|
154
|
+
else
|
|
155
|
+
raise HttpError.new(res.status, "Unknown HttpError", res)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
private
|
|
160
|
+
|
|
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
|
+
def get_strategy_options
|
|
166
|
+
# Try to get strategy options stored on client by the strategy
|
|
167
|
+
if respond_to?(:client) && client
|
|
168
|
+
strategy_options = client.instance_variable_get(:@strategy_options)
|
|
169
|
+
return strategy_options if strategy_options&.is_a?(Hash)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Fallback: try to extract from client attributes if available
|
|
173
|
+
if respond_to?(:client) && client
|
|
174
|
+
# Build minimal options from client
|
|
175
|
+
client_options = {}
|
|
176
|
+
client_options[:jwks_uri] = client.jwks_uri.to_s if client.respond_to?(:jwks_uri) && client.jwks_uri
|
|
177
|
+
client_options[:private_key] = client.private_key if client.respond_to?(:private_key) && client.private_key
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
client_options: client_options
|
|
181
|
+
}
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# Last resort: return empty hash (will cause errors later, but at least won't crash immediately)
|
|
185
|
+
OmniauthOpenidFederation::Logger.warn("[AccessToken] Could not determine strategy options from client. Some features may not work correctly.")
|
|
186
|
+
{}
|
|
187
|
+
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
|
+
end
|
|
504
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require_relative "cache_adapter"
|
|
3
|
+
|
|
4
|
+
# Cache utilities for JWKS caching
|
|
5
|
+
module OmniauthOpenidFederation
|
|
6
|
+
module Cache
|
|
7
|
+
# Generate cache key for JWKS
|
|
8
|
+
#
|
|
9
|
+
# @param jwks_uri [String] The JWKS URI
|
|
10
|
+
# @return [String] Cache key
|
|
11
|
+
def self.key_for_jwks(jwks_uri)
|
|
12
|
+
"omniauth_openid_federation:jwks:#{Digest::SHA256.hexdigest(jwks_uri)}"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Generate cache key for signed JWKS
|
|
16
|
+
#
|
|
17
|
+
# @param signed_jwks_uri [String] The signed JWKS URI
|
|
18
|
+
# @return [String] Cache key
|
|
19
|
+
def self.key_for_signed_jwks(signed_jwks_uri)
|
|
20
|
+
"omniauth_openid_federation:signed_jwks:#{Digest::SHA256.hexdigest(signed_jwks_uri)}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Delete JWKS cache
|
|
24
|
+
#
|
|
25
|
+
# @param jwks_uri [String] The JWKS URI
|
|
26
|
+
def self.delete_jwks(jwks_uri)
|
|
27
|
+
return unless CacheAdapter.available?
|
|
28
|
+
CacheAdapter.delete(key_for_jwks(jwks_uri))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Delete signed JWKS cache
|
|
32
|
+
#
|
|
33
|
+
# @param signed_jwks_uri [String] The signed JWKS URI
|
|
34
|
+
def self.delete_signed_jwks(signed_jwks_uri)
|
|
35
|
+
return unless CacheAdapter.available?
|
|
36
|
+
CacheAdapter.delete(key_for_signed_jwks(signed_jwks_uri))
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|