omniauth_openid_federation 1.3.2 → 2.0.0

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