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,10 +1,8 @@
|
|
|
1
1
|
require "omniauth-oauth2"
|
|
2
|
-
require "openid_connect"
|
|
3
2
|
require "jwt"
|
|
4
|
-
require "jwe"
|
|
5
3
|
require "base64"
|
|
6
4
|
require "securerandom"
|
|
7
|
-
|
|
5
|
+
require_relative "secure_compare"
|
|
8
6
|
require "tempfile"
|
|
9
7
|
require "digest"
|
|
10
8
|
require_relative "string_helpers"
|
|
@@ -19,6 +17,16 @@ require_relative "jwks/fetch"
|
|
|
19
17
|
require_relative "endpoint_resolver"
|
|
20
18
|
require_relative "federation/trust_chain_resolver"
|
|
21
19
|
require_relative "federation/metadata_policy_merger"
|
|
20
|
+
require_relative "strategy/id_token_decoding"
|
|
21
|
+
require_relative "strategy/userinfo_decoding"
|
|
22
|
+
require_relative "strategy/provider_entity_statement"
|
|
23
|
+
require_relative "strategy/endpoint_resolution"
|
|
24
|
+
require_relative "strategy/jwks_resolution"
|
|
25
|
+
require_relative "strategy/client_entity_statement"
|
|
26
|
+
require_relative "strategy/client_construction"
|
|
27
|
+
require_relative "strategy/authorization_request"
|
|
28
|
+
require_relative "strategy/callback_handling"
|
|
29
|
+
require_relative "strategy/failure_handling"
|
|
22
30
|
|
|
23
31
|
# OpenID Federation strategy for OAuth 2.0 / OpenID Connect providers
|
|
24
32
|
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
@@ -44,17 +52,26 @@ require_relative "federation/metadata_policy_merger"
|
|
|
44
52
|
# - Trust marks (Section 7) - Optional feature (parsed but not validated)
|
|
45
53
|
# - Federation endpoints (Section 8) - Server-side feature (Fetch Endpoint implemented separately)
|
|
46
54
|
#
|
|
47
|
-
# This strategy uses
|
|
55
|
+
# This strategy uses OmniauthOpenidFederation::OidcClient and extends OAuth2 with federation-specific features.
|
|
48
56
|
module OmniAuth
|
|
49
57
|
module Strategies
|
|
50
58
|
class OpenIDFederation < OmniAuth::Strategies::OAuth2
|
|
59
|
+
include OmniauthOpenidFederation::Strategy::ClientConstruction
|
|
60
|
+
include OmniauthOpenidFederation::Strategy::AuthorizationRequest
|
|
61
|
+
include OmniauthOpenidFederation::Strategy::CallbackHandling
|
|
62
|
+
include OmniauthOpenidFederation::Strategy::FailureHandling
|
|
63
|
+
include OmniauthOpenidFederation::Strategy::ProviderEntityStatement
|
|
64
|
+
include OmniauthOpenidFederation::Strategy::EndpointResolution
|
|
65
|
+
include OmniauthOpenidFederation::Strategy::JwksResolution
|
|
66
|
+
include OmniauthOpenidFederation::Strategy::ClientEntityStatement
|
|
67
|
+
include OmniauthOpenidFederation::Strategy::IdTokenDecoding
|
|
68
|
+
include OmniauthOpenidFederation::Strategy::UserinfoDecoding
|
|
69
|
+
|
|
51
70
|
# Override the name option from the base class
|
|
52
71
|
option :name, "openid_federation"
|
|
53
72
|
|
|
54
73
|
# Constants for token format validation
|
|
55
74
|
JWT_PARTS_COUNT = 3 # Standard JWT has 3 parts: header.payload.signature
|
|
56
|
-
JWE_PARTS_COUNT = 5 # Encrypted JWT (JWE) has 5 parts
|
|
57
|
-
|
|
58
75
|
# Constants for random value generation
|
|
59
76
|
STATE_BYTES = 32 # Number of hex bytes for state parameter (CSRF protection)
|
|
60
77
|
NONCE_BYTES = 32 # Number of hex bytes for nonce parameter (replay protection)
|
|
@@ -85,289 +102,16 @@ module OmniAuth
|
|
|
85
102
|
option :enable_trust_chain_resolution, true # Enable trust chain resolution when issuer/client_id is an Entity ID
|
|
86
103
|
option :request_object_params, nil # Array of parameter names to include in signed request object from request.params (allow-list)
|
|
87
104
|
option :prepare_request_object_params, nil # Proc to modify params before adding to signed request object: proc { |params| modified_params }
|
|
88
|
-
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
|
|
92
|
-
# This method is called when the option is accessed, ensuring automatic extraction
|
|
93
|
-
def client_jwk_signing_key
|
|
94
|
-
# Return manually configured value if present (allows override)
|
|
95
|
-
configured_value = options.client_jwk_signing_key
|
|
96
|
-
return configured_value if OmniauthOpenidFederation::StringHelpers.present?(configured_value)
|
|
97
|
-
|
|
98
|
-
# Automatically extract from client entity statement if available
|
|
99
|
-
extracted_value = extract_client_jwk_signing_key
|
|
100
|
-
return extracted_value if OmniauthOpenidFederation::StringHelpers.present?(extracted_value)
|
|
101
|
-
|
|
102
|
-
# Return nil if not available (allows fallback to other authentication methods)
|
|
103
|
-
nil
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
# Override options accessor to ensure client_jwk_signing_key is dynamically extracted
|
|
107
|
-
# This ensures the underlying openid_connect gem gets the extracted value when accessing options.client_jwk_signing_key
|
|
108
|
-
def options
|
|
109
|
-
opts = super
|
|
110
|
-
# Dynamically set client_jwk_signing_key if not already set and we can extract it
|
|
111
|
-
if opts[:client_jwk_signing_key].nil? && (opts[:client_entity_statement_path] || opts[:client_entity_statement_url])
|
|
112
|
-
extracted = extract_client_jwk_signing_key
|
|
113
|
-
opts[:client_jwk_signing_key] = extracted if OmniauthOpenidFederation::StringHelpers.present?(extracted)
|
|
114
|
-
end
|
|
115
|
-
opts
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def client
|
|
119
|
-
@client ||= begin
|
|
120
|
-
client_options_hash = options.client_options || {}
|
|
121
|
-
|
|
122
|
-
# Automatically resolve endpoints, issuer, scheme, and host from entity statement metadata if available
|
|
123
|
-
# This allows endpoints and issuer to be discovered from entity statement without manual configuration
|
|
124
|
-
# client_options still takes precedence for overrides
|
|
125
|
-
resolved_endpoints = resolve_endpoints_from_metadata(client_options_hash)
|
|
126
|
-
|
|
127
|
-
# Merge resolved endpoints with client_options (client_options takes precedence)
|
|
128
|
-
# resolved_endpoints may contain: endpoints, issuer, scheme, host
|
|
129
|
-
# client_options will override any resolved values
|
|
130
|
-
merged_options = resolved_endpoints.merge(client_options_hash)
|
|
131
|
-
|
|
132
|
-
# Build base URL from scheme, host, and port
|
|
133
|
-
base_url = build_base_url(merged_options)
|
|
134
|
-
|
|
135
|
-
# For automatic registration, identifier is the entity identifier (determined at request time)
|
|
136
|
-
# For explicit registration, identifier comes from client_options
|
|
137
|
-
# Note: For automatic registration, the actual entity identifier will be extracted
|
|
138
|
-
# in authorize_uri and used in the request object. The client identifier here is
|
|
139
|
-
# used for client assertion at the token endpoint, which should also use the entity identifier.
|
|
140
|
-
# However, since the client is cached, we'll handle this in authorize_uri by updating
|
|
141
|
-
# the client's identifier if needed.
|
|
142
|
-
client_identifier = merged_options[:identifier] || merged_options["identifier"]
|
|
143
|
-
|
|
144
|
-
# Create OpenID Connect client (extends OAuth2::Client, so compatible with OmniAuth::Strategies::OAuth2)
|
|
145
|
-
# Build endpoints - use resolved values or nil if not available
|
|
146
|
-
auth_endpoint = build_endpoint(base_url, merged_options[:authorization_endpoint] || merged_options["authorization_endpoint"])
|
|
147
|
-
token_endpoint = build_endpoint(base_url, merged_options[:token_endpoint] || merged_options["token_endpoint"])
|
|
148
|
-
userinfo_endpoint = build_endpoint(base_url, merged_options[:userinfo_endpoint] || merged_options["userinfo_endpoint"])
|
|
149
|
-
jwks_uri_endpoint = build_endpoint(base_url, merged_options[:jwks_uri] || merged_options["jwks_uri"])
|
|
150
|
-
|
|
151
|
-
# Validate that at least authorization_endpoint is present (required)
|
|
152
|
-
unless OmniauthOpenidFederation::StringHelpers.present?(auth_endpoint)
|
|
153
|
-
error_msg = "Authorization endpoint not configured. Provide authorization_endpoint in client_options or entity statement"
|
|
154
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
155
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
oidc_client = ::OpenIDConnect::Client.new(
|
|
159
|
-
identifier: client_identifier,
|
|
160
|
-
secret: nil, # We use private_key_jwt, so no secret needed
|
|
161
|
-
redirect_uri: merged_options[:redirect_uri] || merged_options["redirect_uri"],
|
|
162
|
-
authorization_endpoint: auth_endpoint,
|
|
163
|
-
token_endpoint: token_endpoint,
|
|
164
|
-
userinfo_endpoint: userinfo_endpoint,
|
|
165
|
-
jwks_uri: jwks_uri_endpoint
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
# Store private key for client assertion (private_key_jwt authentication)
|
|
169
|
-
oidc_client.private_key = merged_options[:private_key] || merged_options["private_key"]
|
|
170
|
-
|
|
171
|
-
# Store strategy options on client for AccessToken to access later
|
|
172
|
-
# This allows AccessToken to get configuration without relying on Devise
|
|
173
|
-
# Ensure all entity statement options are included (to_h might not include all options)
|
|
174
|
-
strategy_options_hash = options.to_h.dup
|
|
175
|
-
# Explicitly include entity statement options that AccessToken needs
|
|
176
|
-
strategy_options_hash[:entity_statement_path] = options.entity_statement_path if options.entity_statement_path
|
|
177
|
-
strategy_options_hash[:entity_statement_url] = options.entity_statement_url if options.entity_statement_url
|
|
178
|
-
strategy_options_hash[:entity_statement_fingerprint] = options.entity_statement_fingerprint if options.entity_statement_fingerprint
|
|
179
|
-
strategy_options_hash[:issuer] = options.issuer if options.issuer
|
|
180
|
-
oidc_client.instance_variable_set(:@strategy_options, strategy_options_hash)
|
|
181
|
-
|
|
182
|
-
# OpenIDConnect::Client extends OAuth2::Client, so it's compatible with OmniAuth::Strategies::OAuth2
|
|
183
|
-
oidc_client
|
|
184
|
-
end
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
# Store reference to OpenID Connect client for ID token operations
|
|
188
|
-
def oidc_client
|
|
189
|
-
client
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
# Override fail! to instrument all authentication failures
|
|
193
|
-
# This catches failures from OmniAuth middleware (like AuthenticityTokenProtection)
|
|
194
|
-
# as well as failures from within the strategy
|
|
195
|
-
#
|
|
196
|
-
# @param error_type [Symbol] Error type identifier
|
|
197
|
-
# @param exception [Exception] Exception object
|
|
198
|
-
# @return [void]
|
|
199
|
-
def fail!(error_type, exception = nil)
|
|
200
|
-
# Determine if this error has already been instrumented
|
|
201
|
-
# Errors instrumented before calling fail! will have a flag set
|
|
202
|
-
already_instrumented = env["omniauth_openid_federation.instrumented"] == true
|
|
203
|
-
|
|
204
|
-
unless already_instrumented
|
|
205
|
-
# Extract error information
|
|
206
|
-
error_message = exception&.message || error_type.to_s
|
|
207
|
-
error_class = exception&.class&.name || "UnknownError"
|
|
208
|
-
|
|
209
|
-
# Determine the phase (request or callback)
|
|
210
|
-
phase = request.path.end_with?("/callback") ? "callback_phase" : "request_phase"
|
|
211
|
-
|
|
212
|
-
# Build request info
|
|
213
|
-
request_info = {
|
|
214
|
-
remote_ip: request.env["REMOTE_ADDR"],
|
|
215
|
-
user_agent: request.env["HTTP_USER_AGENT"],
|
|
216
|
-
path: request.path,
|
|
217
|
-
method: request.request_method
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
# Instrument based on error type
|
|
221
|
-
case error_type.to_sym
|
|
222
|
-
when :authenticity_error
|
|
223
|
-
# OmniAuth CSRF protection error (from middleware)
|
|
224
|
-
OmniauthOpenidFederation::Instrumentation.notify_authenticity_error(
|
|
225
|
-
error_type: error_type.to_s,
|
|
226
|
-
error_message: error_message,
|
|
227
|
-
error_class: error_class,
|
|
228
|
-
phase: phase,
|
|
229
|
-
request_info: request_info
|
|
230
|
-
)
|
|
231
|
-
when :csrf_detected
|
|
232
|
-
# This should already be instrumented before calling fail!, but instrument here as fallback
|
|
233
|
-
# (e.g., if fail! is called directly without prior instrumentation)
|
|
234
|
-
OmniauthOpenidFederation::Instrumentation.notify_csrf_detected(
|
|
235
|
-
error_type: error_type.to_s,
|
|
236
|
-
error_message: error_message,
|
|
237
|
-
phase: phase,
|
|
238
|
-
request_info: request_info
|
|
239
|
-
)
|
|
240
|
-
when :missing_code, :token_exchange_error
|
|
241
|
-
# These should already be instrumented before calling fail!, but instrument here as fallback
|
|
242
|
-
# (e.g., if fail! is called directly without prior instrumentation)
|
|
243
|
-
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
244
|
-
stage: phase,
|
|
245
|
-
error_message: error_message,
|
|
246
|
-
error_class: error_class,
|
|
247
|
-
error_type: error_type.to_s,
|
|
248
|
-
request_info: request_info
|
|
249
|
-
)
|
|
250
|
-
else
|
|
251
|
-
# Unknown error type - instrument as unexpected authentication break
|
|
252
|
-
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
253
|
-
stage: phase,
|
|
254
|
-
error_message: error_message,
|
|
255
|
-
error_class: error_class,
|
|
256
|
-
error_type: error_type.to_s,
|
|
257
|
-
request_info: request_info
|
|
258
|
-
)
|
|
259
|
-
end
|
|
260
|
-
end
|
|
261
|
-
|
|
262
|
-
# Mark as instrumented to prevent double instrumentation
|
|
263
|
-
env["omniauth_openid_federation.instrumented"] = true
|
|
264
|
-
|
|
265
|
-
# Call parent fail! method
|
|
266
|
-
super
|
|
267
|
-
end
|
|
105
|
+
option :default_request_object_claims, nil # Hash of claim defaults merged before prepare_request_object_params (request params override)
|
|
106
|
+
option :required_request_object_claims, nil # Array of claim names that must be present in the signed request object
|
|
107
|
+
option :allowed_acr_values, nil # When set, ID token acr must match one of these values
|
|
108
|
+
option :require_entity_statement_fingerprint, false # When true, remote entity statement sources require entity_statement_fingerprint
|
|
268
109
|
|
|
269
110
|
# Override request_phase to use signed request objects (RFC 9101)
|
|
270
111
|
def request_phase
|
|
271
112
|
redirect authorize_uri
|
|
272
113
|
end
|
|
273
114
|
|
|
274
|
-
# Override callback_phase to handle token exchange with OpenIDConnect::Client
|
|
275
|
-
def callback_phase
|
|
276
|
-
# Security: Validate user input from HTTP request
|
|
277
|
-
state_param_raw = request.params["state"]
|
|
278
|
-
code_param_raw = request.params["code"]
|
|
279
|
-
error_param_raw = request.params["error"]
|
|
280
|
-
error_description_raw = request.params["error_description"]
|
|
281
|
-
|
|
282
|
-
state_param = state_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(state_param_raw) : nil
|
|
283
|
-
code_param = code_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(code_param_raw) : nil
|
|
284
|
-
error_param = error_param_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(error_param_raw) : nil
|
|
285
|
-
error_description_param = error_description_raw ? OmniauthOpenidFederation::Validators.sanitize_request_param(error_description_raw) : nil
|
|
286
|
-
if error_param
|
|
287
|
-
error_msg = "Authorization error: #{error_param}"
|
|
288
|
-
error_msg += " - #{error_description_param}" if error_description_param
|
|
289
|
-
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
290
|
-
stage: "callback_phase",
|
|
291
|
-
error_message: error_msg,
|
|
292
|
-
error_class: "AuthorizationError",
|
|
293
|
-
request_info: {
|
|
294
|
-
remote_ip: request.env["REMOTE_ADDR"],
|
|
295
|
-
user_agent: request.env["HTTP_USER_AGENT"],
|
|
296
|
-
path: request.path
|
|
297
|
-
}
|
|
298
|
-
)
|
|
299
|
-
env["omniauth_openid_federation.instrumented"] = true
|
|
300
|
-
fail!(:authorization_error, OmniauthOpenidFederation::ValidationError.new(error_msg))
|
|
301
|
-
return
|
|
302
|
-
end
|
|
303
|
-
|
|
304
|
-
# CSRF protection: constant-time state comparison
|
|
305
|
-
state_session = session["omniauth.state"]
|
|
306
|
-
|
|
307
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(state_param) ||
|
|
308
|
-
state_session.nil? ||
|
|
309
|
-
!Rack::Utils.secure_compare(state_param.to_s, state_session.to_s)
|
|
310
|
-
# Instrument CSRF detection
|
|
311
|
-
OmniauthOpenidFederation::Instrumentation.notify_csrf_detected(
|
|
312
|
-
state_param: state_param ? "[PRESENT]" : "[MISSING]",
|
|
313
|
-
state_session: state_session ? "[PRESENT]" : "[MISSING]",
|
|
314
|
-
request_info: {
|
|
315
|
-
remote_ip: request.env["REMOTE_ADDR"],
|
|
316
|
-
user_agent: request.env["HTTP_USER_AGENT"],
|
|
317
|
-
path: request.path
|
|
318
|
-
}
|
|
319
|
-
)
|
|
320
|
-
# Mark as instrumented to prevent double instrumentation in fail!
|
|
321
|
-
env["omniauth_openid_federation.instrumented"] = true
|
|
322
|
-
fail!(:csrf_detected, OmniauthOpenidFederation::SecurityError.new("CSRF detected"))
|
|
323
|
-
return
|
|
324
|
-
end
|
|
325
|
-
|
|
326
|
-
# Clear state from session
|
|
327
|
-
session.delete("omniauth.state")
|
|
328
|
-
|
|
329
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(code_param)
|
|
330
|
-
# Instrument unexpected authentication break
|
|
331
|
-
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
332
|
-
stage: "callback_phase",
|
|
333
|
-
error_message: "Missing authorization code",
|
|
334
|
-
error_class: "ValidationError",
|
|
335
|
-
request_info: {
|
|
336
|
-
remote_ip: request.env["REMOTE_ADDR"],
|
|
337
|
-
user_agent: request.env["HTTP_USER_AGENT"],
|
|
338
|
-
path: request.path
|
|
339
|
-
}
|
|
340
|
-
)
|
|
341
|
-
# Mark as instrumented to prevent double instrumentation in fail!
|
|
342
|
-
env["omniauth_openid_federation.instrumented"] = true
|
|
343
|
-
fail!(:missing_code, OmniauthOpenidFederation::ValidationError.new("Missing authorization code"))
|
|
344
|
-
return
|
|
345
|
-
end
|
|
346
|
-
|
|
347
|
-
begin
|
|
348
|
-
@access_token = exchange_authorization_code(code_param)
|
|
349
|
-
rescue => e
|
|
350
|
-
# Instrument unexpected authentication break
|
|
351
|
-
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
352
|
-
stage: "token_exchange",
|
|
353
|
-
error_message: e.message,
|
|
354
|
-
error_class: e.class.name,
|
|
355
|
-
request_info: {
|
|
356
|
-
remote_ip: request.env["REMOTE_ADDR"],
|
|
357
|
-
user_agent: request.env["HTTP_USER_AGENT"],
|
|
358
|
-
path: request.path
|
|
359
|
-
}
|
|
360
|
-
)
|
|
361
|
-
# Mark as instrumented to prevent double instrumentation in fail!
|
|
362
|
-
env["omniauth_openid_federation.instrumented"] = true
|
|
363
|
-
fail!(:token_exchange_error, e)
|
|
364
|
-
return
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
env["omniauth.auth"] = auth_hash
|
|
368
|
-
call_app!
|
|
369
|
-
end
|
|
370
|
-
|
|
371
115
|
def auth_hash
|
|
372
116
|
OmniAuth::AuthHash.new(
|
|
373
117
|
provider: "openid_federation",
|
|
@@ -383,187 +127,6 @@ module OmniAuth
|
|
|
383
127
|
)
|
|
384
128
|
end
|
|
385
129
|
|
|
386
|
-
def authorize_uri
|
|
387
|
-
request_params = request.params
|
|
388
|
-
|
|
389
|
-
# Security: Only validate user input from HTTP requests, not config values
|
|
390
|
-
# Note: Rack params can return arrays for multi-value parameters
|
|
391
|
-
sanitized_params = {}
|
|
392
|
-
request_params.each do |key, value|
|
|
393
|
-
next unless value
|
|
394
|
-
key_str = key.to_s
|
|
395
|
-
next if key_str.length > 256
|
|
396
|
-
# For arrays (multi-value params), sanitize each element and limit size
|
|
397
|
-
if value.is_a?(Array)
|
|
398
|
-
# Prevent DoS: limit array size
|
|
399
|
-
if value.length > 100
|
|
400
|
-
next
|
|
401
|
-
end
|
|
402
|
-
# Sanitize each element
|
|
403
|
-
sanitized_array = value.map { |v| OmniauthOpenidFederation::Validators.sanitize_request_param(v) }.compact
|
|
404
|
-
next if sanitized_array.empty?
|
|
405
|
-
# Keep as array for acr_values (handled by normalize_acr_values)
|
|
406
|
-
# Convert to space-separated string for other parameters (ui_locales, claims_locales)
|
|
407
|
-
sanitized_params[key_str] = if key_str == "acr_values"
|
|
408
|
-
sanitized_array
|
|
409
|
-
else
|
|
410
|
-
sanitized_array.join(" ")
|
|
411
|
-
end
|
|
412
|
-
else
|
|
413
|
-
sanitized = OmniauthOpenidFederation::Validators.sanitize_request_param(value)
|
|
414
|
-
sanitized_params[key_str] = sanitized if sanitized
|
|
415
|
-
end
|
|
416
|
-
end
|
|
417
|
-
request_params = sanitized_params
|
|
418
|
-
|
|
419
|
-
# Apply custom proc to modify params before adding to signed request object
|
|
420
|
-
if options.prepare_request_object_params.respond_to?(:call)
|
|
421
|
-
request_params = options.prepare_request_object_params.call(request_params.dup) || request_params
|
|
422
|
-
request_params = {} unless request_params.is_a?(Hash)
|
|
423
|
-
end
|
|
424
|
-
|
|
425
|
-
# Enforce signed request objects (RFC 9101) - unsigned requests are not allowed
|
|
426
|
-
client_options_hash = options.client_options || {}
|
|
427
|
-
normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
|
|
428
|
-
private_key = normalized_options[:private_key]
|
|
429
|
-
OmniauthOpenidFederation::Validators.validate_private_key!(private_key)
|
|
430
|
-
|
|
431
|
-
resolved_issuer = options.issuer
|
|
432
|
-
unless OmniauthOpenidFederation::StringHelpers.present?(resolved_issuer)
|
|
433
|
-
resolved_issuer = resolve_issuer_from_metadata
|
|
434
|
-
options.issuer = resolved_issuer if resolved_issuer
|
|
435
|
-
end
|
|
436
|
-
|
|
437
|
-
audience_value = resolve_audience(client_options_hash, resolved_issuer)
|
|
438
|
-
|
|
439
|
-
unless OmniauthOpenidFederation::StringHelpers.present?(audience_value)
|
|
440
|
-
error_msg = "Audience is required for signed request objects. " \
|
|
441
|
-
"Set audience option, provide entity statement with provider issuer, or configure token_endpoint"
|
|
442
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
443
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
444
|
-
end
|
|
445
|
-
|
|
446
|
-
state_value = new_state
|
|
447
|
-
nonce_value = options.send_nonce ? new_nonce : nil
|
|
448
|
-
configured_redirect_uri = normalized_options[:redirect_uri] || callback_url
|
|
449
|
-
|
|
450
|
-
# Automatic registration uses entity identifier as client_id (OpenID Federation Section 12.1)
|
|
451
|
-
client_registration_type = options.client_registration_type || :explicit
|
|
452
|
-
client_id_for_request = normalized_options[:identifier]
|
|
453
|
-
client_entity_statement = nil
|
|
454
|
-
|
|
455
|
-
if client_registration_type == :automatic
|
|
456
|
-
client_entity_statement = load_client_entity_statement(
|
|
457
|
-
options.client_entity_statement_path,
|
|
458
|
-
options.client_entity_statement_url
|
|
459
|
-
)
|
|
460
|
-
entity_identifier = extract_entity_identifier_from_statement(client_entity_statement, options.client_entity_identifier)
|
|
461
|
-
unless OmniauthOpenidFederation::StringHelpers.present?(entity_identifier)
|
|
462
|
-
error_msg = "Failed to extract entity identifier from client entity statement. " \
|
|
463
|
-
"Set client_entity_identifier option or ensure entity statement has 'sub' claim"
|
|
464
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
465
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
466
|
-
end
|
|
467
|
-
client_id_for_request = entity_identifier
|
|
468
|
-
if client.respond_to?(:identifier=)
|
|
469
|
-
client.identifier = entity_identifier
|
|
470
|
-
elsif client.respond_to?(:client_id=)
|
|
471
|
-
client.client_id = entity_identifier
|
|
472
|
-
end
|
|
473
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using automatic registration with entity identifier: #{entity_identifier}")
|
|
474
|
-
end
|
|
475
|
-
|
|
476
|
-
signing_key_source = options.signing_key_source || options.key_source || :local
|
|
477
|
-
jwks = normalized_options[:jwks] || normalized_options["jwks"]
|
|
478
|
-
|
|
479
|
-
# Extract already-sanitized user input params (sanitized above)
|
|
480
|
-
validated_state = state_value.to_s.strip
|
|
481
|
-
validated_nonce = nonce_value&.to_s&.strip
|
|
482
|
-
validated_login_hint = request_params["login_hint"]
|
|
483
|
-
validated_ui_locales = request_params["ui_locales"]
|
|
484
|
-
validated_claims_locales = request_params["claims_locales"]
|
|
485
|
-
|
|
486
|
-
# Config values are trusted (no sanitization needed)
|
|
487
|
-
validated_client_id = client_id_for_request.to_s.strip
|
|
488
|
-
validated_redirect_uri = configured_redirect_uri.to_s.strip
|
|
489
|
-
validated_scope = Array(options.scope).join(" ").strip
|
|
490
|
-
validated_response_type = options.response_type.to_s.strip
|
|
491
|
-
validated_prompt = options.prompt&.to_s&.strip
|
|
492
|
-
validated_hd = options.hd&.to_s&.strip
|
|
493
|
-
validated_response_mode = options.response_mode&.to_s&.strip
|
|
494
|
-
validated_issuer = (resolved_issuer || options.issuer)&.to_s&.strip
|
|
495
|
-
validated_audience = audience_value&.to_s&.strip
|
|
496
|
-
normalized_acr_values = OmniauthOpenidFederation::Validators.normalize_acr_values(request_params["acr_values"], skip_sanitization: true) || nil
|
|
497
|
-
|
|
498
|
-
jws_builder = OmniauthOpenidFederation::Jws.new(
|
|
499
|
-
client_id: validated_client_id,
|
|
500
|
-
redirect_uri: validated_redirect_uri,
|
|
501
|
-
scope: validated_scope,
|
|
502
|
-
issuer: validated_issuer,
|
|
503
|
-
audience: validated_audience,
|
|
504
|
-
state: validated_state,
|
|
505
|
-
nonce: validated_nonce,
|
|
506
|
-
response_type: validated_response_type,
|
|
507
|
-
response_mode: validated_response_mode,
|
|
508
|
-
login_hint: validated_login_hint,
|
|
509
|
-
ui_locales: validated_ui_locales,
|
|
510
|
-
claims_locales: validated_claims_locales,
|
|
511
|
-
prompt: validated_prompt,
|
|
512
|
-
hd: validated_hd,
|
|
513
|
-
acr_values: normalized_acr_values,
|
|
514
|
-
extra_params: options.extra_authorize_params || {},
|
|
515
|
-
private_key: normalized_options[:private_key],
|
|
516
|
-
jwks: jwks,
|
|
517
|
-
entity_statement_path: options.entity_statement_path,
|
|
518
|
-
key_source: signing_key_source,
|
|
519
|
-
client_entity_statement: client_entity_statement
|
|
520
|
-
)
|
|
521
|
-
|
|
522
|
-
# Add dynamic request object params from HTTP request (already sanitized above)
|
|
523
|
-
options.request_object_params&.each do |key|
|
|
524
|
-
key_str = key.to_s
|
|
525
|
-
next if key_str.length > 256
|
|
526
|
-
value = request_params[key_str]
|
|
527
|
-
jws_builder.add_claim(key_str.to_sym, value) if value
|
|
528
|
-
end
|
|
529
|
-
|
|
530
|
-
# RFC 9101: Only 'request' parameter in query, all params in JWT
|
|
531
|
-
provider_metadata = load_provider_metadata_for_encryption
|
|
532
|
-
signed_request_object = jws_builder.sign(
|
|
533
|
-
provider_metadata: provider_metadata,
|
|
534
|
-
always_encrypt: options.always_encrypt_request_object
|
|
535
|
-
)
|
|
536
|
-
unless OmniauthOpenidFederation::StringHelpers.present?(signed_request_object)
|
|
537
|
-
error_msg = "Failed to generate signed request object - authentication cannot proceed without signed request"
|
|
538
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
539
|
-
raise OmniauthOpenidFederation::SecurityError, error_msg
|
|
540
|
-
end
|
|
541
|
-
|
|
542
|
-
# Build URL manually to ensure RFC 9101 compliance (only 'request' param in query)
|
|
543
|
-
auth_endpoint = client.authorization_endpoint
|
|
544
|
-
unless OmniauthOpenidFederation::StringHelpers.present?(auth_endpoint)
|
|
545
|
-
error_msg = "Authorization endpoint not configured. Provide authorization_endpoint in client_options or entity statement"
|
|
546
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
547
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
548
|
-
end
|
|
549
|
-
|
|
550
|
-
begin
|
|
551
|
-
uri = URI.parse(auth_endpoint)
|
|
552
|
-
rescue URI::InvalidURIError => e
|
|
553
|
-
error_msg = "Invalid authorization endpoint URI format: #{e.message}"
|
|
554
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
555
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
556
|
-
end
|
|
557
|
-
|
|
558
|
-
max_string_length = ::OmniauthOpenidFederation::Configuration.config.max_string_length
|
|
559
|
-
if signed_request_object.length > max_string_length
|
|
560
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Request object exceeds maximum length")
|
|
561
|
-
end
|
|
562
|
-
|
|
563
|
-
uri.query = URI.encode_www_form(request: signed_request_object)
|
|
564
|
-
uri.to_s
|
|
565
|
-
end
|
|
566
|
-
|
|
567
130
|
uid do
|
|
568
131
|
raw_info["sub"] || raw_info[:sub]
|
|
569
132
|
end
|
|
@@ -612,1452 +175,6 @@ module OmniAuth
|
|
|
612
175
|
end
|
|
613
176
|
end
|
|
614
177
|
end
|
|
615
|
-
|
|
616
|
-
private
|
|
617
|
-
|
|
618
|
-
# Exchange authorization code for access token
|
|
619
|
-
def exchange_authorization_code(authorization_code)
|
|
620
|
-
client_options_hash = options.client_options || {}
|
|
621
|
-
normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
|
|
622
|
-
configured_redirect_uri = normalized_options[:redirect_uri] || callback_url
|
|
623
|
-
|
|
624
|
-
oidc_client.authorization_code = authorization_code
|
|
625
|
-
oidc_client.redirect_uri = configured_redirect_uri
|
|
626
|
-
|
|
627
|
-
begin
|
|
628
|
-
oidc_client.access_token!(
|
|
629
|
-
options.client_auth_method || :jwt_bearer
|
|
630
|
-
)
|
|
631
|
-
rescue => e
|
|
632
|
-
error_msg = "Failed to exchange authorization code for access token: #{e.class} - #{e.message}"
|
|
633
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
634
|
-
raise OmniauthOpenidFederation::NetworkError, error_msg, e.backtrace
|
|
635
|
-
end
|
|
636
|
-
end
|
|
637
|
-
|
|
638
|
-
# Generate a new state parameter for CSRF protection
|
|
639
|
-
# This method is expected by the base OAuth2 strategy
|
|
640
|
-
def new_state
|
|
641
|
-
# Generate a random state value and store it in the session
|
|
642
|
-
state = SecureRandom.hex(STATE_BYTES)
|
|
643
|
-
session["omniauth.state"] = state
|
|
644
|
-
state
|
|
645
|
-
end
|
|
646
|
-
|
|
647
|
-
# Generate a new nonce for replay attack protection
|
|
648
|
-
def new_nonce
|
|
649
|
-
SecureRandom.hex(NONCE_BYTES)
|
|
650
|
-
end
|
|
651
|
-
|
|
652
|
-
# Resolve endpoints and issuer from entity statement metadata automatically
|
|
653
|
-
# This allows endpoints and issuer to be discovered from entity statement without manual configuration
|
|
654
|
-
# If trust chain resolution is enabled and trust anchors are configured, resolves trust chain
|
|
655
|
-
# and applies metadata policies to get effective metadata.
|
|
656
|
-
# client_options still takes precedence for overrides
|
|
657
|
-
#
|
|
658
|
-
# @param client_options_hash [Hash] Current client options (used to check what's already configured)
|
|
659
|
-
# @return [Hash] Hash with resolved endpoints, issuer, scheme, and host (may be empty if entity statement not available)
|
|
660
|
-
def resolve_endpoints_from_metadata(client_options_hash)
|
|
661
|
-
# Determine if we should use trust chain resolution
|
|
662
|
-
issuer_entity_id = options.issuer || client_options_hash[:issuer] || client_options_hash["issuer"]
|
|
663
|
-
use_trust_chain = options.enable_trust_chain_resolution &&
|
|
664
|
-
issuer_entity_id &&
|
|
665
|
-
is_entity_id?(issuer_entity_id) &&
|
|
666
|
-
options.trust_anchors.any?
|
|
667
|
-
|
|
668
|
-
if use_trust_chain
|
|
669
|
-
return resolve_endpoints_from_trust_chain(issuer_entity_id, client_options_hash)
|
|
670
|
-
end
|
|
671
|
-
|
|
672
|
-
# Fall back to direct entity statement resolution
|
|
673
|
-
# Load entity statement from path, URL, or issuer
|
|
674
|
-
entity_statement_content = load_provider_entity_statement
|
|
675
|
-
return {} unless entity_statement_content
|
|
676
|
-
|
|
677
|
-
begin
|
|
678
|
-
# Resolve endpoints from entity statement
|
|
679
|
-
# Use temporary file if we have content but no path
|
|
680
|
-
entity_statement_path = if options.entity_statement_path && File.exist?(resolve_entity_statement_path(options.entity_statement_path))
|
|
681
|
-
resolve_entity_statement_path(options.entity_statement_path)
|
|
682
|
-
else
|
|
683
|
-
# Create temporary file for EndpointResolver
|
|
684
|
-
temp_file = Tempfile.new(["entity_statement", ".jwt"])
|
|
685
|
-
temp_file.write(entity_statement_content)
|
|
686
|
-
temp_file.close
|
|
687
|
-
temp_file.path
|
|
688
|
-
end
|
|
689
|
-
|
|
690
|
-
resolved = OmniauthOpenidFederation::EndpointResolver.resolve(
|
|
691
|
-
entity_statement_path: entity_statement_path,
|
|
692
|
-
config: {} # Don't pass client_options here - we want entity statement values
|
|
693
|
-
)
|
|
694
|
-
|
|
695
|
-
# Clean up temp file if we created one
|
|
696
|
-
if entity_statement_path.start_with?(Dir.tmpdir)
|
|
697
|
-
begin
|
|
698
|
-
File.unlink(entity_statement_path)
|
|
699
|
-
rescue
|
|
700
|
-
nil
|
|
701
|
-
end
|
|
702
|
-
end
|
|
703
|
-
|
|
704
|
-
# Resolve issuer from entity statement if not already configured
|
|
705
|
-
resolved_issuer = nil
|
|
706
|
-
unless options.issuer || client_options_hash[:issuer] || client_options_hash["issuer"]
|
|
707
|
-
resolved_issuer = resolve_issuer_from_metadata
|
|
708
|
-
end
|
|
709
|
-
|
|
710
|
-
# Build full URLs from paths if needed
|
|
711
|
-
# Use resolved issuer if available, otherwise fall back to configured issuer
|
|
712
|
-
# Note: Config values are trusted, no security validation needed
|
|
713
|
-
issuer_uri = if resolved_issuer
|
|
714
|
-
begin
|
|
715
|
-
URI.parse(resolved_issuer)
|
|
716
|
-
rescue URI::InvalidURIError
|
|
717
|
-
nil
|
|
718
|
-
end
|
|
719
|
-
elsif options.issuer
|
|
720
|
-
begin
|
|
721
|
-
URI.parse(options.issuer.to_s)
|
|
722
|
-
rescue URI::InvalidURIError
|
|
723
|
-
nil
|
|
724
|
-
end
|
|
725
|
-
end
|
|
726
|
-
|
|
727
|
-
resolved_hash = {}
|
|
728
|
-
|
|
729
|
-
# Add issuer, scheme, and host to resolved hash if resolved from entity statement
|
|
730
|
-
if resolved_issuer && !(client_options_hash[:issuer] || client_options_hash["issuer"])
|
|
731
|
-
resolved_hash[:issuer] = resolved_issuer
|
|
732
|
-
if issuer_uri
|
|
733
|
-
resolved_hash[:scheme] = issuer_uri.scheme unless client_options_hash[:scheme] || client_options_hash["scheme"]
|
|
734
|
-
resolved_hash[:host] = issuer_uri.host unless client_options_hash[:host] || client_options_hash["host"]
|
|
735
|
-
end
|
|
736
|
-
end
|
|
737
|
-
|
|
738
|
-
# Convert endpoint paths to full URLs if they're paths
|
|
739
|
-
# Entity statement may contain full URLs (preferred) or paths
|
|
740
|
-
if resolved[:authorization_endpoint] && !(client_options_hash[:authorization_endpoint] || client_options_hash["authorization_endpoint"])
|
|
741
|
-
resolved_hash[:authorization_endpoint] = if resolved[:authorization_endpoint].start_with?("http://", "https://")
|
|
742
|
-
resolved[:authorization_endpoint]
|
|
743
|
-
elsif issuer_uri
|
|
744
|
-
OmniauthOpenidFederation::EndpointResolver.build_endpoint_url(issuer_uri, resolved[:authorization_endpoint])
|
|
745
|
-
else
|
|
746
|
-
resolved[:authorization_endpoint]
|
|
747
|
-
end
|
|
748
|
-
end
|
|
749
|
-
|
|
750
|
-
if resolved[:token_endpoint] && !(client_options_hash[:token_endpoint] || client_options_hash["token_endpoint"])
|
|
751
|
-
resolved_hash[:token_endpoint] = if resolved[:token_endpoint].start_with?("http://", "https://")
|
|
752
|
-
resolved[:token_endpoint]
|
|
753
|
-
elsif issuer_uri
|
|
754
|
-
OmniauthOpenidFederation::EndpointResolver.build_endpoint_url(issuer_uri, resolved[:token_endpoint])
|
|
755
|
-
else
|
|
756
|
-
resolved[:token_endpoint]
|
|
757
|
-
end
|
|
758
|
-
end
|
|
759
|
-
|
|
760
|
-
if resolved[:userinfo_endpoint] && !(client_options_hash[:userinfo_endpoint] || client_options_hash["userinfo_endpoint"])
|
|
761
|
-
resolved_hash[:userinfo_endpoint] = if resolved[:userinfo_endpoint].start_with?("http://", "https://")
|
|
762
|
-
resolved[:userinfo_endpoint]
|
|
763
|
-
elsif issuer_uri
|
|
764
|
-
OmniauthOpenidFederation::EndpointResolver.build_endpoint_url(issuer_uri, resolved[:userinfo_endpoint])
|
|
765
|
-
else
|
|
766
|
-
resolved[:userinfo_endpoint]
|
|
767
|
-
end
|
|
768
|
-
end
|
|
769
|
-
|
|
770
|
-
if resolved[:jwks_uri] && !(client_options_hash[:jwks_uri] || client_options_hash["jwks_uri"])
|
|
771
|
-
resolved_hash[:jwks_uri] = if resolved[:jwks_uri].start_with?("http://", "https://")
|
|
772
|
-
resolved[:jwks_uri]
|
|
773
|
-
elsif issuer_uri
|
|
774
|
-
OmniauthOpenidFederation::EndpointResolver.build_endpoint_url(issuer_uri, resolved[:jwks_uri])
|
|
775
|
-
else
|
|
776
|
-
resolved[:jwks_uri]
|
|
777
|
-
end
|
|
778
|
-
end
|
|
779
|
-
|
|
780
|
-
# Set audience if resolved and not already configured
|
|
781
|
-
if resolved[:audience] && !(client_options_hash[:audience] || client_options_hash["audience"])
|
|
782
|
-
resolved_hash[:audience] = resolved[:audience]
|
|
783
|
-
end
|
|
784
|
-
|
|
785
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved from entity statement: #{resolved_hash.keys.join(", ")}") if resolved_hash.any?
|
|
786
|
-
resolved_hash
|
|
787
|
-
rescue => e
|
|
788
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not resolve from entity statement: #{e.message}")
|
|
789
|
-
{}
|
|
790
|
-
end
|
|
791
|
-
end
|
|
792
|
-
|
|
793
|
-
# Resolve issuer from entity statement metadata
|
|
794
|
-
# Priority: provider metadata issuer > entity statement iss claim
|
|
795
|
-
#
|
|
796
|
-
# @return [String, nil] Resolved issuer URI or nil if not available
|
|
797
|
-
def resolve_issuer_from_metadata
|
|
798
|
-
entity_statement_content = load_provider_entity_statement
|
|
799
|
-
return nil unless entity_statement_content
|
|
800
|
-
|
|
801
|
-
begin
|
|
802
|
-
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
803
|
-
parsed = entity_statement.parse
|
|
804
|
-
return nil unless parsed
|
|
805
|
-
|
|
806
|
-
# Prefer provider issuer from metadata, fall back to entity issuer (iss claim)
|
|
807
|
-
issuer = parsed.dig(:metadata, :openid_provider, :issuer) || parsed[:issuer]
|
|
808
|
-
return issuer if OmniauthOpenidFederation::StringHelpers.present?(issuer)
|
|
809
|
-
|
|
810
|
-
nil
|
|
811
|
-
rescue => e
|
|
812
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not resolve issuer from entity statement: #{e.message}")
|
|
813
|
-
nil
|
|
814
|
-
end
|
|
815
|
-
end
|
|
816
|
-
|
|
817
|
-
# Resolve audience for signed request objects
|
|
818
|
-
# Priority: explicit config > entity statement > resolved issuer > token endpoint (from entity/resolved/client) > authorization endpoint > client_options issuer
|
|
819
|
-
#
|
|
820
|
-
# @param client_options_hash [Hash] Client options hash
|
|
821
|
-
# @param resolved_issuer [String, nil] Resolved issuer from entity statement
|
|
822
|
-
# @return [String, nil] Resolved audience URI or nil if not available
|
|
823
|
-
def resolve_audience(client_options_hash, resolved_issuer)
|
|
824
|
-
normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
|
|
825
|
-
|
|
826
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolving audience. Entity statement path: #{options.entity_statement_path}, Resolved issuer: #{resolved_issuer}")
|
|
827
|
-
|
|
828
|
-
# 1. Explicitly configured audience (highest priority)
|
|
829
|
-
audience = options.audience
|
|
830
|
-
if OmniauthOpenidFederation::StringHelpers.present?(audience)
|
|
831
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using explicitly configured audience: #{audience}")
|
|
832
|
-
return audience
|
|
833
|
-
end
|
|
834
|
-
|
|
835
|
-
# 2. Try to resolve from entity statement metadata
|
|
836
|
-
resolved_token_endpoint = nil
|
|
837
|
-
entity_issuer = nil
|
|
838
|
-
entity_statement_content = load_provider_entity_statement
|
|
839
|
-
|
|
840
|
-
if entity_statement_content
|
|
841
|
-
begin
|
|
842
|
-
# Use temporary file for EndpointResolver
|
|
843
|
-
entity_statement_path = if options.entity_statement_path && File.exist?(resolve_entity_statement_path(options.entity_statement_path))
|
|
844
|
-
resolve_entity_statement_path(options.entity_statement_path)
|
|
845
|
-
else
|
|
846
|
-
temp_file = Tempfile.new(["entity_statement", ".jwt"])
|
|
847
|
-
temp_file.write(entity_statement_content)
|
|
848
|
-
temp_file.close
|
|
849
|
-
temp_file.path
|
|
850
|
-
end
|
|
851
|
-
|
|
852
|
-
resolved = OmniauthOpenidFederation::EndpointResolver.resolve(
|
|
853
|
-
entity_statement_path: entity_statement_path,
|
|
854
|
-
config: {}
|
|
855
|
-
)
|
|
856
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] EndpointResolver resolved: #{resolved.keys.join(", ")}")
|
|
857
|
-
|
|
858
|
-
if resolved[:audience] && OmniauthOpenidFederation::StringHelpers.present?(resolved[:audience])
|
|
859
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved audience from entity statement: #{resolved[:audience]}")
|
|
860
|
-
# Clean up temp file if we created one
|
|
861
|
-
if entity_statement_path.start_with?(Dir.tmpdir)
|
|
862
|
-
begin
|
|
863
|
-
File.unlink(entity_statement_path)
|
|
864
|
-
rescue
|
|
865
|
-
nil
|
|
866
|
-
end
|
|
867
|
-
end
|
|
868
|
-
return resolved[:audience]
|
|
869
|
-
end
|
|
870
|
-
# Store token endpoint from entity statement for later use
|
|
871
|
-
resolved_token_endpoint = resolved[:token_endpoint] if resolved[:token_endpoint]
|
|
872
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved token endpoint from entity statement: #{resolved_token_endpoint}")
|
|
873
|
-
|
|
874
|
-
# Also try to get entity issuer (iss claim) from entity statement as fallback
|
|
875
|
-
begin
|
|
876
|
-
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
877
|
-
parsed = entity_statement.parse
|
|
878
|
-
entity_issuer = parsed[:issuer] if parsed
|
|
879
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Entity issuer from entity statement: #{entity_issuer}")
|
|
880
|
-
rescue => e
|
|
881
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get entity issuer from entity statement: #{e.message}")
|
|
882
|
-
end
|
|
883
|
-
|
|
884
|
-
# Clean up temp file if we created one
|
|
885
|
-
if entity_statement_path.start_with?(Dir.tmpdir)
|
|
886
|
-
begin
|
|
887
|
-
File.unlink(entity_statement_path)
|
|
888
|
-
rescue
|
|
889
|
-
nil
|
|
890
|
-
end
|
|
891
|
-
end
|
|
892
|
-
rescue => e
|
|
893
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Could not resolve audience from entity statement: #{e.class} - #{e.message}")
|
|
894
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Entity statement resolution error backtrace: #{e.backtrace.first(3).join(", ")}")
|
|
895
|
-
end
|
|
896
|
-
else
|
|
897
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] No entity statement available (path, URL, or issuer not configured)")
|
|
898
|
-
end
|
|
899
|
-
|
|
900
|
-
# 3. Use resolved issuer as audience (common in OpenID Federation)
|
|
901
|
-
# Only use if it's a valid URL (not just a path)
|
|
902
|
-
if OmniauthOpenidFederation::StringHelpers.present?(resolved_issuer)
|
|
903
|
-
# Resolved issuer should be a full URL, not just a path
|
|
904
|
-
if resolved_issuer.start_with?("http://", "https://")
|
|
905
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using resolved issuer as audience: #{resolved_issuer}")
|
|
906
|
-
return resolved_issuer
|
|
907
|
-
else
|
|
908
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved issuer is not a full URL, skipping: #{resolved_issuer}")
|
|
909
|
-
end
|
|
910
|
-
end
|
|
911
|
-
|
|
912
|
-
# 3b. Use entity issuer (iss claim) from entity statement as fallback
|
|
913
|
-
# Only use if it's a valid URL (not just a path)
|
|
914
|
-
if OmniauthOpenidFederation::StringHelpers.present?(entity_issuer)
|
|
915
|
-
# Entity issuer should be a full URL, not just a path
|
|
916
|
-
if entity_issuer.start_with?("http://", "https://")
|
|
917
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using entity issuer (iss claim) as audience: #{entity_issuer}")
|
|
918
|
-
return entity_issuer
|
|
919
|
-
else
|
|
920
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Entity issuer is not a full URL, skipping: #{entity_issuer}")
|
|
921
|
-
end
|
|
922
|
-
end
|
|
923
|
-
|
|
924
|
-
# 4. Use token endpoint as audience (fallback per OAuth 2.0 spec)
|
|
925
|
-
# Try multiple sources: resolved from entity statement, from client_options, or from OpenID Connect client
|
|
926
|
-
token_endpoint = resolved_token_endpoint ||
|
|
927
|
-
normalized_options[:token_endpoint] ||
|
|
928
|
-
normalized_options["token_endpoint"]
|
|
929
|
-
|
|
930
|
-
# If still no token endpoint, try to get it from the OpenID Connect client
|
|
931
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(token_endpoint)
|
|
932
|
-
begin
|
|
933
|
-
# Get resolved endpoints (includes token_endpoint if resolved from entity statement)
|
|
934
|
-
resolved_endpoints = resolve_endpoints_from_metadata(client_options_hash)
|
|
935
|
-
token_endpoint = resolved_endpoints[:token_endpoint] if resolved_endpoints[:token_endpoint]
|
|
936
|
-
rescue => e
|
|
937
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get token endpoint from resolved endpoints: #{e.message}")
|
|
938
|
-
end
|
|
939
|
-
end
|
|
940
|
-
|
|
941
|
-
# If still no token endpoint, try to get it from the OpenID Connect client
|
|
942
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(token_endpoint)
|
|
943
|
-
begin
|
|
944
|
-
# The client might have been initialized with token_endpoint from discovery or entity statement
|
|
945
|
-
if client.respond_to?(:token_endpoint) && client.token_endpoint
|
|
946
|
-
token_endpoint = client.token_endpoint.to_s
|
|
947
|
-
end
|
|
948
|
-
rescue => e
|
|
949
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get token endpoint from client: #{e.message}")
|
|
950
|
-
end
|
|
951
|
-
end
|
|
952
|
-
|
|
953
|
-
if OmniauthOpenidFederation::StringHelpers.present?(token_endpoint)
|
|
954
|
-
# Build full URL if it's a path
|
|
955
|
-
if token_endpoint.start_with?("http://", "https://")
|
|
956
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using token endpoint as audience: #{token_endpoint}")
|
|
957
|
-
return token_endpoint
|
|
958
|
-
else
|
|
959
|
-
# Build full URL from base URL
|
|
960
|
-
base_url = build_base_url(normalized_options)
|
|
961
|
-
# If base_url is nil (no host), we can't build a valid URL - skip this fallback
|
|
962
|
-
if base_url
|
|
963
|
-
full_token_endpoint = build_endpoint(base_url, token_endpoint)
|
|
964
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using token endpoint as audience: #{full_token_endpoint}")
|
|
965
|
-
return full_token_endpoint
|
|
966
|
-
else
|
|
967
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Cannot build token endpoint URL - no host in client_options")
|
|
968
|
-
end
|
|
969
|
-
end
|
|
970
|
-
end
|
|
971
|
-
|
|
972
|
-
# 5. Use authorization endpoint as audience (fallback - also valid per OAuth 2.0)
|
|
973
|
-
# Some providers use authorization endpoint as audience
|
|
974
|
-
auth_endpoint = normalized_options[:authorization_endpoint] || normalized_options["authorization_endpoint"]
|
|
975
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(auth_endpoint)
|
|
976
|
-
begin
|
|
977
|
-
resolved_endpoints = resolve_endpoints_from_metadata(client_options_hash)
|
|
978
|
-
auth_endpoint = resolved_endpoints[:authorization_endpoint] if resolved_endpoints[:authorization_endpoint]
|
|
979
|
-
rescue => e
|
|
980
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get authorization endpoint from resolved endpoints: #{e.message}")
|
|
981
|
-
end
|
|
982
|
-
end
|
|
983
|
-
|
|
984
|
-
if OmniauthOpenidFederation::StringHelpers.blank?(auth_endpoint)
|
|
985
|
-
begin
|
|
986
|
-
if client.respond_to?(:authorization_endpoint) && client.authorization_endpoint
|
|
987
|
-
auth_endpoint = client.authorization_endpoint.to_s
|
|
988
|
-
end
|
|
989
|
-
rescue => e
|
|
990
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get authorization endpoint from client: #{e.message}")
|
|
991
|
-
end
|
|
992
|
-
end
|
|
993
|
-
|
|
994
|
-
if OmniauthOpenidFederation::StringHelpers.present?(auth_endpoint)
|
|
995
|
-
if auth_endpoint.start_with?("http://", "https://")
|
|
996
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using authorization endpoint as audience: #{auth_endpoint}")
|
|
997
|
-
return auth_endpoint
|
|
998
|
-
else
|
|
999
|
-
base_url = build_base_url(normalized_options)
|
|
1000
|
-
if base_url
|
|
1001
|
-
full_auth_endpoint = build_endpoint(base_url, auth_endpoint)
|
|
1002
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using authorization endpoint as audience: #{full_auth_endpoint}")
|
|
1003
|
-
return full_auth_endpoint
|
|
1004
|
-
end
|
|
1005
|
-
end
|
|
1006
|
-
end
|
|
1007
|
-
|
|
1008
|
-
# 6. Use issuer from client_options as last resort
|
|
1009
|
-
issuer = normalized_options[:issuer] || normalized_options["issuer"]
|
|
1010
|
-
if OmniauthOpenidFederation::StringHelpers.present?(issuer)
|
|
1011
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using client_options issuer as audience: #{issuer}")
|
|
1012
|
-
return issuer
|
|
1013
|
-
end
|
|
1014
|
-
|
|
1015
|
-
# No audience found - log what we tried with details
|
|
1016
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] Could not resolve audience. Tried: explicit config, entity statement (#{options.entity_statement_path}), resolved issuer (#{resolved_issuer}), entity issuer, token endpoint, authorization endpoint, client_options issuer. Client options keys: #{normalized_options.keys.join(", ")}")
|
|
1017
|
-
nil
|
|
1018
|
-
end
|
|
1019
|
-
|
|
1020
|
-
# Resolve JWKS for ID token validation
|
|
1021
|
-
# Priority: entity statement JWKS (we already have it) > fetch from signed JWKS > fetch from standard JWKS URI
|
|
1022
|
-
# We're the client - we should use JWKS from entity statement we already have, not fetch it
|
|
1023
|
-
#
|
|
1024
|
-
# @param normalized_options [Hash] Normalized client options hash
|
|
1025
|
-
# @return [Hash, nil] JWKS hash or nil if not available
|
|
1026
|
-
def resolve_jwks_for_validation(normalized_options)
|
|
1027
|
-
entity_statement_content = load_provider_entity_statement
|
|
1028
|
-
|
|
1029
|
-
# 1. Extract JWKS directly from entity statement (we already have it - no HTTP request needed)
|
|
1030
|
-
if entity_statement_content
|
|
1031
|
-
begin
|
|
1032
|
-
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
1033
|
-
parsed = entity_statement.parse
|
|
1034
|
-
if parsed && parsed[:jwks]
|
|
1035
|
-
entity_jwks = parsed[:jwks]
|
|
1036
|
-
# Ensure it's in the format expected by JWT.decode (hash with "keys" array)
|
|
1037
|
-
if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
|
|
1038
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using JWKS from entity statement for ID token validation")
|
|
1039
|
-
return entity_jwks
|
|
1040
|
-
elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
|
|
1041
|
-
# Convert symbol keys to string keys
|
|
1042
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using JWKS from entity statement for ID token validation")
|
|
1043
|
-
return {"keys" => entity_jwks[:keys]}
|
|
1044
|
-
elsif entity_jwks.is_a?(Array)
|
|
1045
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using JWKS from entity statement for ID token validation")
|
|
1046
|
-
return {"keys" => entity_jwks}
|
|
1047
|
-
end
|
|
1048
|
-
end
|
|
1049
|
-
rescue => e
|
|
1050
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not extract JWKS from entity statement: #{e.message}")
|
|
1051
|
-
end
|
|
1052
|
-
end
|
|
1053
|
-
|
|
1054
|
-
# 2. Try to fetch from signed JWKS (if entity statement has signed_jwks_uri)
|
|
1055
|
-
if entity_statement_content
|
|
1056
|
-
begin
|
|
1057
|
-
parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
|
|
1058
|
-
entity_statement_content
|
|
1059
|
-
)
|
|
1060
|
-
if parsed && parsed[:signed_jwks_uri] && parsed[:entity_jwks]
|
|
1061
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching signed JWKS for ID token validation")
|
|
1062
|
-
signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(
|
|
1063
|
-
parsed[:signed_jwks_uri],
|
|
1064
|
-
parsed[:entity_jwks]
|
|
1065
|
-
)
|
|
1066
|
-
# Ensure it's in the format expected by JWT.decode
|
|
1067
|
-
if signed_jwks.is_a?(Hash) && signed_jwks.key?("keys")
|
|
1068
|
-
return signed_jwks
|
|
1069
|
-
elsif signed_jwks.is_a?(Hash) && signed_jwks.key?(:keys)
|
|
1070
|
-
return {"keys" => signed_jwks[:keys]}
|
|
1071
|
-
elsif signed_jwks.is_a?(Array)
|
|
1072
|
-
return {"keys" => signed_jwks}
|
|
1073
|
-
end
|
|
1074
|
-
end
|
|
1075
|
-
rescue => e
|
|
1076
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not fetch signed JWKS: #{e.message}")
|
|
1077
|
-
end
|
|
1078
|
-
end
|
|
1079
|
-
|
|
1080
|
-
# 3. Fallback: Fetch from standard JWKS URI (only if entity statement doesn't have JWKS)
|
|
1081
|
-
jwks_uri = resolve_jwks_uri(normalized_options)
|
|
1082
|
-
if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
|
|
1083
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching JWKS from URI: #{OmniauthOpenidFederation::Utils.sanitize_uri(jwks_uri)}")
|
|
1084
|
-
begin
|
|
1085
|
-
return fetch_jwks(jwks_uri)
|
|
1086
|
-
rescue => e
|
|
1087
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to fetch JWKS from URI: #{e.message}")
|
|
1088
|
-
end
|
|
1089
|
-
end
|
|
1090
|
-
|
|
1091
|
-
# No JWKS found
|
|
1092
|
-
nil
|
|
1093
|
-
end
|
|
1094
|
-
|
|
1095
|
-
# Resolve JWKS for ID token validation with fallback if kid not found
|
|
1096
|
-
# This handles key rotation by trying multiple JWKS sources
|
|
1097
|
-
#
|
|
1098
|
-
# @param normalized_options [Hash] Normalized client options hash
|
|
1099
|
-
# @param kid [String] Key ID from ID token header
|
|
1100
|
-
# @return [Hash, nil] JWKS hash with the requested kid, or nil if not available
|
|
1101
|
-
def resolve_jwks_for_validation_with_kid(normalized_options, kid)
|
|
1102
|
-
entity_statement_content = load_provider_entity_statement
|
|
1103
|
-
first_valid_jwks = nil # Track first valid JWKS in case kid is not found
|
|
1104
|
-
|
|
1105
|
-
# 1. Try entity statement JWKS first (fastest, no HTTP request)
|
|
1106
|
-
if entity_statement_content
|
|
1107
|
-
begin
|
|
1108
|
-
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
1109
|
-
parsed = entity_statement.parse
|
|
1110
|
-
if parsed && parsed[:jwks]
|
|
1111
|
-
entity_jwks = parsed[:jwks]
|
|
1112
|
-
# Ensure it's in the format expected by JWT.decode (hash with "keys" array)
|
|
1113
|
-
jwks_hash = if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
|
|
1114
|
-
entity_jwks
|
|
1115
|
-
elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
|
|
1116
|
-
{"keys" => entity_jwks[:keys]}
|
|
1117
|
-
elsif entity_jwks.is_a?(Array)
|
|
1118
|
-
{"keys" => entity_jwks}
|
|
1119
|
-
end
|
|
1120
|
-
|
|
1121
|
-
keys = jwks_hash&.dig("keys")
|
|
1122
|
-
if keys&.is_a?(Array) && !keys.empty?
|
|
1123
|
-
# Track first valid JWKS
|
|
1124
|
-
first_valid_jwks ||= jwks_hash
|
|
1125
|
-
# If kid is nil, return JWKS anyway (let JWT decoding fail with proper error)
|
|
1126
|
-
if kid.nil?
|
|
1127
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Kid is nil, returning entity statement JWKS for validation attempt")
|
|
1128
|
-
return jwks_hash
|
|
1129
|
-
end
|
|
1130
|
-
# Check if kid is in this JWKS
|
|
1131
|
-
key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
|
|
1132
|
-
if key_data
|
|
1133
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Found kid '#{kid}' in entity statement JWKS")
|
|
1134
|
-
return jwks_hash
|
|
1135
|
-
else
|
|
1136
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in entity statement JWKS, trying signed JWKS")
|
|
1137
|
-
end
|
|
1138
|
-
end
|
|
1139
|
-
end
|
|
1140
|
-
rescue => e
|
|
1141
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not extract JWKS from entity statement: #{e.message}")
|
|
1142
|
-
end
|
|
1143
|
-
end
|
|
1144
|
-
|
|
1145
|
-
# 2. Try signed JWKS (if entity statement has signed_jwks_uri)
|
|
1146
|
-
# This is more likely to have the latest keys during key rotation
|
|
1147
|
-
if entity_statement_content
|
|
1148
|
-
begin
|
|
1149
|
-
parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
|
|
1150
|
-
entity_statement_content
|
|
1151
|
-
)
|
|
1152
|
-
if parsed && parsed[:signed_jwks_uri] && parsed[:entity_jwks]
|
|
1153
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching signed JWKS for ID token validation (kid: #{kid})")
|
|
1154
|
-
signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(
|
|
1155
|
-
parsed[:signed_jwks_uri],
|
|
1156
|
-
parsed[:entity_jwks]
|
|
1157
|
-
)
|
|
1158
|
-
# Ensure it's in the format expected by JWT.decode
|
|
1159
|
-
jwks_hash = if signed_jwks.is_a?(Hash) && signed_jwks.key?("keys")
|
|
1160
|
-
signed_jwks
|
|
1161
|
-
elsif signed_jwks.is_a?(Hash) && signed_jwks.key?(:keys)
|
|
1162
|
-
{"keys" => signed_jwks[:keys]}
|
|
1163
|
-
elsif signed_jwks.is_a?(Array)
|
|
1164
|
-
{"keys" => signed_jwks}
|
|
1165
|
-
end
|
|
1166
|
-
|
|
1167
|
-
keys = jwks_hash&.dig("keys")
|
|
1168
|
-
if keys&.is_a?(Array) && !keys.empty?
|
|
1169
|
-
# Track first valid JWKS
|
|
1170
|
-
first_valid_jwks ||= jwks_hash
|
|
1171
|
-
# If kid is nil, return JWKS anyway (let JWT decoding fail with proper error)
|
|
1172
|
-
if kid.nil?
|
|
1173
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Kid is nil, returning signed JWKS for validation attempt")
|
|
1174
|
-
return jwks_hash
|
|
1175
|
-
end
|
|
1176
|
-
# Check if kid is in this JWKS
|
|
1177
|
-
key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
|
|
1178
|
-
if key_data
|
|
1179
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Found kid '#{kid}' in signed JWKS")
|
|
1180
|
-
return jwks_hash
|
|
1181
|
-
else
|
|
1182
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in signed JWKS, trying standard JWKS URI")
|
|
1183
|
-
end
|
|
1184
|
-
end
|
|
1185
|
-
end
|
|
1186
|
-
rescue => e
|
|
1187
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not fetch signed JWKS: #{e.message}")
|
|
1188
|
-
end
|
|
1189
|
-
end
|
|
1190
|
-
|
|
1191
|
-
# 3. Fallback: Fetch from standard JWKS URI
|
|
1192
|
-
jwks_uri = resolve_jwks_uri(normalized_options)
|
|
1193
|
-
if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
|
|
1194
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching JWKS from URI for kid '#{kid}': #{OmniauthOpenidFederation::Utils.sanitize_uri(jwks_uri)}")
|
|
1195
|
-
begin
|
|
1196
|
-
jwks_hash = fetch_jwks(jwks_uri)
|
|
1197
|
-
keys = jwks_hash&.dig("keys")
|
|
1198
|
-
if keys&.is_a?(Array) && !keys.empty?
|
|
1199
|
-
# Track first valid JWKS
|
|
1200
|
-
first_valid_jwks ||= jwks_hash
|
|
1201
|
-
# If kid is nil, return JWKS anyway (let JWT decoding fail with proper error)
|
|
1202
|
-
if kid.nil?
|
|
1203
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Kid is nil, returning standard JWKS URI for validation attempt")
|
|
1204
|
-
return jwks_hash
|
|
1205
|
-
end
|
|
1206
|
-
# Check if kid is in this JWKS
|
|
1207
|
-
key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
|
|
1208
|
-
if key_data
|
|
1209
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Found kid '#{kid}' in standard JWKS URI")
|
|
1210
|
-
return jwks_hash
|
|
1211
|
-
else
|
|
1212
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in standard JWKS URI")
|
|
1213
|
-
end
|
|
1214
|
-
end
|
|
1215
|
-
rescue => e
|
|
1216
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to fetch JWKS from URI: #{e.message}")
|
|
1217
|
-
end
|
|
1218
|
-
end
|
|
1219
|
-
|
|
1220
|
-
# If we found valid JWKS but kid was not found, return it anyway
|
|
1221
|
-
# This allows the decoding to fail with "kid not found" instead of "JWKS not available"
|
|
1222
|
-
if first_valid_jwks && kid
|
|
1223
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in any JWKS source, but returning first valid JWKS for validation attempt")
|
|
1224
|
-
return first_valid_jwks
|
|
1225
|
-
end
|
|
1226
|
-
|
|
1227
|
-
# No JWKS found
|
|
1228
|
-
nil
|
|
1229
|
-
end
|
|
1230
|
-
|
|
1231
|
-
# Resolve JWKS URI (for fallback fetching)
|
|
1232
|
-
# Priority: client_options > entity statement > OpenID Connect client
|
|
1233
|
-
#
|
|
1234
|
-
# @param normalized_options [Hash] Normalized client options hash
|
|
1235
|
-
# @return [String, nil] Resolved JWKS URI or nil if not available
|
|
1236
|
-
def resolve_jwks_uri(normalized_options)
|
|
1237
|
-
# 1. Try client_options first
|
|
1238
|
-
jwks_uri = normalized_options[:jwks_uri] || normalized_options["jwks_uri"]
|
|
1239
|
-
if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
|
|
1240
|
-
# Build full URL if it's a path
|
|
1241
|
-
if jwks_uri.start_with?("http://", "https://")
|
|
1242
|
-
return jwks_uri
|
|
1243
|
-
else
|
|
1244
|
-
base_url = build_base_url(normalized_options)
|
|
1245
|
-
return build_endpoint(base_url, jwks_uri) if base_url
|
|
1246
|
-
end
|
|
1247
|
-
end
|
|
1248
|
-
|
|
1249
|
-
# 2. Try to resolve from entity statement
|
|
1250
|
-
if options.entity_statement_path
|
|
1251
|
-
begin
|
|
1252
|
-
resolved_endpoints = resolve_endpoints_from_metadata(normalized_options)
|
|
1253
|
-
jwks_uri = resolved_endpoints[:jwks_uri] if resolved_endpoints[:jwks_uri]
|
|
1254
|
-
if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
|
|
1255
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved JWKS URI from entity statement: #{jwks_uri}")
|
|
1256
|
-
return jwks_uri
|
|
1257
|
-
end
|
|
1258
|
-
rescue => e
|
|
1259
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get JWKS URI from entity statement: #{e.message}")
|
|
1260
|
-
end
|
|
1261
|
-
end
|
|
1262
|
-
|
|
1263
|
-
# 3. Try to get from OpenID Connect client
|
|
1264
|
-
begin
|
|
1265
|
-
if client.respond_to?(:jwks_uri) && client.jwks_uri
|
|
1266
|
-
jwks_uri = client.jwks_uri.to_s
|
|
1267
|
-
if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
|
|
1268
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using JWKS URI from client: #{jwks_uri}")
|
|
1269
|
-
return jwks_uri
|
|
1270
|
-
end
|
|
1271
|
-
end
|
|
1272
|
-
rescue => e
|
|
1273
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get JWKS URI from client: #{e.message}")
|
|
1274
|
-
end
|
|
1275
|
-
|
|
1276
|
-
# No JWKS URI found
|
|
1277
|
-
nil
|
|
1278
|
-
end
|
|
1279
|
-
|
|
1280
|
-
def build_base_url(client_options_hash)
|
|
1281
|
-
normalized = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
|
|
1282
|
-
scheme = normalized[:scheme] || "https"
|
|
1283
|
-
host = normalized[:host]
|
|
1284
|
-
port = normalized[:port]
|
|
1285
|
-
|
|
1286
|
-
# Return nil if host is missing (can't build valid URL)
|
|
1287
|
-
return nil unless OmniauthOpenidFederation::StringHelpers.present?(host)
|
|
1288
|
-
|
|
1289
|
-
url = "#{scheme}://#{host}"
|
|
1290
|
-
url += ":#{port}" if port
|
|
1291
|
-
url
|
|
1292
|
-
end
|
|
1293
|
-
|
|
1294
|
-
def build_endpoint(base_url, path)
|
|
1295
|
-
return path if path.to_s.start_with?("http://", "https://")
|
|
1296
|
-
return nil unless base_url # Can't build endpoint without base URL
|
|
1297
|
-
|
|
1298
|
-
path = path.to_s
|
|
1299
|
-
path = "/#{path}" unless path.start_with?("/")
|
|
1300
|
-
"#{base_url}#{path}"
|
|
1301
|
-
end
|
|
1302
|
-
|
|
1303
|
-
def decode_id_token(id_token)
|
|
1304
|
-
client_options_hash = options.client_options || {}
|
|
1305
|
-
normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
|
|
1306
|
-
|
|
1307
|
-
# Check if ID token is encrypted
|
|
1308
|
-
if encrypted_token?(id_token)
|
|
1309
|
-
# Decrypt first using encryption key
|
|
1310
|
-
# According to OpenID Federation spec: supports separate signing/encryption keys
|
|
1311
|
-
# Decryption key source determines whether to use local static private_key or federation/JWKS
|
|
1312
|
-
decryption_key_source = options.decryption_key_source || options.key_source || :local
|
|
1313
|
-
private_key = normalized_options[:private_key]
|
|
1314
|
-
jwks = normalized_options[:jwks] || normalized_options["jwks"]
|
|
1315
|
-
metadata = load_metadata_for_key_extraction
|
|
1316
|
-
|
|
1317
|
-
# Extract encryption key based on decryption_key_source configuration
|
|
1318
|
-
encryption_key = case decryption_key_source
|
|
1319
|
-
when :federation
|
|
1320
|
-
OmniauthOpenidFederation::KeyExtractor.extract_encryption_key(
|
|
1321
|
-
jwks: jwks,
|
|
1322
|
-
metadata: metadata,
|
|
1323
|
-
private_key: private_key
|
|
1324
|
-
)
|
|
1325
|
-
when :local
|
|
1326
|
-
private_key
|
|
1327
|
-
else
|
|
1328
|
-
raise OmniauthOpenidFederation::ConfigurationError, "Unknown decryption key source: #{decryption_key_source}"
|
|
1329
|
-
end
|
|
1330
|
-
|
|
1331
|
-
OmniauthOpenidFederation::Validators.validate_private_key!(encryption_key)
|
|
1332
|
-
|
|
1333
|
-
begin
|
|
1334
|
-
# Decrypt using JWE gem
|
|
1335
|
-
decrypted_token = JWE.decrypt(id_token, encryption_key)
|
|
1336
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Successfully decrypted ID token using encryption key")
|
|
1337
|
-
|
|
1338
|
-
# Verify decrypted token is a valid JWT (3 parts: header.payload.signature)
|
|
1339
|
-
parts = decrypted_token.to_s.split(".")
|
|
1340
|
-
if parts.length != 3
|
|
1341
|
-
error_msg = "Decrypted token is not a valid JWT (expected 3 parts, got #{parts.length})"
|
|
1342
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1343
|
-
# Instrument decryption failure
|
|
1344
|
-
OmniauthOpenidFederation::Instrumentation.notify_decryption_failed(
|
|
1345
|
-
token_type: "id_token",
|
|
1346
|
-
error_message: error_msg,
|
|
1347
|
-
error_class: "DecryptionError"
|
|
1348
|
-
)
|
|
1349
|
-
raise OmniauthOpenidFederation::DecryptionError, error_msg
|
|
1350
|
-
end
|
|
1351
|
-
|
|
1352
|
-
id_token = decrypted_token
|
|
1353
|
-
rescue => e
|
|
1354
|
-
error_msg = "Failed to decrypt ID token: #{e.class} - #{e.message}"
|
|
1355
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1356
|
-
# Instrument decryption failure
|
|
1357
|
-
OmniauthOpenidFederation::Instrumentation.notify_decryption_failed(
|
|
1358
|
-
token_type: "id_token",
|
|
1359
|
-
error_message: e.message,
|
|
1360
|
-
error_class: e.class.name
|
|
1361
|
-
)
|
|
1362
|
-
raise OmniauthOpenidFederation::DecryptionError, error_msg, e.backtrace
|
|
1363
|
-
end
|
|
1364
|
-
end
|
|
1365
|
-
|
|
1366
|
-
# Extract kid from JWT header first to find the right key
|
|
1367
|
-
header_part = id_token.split(".").first
|
|
1368
|
-
header = JSON.parse(Base64.urlsafe_decode64(header_part))
|
|
1369
|
-
kid = header["kid"] || header[:kid]
|
|
1370
|
-
|
|
1371
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] ID token kid: #{kid}")
|
|
1372
|
-
|
|
1373
|
-
# Get JWKS for ID token validation with fallback if kid not found
|
|
1374
|
-
# Priority: entity statement JWKS > signed JWKS > standard JWKS URI
|
|
1375
|
-
# If kid is not found in entity statement JWKS, try other sources (key rotation handling)
|
|
1376
|
-
jwks = resolve_jwks_for_validation_with_kid(normalized_options, kid)
|
|
1377
|
-
|
|
1378
|
-
unless jwks
|
|
1379
|
-
error_msg = "JWKS not available for ID token validation. Provide entity statement with provider JWKS or configure jwks_uri"
|
|
1380
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1381
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1382
|
-
end
|
|
1383
|
-
|
|
1384
|
-
# Decode and validate ID token
|
|
1385
|
-
# Find matching key in JWKS, then decode with that key
|
|
1386
|
-
begin
|
|
1387
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Decoding ID token with JWKS (keys: #{(jwks.is_a?(Hash) && jwks["keys"]) ? jwks["keys"].length : "N/A"})")
|
|
1388
|
-
|
|
1389
|
-
# Find the key with matching kid in JWKS
|
|
1390
|
-
unless jwks.is_a?(Hash) && jwks["keys"]
|
|
1391
|
-
error_msg = "JWKS format invalid: expected hash with 'keys' array"
|
|
1392
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1393
|
-
raise OmniauthOpenidFederation::ValidationError, error_msg
|
|
1394
|
-
end
|
|
1395
|
-
|
|
1396
|
-
# If kid is missing from JWT header, raise error
|
|
1397
|
-
if kid.nil?
|
|
1398
|
-
error_msg = "No key id (kid) found in JWT header. JWT must include kid in header to identify the signing key."
|
|
1399
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1400
|
-
raise OmniauthOpenidFederation::SignatureError, error_msg
|
|
1401
|
-
end
|
|
1402
|
-
|
|
1403
|
-
key_data = jwks["keys"].find { |key| (key["kid"] || key[:kid]) == kid }
|
|
1404
|
-
|
|
1405
|
-
unless key_data
|
|
1406
|
-
available_kids = jwks["keys"].map { |k| k["kid"] || k[:kid] }.compact
|
|
1407
|
-
error_msg = "Key with kid '#{kid}' not found in JWKS after trying all sources (entity statement, signed JWKS, standard JWKS URI). Available kids: #{available_kids.join(", ")}"
|
|
1408
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1409
|
-
# Instrument kid not found
|
|
1410
|
-
OmniauthOpenidFederation::Instrumentation.notify_kid_not_found(
|
|
1411
|
-
kid: kid,
|
|
1412
|
-
jwks_uri: resolve_jwks_uri(normalized_options),
|
|
1413
|
-
available_kids: available_kids,
|
|
1414
|
-
token_type: "id_token"
|
|
1415
|
-
)
|
|
1416
|
-
raise OmniauthOpenidFederation::ValidationError, error_msg
|
|
1417
|
-
end
|
|
1418
|
-
|
|
1419
|
-
# Convert JWK to OpenSSL key
|
|
1420
|
-
public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(key_data)
|
|
1421
|
-
|
|
1422
|
-
# Decode JWT using the specific key
|
|
1423
|
-
decoded_payload, _ = JWT.decode(
|
|
1424
|
-
id_token,
|
|
1425
|
-
public_key,
|
|
1426
|
-
true, # Verify signature
|
|
1427
|
-
{
|
|
1428
|
-
algorithm: "RS256"
|
|
1429
|
-
}
|
|
1430
|
-
)
|
|
1431
|
-
|
|
1432
|
-
# Normalize keys to strings for consistent access
|
|
1433
|
-
normalized_payload = decoded_payload.each_with_object({}) do |(k, v), h|
|
|
1434
|
-
h[k.to_s] = v
|
|
1435
|
-
end
|
|
1436
|
-
|
|
1437
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Successfully decoded ID token. Claims: #{normalized_payload.keys.join(", ")}")
|
|
1438
|
-
|
|
1439
|
-
# Validate required claims are present (check both string and symbol keys)
|
|
1440
|
-
required_claims = ["iss", "sub", "aud", "exp", "iat"]
|
|
1441
|
-
payload_keys = normalized_payload.keys.map(&:to_s)
|
|
1442
|
-
missing_claims = required_claims - payload_keys
|
|
1443
|
-
|
|
1444
|
-
if missing_claims.any?
|
|
1445
|
-
error_msg = "ID token missing required claims: #{missing_claims.join(", ")}. Available claims: #{payload_keys.join(", ")}"
|
|
1446
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1447
|
-
# Instrument missing required claims
|
|
1448
|
-
OmniauthOpenidFederation::Instrumentation.notify_missing_required_claims(
|
|
1449
|
-
missing_claims: missing_claims,
|
|
1450
|
-
available_claims: payload_keys,
|
|
1451
|
-
token_type: "id_token"
|
|
1452
|
-
)
|
|
1453
|
-
raise OmniauthOpenidFederation::ValidationError, error_msg
|
|
1454
|
-
end
|
|
1455
|
-
|
|
1456
|
-
# Create IdToken object from decoded payload
|
|
1457
|
-
# IdToken.new expects symbol keys based on openid_connect gem implementation
|
|
1458
|
-
payload_with_symbols = normalized_payload.each_with_object({}) do |(k, v), h|
|
|
1459
|
-
h[k.to_sym] = v
|
|
1460
|
-
end
|
|
1461
|
-
|
|
1462
|
-
::OpenIDConnect::ResponseObject::IdToken.new(payload_with_symbols)
|
|
1463
|
-
rescue JWT::DecodeError, JWT::VerificationError => e
|
|
1464
|
-
error_msg = "Failed to decode or verify ID token signature: #{e.class} - #{e.message}"
|
|
1465
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1466
|
-
|
|
1467
|
-
# Add debug info about JWKS structure if available
|
|
1468
|
-
available_kids = []
|
|
1469
|
-
if jwks.is_a?(Hash) && jwks["keys"]
|
|
1470
|
-
available_kids = jwks["keys"].map { |k| k["kid"] || k[:kid] }.compact
|
|
1471
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Available keys in JWKS (kids): #{available_kids.join(", ")}")
|
|
1472
|
-
end
|
|
1473
|
-
|
|
1474
|
-
# Instrument signature verification failure
|
|
1475
|
-
OmniauthOpenidFederation::Instrumentation.notify_signature_verification_failed(
|
|
1476
|
-
token_type: "id_token",
|
|
1477
|
-
kid: kid,
|
|
1478
|
-
jwks_uri: resolve_jwks_uri(normalized_options),
|
|
1479
|
-
error_message: e.message,
|
|
1480
|
-
error_class: e.class.name,
|
|
1481
|
-
available_kids: available_kids
|
|
1482
|
-
)
|
|
1483
|
-
|
|
1484
|
-
raise OmniauthOpenidFederation::SignatureError, error_msg, e.backtrace
|
|
1485
|
-
rescue => e
|
|
1486
|
-
error_msg = "Failed to decode or validate ID token: #{e.class} - #{e.message}"
|
|
1487
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1488
|
-
raise OmniauthOpenidFederation::SignatureError, error_msg, e.backtrace
|
|
1489
|
-
end
|
|
1490
|
-
end
|
|
1491
|
-
|
|
1492
|
-
def encrypted_token?(token)
|
|
1493
|
-
# Check if token is encrypted (JWE format has 5 parts separated by dots)
|
|
1494
|
-
parts = token.to_s.split(".")
|
|
1495
|
-
parts.length == JWE_PARTS_COUNT
|
|
1496
|
-
end
|
|
1497
|
-
|
|
1498
|
-
# Decode userinfo response, handling both encrypted (JWE) and plain JSON formats
|
|
1499
|
-
# According to OpenID Federation spec, userinfo responses can be encrypted
|
|
1500
|
-
#
|
|
1501
|
-
# @param userinfo [Hash, String, Object] Userinfo response (may be encrypted JWT or plain JSON)
|
|
1502
|
-
# @return [Hash] Decoded userinfo hash
|
|
1503
|
-
# @raise [DecryptionError] If decryption fails
|
|
1504
|
-
def decode_userinfo(userinfo)
|
|
1505
|
-
# If userinfo is a string, check if it's encrypted (JWE format)
|
|
1506
|
-
if userinfo.is_a?(String)
|
|
1507
|
-
if encrypted_token?(userinfo)
|
|
1508
|
-
# Decrypt encrypted userinfo using encryption key
|
|
1509
|
-
client_options_hash = options.client_options || {}
|
|
1510
|
-
normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
|
|
1511
|
-
|
|
1512
|
-
# Decryption key source determines whether to use local static private_key or federation/JWKS
|
|
1513
|
-
decryption_key_source = options.decryption_key_source || options.key_source || :local
|
|
1514
|
-
private_key = normalized_options[:private_key]
|
|
1515
|
-
jwks = normalized_options[:jwks] || normalized_options["jwks"]
|
|
1516
|
-
metadata = load_metadata_for_key_extraction
|
|
1517
|
-
|
|
1518
|
-
# Extract encryption key based on decryption_key_source configuration
|
|
1519
|
-
encryption_key = if decryption_key_source == :federation
|
|
1520
|
-
# Try federation/JWKS first, then fallback to local private_key
|
|
1521
|
-
OmniauthOpenidFederation::KeyExtractor.extract_encryption_key(
|
|
1522
|
-
jwks: jwks,
|
|
1523
|
-
metadata: metadata,
|
|
1524
|
-
private_key: private_key
|
|
1525
|
-
) || private_key
|
|
1526
|
-
else
|
|
1527
|
-
# :local - Use local private_key directly, ignore JWKS/metadata
|
|
1528
|
-
private_key
|
|
1529
|
-
end
|
|
1530
|
-
|
|
1531
|
-
OmniauthOpenidFederation::Validators.validate_private_key!(encryption_key)
|
|
1532
|
-
|
|
1533
|
-
begin
|
|
1534
|
-
# Decrypt using JWE gem
|
|
1535
|
-
userinfo_string = JWE.decrypt(userinfo, encryption_key)
|
|
1536
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Successfully decrypted userinfo using encryption key")
|
|
1537
|
-
|
|
1538
|
-
# Parse the decrypted JSON
|
|
1539
|
-
JSON.parse(userinfo_string)
|
|
1540
|
-
rescue => e
|
|
1541
|
-
error_msg = "Failed to decrypt userinfo: #{e.class} - #{e.message}"
|
|
1542
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1543
|
-
# Instrument decryption failure
|
|
1544
|
-
OmniauthOpenidFederation::Instrumentation.notify_decryption_failed(
|
|
1545
|
-
token_type: "userinfo",
|
|
1546
|
-
error_message: e.message,
|
|
1547
|
-
error_class: e.class.name
|
|
1548
|
-
)
|
|
1549
|
-
raise OmniauthOpenidFederation::DecryptionError, error_msg, e.backtrace
|
|
1550
|
-
end
|
|
1551
|
-
else
|
|
1552
|
-
# Plain JSON string
|
|
1553
|
-
JSON.parse(userinfo)
|
|
1554
|
-
end
|
|
1555
|
-
elsif userinfo.is_a?(Hash)
|
|
1556
|
-
# Already a hash
|
|
1557
|
-
userinfo
|
|
1558
|
-
elsif userinfo.respond_to?(:raw_attributes)
|
|
1559
|
-
# OpenIDConnect::ResponseObject::UserInfo extends ConnectObject which has raw_attributes
|
|
1560
|
-
userinfo.raw_attributes || {}
|
|
1561
|
-
elsif userinfo.respond_to?(:as_json)
|
|
1562
|
-
# Fallback to as_json if raw_attributes not available
|
|
1563
|
-
userinfo.as_json(skip_validation: true)
|
|
1564
|
-
else
|
|
1565
|
-
# Last resort: extract instance variables
|
|
1566
|
-
userinfo.instance_variables.each_with_object({}) do |var, hash|
|
|
1567
|
-
key = var.to_s.delete_prefix("@").to_sym
|
|
1568
|
-
hash[key] = userinfo.instance_variable_get(var)
|
|
1569
|
-
end
|
|
1570
|
-
end
|
|
1571
|
-
end
|
|
1572
|
-
|
|
1573
|
-
# Load metadata for key extraction
|
|
1574
|
-
# Load provider entity statement from path or fetch from URL/issuer
|
|
1575
|
-
# Priority:
|
|
1576
|
-
# 1. File path (if provided) - for manual cache, development, debugging
|
|
1577
|
-
# 2. Fetch from URL (if provided) - with fingerprint verification and caching
|
|
1578
|
-
# 3. Fetch from issuer (if issuer provided) - builds URL from issuer + /.well-known/openid-federation
|
|
1579
|
-
#
|
|
1580
|
-
# @return [String, nil] Entity statement JWT string or nil if not available
|
|
1581
|
-
# @raise [ConfigurationError] If fetching fails
|
|
1582
|
-
def load_provider_entity_statement
|
|
1583
|
-
# Priority 1: Use file path if provided
|
|
1584
|
-
if OmniauthOpenidFederation::StringHelpers.present?(options.entity_statement_path)
|
|
1585
|
-
path = resolve_entity_statement_path(options.entity_statement_path)
|
|
1586
|
-
if File.exist?(path)
|
|
1587
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Loading provider entity statement from file: #{path}")
|
|
1588
|
-
return File.read(path).strip
|
|
1589
|
-
else
|
|
1590
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Provider entity statement file not found: #{path}, will try to fetch from URL")
|
|
1591
|
-
end
|
|
1592
|
-
end
|
|
1593
|
-
|
|
1594
|
-
# Priority 2: Fetch from URL if provided
|
|
1595
|
-
if OmniauthOpenidFederation::StringHelpers.present?(options.entity_statement_url)
|
|
1596
|
-
return fetch_and_cache_entity_statement(
|
|
1597
|
-
options.entity_statement_url,
|
|
1598
|
-
fingerprint: options.entity_statement_fingerprint
|
|
1599
|
-
)
|
|
1600
|
-
end
|
|
1601
|
-
|
|
1602
|
-
# Priority 3: Fetch from issuer if provided (only if issuer is a valid URL)
|
|
1603
|
-
if OmniauthOpenidFederation::StringHelpers.present?(options.issuer)
|
|
1604
|
-
# Check that issuer is a valid URL format before trying to fetch
|
|
1605
|
-
# Note: Config values are trusted, only basic format check needed
|
|
1606
|
-
begin
|
|
1607
|
-
parsed_issuer = URI.parse(options.issuer)
|
|
1608
|
-
unless parsed_issuer.is_a?(URI::HTTP) || parsed_issuer.is_a?(URI::HTTPS)
|
|
1609
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Issuer is not a valid HTTP/HTTPS URL, skipping entity statement fetch from URL: #{options.issuer}")
|
|
1610
|
-
return nil
|
|
1611
|
-
end
|
|
1612
|
-
rescue URI::InvalidURIError
|
|
1613
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Issuer is not a valid URL, skipping entity statement fetch from URL: #{options.issuer}")
|
|
1614
|
-
return nil
|
|
1615
|
-
end
|
|
1616
|
-
|
|
1617
|
-
entity_statement_url = OmniauthOpenidFederation::Utils.build_entity_statement_url(options.issuer)
|
|
1618
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Building entity statement URL from issuer: #{entity_statement_url}")
|
|
1619
|
-
return fetch_and_cache_entity_statement(
|
|
1620
|
-
entity_statement_url,
|
|
1621
|
-
fingerprint: options.entity_statement_fingerprint
|
|
1622
|
-
)
|
|
1623
|
-
end
|
|
1624
|
-
|
|
1625
|
-
nil
|
|
1626
|
-
end
|
|
1627
|
-
|
|
1628
|
-
# Fetch entity statement from URL and cache it
|
|
1629
|
-
#
|
|
1630
|
-
# @param url [String] Entity statement URL
|
|
1631
|
-
# @param fingerprint [String, nil] Expected fingerprint for verification
|
|
1632
|
-
# @return [String] Entity statement JWT string
|
|
1633
|
-
# @raise [ConfigurationError] If fetching fails
|
|
1634
|
-
def fetch_and_cache_entity_statement(url, fingerprint: nil)
|
|
1635
|
-
cache_key = "federation:provider_entity_statement:#{Digest::SHA256.hexdigest(url)}"
|
|
1636
|
-
|
|
1637
|
-
# Check cache first (if Rails.cache is available)
|
|
1638
|
-
if defined?(Rails) && Rails.cache
|
|
1639
|
-
begin
|
|
1640
|
-
cached = Rails.cache.read(cache_key)
|
|
1641
|
-
if cached
|
|
1642
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using cached provider entity statement from: #{url}")
|
|
1643
|
-
return cached
|
|
1644
|
-
end
|
|
1645
|
-
rescue => e
|
|
1646
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Cache read failed, fetching fresh: #{e.message}")
|
|
1647
|
-
end
|
|
1648
|
-
end
|
|
1649
|
-
|
|
1650
|
-
# Fetch from URL
|
|
1651
|
-
OmniauthOpenidFederation::Logger.info("[Strategy] Fetching provider entity statement from: #{url}")
|
|
1652
|
-
begin
|
|
1653
|
-
statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
|
|
1654
|
-
url,
|
|
1655
|
-
fingerprint: fingerprint,
|
|
1656
|
-
timeout: 10
|
|
1657
|
-
)
|
|
1658
|
-
|
|
1659
|
-
entity_statement_content = statement.entity_statement
|
|
1660
|
-
|
|
1661
|
-
# Cache the fetched statement (if Rails.cache is available)
|
|
1662
|
-
if defined?(Rails) && Rails.cache
|
|
1663
|
-
begin
|
|
1664
|
-
# Cache for 1 hour (entity statements typically expire after 24 hours)
|
|
1665
|
-
Rails.cache.write(cache_key, entity_statement_content, expires_in: 3600)
|
|
1666
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Cached provider entity statement from: #{url}")
|
|
1667
|
-
rescue => e
|
|
1668
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Cache write failed: #{e.message}")
|
|
1669
|
-
end
|
|
1670
|
-
end
|
|
1671
|
-
|
|
1672
|
-
entity_statement_content
|
|
1673
|
-
rescue OmniauthOpenidFederation::FetchError, OmniauthOpenidFederation::ValidationError => e
|
|
1674
|
-
error_msg = "Failed to fetch provider entity statement from #{url}: #{e.message}"
|
|
1675
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1676
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1677
|
-
end
|
|
1678
|
-
end
|
|
1679
|
-
|
|
1680
|
-
# Resolve endpoints from trust chain (for federation scenarios)
|
|
1681
|
-
#
|
|
1682
|
-
# @param issuer_entity_id [String] Entity Identifier of the OP
|
|
1683
|
-
# @param client_options_hash [Hash] Current client options
|
|
1684
|
-
# @return [Hash] Hash with resolved endpoints from effective metadata
|
|
1685
|
-
def resolve_endpoints_from_trust_chain(issuer_entity_id, client_options_hash)
|
|
1686
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolving endpoints from trust chain for: #{issuer_entity_id}")
|
|
1687
|
-
|
|
1688
|
-
begin
|
|
1689
|
-
# Resolve trust chain
|
|
1690
|
-
resolver = OmniauthOpenidFederation::Federation::TrustChainResolver.new(
|
|
1691
|
-
leaf_entity_id: issuer_entity_id,
|
|
1692
|
-
trust_anchors: normalize_trust_anchors(options.trust_anchors)
|
|
1693
|
-
)
|
|
1694
|
-
trust_chain = resolver.resolve!
|
|
1695
|
-
|
|
1696
|
-
# Extract metadata from leaf entity configuration
|
|
1697
|
-
leaf_statement = trust_chain.first
|
|
1698
|
-
leaf_parsed = leaf_statement.is_a?(Hash) ? leaf_statement : leaf_statement.parse
|
|
1699
|
-
leaf_metadata = extract_metadata_from_parsed(leaf_parsed)
|
|
1700
|
-
|
|
1701
|
-
# Merge metadata policies
|
|
1702
|
-
merger = OmniauthOpenidFederation::Federation::MetadataPolicyMerger.new(trust_chain: trust_chain)
|
|
1703
|
-
effective_metadata = merger.merge_and_apply(leaf_metadata)
|
|
1704
|
-
|
|
1705
|
-
# Extract OP metadata from effective metadata
|
|
1706
|
-
op_metadata = effective_metadata[:openid_provider] || effective_metadata["openid_provider"] || {}
|
|
1707
|
-
|
|
1708
|
-
# Build resolved endpoints hash
|
|
1709
|
-
resolved = {}
|
|
1710
|
-
resolved[:authorization_endpoint] = op_metadata[:authorization_endpoint] || op_metadata["authorization_endpoint"]
|
|
1711
|
-
resolved[:token_endpoint] = op_metadata[:token_endpoint] || op_metadata["token_endpoint"]
|
|
1712
|
-
resolved[:userinfo_endpoint] = op_metadata[:userinfo_endpoint] || op_metadata["userinfo_endpoint"]
|
|
1713
|
-
resolved[:jwks_uri] = op_metadata[:jwks_uri] || op_metadata["jwks_uri"]
|
|
1714
|
-
resolved[:issuer] = op_metadata[:issuer] || op_metadata["issuer"] || issuer_entity_id
|
|
1715
|
-
resolved[:audience] = resolved[:issuer] # Audience is typically the issuer
|
|
1716
|
-
|
|
1717
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved endpoints from trust chain: #{resolved.keys.join(", ")}")
|
|
1718
|
-
resolved
|
|
1719
|
-
rescue OmniauthOpenidFederation::ValidationError, OmniauthOpenidFederation::FetchError => e
|
|
1720
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] Trust chain resolution failed: #{e.message}")
|
|
1721
|
-
# Fall back to direct entity statement
|
|
1722
|
-
{}
|
|
1723
|
-
end
|
|
1724
|
-
end
|
|
1725
|
-
|
|
1726
|
-
# Extract metadata from parsed entity statement
|
|
1727
|
-
#
|
|
1728
|
-
# @param parsed [Hash] Parsed entity statement
|
|
1729
|
-
# @return [Hash] Metadata hash by entity type
|
|
1730
|
-
def extract_metadata_from_parsed(parsed)
|
|
1731
|
-
metadata = parsed[:metadata] || parsed["metadata"] || {}
|
|
1732
|
-
# Ensure it's a hash with entity type keys
|
|
1733
|
-
result = {}
|
|
1734
|
-
metadata.each do |entity_type, entity_metadata|
|
|
1735
|
-
result[entity_type.to_sym] = entity_metadata
|
|
1736
|
-
end
|
|
1737
|
-
result
|
|
1738
|
-
end
|
|
1739
|
-
|
|
1740
|
-
# Normalize trust anchors configuration
|
|
1741
|
-
#
|
|
1742
|
-
# @param trust_anchors [Array] Trust anchor configurations
|
|
1743
|
-
# @return [Array] Normalized trust anchor configurations
|
|
1744
|
-
def normalize_trust_anchors(trust_anchors)
|
|
1745
|
-
trust_anchors.map do |ta|
|
|
1746
|
-
{
|
|
1747
|
-
entity_id: ta[:entity_id] || ta["entity_id"],
|
|
1748
|
-
jwks: ta[:jwks] || ta["jwks"]
|
|
1749
|
-
}
|
|
1750
|
-
end
|
|
1751
|
-
end
|
|
1752
|
-
|
|
1753
|
-
# Check if a string is an Entity ID (URI)
|
|
1754
|
-
#
|
|
1755
|
-
# @param str [String] String to check
|
|
1756
|
-
# @return [Boolean] true if string is an Entity ID
|
|
1757
|
-
def is_entity_id?(str)
|
|
1758
|
-
str.is_a?(String) && str.start_with?("http://", "https://")
|
|
1759
|
-
end
|
|
1760
|
-
|
|
1761
|
-
# Resolve entity statement path (relative to Rails root if available)
|
|
1762
|
-
#
|
|
1763
|
-
# @param path [String] Entity statement path
|
|
1764
|
-
# @return [String] Absolute path
|
|
1765
|
-
def resolve_entity_statement_path(path)
|
|
1766
|
-
if path.start_with?("/")
|
|
1767
|
-
path
|
|
1768
|
-
elsif defined?(Rails) && Rails.root
|
|
1769
|
-
Rails.root.join(path).to_s
|
|
1770
|
-
else
|
|
1771
|
-
File.expand_path(path)
|
|
1772
|
-
end
|
|
1773
|
-
end
|
|
1774
|
-
|
|
1775
|
-
# Used to extract signing/encryption keys from metadata JWKS
|
|
1776
|
-
#
|
|
1777
|
-
# @return [Hash, nil] Metadata hash or nil if not available
|
|
1778
|
-
def load_metadata_for_key_extraction
|
|
1779
|
-
entity_statement_content = load_provider_entity_statement
|
|
1780
|
-
return nil unless entity_statement_content
|
|
1781
|
-
|
|
1782
|
-
begin
|
|
1783
|
-
# Parse entity statement to extract metadata and JWKS from content
|
|
1784
|
-
parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
|
|
1785
|
-
entity_statement_content
|
|
1786
|
-
)
|
|
1787
|
-
|
|
1788
|
-
return nil unless parsed && parsed[:metadata]
|
|
1789
|
-
|
|
1790
|
-
# Return metadata in format expected by KeyExtractor
|
|
1791
|
-
# KeyExtractor expects metadata hash that may contain JWKS
|
|
1792
|
-
metadata = parsed[:metadata]
|
|
1793
|
-
entity_jwks = parsed[:entity_jwks] || metadata[:jwks] || {}
|
|
1794
|
-
|
|
1795
|
-
# Return metadata with JWKS included
|
|
1796
|
-
metadata.merge(jwks: entity_jwks)
|
|
1797
|
-
rescue => e
|
|
1798
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to load metadata from entity statement for key extraction: #{e.message}")
|
|
1799
|
-
nil
|
|
1800
|
-
end
|
|
1801
|
-
end
|
|
1802
|
-
|
|
1803
|
-
# Load client entity statement from file or generate dynamically with caching
|
|
1804
|
-
# Priority:
|
|
1805
|
-
# 1. File path (if provided) - for manual cache, development, debugging
|
|
1806
|
-
# 2. Cache (if available) - respects cache TTL and background job refresh
|
|
1807
|
-
# 3. Generate dynamically - always available via FederationEndpoint
|
|
1808
|
-
# Note: URL is for external consumers only - we never access it ourselves
|
|
1809
|
-
#
|
|
1810
|
-
# @param entity_statement_path [String, nil] Path to client entity statement file (optional, for manual cache/dev/debug)
|
|
1811
|
-
# @param entity_statement_url [String, nil] URL to client entity statement (for external consumers only, never accessed)
|
|
1812
|
-
# @return [String] The entity statement JWT string
|
|
1813
|
-
# @raise [ConfigurationError] If entity statement cannot be loaded or generated
|
|
1814
|
-
def load_client_entity_statement(entity_statement_path = nil, entity_statement_url = nil)
|
|
1815
|
-
# Priority 1: Use file path if provided (for manual cache, development, debugging)
|
|
1816
|
-
if OmniauthOpenidFederation::StringHelpers.present?(entity_statement_path)
|
|
1817
|
-
return load_client_entity_statement_from_file(entity_statement_path)
|
|
1818
|
-
end
|
|
1819
|
-
|
|
1820
|
-
# Priority 2: Check cache (if Rails.cache is available)
|
|
1821
|
-
# This respects background job cache refresh and key rotation
|
|
1822
|
-
if defined?(Rails) && Rails.cache
|
|
1823
|
-
cache_key = "federation:entity_statement"
|
|
1824
|
-
config = OmniauthOpenidFederation::FederationEndpoint.configuration
|
|
1825
|
-
|
|
1826
|
-
# Use cache TTL based on entity statement expiration or default to 1 hour
|
|
1827
|
-
# The entity statement JWT itself has an expiration, but we cache it for performance
|
|
1828
|
-
# Cache TTL should be shorter than JWT expiration to ensure fresh keys
|
|
1829
|
-
cache_ttl = config.jwks_cache_ttl || 3600 # Default to 1 hour, same as JWKS cache
|
|
1830
|
-
|
|
1831
|
-
begin
|
|
1832
|
-
cached_statement = Rails.cache.fetch(cache_key, expires_in: cache_ttl) do
|
|
1833
|
-
# Generate and cache if not in cache
|
|
1834
|
-
entity_statement = OmniauthOpenidFederation::FederationEndpoint.generate_entity_statement
|
|
1835
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Generated and cached client entity statement")
|
|
1836
|
-
entity_statement
|
|
1837
|
-
end
|
|
1838
|
-
|
|
1839
|
-
if cached_statement
|
|
1840
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Using cached client entity statement")
|
|
1841
|
-
return cached_statement
|
|
1842
|
-
end
|
|
1843
|
-
rescue => e
|
|
1844
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Cache fetch failed, generating fresh entity statement: #{e.message}")
|
|
1845
|
-
# Fall through to generate dynamically
|
|
1846
|
-
end
|
|
1847
|
-
end
|
|
1848
|
-
|
|
1849
|
-
# Priority 3: Generate dynamically (always available)
|
|
1850
|
-
# The entity statement is always generated via FederationEndpoint
|
|
1851
|
-
begin
|
|
1852
|
-
entity_statement = OmniauthOpenidFederation::FederationEndpoint.generate_entity_statement
|
|
1853
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Generated client entity statement dynamically")
|
|
1854
|
-
entity_statement
|
|
1855
|
-
rescue OmniauthOpenidFederation::ConfigurationError => e
|
|
1856
|
-
# FederationEndpoint not configured - provide helpful error message
|
|
1857
|
-
error_msg = "Failed to generate client entity statement: #{e.message}. " \
|
|
1858
|
-
"Either configure OmniauthOpenidFederation::FederationEndpoint.configure " \
|
|
1859
|
-
"or provide client_entity_statement_path for manual cache/dev/debug."
|
|
1860
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1861
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1862
|
-
rescue => e
|
|
1863
|
-
error_msg = "Failed to generate client entity statement: #{e.message}"
|
|
1864
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1865
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1866
|
-
end
|
|
1867
|
-
end
|
|
1868
|
-
|
|
1869
|
-
# Load client entity statement from file
|
|
1870
|
-
#
|
|
1871
|
-
# @param entity_statement_path [String] Path to client entity statement file
|
|
1872
|
-
# @return [String] The entity statement JWT string
|
|
1873
|
-
# @raise [ConfigurationError] If entity statement cannot be loaded
|
|
1874
|
-
def load_client_entity_statement_from_file(entity_statement_path)
|
|
1875
|
-
# Resolve path (relative to Rails root if available)
|
|
1876
|
-
path = if entity_statement_path.start_with?("/")
|
|
1877
|
-
entity_statement_path
|
|
1878
|
-
elsif defined?(Rails) && Rails.root
|
|
1879
|
-
Rails.root.join(entity_statement_path).to_s
|
|
1880
|
-
else
|
|
1881
|
-
File.expand_path(entity_statement_path)
|
|
1882
|
-
end
|
|
1883
|
-
|
|
1884
|
-
unless File.exist?(path)
|
|
1885
|
-
error_msg = "Client entity statement file not found: #{path}"
|
|
1886
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1887
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1888
|
-
end
|
|
1889
|
-
|
|
1890
|
-
entity_statement = File.read(path)
|
|
1891
|
-
unless OmniauthOpenidFederation::StringHelpers.present?(entity_statement)
|
|
1892
|
-
error_msg = "Client entity statement file is empty: #{path}"
|
|
1893
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1894
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1895
|
-
end
|
|
1896
|
-
|
|
1897
|
-
# Validate it's a JWT (has 3 parts)
|
|
1898
|
-
jwt_parts = entity_statement.strip.split(".")
|
|
1899
|
-
unless jwt_parts.length == 3
|
|
1900
|
-
error_msg = "Client entity statement is not a valid JWT (expected 3 parts, got #{jwt_parts.length}): #{path}"
|
|
1901
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1902
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1903
|
-
end
|
|
1904
|
-
|
|
1905
|
-
entity_statement.strip
|
|
1906
|
-
end
|
|
1907
|
-
|
|
1908
|
-
# Load client entity statement from URL (for dynamic federation endpoints)
|
|
1909
|
-
#
|
|
1910
|
-
# @param entity_statement_url [String] URL to client entity statement
|
|
1911
|
-
# @return [String] The entity statement JWT string
|
|
1912
|
-
# @raise [ConfigurationError] If entity statement cannot be loaded
|
|
1913
|
-
def load_client_entity_statement_from_url(entity_statement_url)
|
|
1914
|
-
response = HttpClient.get(entity_statement_url)
|
|
1915
|
-
unless response.status.success?
|
|
1916
|
-
error_msg = "Failed to fetch client entity statement from #{entity_statement_url}: HTTP #{response.status}"
|
|
1917
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1918
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1919
|
-
end
|
|
1920
|
-
|
|
1921
|
-
entity_statement = response.body.to_s
|
|
1922
|
-
unless OmniauthOpenidFederation::StringHelpers.present?(entity_statement)
|
|
1923
|
-
error_msg = "Client entity statement from URL is empty: #{entity_statement_url}"
|
|
1924
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1925
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1926
|
-
end
|
|
1927
|
-
|
|
1928
|
-
# Validate it's a JWT (has 3 parts)
|
|
1929
|
-
jwt_parts = entity_statement.strip.split(".")
|
|
1930
|
-
unless jwt_parts.length == 3
|
|
1931
|
-
error_msg = "Client entity statement from URL is not a valid JWT (expected 3 parts, got #{jwt_parts.length}): #{entity_statement_url}"
|
|
1932
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1933
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1934
|
-
end
|
|
1935
|
-
|
|
1936
|
-
entity_statement.strip
|
|
1937
|
-
rescue OmniauthOpenidFederation::NetworkError => e
|
|
1938
|
-
error_msg = "Failed to fetch client entity statement from #{entity_statement_url}: #{e.message}"
|
|
1939
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
|
|
1940
|
-
raise OmniauthOpenidFederation::ConfigurationError, error_msg
|
|
1941
|
-
end
|
|
1942
|
-
|
|
1943
|
-
# Extract JWKS from client entity statement for client_jwk_signing_key
|
|
1944
|
-
# According to OpenID Federation spec, client JWKS should come from client entity statement
|
|
1945
|
-
# Entity statement is either loaded from file (if provided) or generated dynamically
|
|
1946
|
-
#
|
|
1947
|
-
# @return [String, nil] JWKS as JSON string, or nil if not available
|
|
1948
|
-
def extract_client_jwk_signing_key
|
|
1949
|
-
# Access raw options hash to avoid recursion (don't call options method which triggers extraction)
|
|
1950
|
-
raw_opts = @options || {}
|
|
1951
|
-
|
|
1952
|
-
# If explicit JWKS is provided, use it
|
|
1953
|
-
return raw_opts[:client_jwk_signing_key] if OmniauthOpenidFederation::StringHelpers.present?(raw_opts[:client_jwk_signing_key])
|
|
1954
|
-
|
|
1955
|
-
# Entity statement is always available (either from file or generated dynamically)
|
|
1956
|
-
begin
|
|
1957
|
-
entity_statement_content = load_client_entity_statement(
|
|
1958
|
-
raw_opts[:client_entity_statement_path],
|
|
1959
|
-
raw_opts[:client_entity_statement_url]
|
|
1960
|
-
)
|
|
1961
|
-
return nil unless OmniauthOpenidFederation::StringHelpers.present?(entity_statement_content)
|
|
1962
|
-
|
|
1963
|
-
# Extract JWKS from client entity statement
|
|
1964
|
-
jwt_parts = entity_statement_content.split(".")
|
|
1965
|
-
return nil if jwt_parts.length != 3
|
|
1966
|
-
|
|
1967
|
-
payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
1968
|
-
entity_jwks = payload.fetch("jwks", {})
|
|
1969
|
-
return nil if entity_jwks.empty?
|
|
1970
|
-
|
|
1971
|
-
# Return JWKS as JSON string (format expected by openid_connect gem)
|
|
1972
|
-
JSON.dump(entity_jwks)
|
|
1973
|
-
rescue => e
|
|
1974
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to extract client JWKS from entity statement: #{e.message}")
|
|
1975
|
-
nil
|
|
1976
|
-
end
|
|
1977
|
-
end
|
|
1978
|
-
|
|
1979
|
-
# Extract entity identifier from client entity statement
|
|
1980
|
-
# For automatic registration, the client_id is the entity identifier (sub claim)
|
|
1981
|
-
#
|
|
1982
|
-
# @param entity_statement [String] The entity statement JWT string
|
|
1983
|
-
# @param configured_identifier [String, nil] Manually configured entity identifier (takes precedence)
|
|
1984
|
-
# @return [String, nil] The entity identifier (sub claim) or configured identifier
|
|
1985
|
-
def extract_entity_identifier_from_statement(entity_statement, configured_identifier = nil)
|
|
1986
|
-
# Use configured identifier if provided
|
|
1987
|
-
return configured_identifier if OmniauthOpenidFederation::StringHelpers.present?(configured_identifier)
|
|
1988
|
-
|
|
1989
|
-
# Extract from entity statement
|
|
1990
|
-
begin
|
|
1991
|
-
jwt_parts = entity_statement.split(".")
|
|
1992
|
-
payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
1993
|
-
entity_identifier = payload["sub"] || payload[:sub]
|
|
1994
|
-
return entity_identifier if OmniauthOpenidFederation::StringHelpers.present?(entity_identifier)
|
|
1995
|
-
|
|
1996
|
-
# Fallback to issuer if sub is not present
|
|
1997
|
-
entity_identifier = payload["iss"] || payload[:iss]
|
|
1998
|
-
return entity_identifier if OmniauthOpenidFederation::StringHelpers.present?(entity_identifier)
|
|
1999
|
-
|
|
2000
|
-
OmniauthOpenidFederation::Logger.warn("[Strategy] Could not extract entity identifier from entity statement (no 'sub' or 'iss' claim)")
|
|
2001
|
-
nil
|
|
2002
|
-
rescue => e
|
|
2003
|
-
OmniauthOpenidFederation::Logger.error("[Strategy] Failed to extract entity identifier from entity statement: #{e.message}")
|
|
2004
|
-
nil
|
|
2005
|
-
end
|
|
2006
|
-
end
|
|
2007
|
-
|
|
2008
|
-
# Load provider metadata from entity statement for request object encryption
|
|
2009
|
-
# According to OpenID Connect Core spec, provider metadata may specify
|
|
2010
|
-
# request_object_encryption_alg and request_object_encryption_enc
|
|
2011
|
-
#
|
|
2012
|
-
# @return [Hash, nil] Provider metadata hash with encryption parameters and JWKS, or nil if not available
|
|
2013
|
-
def load_provider_metadata_for_encryption
|
|
2014
|
-
entity_statement_content = load_provider_entity_statement
|
|
2015
|
-
return nil unless entity_statement_content
|
|
2016
|
-
|
|
2017
|
-
begin
|
|
2018
|
-
# Decode entity statement payload to get all provider metadata fields
|
|
2019
|
-
# EntityStatement.parse only extracts specific fields, so we need to access raw payload
|
|
2020
|
-
jwt_parts = entity_statement_content.split(".")
|
|
2021
|
-
return nil if jwt_parts.length != 3
|
|
2022
|
-
|
|
2023
|
-
payload = JSON.parse(Base64.urlsafe_decode64(jwt_parts[1]))
|
|
2024
|
-
metadata_section = payload.fetch("metadata", {})
|
|
2025
|
-
provider_metadata = metadata_section.fetch("openid_provider", {})
|
|
2026
|
-
entity_jwks = payload.fetch("jwks", {})
|
|
2027
|
-
|
|
2028
|
-
# Combine provider metadata with entity JWKS for encryption
|
|
2029
|
-
# Note: Provider's encryption requirements would be in their discovery document,
|
|
2030
|
-
# but we can also check client metadata as a fallback
|
|
2031
|
-
{
|
|
2032
|
-
"request_object_encryption_alg" => provider_metadata["request_object_encryption_alg"] ||
|
|
2033
|
-
provider_metadata[:request_object_encryption_alg],
|
|
2034
|
-
"request_object_encryption_enc" => provider_metadata["request_object_encryption_enc"] ||
|
|
2035
|
-
provider_metadata[:request_object_encryption_enc],
|
|
2036
|
-
"jwks" => entity_jwks
|
|
2037
|
-
}
|
|
2038
|
-
rescue => e
|
|
2039
|
-
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not load provider metadata for encryption: #{e.message}")
|
|
2040
|
-
nil
|
|
2041
|
-
end
|
|
2042
|
-
end
|
|
2043
|
-
|
|
2044
|
-
def fetch_jwks(jwks_uri)
|
|
2045
|
-
# Use our JWKS fetching logic
|
|
2046
|
-
# Returns a hash with "keys" array that JWT.decode can use directly
|
|
2047
|
-
jwks = OmniauthOpenidFederation::Jwks::Fetch.run(jwks_uri)
|
|
2048
|
-
|
|
2049
|
-
# Ensure it's in the format expected by JWT.decode (hash with "keys" array)
|
|
2050
|
-
if jwks.is_a?(Hash) && jwks.key?("keys")
|
|
2051
|
-
# Already in correct format - JWT.decode accepts this directly
|
|
2052
|
-
jwks
|
|
2053
|
-
elsif jwks.is_a?(Array)
|
|
2054
|
-
# If it's an array of keys, wrap it in a hash
|
|
2055
|
-
{"keys" => jwks}
|
|
2056
|
-
else
|
|
2057
|
-
# Fallback: wrap in keys array
|
|
2058
|
-
{"keys" => [jwks].compact}
|
|
2059
|
-
end
|
|
2060
|
-
end
|
|
2061
178
|
end
|
|
2062
179
|
end
|
|
2063
180
|
end
|