omniauth_openid_federation 1.3.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/README.md +119 -13
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
- data/config/polyrun_coverage.yml +15 -0
- data/config/polyrun_spec_quality.yml +29 -0
- data/examples/README_INTEGRATION_TESTING.md +3 -0
- data/examples/integration_test_flow.rb +10 -8
- data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
- data/examples/jobs/federation_files_generation_job.rb.example +3 -2
- data/examples/mock_op_server.rb +4 -5
- data/examples/mock_rp_server.rb +3 -3
- data/examples/standalone_multiple_endpoints_example.rb +494 -0
- data/lib/omniauth_openid_federation/access_token.rb +91 -450
- data/lib/omniauth_openid_federation/cache.rb +16 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
- data/lib/omniauth_openid_federation/constants.rb +3 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +2 -4
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +51 -6
- data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
- data/lib/omniauth_openid_federation/http_client.rb +145 -32
- data/lib/omniauth_openid_federation/http_errors.rb +14 -0
- data/lib/omniauth_openid_federation/id_token.rb +19 -0
- data/lib/omniauth_openid_federation/jwe.rb +26 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
- data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
- data/lib/omniauth_openid_federation/jws.rb +2 -11
- data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
- data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
- data/lib/omniauth_openid_federation/rack.rb +2 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
- data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
- data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
- data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
- data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
- data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
- data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
- data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
- data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
- data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
- data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
- data/lib/omniauth_openid_federation/strategy.rb +27 -1910
- data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
- data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
- data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
- data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
- data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
- data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
- data/lib/omniauth_openid_federation/user_info.rb +15 -0
- data/lib/omniauth_openid_federation/utils.rb +10 -2
- data/lib/omniauth_openid_federation/validators.rb +43 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +9 -3
- data/lib/tasks/omniauth_openid_federation.rake +1 -2
- data/sig/omniauth_openid_federation.rbs +2 -1
- data/sig/strategy.rbs +6 -6
- metadata +181 -71
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
module OmniauthOpenidFederation
|
|
2
|
+
module Strategy
|
|
3
|
+
module EndpointResolution
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def resolve_endpoints_from_metadata(client_options_hash)
|
|
7
|
+
# Determine if we should use trust chain resolution
|
|
8
|
+
issuer_entity_id = options.issuer || client_options_hash[:issuer] || client_options_hash["issuer"]
|
|
9
|
+
use_trust_chain = options.enable_trust_chain_resolution &&
|
|
10
|
+
issuer_entity_id &&
|
|
11
|
+
is_entity_id?(issuer_entity_id) &&
|
|
12
|
+
options.trust_anchors.any?
|
|
13
|
+
|
|
14
|
+
if use_trust_chain
|
|
15
|
+
return resolve_endpoints_from_trust_chain(issuer_entity_id, client_options_hash)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Fall back to direct entity statement resolution
|
|
19
|
+
# Load entity statement from path, URL, or issuer
|
|
20
|
+
entity_statement_content = load_provider_entity_statement
|
|
21
|
+
return {} unless entity_statement_content
|
|
22
|
+
|
|
23
|
+
begin
|
|
24
|
+
# Resolve endpoints from entity statement
|
|
25
|
+
# Use temporary file if we have content but no path
|
|
26
|
+
entity_statement_path = if options.entity_statement_path && File.exist?(resolve_entity_statement_path(options.entity_statement_path))
|
|
27
|
+
resolve_entity_statement_path(options.entity_statement_path)
|
|
28
|
+
else
|
|
29
|
+
# Create temporary file for EndpointResolver
|
|
30
|
+
temp_file = Tempfile.new(["entity_statement", ".jwt"])
|
|
31
|
+
temp_file.write(entity_statement_content)
|
|
32
|
+
temp_file.close
|
|
33
|
+
temp_file.path
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
resolved = OmniauthOpenidFederation::EndpointResolver.resolve(
|
|
37
|
+
entity_statement_path: entity_statement_path,
|
|
38
|
+
config: {} # Don't pass client_options here - we want entity statement values
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
# Clean up temp file if we created one
|
|
42
|
+
if entity_statement_path.start_with?(Dir.tmpdir)
|
|
43
|
+
begin
|
|
44
|
+
File.unlink(entity_statement_path)
|
|
45
|
+
rescue
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Resolve issuer from entity statement if not already configured
|
|
51
|
+
resolved_issuer = nil
|
|
52
|
+
unless options.issuer || client_options_hash[:issuer] || client_options_hash["issuer"]
|
|
53
|
+
resolved_issuer = resolve_issuer_from_metadata
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Build full URLs from paths if needed
|
|
57
|
+
# Use resolved issuer if available, otherwise fall back to configured issuer
|
|
58
|
+
# Note: Config values are trusted, no security validation needed
|
|
59
|
+
issuer_uri = if resolved_issuer
|
|
60
|
+
begin
|
|
61
|
+
URI.parse(resolved_issuer)
|
|
62
|
+
rescue URI::InvalidURIError
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
elsif options.issuer
|
|
66
|
+
begin
|
|
67
|
+
URI.parse(options.issuer.to_s)
|
|
68
|
+
rescue URI::InvalidURIError
|
|
69
|
+
nil
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
resolved_hash = {}
|
|
74
|
+
|
|
75
|
+
# Add issuer, scheme, and host to resolved hash if resolved from entity statement
|
|
76
|
+
if resolved_issuer && !(client_options_hash[:issuer] || client_options_hash["issuer"])
|
|
77
|
+
resolved_hash[:issuer] = resolved_issuer
|
|
78
|
+
if issuer_uri
|
|
79
|
+
resolved_hash[:scheme] = issuer_uri.scheme unless client_options_hash[:scheme] || client_options_hash["scheme"]
|
|
80
|
+
resolved_hash[:host] = issuer_uri.host unless client_options_hash[:host] || client_options_hash["host"]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Convert endpoint paths to full URLs if they're paths
|
|
85
|
+
# Entity statement may contain full URLs (preferred) or paths
|
|
86
|
+
if resolved[:authorization_endpoint] && !(client_options_hash[:authorization_endpoint] || client_options_hash["authorization_endpoint"])
|
|
87
|
+
resolved_hash[:authorization_endpoint] = if resolved[:authorization_endpoint].start_with?("http://", "https://")
|
|
88
|
+
resolved[:authorization_endpoint]
|
|
89
|
+
elsif issuer_uri
|
|
90
|
+
OmniauthOpenidFederation::EndpointResolver.build_endpoint_url(issuer_uri, resolved[:authorization_endpoint])
|
|
91
|
+
else
|
|
92
|
+
resolved[:authorization_endpoint]
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
if resolved[:token_endpoint] && !(client_options_hash[:token_endpoint] || client_options_hash["token_endpoint"])
|
|
97
|
+
resolved_hash[:token_endpoint] = if resolved[:token_endpoint].start_with?("http://", "https://")
|
|
98
|
+
resolved[:token_endpoint]
|
|
99
|
+
elsif issuer_uri
|
|
100
|
+
OmniauthOpenidFederation::EndpointResolver.build_endpoint_url(issuer_uri, resolved[:token_endpoint])
|
|
101
|
+
else
|
|
102
|
+
resolved[:token_endpoint]
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if resolved[:userinfo_endpoint] && !(client_options_hash[:userinfo_endpoint] || client_options_hash["userinfo_endpoint"])
|
|
107
|
+
resolved_hash[:userinfo_endpoint] = if resolved[:userinfo_endpoint].start_with?("http://", "https://")
|
|
108
|
+
resolved[:userinfo_endpoint]
|
|
109
|
+
elsif issuer_uri
|
|
110
|
+
OmniauthOpenidFederation::EndpointResolver.build_endpoint_url(issuer_uri, resolved[:userinfo_endpoint])
|
|
111
|
+
else
|
|
112
|
+
resolved[:userinfo_endpoint]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
if resolved[:jwks_uri] && !(client_options_hash[:jwks_uri] || client_options_hash["jwks_uri"])
|
|
117
|
+
resolved_hash[:jwks_uri] = if resolved[:jwks_uri].start_with?("http://", "https://")
|
|
118
|
+
resolved[:jwks_uri]
|
|
119
|
+
elsif issuer_uri
|
|
120
|
+
OmniauthOpenidFederation::EndpointResolver.build_endpoint_url(issuer_uri, resolved[:jwks_uri])
|
|
121
|
+
else
|
|
122
|
+
resolved[:jwks_uri]
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Set audience if resolved and not already configured
|
|
127
|
+
if resolved[:audience] && !(client_options_hash[:audience] || client_options_hash["audience"])
|
|
128
|
+
resolved_hash[:audience] = resolved[:audience]
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved from entity statement: #{resolved_hash.keys.join(", ")}") if resolved_hash.any?
|
|
132
|
+
resolved_hash
|
|
133
|
+
rescue => e
|
|
134
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not resolve from entity statement: #{e.message}")
|
|
135
|
+
{}
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def resolve_issuer_from_metadata
|
|
140
|
+
entity_statement_content = load_provider_entity_statement
|
|
141
|
+
return nil unless entity_statement_content
|
|
142
|
+
|
|
143
|
+
begin
|
|
144
|
+
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
145
|
+
parsed = entity_statement.parse
|
|
146
|
+
return nil unless parsed
|
|
147
|
+
|
|
148
|
+
# Prefer provider issuer from metadata, fall back to entity issuer (iss claim)
|
|
149
|
+
issuer = parsed.dig(:metadata, :openid_provider, :issuer) || parsed[:issuer]
|
|
150
|
+
return issuer if OmniauthOpenidFederation::StringHelpers.present?(issuer)
|
|
151
|
+
|
|
152
|
+
nil
|
|
153
|
+
rescue => e
|
|
154
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not resolve issuer from entity statement: #{e.message}")
|
|
155
|
+
nil
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def resolve_audience(client_options_hash, resolved_issuer)
|
|
160
|
+
normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
|
|
161
|
+
|
|
162
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolving audience. Entity statement path: #{options.entity_statement_path}, Resolved issuer: #{resolved_issuer}")
|
|
163
|
+
|
|
164
|
+
# 1. Explicitly configured audience (highest priority)
|
|
165
|
+
audience = options.audience
|
|
166
|
+
if OmniauthOpenidFederation::StringHelpers.present?(audience)
|
|
167
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Using explicitly configured audience: #{audience}")
|
|
168
|
+
return audience
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# 2. Try to resolve from entity statement metadata
|
|
172
|
+
resolved_token_endpoint = nil
|
|
173
|
+
entity_issuer = nil
|
|
174
|
+
entity_statement_content = load_provider_entity_statement
|
|
175
|
+
|
|
176
|
+
if entity_statement_content
|
|
177
|
+
begin
|
|
178
|
+
# Use temporary file for EndpointResolver
|
|
179
|
+
entity_statement_path = if options.entity_statement_path && File.exist?(resolve_entity_statement_path(options.entity_statement_path))
|
|
180
|
+
resolve_entity_statement_path(options.entity_statement_path)
|
|
181
|
+
else
|
|
182
|
+
temp_file = Tempfile.new(["entity_statement", ".jwt"])
|
|
183
|
+
temp_file.write(entity_statement_content)
|
|
184
|
+
temp_file.close
|
|
185
|
+
temp_file.path
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
resolved = OmniauthOpenidFederation::EndpointResolver.resolve(
|
|
189
|
+
entity_statement_path: entity_statement_path,
|
|
190
|
+
config: {}
|
|
191
|
+
)
|
|
192
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] EndpointResolver resolved: #{resolved.keys.join(", ")}")
|
|
193
|
+
|
|
194
|
+
if resolved[:audience] && OmniauthOpenidFederation::StringHelpers.present?(resolved[:audience])
|
|
195
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved audience from entity statement: #{resolved[:audience]}")
|
|
196
|
+
# Clean up temp file if we created one
|
|
197
|
+
if entity_statement_path.start_with?(Dir.tmpdir)
|
|
198
|
+
begin
|
|
199
|
+
File.unlink(entity_statement_path)
|
|
200
|
+
rescue
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
return resolved[:audience]
|
|
205
|
+
end
|
|
206
|
+
# Store token endpoint from entity statement for later use
|
|
207
|
+
resolved_token_endpoint = resolved[:token_endpoint] if resolved[:token_endpoint]
|
|
208
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved token endpoint from entity statement: #{resolved_token_endpoint}")
|
|
209
|
+
|
|
210
|
+
# Also try to get entity issuer (iss claim) from entity statement as fallback
|
|
211
|
+
begin
|
|
212
|
+
entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
|
|
213
|
+
parsed = entity_statement.parse
|
|
214
|
+
entity_issuer = parsed[:issuer] if parsed
|
|
215
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Entity issuer from entity statement: #{entity_issuer}")
|
|
216
|
+
rescue => e
|
|
217
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get entity issuer from entity statement: #{e.message}")
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# Clean up temp file if we created one
|
|
221
|
+
if entity_statement_path.start_with?(Dir.tmpdir)
|
|
222
|
+
begin
|
|
223
|
+
File.unlink(entity_statement_path)
|
|
224
|
+
rescue
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
rescue => e
|
|
229
|
+
OmniauthOpenidFederation::Logger.warn("[Strategy] Could not resolve audience from entity statement: #{e.class} - #{e.message}")
|
|
230
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Entity statement resolution error backtrace: #{e.backtrace.first(3).join(", ")}")
|
|
231
|
+
end
|
|
232
|
+
else
|
|
233
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] No entity statement available (path, URL, or issuer not configured)")
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# 3. Use resolved issuer as audience (common in OpenID Federation)
|
|
237
|
+
# Only use if it's a valid URL (not just a path)
|
|
238
|
+
if OmniauthOpenidFederation::StringHelpers.present?(resolved_issuer)
|
|
239
|
+
# Resolved issuer should be a full URL, not just a path
|
|
240
|
+
if resolved_issuer.start_with?("http://", "https://")
|
|
241
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Using resolved issuer as audience: #{resolved_issuer}")
|
|
242
|
+
return resolved_issuer
|
|
243
|
+
else
|
|
244
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved issuer is not a full URL, skipping: #{resolved_issuer}")
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# 3b. Use entity issuer (iss claim) from entity statement as fallback
|
|
249
|
+
# Only use if it's a valid URL (not just a path)
|
|
250
|
+
if OmniauthOpenidFederation::StringHelpers.present?(entity_issuer)
|
|
251
|
+
# Entity issuer should be a full URL, not just a path
|
|
252
|
+
if entity_issuer.start_with?("http://", "https://")
|
|
253
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Using entity issuer (iss claim) as audience: #{entity_issuer}")
|
|
254
|
+
return entity_issuer
|
|
255
|
+
else
|
|
256
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Entity issuer is not a full URL, skipping: #{entity_issuer}")
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# 4. Use token endpoint as audience (fallback per OAuth 2.0 spec)
|
|
261
|
+
# Try multiple sources: resolved from entity statement, from client_options, or from OpenID Connect client
|
|
262
|
+
token_endpoint = resolved_token_endpoint ||
|
|
263
|
+
normalized_options[:token_endpoint] ||
|
|
264
|
+
normalized_options["token_endpoint"]
|
|
265
|
+
|
|
266
|
+
# If still no token endpoint, try to get it from the OpenID Connect client
|
|
267
|
+
if OmniauthOpenidFederation::StringHelpers.blank?(token_endpoint)
|
|
268
|
+
begin
|
|
269
|
+
# Get resolved endpoints (includes token_endpoint if resolved from entity statement)
|
|
270
|
+
resolved_endpoints = resolve_endpoints_from_metadata(client_options_hash)
|
|
271
|
+
token_endpoint = resolved_endpoints[:token_endpoint] if resolved_endpoints[:token_endpoint]
|
|
272
|
+
rescue => e
|
|
273
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get token endpoint from resolved endpoints: #{e.message}")
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# If still no token endpoint, try to get it from the OpenID Connect client
|
|
278
|
+
if OmniauthOpenidFederation::StringHelpers.blank?(token_endpoint)
|
|
279
|
+
begin
|
|
280
|
+
# The client might have been initialized with token_endpoint from discovery or entity statement
|
|
281
|
+
if client.respond_to?(:token_endpoint) && client.token_endpoint
|
|
282
|
+
token_endpoint = client.token_endpoint.to_s
|
|
283
|
+
end
|
|
284
|
+
rescue => e
|
|
285
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get token endpoint from client: #{e.message}")
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
if OmniauthOpenidFederation::StringHelpers.present?(token_endpoint)
|
|
290
|
+
# Build full URL if it's a path
|
|
291
|
+
if token_endpoint.start_with?("http://", "https://")
|
|
292
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Using token endpoint as audience: #{token_endpoint}")
|
|
293
|
+
return token_endpoint
|
|
294
|
+
else
|
|
295
|
+
# Build full URL from base URL
|
|
296
|
+
base_url = build_base_url(normalized_options)
|
|
297
|
+
# If base_url is nil (no host), we can't build a valid URL - skip this fallback
|
|
298
|
+
if base_url
|
|
299
|
+
full_token_endpoint = build_endpoint(base_url, token_endpoint)
|
|
300
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Using token endpoint as audience: #{full_token_endpoint}")
|
|
301
|
+
return full_token_endpoint
|
|
302
|
+
else
|
|
303
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Cannot build token endpoint URL - no host in client_options")
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# 5. Use authorization endpoint as audience (fallback - also valid per OAuth 2.0)
|
|
309
|
+
# Some providers use authorization endpoint as audience
|
|
310
|
+
auth_endpoint = normalized_options[:authorization_endpoint] || normalized_options["authorization_endpoint"]
|
|
311
|
+
if OmniauthOpenidFederation::StringHelpers.blank?(auth_endpoint)
|
|
312
|
+
begin
|
|
313
|
+
resolved_endpoints = resolve_endpoints_from_metadata(client_options_hash)
|
|
314
|
+
auth_endpoint = resolved_endpoints[:authorization_endpoint] if resolved_endpoints[:authorization_endpoint]
|
|
315
|
+
rescue => e
|
|
316
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get authorization endpoint from resolved endpoints: #{e.message}")
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
if OmniauthOpenidFederation::StringHelpers.blank?(auth_endpoint)
|
|
321
|
+
begin
|
|
322
|
+
if client.respond_to?(:authorization_endpoint) && client.authorization_endpoint
|
|
323
|
+
auth_endpoint = client.authorization_endpoint.to_s
|
|
324
|
+
end
|
|
325
|
+
rescue => e
|
|
326
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get authorization endpoint from client: #{e.message}")
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
if OmniauthOpenidFederation::StringHelpers.present?(auth_endpoint)
|
|
331
|
+
if auth_endpoint.start_with?("http://", "https://")
|
|
332
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Using authorization endpoint as audience: #{auth_endpoint}")
|
|
333
|
+
return auth_endpoint
|
|
334
|
+
else
|
|
335
|
+
base_url = build_base_url(normalized_options)
|
|
336
|
+
if base_url
|
|
337
|
+
full_auth_endpoint = build_endpoint(base_url, auth_endpoint)
|
|
338
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Using authorization endpoint as audience: #{full_auth_endpoint}")
|
|
339
|
+
return full_auth_endpoint
|
|
340
|
+
end
|
|
341
|
+
end
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# 6. Use issuer from client_options as last resort
|
|
345
|
+
issuer = normalized_options[:issuer] || normalized_options["issuer"]
|
|
346
|
+
if OmniauthOpenidFederation::StringHelpers.present?(issuer)
|
|
347
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Using client_options issuer as audience: #{issuer}")
|
|
348
|
+
return issuer
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
# No audience found - log what we tried with details
|
|
352
|
+
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(", ")}")
|
|
353
|
+
nil
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
def build_base_url(client_options_hash)
|
|
357
|
+
normalized = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
|
|
358
|
+
scheme = normalized[:scheme] || "https"
|
|
359
|
+
host = normalized[:host]
|
|
360
|
+
port = normalized[:port]
|
|
361
|
+
|
|
362
|
+
# Return nil if host is missing (can't build valid URL)
|
|
363
|
+
return nil unless OmniauthOpenidFederation::StringHelpers.present?(host)
|
|
364
|
+
|
|
365
|
+
url = "#{scheme}://#{host}"
|
|
366
|
+
url += ":#{port}" if port
|
|
367
|
+
url
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def build_endpoint(base_url, path)
|
|
371
|
+
return path if path.to_s.start_with?("http://", "https://")
|
|
372
|
+
return nil unless base_url # Can't build endpoint without base URL
|
|
373
|
+
|
|
374
|
+
path = path.to_s
|
|
375
|
+
path = "/#{path}" unless path.start_with?("/")
|
|
376
|
+
"#{base_url}#{path}"
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
def resolve_endpoints_from_trust_chain(issuer_entity_id, client_options_hash)
|
|
380
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolving endpoints from trust chain for: #{issuer_entity_id}")
|
|
381
|
+
|
|
382
|
+
resolver = OmniauthOpenidFederation::Federation::TrustChainResolver.new(
|
|
383
|
+
leaf_entity_id: issuer_entity_id,
|
|
384
|
+
trust_anchors: normalize_trust_anchors(options.trust_anchors)
|
|
385
|
+
)
|
|
386
|
+
trust_chain = resolver.resolve!
|
|
387
|
+
|
|
388
|
+
leaf_statement = trust_chain.first
|
|
389
|
+
leaf_parsed = leaf_statement.is_a?(Hash) ? leaf_statement : leaf_statement.parse
|
|
390
|
+
leaf_metadata = extract_metadata_from_parsed(leaf_parsed)
|
|
391
|
+
|
|
392
|
+
merger = OmniauthOpenidFederation::Federation::MetadataPolicyMerger.new(trust_chain: trust_chain)
|
|
393
|
+
effective_metadata = merger.merge_and_apply(leaf_metadata)
|
|
394
|
+
|
|
395
|
+
op_metadata = effective_metadata[:openid_provider] || effective_metadata["openid_provider"] || {}
|
|
396
|
+
|
|
397
|
+
resolved = {}
|
|
398
|
+
resolved[:authorization_endpoint] = op_metadata[:authorization_endpoint] || op_metadata["authorization_endpoint"]
|
|
399
|
+
resolved[:token_endpoint] = op_metadata[:token_endpoint] || op_metadata["token_endpoint"]
|
|
400
|
+
resolved[:userinfo_endpoint] = op_metadata[:userinfo_endpoint] || op_metadata["userinfo_endpoint"]
|
|
401
|
+
resolved[:jwks_uri] = op_metadata[:jwks_uri] || op_metadata["jwks_uri"]
|
|
402
|
+
resolved[:issuer] = op_metadata[:issuer] || op_metadata["issuer"] || issuer_entity_id
|
|
403
|
+
resolved[:audience] = resolved[:issuer]
|
|
404
|
+
|
|
405
|
+
OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved endpoints from trust chain: #{resolved.keys.join(", ")}")
|
|
406
|
+
resolved
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
def extract_metadata_from_parsed(parsed)
|
|
410
|
+
metadata = parsed[:metadata] || parsed["metadata"] || {}
|
|
411
|
+
# Ensure it's a hash with entity type keys
|
|
412
|
+
result = {}
|
|
413
|
+
metadata.each do |entity_type, entity_metadata|
|
|
414
|
+
result[entity_type.to_sym] = entity_metadata
|
|
415
|
+
end
|
|
416
|
+
result
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
def normalize_trust_anchors(trust_anchors)
|
|
420
|
+
trust_anchors.map do |ta|
|
|
421
|
+
{
|
|
422
|
+
entity_id: ta[:entity_id] || ta["entity_id"],
|
|
423
|
+
jwks: ta[:jwks] || ta["jwks"]
|
|
424
|
+
}
|
|
425
|
+
end
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def is_entity_id?(str)
|
|
429
|
+
str.is_a?(String) && str.start_with?("http://", "https://")
|
|
430
|
+
end
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module OmniauthOpenidFederation
|
|
2
|
+
module Strategy
|
|
3
|
+
module FailureHandling
|
|
4
|
+
def fail!(error_type, exception = nil)
|
|
5
|
+
# Determine if this error has already been instrumented
|
|
6
|
+
# Errors instrumented before calling fail! will have a flag set
|
|
7
|
+
already_instrumented = env["omniauth_openid_federation.instrumented"] == true
|
|
8
|
+
|
|
9
|
+
unless already_instrumented
|
|
10
|
+
# Extract error information
|
|
11
|
+
error_message = exception&.message || error_type.to_s
|
|
12
|
+
error_class = exception&.class&.name || "UnknownError"
|
|
13
|
+
|
|
14
|
+
# Determine the phase (request or callback)
|
|
15
|
+
phase = request.path.end_with?("/callback") ? "callback_phase" : "request_phase"
|
|
16
|
+
|
|
17
|
+
# Build request info
|
|
18
|
+
request_info = {
|
|
19
|
+
remote_ip: request.env["REMOTE_ADDR"],
|
|
20
|
+
user_agent: request.env["HTTP_USER_AGENT"],
|
|
21
|
+
path: request.path,
|
|
22
|
+
method: request.request_method
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# Instrument based on error type
|
|
26
|
+
case error_type.to_sym
|
|
27
|
+
when :authenticity_error
|
|
28
|
+
# OmniAuth CSRF protection error (from middleware)
|
|
29
|
+
OmniauthOpenidFederation::Instrumentation.notify_authenticity_error(
|
|
30
|
+
error_type: error_type.to_s,
|
|
31
|
+
error_message: error_message,
|
|
32
|
+
error_class: error_class,
|
|
33
|
+
phase: phase,
|
|
34
|
+
request_info: request_info
|
|
35
|
+
)
|
|
36
|
+
when :csrf_detected
|
|
37
|
+
# This should already be instrumented before calling fail!, but instrument here as fallback
|
|
38
|
+
# (e.g., if fail! is called directly without prior instrumentation)
|
|
39
|
+
OmniauthOpenidFederation::Instrumentation.notify_csrf_detected(
|
|
40
|
+
error_type: error_type.to_s,
|
|
41
|
+
error_message: error_message,
|
|
42
|
+
phase: phase,
|
|
43
|
+
request_info: request_info
|
|
44
|
+
)
|
|
45
|
+
when :missing_code, :token_exchange_error
|
|
46
|
+
# These should already be instrumented before calling fail!, but instrument here as fallback
|
|
47
|
+
# (e.g., if fail! is called directly without prior instrumentation)
|
|
48
|
+
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
49
|
+
stage: phase,
|
|
50
|
+
error_message: error_message,
|
|
51
|
+
error_class: error_class,
|
|
52
|
+
error_type: error_type.to_s,
|
|
53
|
+
request_info: request_info
|
|
54
|
+
)
|
|
55
|
+
else
|
|
56
|
+
# Unknown error type - instrument as unexpected authentication break
|
|
57
|
+
OmniauthOpenidFederation::Instrumentation.notify_unexpected_authentication_break(
|
|
58
|
+
stage: phase,
|
|
59
|
+
error_message: error_message,
|
|
60
|
+
error_class: error_class,
|
|
61
|
+
error_type: error_type.to_s,
|
|
62
|
+
request_info: request_info
|
|
63
|
+
)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Mark as instrumented to prevent double instrumentation
|
|
68
|
+
env["omniauth_openid_federation.instrumented"] = true
|
|
69
|
+
|
|
70
|
+
# Call parent fail! method
|
|
71
|
+
super
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|