omniauth_openid_federation 1.3.2 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -1
  3. data/README.md +119 -13
  4. data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
  5. data/config/polyrun_coverage.yml +15 -0
  6. data/config/polyrun_spec_quality.yml +29 -0
  7. data/examples/README_INTEGRATION_TESTING.md +3 -0
  8. data/examples/integration_test_flow.rb +10 -8
  9. data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
  10. data/examples/jobs/federation_files_generation_job.rb.example +3 -2
  11. data/examples/mock_op_server.rb +4 -5
  12. data/examples/mock_rp_server.rb +3 -3
  13. data/examples/standalone_multiple_endpoints_example.rb +494 -0
  14. data/lib/omniauth_openid_federation/access_token.rb +91 -450
  15. data/lib/omniauth_openid_federation/cache.rb +16 -0
  16. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  17. data/lib/omniauth_openid_federation/constants.rb +3 -0
  18. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  19. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +2 -4
  20. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
  21. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +51 -6
  22. data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
  23. data/lib/omniauth_openid_federation/http_client.rb +145 -32
  24. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  25. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  26. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  27. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  28. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  29. data/lib/omniauth_openid_federation/jws.rb +2 -11
  30. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
  31. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  32. data/lib/omniauth_openid_federation/rack.rb +2 -0
  33. data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
  34. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  35. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  36. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  37. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
  38. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  39. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
  40. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
  41. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  42. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  43. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
  44. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
  45. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
  46. data/lib/omniauth_openid_federation/strategy.rb +27 -1910
  47. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  48. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  49. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  50. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  51. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  52. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
  53. data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
  54. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  55. data/lib/omniauth_openid_federation/utils.rb +10 -2
  56. data/lib/omniauth_openid_federation/validators.rb +43 -8
  57. data/lib/omniauth_openid_federation/version.rb +1 -1
  58. data/lib/omniauth_openid_federation.rb +9 -3
  59. data/lib/tasks/omniauth_openid_federation.rake +1 -2
  60. data/sig/omniauth_openid_federation.rbs +2 -1
  61. data/sig/strategy.rbs +6 -6
  62. metadata +181 -71
@@ -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
- require "rack/utils"
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 the openid_connect gem and extends it with federation-specific features.
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
- # Override client_jwk_signing_key to automatically extract from client entity statement
90
- # This automates client JWKS extraction according to OpenID Federation spec
91
- # The underlying openid_connect gem will use this for client authentication (private_key_jwt)
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