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,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,268 @@
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
+ # This is more likely to have the latest keys during key rotation
121
+ if entity_statement_content
122
+ begin
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
+ # Ensure it's in the format expected by JWT.decode
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
+ # Track first valid JWKS
144
+ first_valid_jwks ||= jwks_hash
145
+ # If kid is nil, return JWKS anyway (let JWT decoding fail with proper error)
146
+ if kid.nil?
147
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid is nil, returning signed JWKS for validation attempt")
148
+ return jwks_hash
149
+ end
150
+ # Check if kid is in this JWKS
151
+ key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
152
+ if key_data
153
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Found kid '#{kid}' in signed JWKS")
154
+ return jwks_hash
155
+ else
156
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in signed JWKS, trying standard JWKS URI")
157
+ end
158
+ end
159
+ end
160
+ rescue => e
161
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not fetch signed JWKS: #{e.message}")
162
+ end
163
+ end
164
+
165
+ # 3. Fallback: Fetch from standard JWKS URI
166
+ jwks_uri = resolve_jwks_uri(normalized_options)
167
+ if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
168
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Fetching JWKS from URI for kid '#{kid}': #{OmniauthOpenidFederation::Utils.sanitize_uri(jwks_uri)}")
169
+ begin
170
+ jwks_hash = fetch_jwks(jwks_uri)
171
+ keys = jwks_hash&.dig("keys")
172
+ if keys&.is_a?(Array) && !keys.empty?
173
+ # Track first valid JWKS
174
+ first_valid_jwks ||= jwks_hash
175
+ # If kid is nil, return JWKS anyway (let JWT decoding fail with proper error)
176
+ if kid.nil?
177
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid is nil, returning standard JWKS URI for validation attempt")
178
+ return jwks_hash
179
+ end
180
+ # Check if kid is in this JWKS
181
+ key_data = keys.find { |key| (key["kid"] || key[:kid]) == kid }
182
+ if key_data
183
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Found kid '#{kid}' in standard JWKS URI")
184
+ return jwks_hash
185
+ else
186
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in standard JWKS URI")
187
+ end
188
+ end
189
+ rescue => e
190
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to fetch JWKS from URI: #{e.message}")
191
+ end
192
+ end
193
+
194
+ # If we found valid JWKS but kid was not found, return it anyway
195
+ # This allows the decoding to fail with "kid not found" instead of "JWKS not available"
196
+ if first_valid_jwks && kid
197
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Kid '#{kid}' not found in any JWKS source, but returning first valid JWKS for validation attempt")
198
+ return first_valid_jwks
199
+ end
200
+
201
+ # No JWKS found
202
+ nil
203
+ end
204
+
205
+ def resolve_jwks_uri(normalized_options)
206
+ # 1. Try client_options first
207
+ jwks_uri = normalized_options[:jwks_uri] || normalized_options["jwks_uri"]
208
+ if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
209
+ # Build full URL if it's a path
210
+ if jwks_uri.start_with?("http://", "https://")
211
+ return jwks_uri
212
+ else
213
+ base_url = build_base_url(normalized_options)
214
+ return build_endpoint(base_url, jwks_uri) if base_url
215
+ end
216
+ end
217
+
218
+ # 2. Try to resolve from entity statement
219
+ if options.entity_statement_path
220
+ begin
221
+ resolved_endpoints = resolve_endpoints_from_metadata(normalized_options)
222
+ jwks_uri = resolved_endpoints[:jwks_uri] if resolved_endpoints[:jwks_uri]
223
+ if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
224
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Resolved JWKS URI from entity statement: #{jwks_uri}")
225
+ return jwks_uri
226
+ end
227
+ rescue => e
228
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get JWKS URI from entity statement: #{e.message}")
229
+ end
230
+ end
231
+
232
+ # 3. Try to get from OpenID Connect client
233
+ begin
234
+ if client.respond_to?(:jwks_uri) && client.jwks_uri
235
+ jwks_uri = client.jwks_uri.to_s
236
+ if OmniauthOpenidFederation::StringHelpers.present?(jwks_uri)
237
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using JWKS URI from client: #{jwks_uri}")
238
+ return jwks_uri
239
+ end
240
+ end
241
+ rescue => e
242
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Could not get JWKS URI from client: #{e.message}")
243
+ end
244
+
245
+ # No JWKS URI found
246
+ nil
247
+ end
248
+
249
+ def fetch_jwks(jwks_uri)
250
+ # Use our JWKS fetching logic
251
+ # Returns a hash with "keys" array that JWT.decode can use directly
252
+ jwks = OmniauthOpenidFederation::Jwks::Fetch.run(jwks_uri)
253
+
254
+ # Ensure it's in the format expected by JWT.decode (hash with "keys" array)
255
+ if jwks.is_a?(Hash) && jwks.key?("keys")
256
+ # Already in correct format - JWT.decode accepts this directly
257
+ jwks
258
+ elsif jwks.is_a?(Array)
259
+ # If it's an array of keys, wrap it in a hash
260
+ {"keys" => jwks}
261
+ else
262
+ # Fallback: wrap in keys array
263
+ {"keys" => [jwks].compact}
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,134 @@
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
+ else
24
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Provider entity statement file not found: #{path}, will try to fetch from URL")
25
+ end
26
+ end
27
+
28
+ # Priority 2: Fetch from URL if provided
29
+ if OmniauthOpenidFederation::StringHelpers.present?(options.entity_statement_url)
30
+ return fetch_and_cache_entity_statement(
31
+ options.entity_statement_url,
32
+ fingerprint: options.entity_statement_fingerprint
33
+ )
34
+ end
35
+
36
+ # Priority 3: Fetch from issuer if provided (only if issuer is a valid URL)
37
+ if OmniauthOpenidFederation::StringHelpers.present?(options.issuer)
38
+ # Check that issuer is a valid URL format before trying to fetch
39
+ # Note: Config values are trusted, only basic format check needed
40
+ begin
41
+ parsed_issuer = URI.parse(options.issuer)
42
+ unless parsed_issuer.is_a?(URI::HTTP) || parsed_issuer.is_a?(URI::HTTPS)
43
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Issuer is not a valid HTTP/HTTPS URL, skipping entity statement fetch from URL: #{options.issuer}")
44
+ return nil
45
+ end
46
+ rescue URI::InvalidURIError
47
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Issuer is not a valid URL, skipping entity statement fetch from URL: #{options.issuer}")
48
+ return nil
49
+ end
50
+
51
+ entity_statement_url = OmniauthOpenidFederation::Utils.build_entity_statement_url(options.issuer)
52
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Building entity statement URL from issuer: #{entity_statement_url}")
53
+ return fetch_and_cache_entity_statement(
54
+ entity_statement_url,
55
+ fingerprint: options.entity_statement_fingerprint
56
+ )
57
+ end
58
+
59
+ nil
60
+ end
61
+
62
+ def fetch_and_cache_entity_statement(url, fingerprint: nil)
63
+ cache_key = "federation:provider_entity_statement:#{Digest::SHA256.hexdigest(url)}"
64
+
65
+ # Check cache first (if Rails.cache is available)
66
+ if defined?(Rails) && Rails.cache
67
+ begin
68
+ cached = Rails.cache.read(cache_key)
69
+ if cached
70
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Using cached provider entity statement from: #{url}")
71
+ return cached
72
+ end
73
+ rescue => e
74
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Cache read failed, fetching fresh: #{e.message}")
75
+ end
76
+ end
77
+
78
+ # Fetch from URL
79
+ OmniauthOpenidFederation::Logger.info("[Strategy] Fetching provider entity statement from: #{url}")
80
+ begin
81
+ statement = OmniauthOpenidFederation::Federation::EntityStatement.fetch!(
82
+ url,
83
+ fingerprint: fingerprint,
84
+ timeout: 10
85
+ )
86
+
87
+ entity_statement_content = statement.entity_statement
88
+
89
+ # Cache the fetched statement (if Rails.cache is available)
90
+ if defined?(Rails) && Rails.cache
91
+ begin
92
+ # Cache for 1 hour (entity statements typically expire after 24 hours)
93
+ Rails.cache.write(cache_key, entity_statement_content, expires_in: 3600)
94
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Cached provider entity statement from: #{url}")
95
+ rescue => e
96
+ OmniauthOpenidFederation::Logger.debug("[Strategy] Cache write failed: #{e.message}")
97
+ end
98
+ end
99
+
100
+ entity_statement_content
101
+ rescue OmniauthOpenidFederation::FetchError, OmniauthOpenidFederation::ValidationError => e
102
+ error_msg = "Failed to fetch provider entity statement from #{url}: #{e.message}"
103
+ OmniauthOpenidFederation::Logger.error("[Strategy] #{error_msg}")
104
+ raise OmniauthOpenidFederation::ConfigurationError, error_msg
105
+ end
106
+ end
107
+
108
+ def load_metadata_for_key_extraction
109
+ entity_statement_content = load_provider_entity_statement
110
+ return nil unless entity_statement_content
111
+
112
+ begin
113
+ # Parse entity statement to extract metadata and JWKS from content
114
+ parsed = OmniauthOpenidFederation::Federation::EntityStatementHelper.parse_for_signed_jwks_from_content(
115
+ entity_statement_content
116
+ )
117
+
118
+ return nil unless parsed && parsed[:metadata]
119
+
120
+ # Return metadata in format expected by KeyExtractor
121
+ # KeyExtractor expects metadata hash that may contain JWKS
122
+ metadata = parsed[:metadata]
123
+ entity_jwks = parsed[:entity_jwks] || metadata[:jwks] || {}
124
+
125
+ # Return metadata with JWKS included
126
+ metadata.merge(jwks: entity_jwks)
127
+ rescue => e
128
+ OmniauthOpenidFederation::Logger.warn("[Strategy] Failed to load metadata from entity statement for key extraction: #{e.message}")
129
+ nil
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end