omniauth_openid_federation 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.md +22 -0
- data/README.md +822 -0
- data/SECURITY.md +129 -0
- data/examples/README_INTEGRATION_TESTING.md +399 -0
- data/examples/README_MOCK_OP.md +243 -0
- data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
- data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
- data/examples/app/models/user.rb.example +39 -0
- data/examples/config/initializers/devise.rb.example +97 -0
- data/examples/config/initializers/federation_endpoint.rb.example +206 -0
- data/examples/config/mock_op.yml.example +83 -0
- data/examples/config/open_id_connect_config.rb.example +210 -0
- data/examples/config/routes.rb.example +12 -0
- data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
- data/examples/integration_test_flow.rb +1334 -0
- data/examples/jobs/README.md +194 -0
- data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
- data/examples/jobs/federation_files_generation_job.rb.example +87 -0
- data/examples/mock_op_server.rb +775 -0
- data/examples/mock_rp_server.rb +435 -0
- data/lib/omniauth_openid_federation/access_token.rb +504 -0
- data/lib/omniauth_openid_federation/cache.rb +39 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
- data/lib/omniauth_openid_federation/configuration.rb +135 -0
- data/lib/omniauth_openid_federation/constants.rb +13 -0
- data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
- data/lib/omniauth_openid_federation/errors.rb +52 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
- data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
- data/lib/omniauth_openid_federation/http_client.rb +70 -0
- data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
- data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
- data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
- data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
- data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
- data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
- data/lib/omniauth_openid_federation/jws.rb +416 -0
- data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
- data/lib/omniauth_openid_federation/logger.rb +99 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
- data/lib/omniauth_openid_federation/railtie.rb +29 -0
- data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
- data/lib/omniauth_openid_federation/strategy.rb +2029 -0
- data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
- data/lib/omniauth_openid_federation/utils.rb +166 -0
- data/lib/omniauth_openid_federation/validators.rb +126 -0
- data/lib/omniauth_openid_federation/version.rb +3 -0
- data/lib/omniauth_openid_federation.rb +98 -0
- data/lib/tasks/omniauth_openid_federation.rake +376 -0
- data/sig/federation.rbs +218 -0
- data/sig/jwks.rbs +63 -0
- data/sig/omniauth_openid_federation.rbs +254 -0
- data/sig/strategy.rbs +60 -0
- metadata +352 -0
|
@@ -0,0 +1,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
|