omniauth_openid_federation 1.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 +7 -0
- data/CHANGELOG.md +16 -0
- data/LICENSE.md +22 -0
- data/README.md +822 -0
- data/SECURITY.md +129 -0
- data/examples/README_INTEGRATION_TESTING.md +399 -0
- data/examples/README_MOCK_OP.md +243 -0
- data/examples/app/controllers/users/omniauth_callbacks_controller.rb.example +33 -0
- data/examples/app/jobs/jwks_rotation_job.rb.example +60 -0
- data/examples/app/models/user.rb.example +39 -0
- data/examples/config/initializers/devise.rb.example +97 -0
- data/examples/config/initializers/federation_endpoint.rb.example +206 -0
- data/examples/config/mock_op.yml.example +83 -0
- data/examples/config/open_id_connect_config.rb.example +210 -0
- data/examples/config/routes.rb.example +12 -0
- data/examples/db/migrate/add_omniauth_to_users.rb.example +16 -0
- data/examples/integration_test_flow.rb +1334 -0
- data/examples/jobs/README.md +194 -0
- data/examples/jobs/federation_cache_refresh_job.rb.example +78 -0
- data/examples/jobs/federation_files_generation_job.rb.example +87 -0
- data/examples/mock_op_server.rb +775 -0
- data/examples/mock_rp_server.rb +435 -0
- data/lib/omniauth_openid_federation/access_token.rb +504 -0
- data/lib/omniauth_openid_federation/cache.rb +39 -0
- data/lib/omniauth_openid_federation/cache_adapter.rb +173 -0
- data/lib/omniauth_openid_federation/configuration.rb +135 -0
- data/lib/omniauth_openid_federation/constants.rb +13 -0
- data/lib/omniauth_openid_federation/endpoint_resolver.rb +168 -0
- data/lib/omniauth_openid_federation/entity_statement_reader.rb +122 -0
- data/lib/omniauth_openid_federation/errors.rb +52 -0
- data/lib/omniauth_openid_federation/federation/entity_statement.rb +331 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_builder.rb +188 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_fetcher.rb +142 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_helper.rb +87 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_parser.rb +198 -0
- data/lib/omniauth_openid_federation/federation/entity_statement_validator.rb +502 -0
- data/lib/omniauth_openid_federation/federation/metadata_policy_merger.rb +276 -0
- data/lib/omniauth_openid_federation/federation/signed_jwks.rb +210 -0
- data/lib/omniauth_openid_federation/federation/trust_chain_resolver.rb +225 -0
- data/lib/omniauth_openid_federation/federation_endpoint.rb +949 -0
- data/lib/omniauth_openid_federation/http_client.rb +70 -0
- data/lib/omniauth_openid_federation/instrumentation.rb +383 -0
- data/lib/omniauth_openid_federation/jwks/cache.rb +76 -0
- data/lib/omniauth_openid_federation/jwks/decode.rb +174 -0
- data/lib/omniauth_openid_federation/jwks/fetch.rb +153 -0
- data/lib/omniauth_openid_federation/jwks/normalizer.rb +49 -0
- data/lib/omniauth_openid_federation/jwks/rotate.rb +97 -0
- data/lib/omniauth_openid_federation/jwks/selector.rb +101 -0
- data/lib/omniauth_openid_federation/jws.rb +416 -0
- data/lib/omniauth_openid_federation/key_extractor.rb +173 -0
- data/lib/omniauth_openid_federation/logger.rb +99 -0
- data/lib/omniauth_openid_federation/rack_endpoint.rb +187 -0
- data/lib/omniauth_openid_federation/railtie.rb +29 -0
- data/lib/omniauth_openid_federation/rate_limiter.rb +55 -0
- data/lib/omniauth_openid_federation/strategy.rb +2029 -0
- data/lib/omniauth_openid_federation/string_helpers.rb +30 -0
- data/lib/omniauth_openid_federation/tasks_helper.rb +428 -0
- data/lib/omniauth_openid_federation/utils.rb +166 -0
- data/lib/omniauth_openid_federation/validators.rb +126 -0
- data/lib/omniauth_openid_federation/version.rb +3 -0
- data/lib/omniauth_openid_federation.rb +98 -0
- data/lib/tasks/omniauth_openid_federation.rake +376 -0
- data/sig/federation.rbs +218 -0
- data/sig/jwks.rbs +63 -0
- data/sig/omniauth_openid_federation.rbs +254 -0
- data/sig/strategy.rbs +60 -0
- metadata +352 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
require_relative "constants"
|
|
2
|
+
|
|
3
|
+
# HTTP client with retry logic and SSL configuration
|
|
4
|
+
module OmniauthOpenidFederation
|
|
5
|
+
class HttpClient
|
|
6
|
+
# Execute an HTTP GET request with retry logic
|
|
7
|
+
#
|
|
8
|
+
# @param uri [String, URI] The URI to fetch
|
|
9
|
+
# @param options [Hash] Request options
|
|
10
|
+
# @option options [Integer] :max_retries Maximum number of retries (default: from config)
|
|
11
|
+
# @option options [Integer] :retry_delay Base retry delay in seconds (default: from config)
|
|
12
|
+
# @option options [Integer] :timeout Request timeout in seconds (default: from config)
|
|
13
|
+
# @return [HTTP::Response] The HTTP response
|
|
14
|
+
# @raise [NetworkError] If the request fails after all retries
|
|
15
|
+
def self.get(uri, options = {})
|
|
16
|
+
max_retries = options[:max_retries] || Configuration.config.max_retries
|
|
17
|
+
retry_delay = options[:retry_delay] || Configuration.config.retry_delay
|
|
18
|
+
timeout = options[:timeout] || Configuration.config.http_timeout
|
|
19
|
+
|
|
20
|
+
http_client = build_http_client(timeout)
|
|
21
|
+
|
|
22
|
+
retries = 0
|
|
23
|
+
begin
|
|
24
|
+
http_client.get(uri)
|
|
25
|
+
rescue HTTP::Error, Timeout::Error, Errno::ECONNREFUSED, Errno::ETIMEDOUT => e
|
|
26
|
+
retries += 1
|
|
27
|
+
if retries > max_retries
|
|
28
|
+
error_msg = "Failed to fetch #{uri} after #{max_retries} retries: #{e.class} - #{e.message}"
|
|
29
|
+
OmniauthOpenidFederation::Logger.error("[HttpClient] #{error_msg}")
|
|
30
|
+
raise OmniauthOpenidFederation::NetworkError, error_msg, e.backtrace
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
delay = [retry_delay * retries, Constants::MAX_RETRY_DELAY_SECONDS].min
|
|
34
|
+
OmniauthOpenidFederation::Logger.warn("[HttpClient] Request failed (attempt #{retries}/#{max_retries}), retrying in #{delay}s: #{e.message}")
|
|
35
|
+
sleep(delay)
|
|
36
|
+
retry
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Build HTTP client with SSL configuration
|
|
41
|
+
#
|
|
42
|
+
# @param timeout [Integer] Request timeout in seconds
|
|
43
|
+
# @return [HTTP::Client] Configured HTTP client
|
|
44
|
+
def self.build_http_client(timeout)
|
|
45
|
+
http_options_hash = build_http_options_hash || {}
|
|
46
|
+
http_options = HTTP::Options.new(http_options_hash)
|
|
47
|
+
HTTP::Client.new(http_options).timeout(timeout)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Build HTTP options hash from configuration
|
|
51
|
+
#
|
|
52
|
+
# @return [Hash, nil] HTTP options hash or nil
|
|
53
|
+
def self.build_http_options_hash
|
|
54
|
+
config = Configuration.config
|
|
55
|
+
|
|
56
|
+
# If http_options is configured, use it (can be Hash or Proc)
|
|
57
|
+
if config.http_options
|
|
58
|
+
if config.http_options.is_a?(Proc)
|
|
59
|
+
config.http_options.call
|
|
60
|
+
else
|
|
61
|
+
config.http_options
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private_class_method :build_http_options_hash
|
|
67
|
+
|
|
68
|
+
private_class_method :build_http_client
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
# Instrumentation for omniauth_openid_federation
|
|
2
|
+
# Provides configurable notifications for security events, MITM attacks, and authentication mismatches
|
|
3
|
+
#
|
|
4
|
+
# @example Configure with Sentry
|
|
5
|
+
# OmniauthOpenidFederation.configure do |config|
|
|
6
|
+
# config.instrumentation = ->(event, data) do
|
|
7
|
+
# Sentry.capture_message("OpenID Federation: #{event}", level: :warning, extra: data)
|
|
8
|
+
# end
|
|
9
|
+
# end
|
|
10
|
+
#
|
|
11
|
+
# @example Configure with Honeybadger
|
|
12
|
+
# OmniauthOpenidFederation.configure do |config|
|
|
13
|
+
# config.instrumentation = ->(event, data) do
|
|
14
|
+
# Honeybadger.notify("OpenID Federation: #{event}", context: data)
|
|
15
|
+
# end
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Configure with custom logger
|
|
19
|
+
# OmniauthOpenidFederation.configure do |config|
|
|
20
|
+
# config.instrumentation = ->(event, data) do
|
|
21
|
+
# Rails.logger.warn("[Security] #{event}: #{data.inspect}")
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @example Disable instrumentation
|
|
26
|
+
# OmniauthOpenidFederation.configure do |config|
|
|
27
|
+
# config.instrumentation = nil
|
|
28
|
+
# end
|
|
29
|
+
module OmniauthOpenidFederation
|
|
30
|
+
module Instrumentation
|
|
31
|
+
# Security event types
|
|
32
|
+
EVENT_CSRF_DETECTED = "csrf_detected"
|
|
33
|
+
EVENT_SIGNATURE_VERIFICATION_FAILED = "signature_verification_failed"
|
|
34
|
+
EVENT_DECRYPTION_FAILED = "decryption_failed"
|
|
35
|
+
EVENT_TOKEN_VALIDATION_FAILED = "token_validation_failed"
|
|
36
|
+
EVENT_KEY_ROTATION_DETECTED = "key_rotation_detected"
|
|
37
|
+
EVENT_KID_NOT_FOUND = "kid_not_found"
|
|
38
|
+
EVENT_ENTITY_STATEMENT_VALIDATION_FAILED = "entity_statement_validation_failed"
|
|
39
|
+
EVENT_FINGERPRINT_MISMATCH = "fingerprint_mismatch"
|
|
40
|
+
EVENT_TRUST_CHAIN_VALIDATION_FAILED = "trust_chain_validation_failed"
|
|
41
|
+
EVENT_ENDPOINT_MISMATCH = "endpoint_mismatch"
|
|
42
|
+
EVENT_UNEXPECTED_AUTHENTICATION_BREAK = "unexpected_authentication_break"
|
|
43
|
+
EVENT_STATE_MISMATCH = "state_mismatch"
|
|
44
|
+
EVENT_MISSING_REQUIRED_CLAIMS = "missing_required_claims"
|
|
45
|
+
EVENT_AUDIENCE_MISMATCH = "audience_mismatch"
|
|
46
|
+
EVENT_ISSUER_MISMATCH = "issuer_mismatch"
|
|
47
|
+
EVENT_EXPIRED_TOKEN = "expired_token"
|
|
48
|
+
EVENT_INVALID_NONCE = "invalid_nonce"
|
|
49
|
+
|
|
50
|
+
class << self
|
|
51
|
+
# Notify about a security event
|
|
52
|
+
#
|
|
53
|
+
# @param event [String] Event type (use constants from this module)
|
|
54
|
+
# @param data [Hash] Event data (will be sanitized to remove sensitive information)
|
|
55
|
+
# @param severity [Symbol] Event severity (:info, :warning, :error)
|
|
56
|
+
# @return [void]
|
|
57
|
+
def notify(event, data: {}, severity: :warning)
|
|
58
|
+
config = Configuration.config
|
|
59
|
+
return unless config.instrumentation
|
|
60
|
+
|
|
61
|
+
# Sanitize data to remove sensitive information
|
|
62
|
+
sanitized_data = sanitize_data(data)
|
|
63
|
+
|
|
64
|
+
# Build notification payload
|
|
65
|
+
payload = {
|
|
66
|
+
event: event,
|
|
67
|
+
severity: severity,
|
|
68
|
+
timestamp: Time.now.utc.iso8601,
|
|
69
|
+
data: sanitized_data
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
# Call the configured instrumentation callback
|
|
73
|
+
begin
|
|
74
|
+
if config.instrumentation.respond_to?(:call)
|
|
75
|
+
config.instrumentation.call(event, payload)
|
|
76
|
+
elsif config.instrumentation.respond_to?(:notify)
|
|
77
|
+
config.instrumentation.notify(event, payload)
|
|
78
|
+
else
|
|
79
|
+
# Assume it's a logger-like object
|
|
80
|
+
log_message = "[OpenID Federation Security] #{event}: #{sanitized_data.inspect}"
|
|
81
|
+
case severity
|
|
82
|
+
when :error
|
|
83
|
+
config.instrumentation.error(log_message)
|
|
84
|
+
when :warning
|
|
85
|
+
config.instrumentation.warn(log_message)
|
|
86
|
+
else
|
|
87
|
+
config.instrumentation.info(log_message)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
rescue => e
|
|
91
|
+
# Don't let instrumentation failures break the authentication flow
|
|
92
|
+
Logger.warn("[Instrumentation] Failed to notify about #{event}: #{e.message}")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Notify about CSRF detection
|
|
97
|
+
#
|
|
98
|
+
# @param data [Hash] Additional context (state_param, state_session, request_info)
|
|
99
|
+
# @return [void]
|
|
100
|
+
def notify_csrf_detected(data = {})
|
|
101
|
+
notify(
|
|
102
|
+
EVENT_CSRF_DETECTED,
|
|
103
|
+
data: {
|
|
104
|
+
reason: "State parameter mismatch - possible CSRF attack",
|
|
105
|
+
**data
|
|
106
|
+
},
|
|
107
|
+
severity: :error
|
|
108
|
+
)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Notify about signature verification failure
|
|
112
|
+
#
|
|
113
|
+
# @param data [Hash] Additional context (token_type, kid, jwks_uri, error_message)
|
|
114
|
+
# @return [void]
|
|
115
|
+
def notify_signature_verification_failed(data = {})
|
|
116
|
+
notify(
|
|
117
|
+
EVENT_SIGNATURE_VERIFICATION_FAILED,
|
|
118
|
+
data: {
|
|
119
|
+
reason: "JWT signature verification failed - possible MITM attack or key rotation",
|
|
120
|
+
**data
|
|
121
|
+
},
|
|
122
|
+
severity: :error
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Notify about decryption failure
|
|
127
|
+
#
|
|
128
|
+
# @param data [Hash] Additional context (token_type, error_message)
|
|
129
|
+
# @return [void]
|
|
130
|
+
def notify_decryption_failed(data = {})
|
|
131
|
+
notify(
|
|
132
|
+
EVENT_DECRYPTION_FAILED,
|
|
133
|
+
data: {
|
|
134
|
+
reason: "Token decryption failed - possible MITM attack or key mismatch",
|
|
135
|
+
**data
|
|
136
|
+
},
|
|
137
|
+
severity: :error
|
|
138
|
+
)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Notify about token validation failure
|
|
142
|
+
#
|
|
143
|
+
# @param data [Hash] Additional context (validation_type, missing_claims, error_message)
|
|
144
|
+
# @return [void]
|
|
145
|
+
def notify_token_validation_failed(data = {})
|
|
146
|
+
notify(
|
|
147
|
+
EVENT_TOKEN_VALIDATION_FAILED,
|
|
148
|
+
data: {
|
|
149
|
+
reason: "Token validation failed - possible tampering or configuration mismatch",
|
|
150
|
+
**data
|
|
151
|
+
},
|
|
152
|
+
severity: :error
|
|
153
|
+
)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Notify about key rotation detection
|
|
157
|
+
#
|
|
158
|
+
# @param data [Hash] Additional context (jwks_uri, kid, available_kids)
|
|
159
|
+
# @return [void]
|
|
160
|
+
def notify_key_rotation_detected(data = {})
|
|
161
|
+
notify(
|
|
162
|
+
EVENT_KEY_ROTATION_DETECTED,
|
|
163
|
+
data: {
|
|
164
|
+
reason: "Key rotation detected - kid not found in current JWKS",
|
|
165
|
+
**data
|
|
166
|
+
},
|
|
167
|
+
severity: :warning
|
|
168
|
+
)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Notify about kid not found
|
|
172
|
+
#
|
|
173
|
+
# @param data [Hash] Additional context (kid, jwks_uri, available_kids)
|
|
174
|
+
# @return [void]
|
|
175
|
+
def notify_kid_not_found(data = {})
|
|
176
|
+
notify(
|
|
177
|
+
EVENT_KID_NOT_FOUND,
|
|
178
|
+
data: {
|
|
179
|
+
reason: "Key ID not found in JWKS - possible key rotation or MITM attack",
|
|
180
|
+
**data
|
|
181
|
+
},
|
|
182
|
+
severity: :error
|
|
183
|
+
)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Notify about entity statement validation failure
|
|
187
|
+
#
|
|
188
|
+
# @param data [Hash] Additional context (entity_id, validation_step, error_message)
|
|
189
|
+
# @return [void]
|
|
190
|
+
def notify_entity_statement_validation_failed(data = {})
|
|
191
|
+
notify(
|
|
192
|
+
EVENT_ENTITY_STATEMENT_VALIDATION_FAILED,
|
|
193
|
+
data: {
|
|
194
|
+
reason: "Entity statement validation failed - possible tampering or MITM attack",
|
|
195
|
+
**data
|
|
196
|
+
},
|
|
197
|
+
severity: :error
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Notify about fingerprint mismatch
|
|
202
|
+
#
|
|
203
|
+
# @param data [Hash] Additional context (expected_fingerprint, calculated_fingerprint, entity_statement_url)
|
|
204
|
+
# @return [void]
|
|
205
|
+
def notify_fingerprint_mismatch(data = {})
|
|
206
|
+
notify(
|
|
207
|
+
EVENT_FINGERPRINT_MISMATCH,
|
|
208
|
+
data: {
|
|
209
|
+
reason: "Entity statement fingerprint mismatch - possible MITM attack or tampering",
|
|
210
|
+
**data
|
|
211
|
+
},
|
|
212
|
+
severity: :error
|
|
213
|
+
)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Notify about trust chain validation failure
|
|
217
|
+
#
|
|
218
|
+
# @param data [Hash] Additional context (entity_id, trust_anchor, validation_step, error_message)
|
|
219
|
+
# @return [void]
|
|
220
|
+
def notify_trust_chain_validation_failed(data = {})
|
|
221
|
+
notify(
|
|
222
|
+
EVENT_TRUST_CHAIN_VALIDATION_FAILED,
|
|
223
|
+
data: {
|
|
224
|
+
reason: "Trust chain validation failed - possible MITM attack or configuration issue",
|
|
225
|
+
**data
|
|
226
|
+
},
|
|
227
|
+
severity: :error
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Notify about endpoint mismatch
|
|
232
|
+
#
|
|
233
|
+
# @param data [Hash] Additional context (endpoint_type, expected, actual, source)
|
|
234
|
+
# @return [void]
|
|
235
|
+
def notify_endpoint_mismatch(data = {})
|
|
236
|
+
notify(
|
|
237
|
+
EVENT_ENDPOINT_MISMATCH,
|
|
238
|
+
data: {
|
|
239
|
+
reason: "Endpoint mismatch detected - possible MITM attack or configuration issue",
|
|
240
|
+
**data
|
|
241
|
+
},
|
|
242
|
+
severity: :warning
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Notify about unexpected authentication break
|
|
247
|
+
#
|
|
248
|
+
# @param data [Hash] Additional context (stage, error_message, error_class)
|
|
249
|
+
# @return [void]
|
|
250
|
+
def notify_unexpected_authentication_break(data = {})
|
|
251
|
+
notify(
|
|
252
|
+
EVENT_UNEXPECTED_AUTHENTICATION_BREAK,
|
|
253
|
+
data: {
|
|
254
|
+
reason: "Unexpected authentication break - something that should not fail has failed",
|
|
255
|
+
**data
|
|
256
|
+
},
|
|
257
|
+
severity: :error
|
|
258
|
+
)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Notify about state mismatch
|
|
262
|
+
#
|
|
263
|
+
# @param data [Hash] Additional context (state_param, state_session)
|
|
264
|
+
# @return [void]
|
|
265
|
+
def notify_state_mismatch(data = {})
|
|
266
|
+
notify(
|
|
267
|
+
EVENT_STATE_MISMATCH,
|
|
268
|
+
data: {
|
|
269
|
+
reason: "State parameter mismatch - possible CSRF attack or session issue",
|
|
270
|
+
**data
|
|
271
|
+
},
|
|
272
|
+
severity: :error
|
|
273
|
+
)
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Notify about missing required claims
|
|
277
|
+
#
|
|
278
|
+
# @param data [Hash] Additional context (missing_claims, available_claims, token_type)
|
|
279
|
+
# @return [void]
|
|
280
|
+
def notify_missing_required_claims(data = {})
|
|
281
|
+
notify(
|
|
282
|
+
EVENT_MISSING_REQUIRED_CLAIMS,
|
|
283
|
+
data: {
|
|
284
|
+
reason: "Token missing required claims - possible tampering or provider issue",
|
|
285
|
+
**data
|
|
286
|
+
},
|
|
287
|
+
severity: :error
|
|
288
|
+
)
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Notify about audience mismatch
|
|
292
|
+
#
|
|
293
|
+
# @param data [Hash] Additional context (expected_audience, actual_audience, token_type)
|
|
294
|
+
# @return [void]
|
|
295
|
+
def notify_audience_mismatch(data = {})
|
|
296
|
+
notify(
|
|
297
|
+
EVENT_AUDIENCE_MISMATCH,
|
|
298
|
+
data: {
|
|
299
|
+
reason: "Token audience mismatch - possible MITM attack or configuration issue",
|
|
300
|
+
**data
|
|
301
|
+
},
|
|
302
|
+
severity: :error
|
|
303
|
+
)
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
# Notify about issuer mismatch
|
|
307
|
+
#
|
|
308
|
+
# @param data [Hash] Additional context (expected_issuer, actual_issuer, token_type)
|
|
309
|
+
# @return [void]
|
|
310
|
+
def notify_issuer_mismatch(data = {})
|
|
311
|
+
notify(
|
|
312
|
+
EVENT_ISSUER_MISMATCH,
|
|
313
|
+
data: {
|
|
314
|
+
reason: "Token issuer mismatch - possible MITM attack or configuration issue",
|
|
315
|
+
**data
|
|
316
|
+
},
|
|
317
|
+
severity: :error
|
|
318
|
+
)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Notify about expired token
|
|
322
|
+
#
|
|
323
|
+
# @param data [Hash] Additional context (exp, current_time, token_type)
|
|
324
|
+
# @return [void]
|
|
325
|
+
def notify_expired_token(data = {})
|
|
326
|
+
notify(
|
|
327
|
+
EVENT_EXPIRED_TOKEN,
|
|
328
|
+
data: {
|
|
329
|
+
reason: "Token expired - possible clock skew or replay attack",
|
|
330
|
+
**data
|
|
331
|
+
},
|
|
332
|
+
severity: :warning
|
|
333
|
+
)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Notify about invalid nonce
|
|
337
|
+
#
|
|
338
|
+
# @param data [Hash] Additional context (expected_nonce, actual_nonce)
|
|
339
|
+
# @return [void]
|
|
340
|
+
def notify_invalid_nonce(data = {})
|
|
341
|
+
notify(
|
|
342
|
+
EVENT_INVALID_NONCE,
|
|
343
|
+
data: {
|
|
344
|
+
reason: "Nonce mismatch - possible replay attack",
|
|
345
|
+
**data
|
|
346
|
+
},
|
|
347
|
+
severity: :error
|
|
348
|
+
)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
private
|
|
352
|
+
|
|
353
|
+
# Sanitize data to remove sensitive information
|
|
354
|
+
#
|
|
355
|
+
# @param data [Hash] Raw data
|
|
356
|
+
# @return [Hash] Sanitized data
|
|
357
|
+
def sanitize_data(data)
|
|
358
|
+
return {} unless data.is_a?(Hash)
|
|
359
|
+
|
|
360
|
+
sensitive_keys = [
|
|
361
|
+
:token, :access_token, :id_token, :refresh_token,
|
|
362
|
+
:private_key, :key, :secret, :password,
|
|
363
|
+
:authorization_code, :code,
|
|
364
|
+
:state, :nonce, :state_param, :state_session,
|
|
365
|
+
:fingerprint, :calculated_fingerprint, :expected_fingerprint
|
|
366
|
+
]
|
|
367
|
+
|
|
368
|
+
data.each_with_object({}) do |(key, value), result|
|
|
369
|
+
key_sym = key.to_sym
|
|
370
|
+
result[key] = if sensitive_keys.include?(key_sym)
|
|
371
|
+
"[REDACTED]"
|
|
372
|
+
elsif value.is_a?(Hash)
|
|
373
|
+
sanitize_data(value)
|
|
374
|
+
elsif value.is_a?(Array)
|
|
375
|
+
value.map { |v| v.is_a?(Hash) ? sanitize_data(v) : v }
|
|
376
|
+
else
|
|
377
|
+
value
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
require_relative "../logger"
|
|
2
|
+
require_relative "../cache"
|
|
3
|
+
require_relative "../cache_adapter"
|
|
4
|
+
require_relative "fetch"
|
|
5
|
+
|
|
6
|
+
# JWKS Cache with automatic invalidation on kid_not_found
|
|
7
|
+
# @see https://openid.net/specs/openid-federation-1_0.html OpenID Federation 1.0 Specification
|
|
8
|
+
#
|
|
9
|
+
# Provides intelligent caching for JWKS with automatic cache invalidation when a key ID (kid)
|
|
10
|
+
# is not found. This handles provider key rotation gracefully by:
|
|
11
|
+
# - Caching JWKS for performance
|
|
12
|
+
# - Invalidating cache when kid_not_found error occurs (after timeout)
|
|
13
|
+
# - Automatically reloading JWKS source on cache miss
|
|
14
|
+
#
|
|
15
|
+
# This prevents malicious requests from triggering cache invalidations by enforcing
|
|
16
|
+
# a grace period (timeout) between invalidations.
|
|
17
|
+
module OmniauthOpenidFederation
|
|
18
|
+
module Jwks
|
|
19
|
+
class Cache
|
|
20
|
+
attr_reader :jwks_source, :timeout_sec, :cache_last_update
|
|
21
|
+
|
|
22
|
+
# Initialize JWKS cache
|
|
23
|
+
#
|
|
24
|
+
# @param jwks_source [Object] The JWKS source (must respond to #jwks and optionally #reload!)
|
|
25
|
+
# @param timeout_sec [Integer] Minimum seconds between cache invalidations (default: 300 = 5 minutes)
|
|
26
|
+
def initialize(jwks_source, timeout_sec = 300)
|
|
27
|
+
@jwks_source = jwks_source
|
|
28
|
+
@timeout_sec = timeout_sec
|
|
29
|
+
@cache_last_update = 0
|
|
30
|
+
@cached_keys = nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Get JWKS with automatic cache invalidation on kid_not_found
|
|
34
|
+
#
|
|
35
|
+
# @param options [Hash] Options hash
|
|
36
|
+
# @option options [Boolean] :kid_not_found Whether kid was not found (triggers invalidation if timeout passed)
|
|
37
|
+
# @option options [String] :kid The key ID that was not found
|
|
38
|
+
# @return [Array<Hash>] Array of signing keys (filtered from JWKS)
|
|
39
|
+
def call(options = {})
|
|
40
|
+
# Check if we should invalidate cache due to kid_not_found
|
|
41
|
+
if options[:kid_not_found] && @cache_last_update < Time.now.to_i - @timeout_sec
|
|
42
|
+
kid = options[:kid]
|
|
43
|
+
OmniauthOpenidFederation::Logger.info("[Jwks::Cache] Invalidating JWK cache. Kid '#{kid}' not found from previous cache")
|
|
44
|
+
@cached_keys = nil
|
|
45
|
+
@jwks_source.reload! if @jwks_source.respond_to?(:reload!)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Return cached keys or fetch fresh
|
|
49
|
+
@cached_keys ||= begin
|
|
50
|
+
@cache_last_update = Time.now.to_i
|
|
51
|
+
jwks = @jwks_source.jwks
|
|
52
|
+
|
|
53
|
+
# Ensure jwks is in the expected format
|
|
54
|
+
keys = if jwks.is_a?(Hash) && (jwks.key?("keys") || jwks.key?(:keys))
|
|
55
|
+
jwks["keys"] || jwks[:keys] || []
|
|
56
|
+
elsif jwks.is_a?(Array)
|
|
57
|
+
jwks
|
|
58
|
+
else
|
|
59
|
+
[]
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Filter to signing keys only (for JWT verification)
|
|
63
|
+
keys.select { |key| (key[:use] || key["use"]) == "sig" }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Clear the cache
|
|
68
|
+
#
|
|
69
|
+
# @return [void]
|
|
70
|
+
def clear!
|
|
71
|
+
@cached_keys = nil
|
|
72
|
+
@cache_last_update = 0
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|