omniauth_openid_federation 1.3.2 → 2.1.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/README.md +136 -15
  4. data/SECURITY.md +11 -14
  5. data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
  6. data/config/polyrun_coverage.yml +15 -0
  7. data/config/polyrun_spec_quality.yml +29 -0
  8. data/examples/README_INTEGRATION_TESTING.md +3 -0
  9. data/examples/integration_test_flow.rb +10 -8
  10. data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
  11. data/examples/jobs/federation_files_generation_job.rb.example +3 -2
  12. data/examples/mock_op_server.rb +4 -5
  13. data/examples/mock_rp_server.rb +3 -3
  14. data/examples/standalone_multiple_endpoints_example.rb +494 -0
  15. data/lib/omniauth_openid_federation/access_token.rb +91 -450
  16. data/lib/omniauth_openid_federation/cache.rb +42 -4
  17. data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
  18. data/lib/omniauth_openid_federation/constants.rb +6 -0
  19. data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
  20. data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +33 -21
  21. data/lib/omniauth_openid_federation/federation/signed_jwks.rb +4 -2
  22. data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +85 -20
  23. data/lib/omniauth_openid_federation/federation/trust_chain_signature.rb +62 -0
  24. data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
  25. data/lib/omniauth_openid_federation/http_client.rb +182 -32
  26. data/lib/omniauth_openid_federation/http_errors.rb +14 -0
  27. data/lib/omniauth_openid_federation/id_token.rb +19 -0
  28. data/lib/omniauth_openid_federation/jwe.rb +26 -0
  29. data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
  30. data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
  31. data/lib/omniauth_openid_federation/jws.rb +2 -11
  32. data/lib/omniauth_openid_federation/jwt_response_decoder.rb +288 -0
  33. data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
  34. data/lib/omniauth_openid_federation/rack.rb +2 -0
  35. data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
  36. data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
  37. data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
  38. data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
  39. data/lib/omniauth_openid_federation/strategy/callback_handling.rb +133 -0
  40. data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
  41. data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +216 -0
  42. data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +432 -0
  43. data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
  44. data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
  45. data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +262 -0
  46. data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +135 -0
  47. data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +93 -0
  48. data/lib/omniauth_openid_federation/strategy.rb +30 -1911
  49. data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
  50. data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
  51. data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
  52. data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
  53. data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
  54. data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
  55. data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
  56. data/lib/omniauth_openid_federation/user_info.rb +15 -0
  57. data/lib/omniauth_openid_federation/utils.rb +11 -3
  58. data/lib/omniauth_openid_federation/validators.rb +43 -8
  59. data/lib/omniauth_openid_federation/version.rb +1 -1
  60. data/lib/omniauth_openid_federation.rb +9 -3
  61. data/lib/tasks/omniauth_openid_federation.rake +1 -2
  62. data/sig/omniauth_openid_federation.rbs +2 -1
  63. data/sig/strategy.rbs +6 -6
  64. metadata +188 -63
