omniauth_openid_federation 1.0.0

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