omniauth_openid_federation 1.2.2

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