omniauth_openid_federation 1.3.2 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +32 -1
- data/README.md +119 -13
- data/app/controllers/omniauth_openid_federation/federation_controller.rb +2 -2
- data/config/polyrun_coverage.yml +15 -0
- data/config/polyrun_spec_quality.yml +29 -0
- data/examples/README_INTEGRATION_TESTING.md +3 -0
- data/examples/integration_test_flow.rb +10 -8
- data/examples/jobs/federation_cache_refresh_job.rb.example +7 -7
- data/examples/jobs/federation_files_generation_job.rb.example +3 -2
- data/examples/mock_op_server.rb +4 -5
- data/examples/mock_rp_server.rb +3 -3
- data/examples/standalone_multiple_endpoints_example.rb +494 -0
- data/lib/omniauth_openid_federation/access_token.rb +91 -450
- data/lib/omniauth_openid_federation/cache.rb +16 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +6 -3
- data/lib/omniauth_openid_federation/constants.rb +3 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +0 -4
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +2 -4
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +0 -1
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +51 -6
- data/lib/omniauth_openid_federation/federation_endpoint.rb +1 -1
- data/lib/omniauth_openid_federation/http_client.rb +145 -32
- data/lib/omniauth_openid_federation/http_errors.rb +14 -0
- data/lib/omniauth_openid_federation/id_token.rb +19 -0
- data/lib/omniauth_openid_federation/jwe.rb +26 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +0 -13
- data/lib/omniauth_openid_federation/jwks/fetch.rb +16 -39
- data/lib/omniauth_openid_federation/jws.rb +2 -11
- data/lib/omniauth_openid_federation/jwt_response_decoder.rb +290 -0
- data/lib/omniauth_openid_federation/oidc_client.rb +101 -0
- data/lib/omniauth_openid_federation/rack.rb +2 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +2 -2
- data/lib/omniauth_openid_federation/rate_limiter.rb +10 -12
- data/lib/omniauth_openid_federation/secure_compare.rb +15 -0
- data/lib/omniauth_openid_federation/strategy/authorization_request.rb +220 -0
- data/lib/omniauth_openid_federation/strategy/callback_handling.rb +135 -0
- data/lib/omniauth_openid_federation/strategy/client_construction.rb +101 -0
- data/lib/omniauth_openid_federation/strategy/client_entity_statement.rb +211 -0
- data/lib/omniauth_openid_federation/strategy/endpoint_resolution.rb +433 -0
- data/lib/omniauth_openid_federation/strategy/failure_handling.rb +75 -0
- data/lib/omniauth_openid_federation/strategy/id_token_decoding.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/jwks_resolution.rb +268 -0
- data/lib/omniauth_openid_federation/strategy/provider_entity_statement.rb +134 -0
- data/lib/omniauth_openid_federation/strategy/userinfo_decoding.rb +61 -0
- data/lib/omniauth_openid_federation/strategy.rb +27 -1910
- data/lib/omniauth_openid_federation/tasks/authentication_flow_tester.rb +237 -0
- data/lib/omniauth_openid_federation/tasks/callback_processor.rb +235 -0
- data/lib/omniauth_openid_federation/tasks/client_keys_preparer.rb +96 -0
- data/lib/omniauth_openid_federation/tasks/local_endpoint_tester.rb +189 -0
- data/lib/omniauth_openid_federation/tasks/path_resolver.rb +20 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +28 -808
- data/lib/omniauth_openid_federation/time_helpers.rb +7 -0
- data/lib/omniauth_openid_federation/user_info.rb +15 -0
- data/lib/omniauth_openid_federation/utils.rb +10 -2
- data/lib/omniauth_openid_federation/validators.rb +43 -8
- data/lib/omniauth_openid_federation/version.rb +1 -1
- data/lib/omniauth_openid_federation.rb +9 -3
- data/lib/tasks/omniauth_openid_federation.rake +1 -2
- data/sig/omniauth_openid_federation.rbs +2 -1
- data/sig/strategy.rbs +6 -6
- metadata +181 -71
|
@@ -0,0 +1,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
|