@@ -0,0 +1,432 @@
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 readable configured path
26
+ created_temp_entity_statement = false
27
+ entity_statement_path = if options.entity_statement_path && File.exist?(resolve_entity_statement_path(options.entity_statement_path))
28
+ resolve_entity_statement_path(options.entity_statement_path)
29
+ else
30
+ temp_file = Tempfile.new(["entity_statement", ".jwt"])
31
+ temp_file.write(entity_statement_content)
32
+ temp_file.close
33
+ created_temp_entity_statement = true
34
+ temp_file.path
35
+ end
36
+
37
+ resolved = OmniauthOpenidFederation::EndpointResolver.resolve(
38
+ entity_statement_path: entity_statement_path,
39
+ config: {} # Don't pass client_options here - we want entity statement values
40
+ )
41
+
42
+ if created_temp_entity_statement
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
+ created_temp_entity_statement = false
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
+ created_temp_entity_statement = true
186
+ temp_file.path
187
+ end
188
+
189
+ resolved = OmniauthOpenidFederation::EndpointResolver.resolve(
190
+ entity_statement_path: entity_statement_path,
191
+ config: {}
192
+ )
193
+ OmniauthOpenidFederation::Logger.debug("[Strategy] EndpointResolver resolved: #{resolved.keys.join(", ")}")
194
+
195
+ if resolved[:audience] && OmniauthOpenidFederation::StringHelpers.present?(resolved[:audience])
196
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved audience from entity statement: #{resolved[:audience]}")
197
+ if created_temp_entity_statement
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
+ if created_temp_entity_statement
221
+ begin
222
+ File.unlink(entity_statement_path)
223
+ rescue
224
+ nil
225
+ end
226
+ end
227
+ rescue => e
228
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Could not resolve audience from entity statement: #{e.class} - #{e.message}")
229
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Entity statement resolution error backtrace: #{e.backtrace.first(3).join(", ")}")
230
+ end
231
+ else
232
+ OmniauthOpenidFederation::Logger.debug("[Strategy] No entity statement available (path, URL, or issuer not configured)")
233
+ end
234
+
235
+ # 3. Use resolved issuer as audience (common in OpenID Federation)
236
+ # Only use if it's a valid URL (not just a path)
237
+ if OmniauthOpenidFederation::StringHelpers.present?(resolved_issuer)
238
+ # Resolved issuer should be a full URL, not just a path
239
+ if resolved_issuer.start_with?("http://", "https://")
240
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using resolved issuer as audience: #{resolved_issuer}")
241
+ return resolved_issuer
242
+ else
243
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved issuer is not a full URL, skipping: #{resolved_issuer}")
244
+ end
245
+ end
246
+
247
+ # 3b. Use entity issuer (iss claim) from entity statement as fallback
248
+ # Only use if it's a valid URL (not just a path)
249
+ if OmniauthOpenidFederation::StringHelpers.present?(entity_issuer)
250
+ # Entity issuer should be a full URL, not just a path
251
+ if entity_issuer.start_with?("http://", "https://")
252
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using entity issuer (iss claim) as audience: #{entity_issuer}")
253
+ return entity_issuer
254
+ else
255
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Entity issuer is not a full URL, skipping: #{entity_issuer}")
256
+ end
257
+ end
258
+
259
+ # 4. Use token endpoint as audience (fallback per OAuth 2.0 spec)
260
+ # Try multiple sources: resolved from entity statement, from client_options, or from OpenID Connect client
261
+ token_endpoint = resolved_token_endpoint ||
262
+ normalized_options[:token_endpoint] ||
263
+ normalized_options["token_endpoint"]
264
+
265
+ # If still no token endpoint, try to get it from the OpenID Connect client
266
+ if OmniauthOpenidFederation::StringHelpers.blank?(token_endpoint)
267
+ begin
268
+ # Get resolved endpoints (includes token_endpoint if resolved from entity statement)
269
+ resolved_endpoints = resolve_endpoints_from_metadata(client_options_hash)
270
+ token_endpoint = resolved_endpoints[:token_endpoint] if resolved_endpoints[:token_endpoint]
271
+ rescue => e
272
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get token endpoint from resolved endpoints: #{e.message}")
273
+ end
274
+ end
275
+
276
+ # If still no token endpoint, try to get it from the OpenID Connect client
277
+ if OmniauthOpenidFederation::StringHelpers.blank?(token_endpoint)
278
+ begin
279
+ # The client might have been initialized with token_endpoint from discovery or entity statement
280
+ if client.respond_to?(:token_endpoint) && client.token_endpoint
281
+ token_endpoint = client.token_endpoint.to_s
282
+ end
283
+ rescue => e
284
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get token endpoint from client: #{e.message}")
285
+ end
286
+ end
287
+
288
+ if OmniauthOpenidFederation::StringHelpers.present?(token_endpoint)
289
+ # Build full URL if it's a path
290
+ if token_endpoint.start_with?("http://", "https://")
291
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using token endpoint as audience: #{token_endpoint}")
292
+ return token_endpoint
293
+ else
294
+ # Build full URL from base URL
295
+ base_url = build_base_url(normalized_options)
296
+ # If base_url is nil (no host), we can't build a valid URL - skip this fallback
297
+ if base_url
298
+ full_token_endpoint = build_endpoint(base_url, token_endpoint)
299
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using token endpoint as audience: #{full_token_endpoint}")
300
+ return full_token_endpoint
301
+ else
302
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Cannot build token endpoint URL - no host in client_options")
303
+ end
304
+ end
305
+ end
306
+
307
+ # 5. Use authorization endpoint as audience (fallback - also valid per OAuth 2.0)
308
+ # Some providers use authorization endpoint as audience
309
+ auth_endpoint = normalized_options[:authorization_endpoint] || normalized_options["authorization_endpoint"]
310
+ if OmniauthOpenidFederation::StringHelpers.blank?(auth_endpoint)
311
+ begin
312
+ resolved_endpoints = resolve_endpoints_from_metadata(client_options_hash)
313
+ auth_endpoint = resolved_endpoints[:authorization_endpoint] if resolved_endpoints[:authorization_endpoint]
314
+ rescue => e
315
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get authorization endpoint from resolved endpoints: #{e.message}")
316
+ end
317
+ end
318
+
319
+ if OmniauthOpenidFederation::StringHelpers.blank?(auth_endpoint)
320
+ begin
321
+ if client.respond_to?(:authorization_endpoint) && client.authorization_endpoint
322
+ auth_endpoint = client.authorization_endpoint.to_s
323
+ end
324
+ rescue => e
325
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get authorization endpoint from client: #{e.message}")
326
+ end
327
+ end
328
+
329
+ if OmniauthOpenidFederation::StringHelpers.present?(auth_endpoint)
330
+ if auth_endpoint.start_with?("http://", "https://")
331
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using authorization endpoint as audience: #{auth_endpoint}")
332
+ return auth_endpoint
333
+ else
334
+ base_url = build_base_url(normalized_options)
335
+ if base_url
336
+ full_auth_endpoint = build_endpoint(base_url, auth_endpoint)
337
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using authorization endpoint as audience: #{full_auth_endpoint}")
338
+ return full_auth_endpoint
339
+ end
340
+ end
341
+ end
342
+
343
+ # 6. Use issuer from client_options as last resort
344
+ issuer = normalized_options[:issuer] || normalized_options["issuer"]
345
+ if OmniauthOpenidFederation::StringHelpers.present?(issuer)
346
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using client_options issuer as audience: #{issuer}")
347
+ return issuer
348
+ end
349
+
350
+ # No audience found - log what we tried with details
351
+ 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(", ")}")
352
+ nil
353
+ end
354
+
355
+ def build_base_url(client_options_hash)
356
+ normalized = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
357
+ scheme = normalized[:scheme] || "https"
358
+ host = normalized[:host]
359
+ port = normalized[:port]
360
+
361
+ # Return nil if host is missing (can't build valid URL)
362
+ return nil unless OmniauthOpenidFederation::StringHelpers.present?(host)
363
+
364
+ url = "#{scheme}://#{host}"
365
+ url += ":#{port}" if port
366
+ url
367
+ end
368
+
369
+ def build_endpoint(base_url, path)
370
+ return path if path.to_s.start_with?("http://", "https://")
371
+ return nil unless base_url # Can't build endpoint without base URL
372
+
373
+ path = path.to_s
374
+ path = "/#{path}" unless path.start_with?("/")
375
+ "#{base_url}#{path}"
376
+ end
377
+
378
+ def resolve_endpoints_from_trust_chain(issuer_entity_id, client_options_hash)
379
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Resolving endpoints from trust chain for: #{issuer_entity_id}")
380
+
381
+ resolver = OmniauthOpenidFederation::Federation::TrustChainResolver.new(
382
+ leaf_entity_id: issuer_entity_id,
383
+ trust_anchors: normalize_trust_anchors(options.trust_anchors)
384
+ )
385
+ trust_chain = resolver.resolve!
386
+
387
+ leaf_statement = trust_chain.first
388
+ leaf_parsed = leaf_statement.is_a?(Hash) ? leaf_statement : leaf_statement.parse
389
+ leaf_metadata = extract_metadata_from_parsed(leaf_parsed)
390
+
391
+ merger = OmniauthOpenidFederation::Federation::MetadataPolicyMerger.new(trust_chain: trust_chain)
392
+ effective_metadata = merger.merge_and_apply(leaf_metadata)
393
+
394
+ op_metadata = effective_metadata[:openid_provider] || effective_metadata["openid_provider"] || {}
395
+
396
+ resolved = {}
397
+ resolved[:authorization_endpoint] = op_metadata[:authorization_endpoint] || op_metadata["authorization_endpoint"]
398
+ resolved[:token_endpoint] = op_metadata[:token_endpoint] || op_metadata["token_endpoint"]
399
+ resolved[:userinfo_endpoint] = op_metadata[:userinfo_endpoint] || op_metadata["userinfo_endpoint"]
400
+ resolved[:jwks_uri] = op_metadata[:jwks_uri] || op_metadata["jwks_uri"]
401
+ resolved[:issuer] = op_metadata[:issuer] || op_metadata["issuer"] || issuer_entity_id
402
+ resolved[:audience] = resolved[:issuer]
403
+
404
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved endpoints from trust chain: #{resolved.keys.join(", ")}")
405
+ resolved
406
+ end
407
+
408
+ def extract_metadata_from_parsed(parsed)
409
+ metadata = parsed[:metadata] || parsed["metadata"] || {}
410
+ # Ensure it's a hash with entity type keys
411
+ result = {}
412
+ metadata.each do |entity_type, entity_metadata|
413
+ result[entity_type.to_sym] = entity_metadata
414
+ end
415
+ result
416
+ end
417
+
418
+ def normalize_trust_anchors(trust_anchors)
419
+ trust_anchors.map do |ta|
420
+ {
421
+ entity_id: ta[:entity_id] || ta["entity_id"],
422
+ jwks: ta[:jwks] || ta["jwks"]
423
+ }
424
+ end
425
+ end
426
+
427
+ def is_entity_id?(str)
428
+ str.is_a?(String) && str.start_with?("http://", "https://")
429
+ end
430
+ end
431
+ end
432
+ 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