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,268 @@
1
+ require "base64"
2
+ require "jwt"
3
+
4
+ require_relative "../secure_compare"
5
+
6
+ module OmniauthOpenidFederation
7
+ module Strategy
8
+ module IdTokenDecoding
9
+ private
10
+
11
+ def decode_id_token(id_token)
12
+ client_options_hash = options.client_options || {}
13
+ normalized_options = OmniauthOpenidFederation::Validators.normalize_hash(client_options_hash)
14
+
15
+ if encrypted_token?(id_token)
16
+ decryption_key_source = options.decryption_key_source || options.key_source || :local
17
+ private_key = normalized_options[:private_key]
18
+ jwks = normalized_options[:jwks] || normalized_options["jwks"]
19
+ metadata = load_metadata_for_key_extraction
20
+
21
+ encryption_key = case decryption_key_source
22
+ when :federation
23
+ OmniauthOpenidFederation::KeyExtractor.extract_encryption_key(
24
+ jwks: jwks,
25
+ metadata: metadata,
26
+ private_key: private_key
27
+ )
28
+ when :local
29
+ private_key
30
+ else
31
+ raise OmniauthOpenidFederation::ConfigurationError, "Unknown decryption key source: #{decryption_key_source}"
32
+ end
33
+
34
+ OmniauthOpenidFederation::Validators.validate_private_key!(encryption_key)
35
+
36
+ begin
37
+ decrypted_token = OmniauthOpenidFederation::Jwe.decrypt(id_token, encryption_key)
38
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Successfully decrypted ID token using encryption key")
39
+
40
+ parts = decrypted_token.to_s.split(".")
41
+ if parts.length != 3
42
+ error_msg = "Decrypted token is not a valid JWT (expected 3 parts, got #{parts.length})"
43
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
44
+ OmniauthOpenidFederation::Instrumentation.notify_decryption_failed(
45
+ token_type: "id_token",
46
+ error_message: error_msg,
47
+ error_class: "DecryptionError"
48
+ )
49
+ raise OmniauthOpenidFederation::DecryptionError, error_msg
50
+ end
51
+
52
+ id_token = decrypted_token
53
+ rescue => e
54
+ error_msg = "Failed to decrypt ID token: #{e.class} - #{e.message}"
55
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
56
+ OmniauthOpenidFederation::Instrumentation.notify_decryption_failed(
57
+ token_type: "id_token",
58
+ error_message: e.message,
59
+ error_class: e.class.name
60
+ )
61
+ raise OmniauthOpenidFederation::DecryptionError, error_msg, e.backtrace
62
+ end
63
+ end
64
+
65
+ header_part = id_token.split(".").first
66
+ header = JSON.parse(Base64.urlsafe_decode64(header_part))
67
+ kid = header["kid"] || header[:kid]
68
+
69
+ OmniauthOpenidFederation::Logger.debug("[Strategy] ID token kid: #{kid}")
70
+
71
+ jwks = resolve_jwks_for_validation_with_kid(normalized_options, kid)
72
+
73
+ unless jwks
74
+ error_msg = "JWKS not available for ID token validation. Provide entity statement with provider JWKS or configure jwks_uri"
75
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
76
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
77
+ end
78
+
79
+ begin
80
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Decoding ID token with JWKS (keys: #{(jwks.is_a?(Hash) && jwks["keys"]) ? jwks["keys"].length : "N/A"})")
81
+
82
+ unless jwks.is_a?(Hash) && jwks["keys"]
83
+ error_msg = "JWKS format invalid: expected hash with 'keys' array"
84
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
85
+ raise OmniauthOpenidFederation::ValidationError, error_msg
86
+ end
87
+
88
+ if kid.nil?
89
+ error_msg = "No key id (kid) found in JWT header. JWT must include kid in header to identify the signing key."
90
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
91
+ raise OmniauthOpenidFederation::SignatureError, error_msg
92
+ end
93
+
94
+ key_data = jwks["keys"].find { |key| (key["kid"] || key[:kid]) == kid }
95
+
96
+ unless key_data
97
+ available_kids = jwks["keys"].map { |k| k["kid"] || k[:kid] }.compact
98
+ 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(", ")}"
99
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
100
+ OmniauthOpenidFederation::Instrumentation.notify_kid_not_found(
101
+ kid: kid,
102
+ jwks_uri: resolve_jwks_uri(normalized_options),
103
+ available_kids: available_kids,
104
+ token_type: "id_token"
105
+ )
106
+ raise OmniauthOpenidFederation::ValidationError, error_msg
107
+ end
108
+
109
+ public_key = OmniauthOpenidFederation::KeyExtractor.jwk_to_openssl_key(key_data)
110
+
111
+ decoded_payload, _ = JWT.decode(
112
+ id_token,
113
+ public_key,
114
+ true,
115
+ {algorithm: "RS256"}
116
+ )
117
+
118
+ normalized_payload = decoded_payload.each_with_object({}) do |(k, v), h|
119
+ h[k.to_s] = v
120
+ end
121
+
122
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Successfully decoded ID token. Claims: #{normalized_payload.keys.join(", ")}")
123
+
124
+ required_claims = ["iss", "sub", "aud", "exp", "iat"]
125
+ payload_keys = normalized_payload.keys.map(&:to_s)
126
+ missing_claims = required_claims - payload_keys
127
+
128
+ if missing_claims.any?
129
+ error_msg = "ID token missing required claims: #{missing_claims.join(", ")}. Available claims: #{payload_keys.join(", ")}"
130
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
131
+ OmniauthOpenidFederation::Instrumentation.notify_missing_required_claims(
132
+ missing_claims: missing_claims,
133
+ available_claims: payload_keys,
134
+ token_type: "id_token"
135
+ )
136
+ raise OmniauthOpenidFederation::ValidationError, error_msg
137
+ end
138
+
139
+ validate_id_token_claims!(normalized_payload, normalized_options)
140
+ omniauth_rack_session&.delete("omniauth.nonce") if options.send_nonce
141
+
142
+ payload_with_symbols = normalized_payload.each_with_object({}) do |(k, v), h|
143
+ h[k.to_sym] = v
144
+ end
145
+
146
+ OmniauthOpenidFederation::IdToken.new(payload_with_symbols)
147
+ rescue JWT::DecodeError, JWT::VerificationError => e
148
+ error_msg = "Failed to decode or verify ID token signature: #{e.class} - #{e.message}"
149
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
150
+
151
+ available_kids = []
152
+ if jwks.is_a?(Hash) && jwks["keys"]
153
+ available_kids = jwks["keys"].map { |k| k["kid"] || k[:kid] }.compact
154
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Available keys in JWKS (kids): #{available_kids.join(", ")}")
155
+ end
156
+
157
+ OmniauthOpenidFederation::Instrumentation.notify_signature_verification_failed(
158
+ token_type: "id_token",
159
+ kid: kid,
160
+ jwks_uri: resolve_jwks_uri(normalized_options),
161
+ error_message: e.message,
162
+ error_class: e.class.name,
163
+ available_kids: available_kids
164
+ )
165
+
166
+ raise OmniauthOpenidFederation::SignatureError, error_msg, e.backtrace
167
+ rescue OmniauthOpenidFederation::ValidationError,
168
+ OmniauthOpenidFederation::SecurityError,
169
+ OmniauthOpenidFederation::ConfigurationError,
170
+ OmniauthOpenidFederation::DecryptionError => error
171
+ raise error
172
+ rescue => e
173
+ error_msg = "Failed to decode or validate ID token: #{e.class} - #{e.message}"
174
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
175
+ raise OmniauthOpenidFederation::SignatureError, error_msg, e.backtrace
176
+ end
177
+ end
178
+
179
+ def encrypted_token?(token)
180
+ OmniauthOpenidFederation::Jwe.encrypted?(token)
181
+ end
182
+
183
+ def validate_id_token_claims!(payload, normalized_options)
184
+ expected_issuer = expected_id_token_issuer(normalized_options)
185
+ token_issuer = payload["iss"]
186
+
187
+ if OmniauthOpenidFederation::StringHelpers.present?(expected_issuer) &&
188
+ token_issuer != expected_issuer
189
+ OmniauthOpenidFederation::Instrumentation.notify_issuer_mismatch(
190
+ expected_issuer: expected_issuer,
191
+ actual_issuer: token_issuer
192
+ )
193
+ raise OmniauthOpenidFederation::ValidationError,
194
+ "ID token issuer mismatch: expected '#{expected_issuer}', got '#{token_issuer}'"
195
+ end
196
+
197
+ expected_client_id = expected_id_token_client_id(normalized_options)
198
+ token_audiences = Array(payload["aud"]).map(&:to_s)
199
+
200
+ if OmniauthOpenidFederation::StringHelpers.present?(expected_client_id) &&
201
+ !token_audiences.include?(expected_client_id.to_s)
202
+ OmniauthOpenidFederation::Instrumentation.notify_audience_mismatch(
203
+ expected_audience: expected_client_id,
204
+ actual_audience: payload["aud"]
205
+ )
206
+ raise OmniauthOpenidFederation::ValidationError,
207
+ "ID token audience mismatch: expected '#{expected_client_id}' in aud, got '#{payload["aud"]}'"
208
+ end
209
+
210
+ if options.send_nonce
211
+ rack_session = omniauth_rack_session
212
+ unless rack_session
213
+ raise OmniauthOpenidFederation::ValidationError,
214
+ "ID token nonce validation failed: no nonce in session"
215
+ end
216
+
217
+ session_nonce = rack_session["omniauth.nonce"]
218
+ token_nonce = payload["nonce"]
219
+
220
+ if OmniauthOpenidFederation::StringHelpers.blank?(session_nonce)
221
+ raise OmniauthOpenidFederation::ValidationError,
222
+ "ID token nonce validation failed: no nonce in session"
223
+ end
224
+
225
+ if OmniauthOpenidFederation::StringHelpers.blank?(token_nonce) ||
226
+ !OmniauthOpenidFederation::SecureCompare.secure_compare(token_nonce.to_s, session_nonce.to_s)
227
+ OmniauthOpenidFederation::Instrumentation.notify_invalid_nonce(
228
+ expected_nonce: "[PRESENT]",
229
+ actual_nonce: token_nonce ? "[PRESENT]" : "[MISSING]"
230
+ )
231
+ raise OmniauthOpenidFederation::SecurityError, "ID token nonce mismatch"
232
+ end
233
+ end
234
+
235
+ OmniauthOpenidFederation::Validators.validate_allowed_acr_value!(
236
+ payload["acr"],
237
+ options.allowed_acr_values
238
+ )
239
+ end
240
+
241
+ def expected_id_token_issuer(normalized_options)
242
+ options.issuer ||
243
+ normalized_options[:issuer] ||
244
+ resolve_issuer_from_metadata
245
+ end
246
+
247
+ def expected_id_token_client_id(normalized_options)
248
+ if (options.client_registration_type || :explicit) == :automatic
249
+ client_entity_statement = load_client_entity_statement(
250
+ options.client_entity_statement_path,
251
+ options.client_entity_statement_url
252
+ )
253
+ entity_identifier = extract_entity_identifier_from_statement(
254
+ client_entity_statement,
255
+ options.client_entity_identifier
256
+ )
257
+ return entity_identifier if OmniauthOpenidFederation::StringHelpers.present?(entity_identifier)
258
+ end
259
+
260
+ if client.respond_to?(:identifier) && OmniauthOpenidFederation::StringHelpers.present?(client.identifier)
261
+ client.identifier
262
+ else
263
+ normalized_options[:identifier]
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,262 @@
1
+ module OmniauthOpenidFederation
2
+ module Strategy
3
+ module JwksResolution
4
+ private
5
+
6
+ def resolve_jwks_for_validation(normalized_options)
7
+ entity_statement_content = load_provider_entity_statement
8
+
9
+ # 1. Extract JWKS directly from entity statement (we already have it - no HTTP request needed)
10
+ if entity_statement_content
11
+ begin
12
+ entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
13
+ parsed = entity_statement.parse
14
+ if parsed && parsed[:jwks]
15
+ entity_jwks = parsed[:jwks]
16
+ # Ensure it's in the format expected by JWT.decode (hash with "keys" array)
17
+ if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
18
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using JWKS from entity statement for ID token validation")
19
+ return entity_jwks
20
+ elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
21
+ # Convert symbol keys to string keys
22
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using JWKS from entity statement for ID token validation")
23
+ return {"keys" => entity_jwks[:keys]}
24
+ elsif entity_jwks.is_a?(Array)
25
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using JWKS from entity statement for ID token validation")
26
+ return {"keys" => entity_jwks}
27
+ end
28
+ end
29
+ rescue => e
30
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not extract JWKS from entity statement: #{e.message}")
31
+ end
32
+ end
33
+
34
+ # 2. Try to fetch from signed JWKS (if entity statement has signed_jwks_uri)
35
+ if entity_statement_content
36
+ begin
37
+ parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
38
+ entity_statement_content
39
+ )
40
+ if parsed && parsed[:signed_jwks_uri] && parsed[:entity_jwks]
41
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching signed JWKS for ID token validation")
42
+ signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(
43
+ parsed[:signed_jwks_uri],
44
+ parsed[:entity_jwks]
45
+ )
46
+ # Ensure it's in the format expected by JWT.decode
47
+ if signed_jwks.is_a?(Hash) && signed_jwks.key?("keys")
48
+ return signed_jwks
49
+ elsif signed_jwks.is_a?(Hash) && signed_jwks.key?(:keys)
50
+ return {"keys" => signed_jwks[:keys]}
51
+ elsif signed_jwks.is_a?(Array)
52
+ return {"keys" => signed_jwks}
53
+ end
54
+ end
55
+ rescue => e
56
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not fetch signed JWKS: #{e.message}")
57
+ end
58
+ end
59
+
60
+ # 3. Fallback: Fetch from standard JWKS URI (only if entity statement doesn't have JWKS)
61
+ jwks_uri = resolve_jwks_uri(normalized_options)
62
+ if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
63
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching JWKS from URI: #{OmniauthOpenidFederation::Utils.sanitize_uri(jwks_uri)}")
64
+ begin
65
+ return fetch_jwks(jwks_uri)
66
+ rescue => e
67
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to fetch JWKS from URI: #{e.message}")
68
+ end
69
+ end
70
+
71
+ # No JWKS found
72
+ nil
73
+ end
74
+
75
+ def resolve_jwks_for_validation_with_kid(normalized_options, kid)
76
+ entity_statement_content = load_provider_entity_statement
77
+ first_valid_jwks = nil # Track first valid JWKS in case kid is not found
78
+
79
+ # 1. Try entity statement JWKS first (fastest, no HTTP request)
80
+ if entity_statement_content
81
+ begin
82
+ entity_statement = OmniauthOpenidFederation::Federation::EntityStatement.new(entity_statement_content)
83
+ parsed = entity_statement.parse
84
+ if parsed && parsed[:jwks]
85
+ entity_jwks = parsed[:jwks]
86
+ # Ensure it's in the format expected by JWT.decode (hash with "keys" array)
87
+ jwks_hash = if entity_jwks.is_a?(Hash) && entity_jwks.key?("keys")
88
+ entity_jwks
89
+ elsif entity_jwks.is_a?(Hash) && entity_jwks.key?(:keys)
90
+ {"keys" => entity_jwks[:keys]}
91
+ elsif entity_jwks.is_a?(Array)
92
+ {"keys" => entity_jwks}
93
+ end
94
+
95
+ keys = jwks_hash&.dig("keys")
96
+ if keys&.is_a?(Array) && !keys.empty?
97
+ # Track first valid JWKS
98
+ first_valid_jwks ||= jwks_hash
99
+ # If kid is nil, return JWKS anyway (let JWT decoding fail with proper error)
100
+ if kid.nil?
101
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid is nil, returning entity statement JWKS for validation attempt")
102
+ return jwks_hash
103
+ end
104
+ # Check if kid is in this JWKS
105
+ key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
106
+ if key_data
107
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Found kid '#{kid}' in entity statement JWKS")
108
+ return jwks_hash
109
+ else
110
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in entity statement JWKS, trying signed JWKS")
111
+ end
112
+ end
113
+ end
114
+ rescue => e
115
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not extract JWKS from entity statement: #{e.message}")
116
+ end
117
+ end
118
+
119
+ # 2. Try signed JWKS (if entity statement has signed_jwks_uri).
120
+ # When signed_jwks_uri is advertised, signature/validation failures fail closed
121
+ # instead of falling back to an unsigned jwks_uri.
122
+ if entity_statement_content
123
+ parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
124
+ entity_statement_content
125
+ )
126
+ if parsed && parsed[:signed_jwks_uri] && parsed[:entity_jwks]
127
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching signed JWKS for ID token validation (kid: #{kid})")
128
+ signed_jwks = OmniauthOpenidFederation::Federation::SignedJWKS.fetch!(
129
+ parsed[:signed_jwks_uri],
130
+ parsed[:entity_jwks]
131
+ )
132
+
133
+ jwks_hash = if signed_jwks.is_a?(Hash) && signed_jwks.key?("keys")
134
+ signed_jwks
135
+ elsif signed_jwks.is_a?(Hash) && signed_jwks.key?(:keys)
136
+ {"keys" => signed_jwks[:keys]}
137
+ elsif signed_jwks.is_a?(Array)
138
+ {"keys" => signed_jwks}
139
+ end
140
+
141
+ keys = jwks_hash&.dig("keys")
142
+ if keys&.is_a?(Array) && !keys.empty?
143
+ first_valid_jwks ||= jwks_hash
144
+ if kid.nil?
145
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid is nil, returning signed JWKS for validation attempt")
146
+ return jwks_hash
147
+ end
148
+ key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
149
+ if key_data
150
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Found kid '#{kid}' in signed JWKS")
151
+ return jwks_hash
152
+ else
153
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in signed JWKS, trying standard JWKS URI")
154
+ end
155
+ end
156
+ end
157
+ end
158
+
159
+ # 3. Fallback: Fetch from standard JWKS URI
160
+ jwks_uri = resolve_jwks_uri(normalized_options)
161
+ if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
162
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching JWKS from URI for kid '#{kid}': #{OmniauthOpenidFederation::Utils.sanitize_uri(jwks_uri)}")
163
+ begin
164
+ jwks_hash = fetch_jwks(jwks_uri)
165
+ keys = jwks_hash&.dig("keys")
166
+ if keys&.is_a?(Array) && !keys.empty?
167
+ # Track first valid JWKS
168
+ first_valid_jwks ||= jwks_hash
169
+ # If kid is nil, return JWKS anyway (let JWT decoding fail with proper error)
170
+ if kid.nil?
171
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid is nil, returning standard JWKS URI for validation attempt")
172
+ return jwks_hash
173
+ end
174
+ # Check if kid is in this JWKS
175
+ key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
176
+ if key_data
177
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Found kid '#{kid}' in standard JWKS URI")
178
+ return jwks_hash
179
+ else
180
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in standard JWKS URI")
181
+ end
182
+ end
183
+ rescue => e
184
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to fetch JWKS from URI: #{e.message}")
185
+ end
186
+ end
187
+
188
+ # If we found valid JWKS but kid was not found, return it anyway
189
+ # This allows the decoding to fail with "kid not found" instead of "JWKS not available"
190
+ if first_valid_jwks && kid
191
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in any JWKS source, but returning first valid JWKS for validation attempt")
192
+ return first_valid_jwks
193
+ end
194
+
195
+ # No JWKS found
196
+ nil
197
+ end
198
+
199
+ def resolve_jwks_uri(normalized_options)
200
+ # 1. Try client_options first
201
+ jwks_uri = normalized_options[:jwks_uri] || normalized_options["jwks_uri"]
202
+ if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
203
+ # Build full URL if it's a path
204
+ if jwks_uri.start_with?("http://", "https://")
205
+ return jwks_uri
206
+ else
207
+ base_url = build_base_url(normalized_options)
208
+ return build_endpoint(base_url, jwks_uri) if base_url
209
+ end
210
+ end
211
+
212
+ # 2. Try to resolve from entity statement
213
+ if options.entity_statement_path
214
+ begin
215
+ resolved_endpoints = resolve_endpoints_from_metadata(normalized_options)
216
+ jwks_uri = resolved_endpoints[:jwks_uri] if resolved_endpoints[:jwks_uri]
217
+ if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
218
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved JWKS URI from entity statement: #{jwks_uri}")
219
+ return jwks_uri
220
+ end
221
+ rescue => e
222
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get JWKS URI from entity statement: #{e.message}")
223
+ end
224
+ end
225
+
226
+ # 3. Try to get from OpenID Connect client
227
+ begin
228
+ if client.respond_to?(:jwks_uri) && client.jwks_uri
229
+ jwks_uri = client.jwks_uri.to_s
230
+ if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
231
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using JWKS URI from client: #{jwks_uri}")
232
+ return jwks_uri
233
+ end
234
+ end
235
+ rescue => e
236
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get JWKS URI from client: #{e.message}")
237
+ end
238
+
239
+ # No JWKS URI found
240
+ nil
241
+ end
242
+
243
+ def fetch_jwks(jwks_uri)
244
+ # Use our JWKS fetching logic
245
+ # Returns a hash with "keys" array that JWT.decode can use directly
246
+ jwks = OmniauthOpenidFederation::Jwks::Fetch.run(jwks_uri)
247
+
248
+ # Ensure it's in the format expected by JWT.decode (hash with "keys" array)
249
+ if jwks.is_a?(Hash) && jwks.key?("keys")
250
+ # Already in correct format - JWT.decode accepts this directly
251
+ jwks
252
+ elsif jwks.is_a?(Array)
253
+ # If it's an array of keys, wrap it in a hash
254
+ {"keys" => jwks}
255
+ else
256
+ # Fallback: wrap in keys array
257
+ {"keys" => [jwks].compact}
258
+ end
259
+ end
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,135 @@
1
+ module OmniauthOpenidFederation
2
+ module Strategy
3
+ module ProviderEntityStatement
4
+ private
5
+
6
+ def resolve_entity_statement_path(path)
7
+ if path.start_with?("/")
8
+ path
9
+ elsif defined?(Rails) && Rails.root
10
+ Rails.root.join(path).to_s
11
+ else
12
+ File.expand_path(path)
13
+ end
14
+ end
15
+
16
+ def load_provider_entity_statement
17
+ # Priority 1: Use file path if provided
18
+ if OmniauthOpenidFederation::StringHelpers.present?(options.entity_statement_path)
19
+ path = resolve_entity_statement_path(options.entity_statement_path)
20
+ if File.exist?(path)
21
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Loading provider entity statement from file: #{path}")
22
+ return File.read(path).strip
23
+ end
24
+
25
+ raise OmniauthOpenidFederation::ConfigurationError,
26
+ "Provider entity statement file not found: #{path}"
27
+ end
28
+
29
+ # Priority 2: Fetch from URL if provided
30
+ if OmniauthOpenidFederation::StringHelpers.present?(options.entity_statement_url)
31
+ return fetch_and_cache_entity_statement(
32
+ options.entity_statement_url,
33
+ fingerprint: options.entity_statement_fingerprint
34
+ )
35
+ end
36
+
37
+ # Priority 3: Fetch from issuer if provided (only if issuer is a valid URL)
38
+ if OmniauthOpenidFederation::StringHelpers.present?(options.issuer)
39
+ # Check that issuer is a valid URL format before trying to fetch
40
+ # Note: Config values are trusted, only basic format check needed
41
+ begin
42
+ parsed_issuer = URI.parse(options.issuer)
43
+ unless parsed_issuer.is_a?(URI::HTTP) || parsed_issuer.is_a?(URI::HTTPS)
44
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Issuer is not a valid HTTP/HTTPS URL, skipping entity statement fetch from URL: #{options.issuer}")
45
+ return nil
46
+ end
47
+ rescue URI::InvalidURIError
48
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Issuer is not a valid URL, skipping entity statement fetch from URL: #{options.issuer}")
49
+ return nil
50
+ end
51
+
52
+ entity_statement_url = OmniauthOpenidFederation::Utils.build_entity_statement_url(options.issuer)
53
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Building entity statement URL from issuer: #{entity_statement_url}")
54
+ return fetch_and_cache_entity_statement(
55
+ entity_statement_url,
56
+ fingerprint: options.entity_statement_fingerprint
57
+ )
58
+ end
59
+
60
+ nil
61
+ end
62
+
63
+ def fetch_and_cache_entity_statement(url, fingerprint: nil)
64
+ cache_key = "federation:provider_entity_statement:#{Digest::SHA256.hexdigest(url)}"
65
+
66
+ # Check cache first (if Rails.cache is available)
67
+ if defined?(Rails) && Rails.cache
68
+ begin
69
+ cached = Rails.cache.read(cache_key)
70
+ if cached
71
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using cached provider entity statement from: #{url}")
72
+ return cached
73
+ end
74
+ rescue => e
75
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Cache read failed, fetching fresh: #{e.message}")
76
+ end
77
+ end
78
+
79
+ # Fetch from URL
80
+ OmniauthOpenidFederation::Logger.info("[Strategy] Fetching provider entity statement from: #{url}")
81
+ begin
82
+ statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
83
+ url,
84
+ fingerprint: fingerprint,
85
+ timeout: 10
86
+ )
87
+
88
+ entity_statement_content = statement.entity_statement
89
+
90
+ # Cache the fetched statement (if Rails.cache is available)
91
+ if defined?(Rails) && Rails.cache
92
+ begin
93
+ # Cache for 1 hour (entity statements typically expire after 24 hours)
94
+ Rails.cache.write(cache_key, entity_statement_content, expires_in: 3600)
95
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Cached provider entity statement from: #{url}")
96
+ rescue => e
97
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Cache write failed: #{e.message}")
98
+ end
99
+ end
100
+
101
+ entity_statement_content
102
+ rescue OmniauthOpenidFederation::FetchError, OmniauthOpenidFederation::ValidationError => e
103
+ error_msg = "Failed to fetch provider entity statement from #{url}: #{e.message}"
104
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
105
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
106
+ end
107
+ end
108
+
109
+ def load_metadata_for_key_extraction
110
+ entity_statement_content = load_provider_entity_statement
111
+ return nil unless entity_statement_content
112
+
113
+ begin
114
+ # Parse entity statement to extract metadata and JWKS from content
115
+ parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
116
+ entity_statement_content
117
+ )
118
+
119
+ return nil unless parsed && parsed[:metadata]
120
+
121
+ # Return metadata in format expected by KeyExtractor
122
+ # KeyExtractor expects metadata hash that may contain JWKS
123
+ metadata = parsed[:metadata]
124
+ entity_jwks = parsed[:entity_jwks] || metadata[:jwks] || {}
125
+
126
+ # Return metadata with JWKS included
127
+ metadata.merge(jwks: entity_jwks)
128
+ rescue => e
129
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to load metadata from entity statement for key extraction: #{e.message}")
130
+ nil
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end