api_keys 0.3.0 → 0.4.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 +51 -0
- data/README.md +101 -39
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +40 -18
- data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
- data/app/views/api_keys/keys/_form.html.erb +3 -3
- data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
- data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
- data/app/views/api_keys/keys/_key_row.html.erb +1 -1
- data/app/views/api_keys/keys/_key_status.html.erb +3 -3
- data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
- data/app/views/api_keys/keys/_show_token.html.erb +5 -46
- data/app/views/api_keys/keys/_token_display.html.erb +3 -3
- data/app/views/api_keys/keys/index.html.erb +2 -2
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +7 -7
- data/app/views/layouts/api_keys/application.html.erb +159 -12
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +374 -24
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/form_builder_extensions.rb +12 -2
- data/lib/api_keys/helpers/expiration_options.rb +11 -3
- data/lib/api_keys/helpers/token_session.rb +143 -8
- data/lib/api_keys/helpers/view_helpers.rb +5 -1
- data/lib/api_keys/jobs/callbacks_job.rb +10 -17
- data/lib/api_keys/jobs/update_stats_job.rb +27 -12
- data/lib/api_keys/models/api_key.rb +244 -25
- data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
- data/lib/api_keys/services/authenticator.rb +263 -118
- data/lib/api_keys/services/digestor.rb +76 -13
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +2 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
- data/lib/generators/api_keys/templates/initializer.rb +36 -17
- metadata +16 -16
- data/.simplecov +0 -36
- data/AGENTS.md +0 -5
- data/Appraisals +0 -17
- data/CLAUDE.md +0 -5
- data/Rakefile +0 -37
- data/context7.json +0 -4
- data/gemfiles/rails_7.2.gemfile +0 -21
- data/gemfiles/rails_8.0.gemfile +0 -21
- data/gemfiles/rails_8.1.gemfile +0 -21
|
@@ -13,6 +13,12 @@ module ApiKeys
|
|
|
13
13
|
class Authenticator
|
|
14
14
|
extend ApiKeys::Logging
|
|
15
15
|
|
|
16
|
+
MAX_TOKEN_BYTESIZE = 512
|
|
17
|
+
MAX_BCRYPT_CANDIDATES = 32
|
|
18
|
+
MAX_KNOWN_PREFIXES = 1_024
|
|
19
|
+
TOKEN_CACHE_NAMESPACE = "api_keys:v2:token"
|
|
20
|
+
KNOWN_PREFIXES_CACHE_KEY = "api_keys:v2:known_prefixes"
|
|
21
|
+
|
|
16
22
|
# Result object for authentication attempts.
|
|
17
23
|
Result = Struct.new(:success?, :api_key, :error_code, :message, keyword_init: true) do
|
|
18
24
|
def self.success(api_key)
|
|
@@ -22,6 +28,14 @@ module ApiKeys
|
|
|
22
28
|
def self.failure(error_code:, message:)
|
|
23
29
|
new(success?: false, error_code: error_code, message: message)
|
|
24
30
|
end
|
|
31
|
+
|
|
32
|
+
# Do not delegate to Struct's default inspection: it recursively inspects
|
|
33
|
+
# the Active Record object and can expose token digests or public tokens.
|
|
34
|
+
def inspect
|
|
35
|
+
"#<#{self.class.name} success?=#{success?.inspect} api_key_id=#{api_key&.id.inspect} error_code=#{error_code.inspect}>"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
alias_method :to_s, :inspect
|
|
25
39
|
end
|
|
26
40
|
|
|
27
41
|
# Authenticates the request.
|
|
@@ -29,20 +43,18 @@ module ApiKeys
|
|
|
29
43
|
# @param request [ActionDispatch::Request] The incoming request object.
|
|
30
44
|
# @return [ApiKeys::Services::Authenticator::Result] The result of the authentication attempt.
|
|
31
45
|
def self.call(request)
|
|
32
|
-
|
|
46
|
+
request_uuid = request.uuid if request.respond_to?(:uuid)
|
|
47
|
+
log_debug "[ApiKeys Auth] Authentication started for request #{request_uuid || '[unknown]'}"
|
|
33
48
|
config = ApiKeys.configuration
|
|
34
|
-
config.before_authentication&.call(request)
|
|
35
49
|
|
|
36
50
|
# === HTTPS Check (Production Only) ===
|
|
37
|
-
if
|
|
38
|
-
|
|
51
|
+
if production_environment? && config.https_only_production
|
|
52
|
+
unless secure_request?(request)
|
|
39
53
|
warning_message = "[ApiKeys Security] API key authentication attempted over insecure HTTP connection in production."
|
|
40
54
|
log_warn warning_message
|
|
41
55
|
if config.https_strict_mode
|
|
42
56
|
log_warn "[ApiKeys Security] Strict mode enabled: Aborting authentication."
|
|
43
|
-
|
|
44
|
-
config.after_authentication&.call(result)
|
|
45
|
-
return result # Halt execution due to strict mode
|
|
57
|
+
return Result.failure(error_code: :insecure_connection, message: "API requests must be made over HTTPS in production.")
|
|
46
58
|
end
|
|
47
59
|
end
|
|
48
60
|
end
|
|
@@ -52,16 +64,22 @@ module ApiKeys
|
|
|
52
64
|
|
|
53
65
|
unless token
|
|
54
66
|
log_debug "[ApiKeys Auth] Token extraction failed."
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
return Result.failure(error_code: :missing_token, message: "API token is missing")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
unless valid_token?(token)
|
|
71
|
+
log_debug "[ApiKeys Auth] Rejected a malformed API token."
|
|
72
|
+
return Result.failure(error_code: :invalid_token, message: "API token is invalid")
|
|
58
73
|
end
|
|
59
74
|
|
|
60
75
|
log_debug "[ApiKeys Auth] Token extracted successfully. Verifying..."
|
|
61
76
|
# Pass the original token AND config to find_and_verify_key
|
|
62
77
|
api_key = find_and_verify_key(token, config)
|
|
63
78
|
|
|
64
|
-
result = if api_key
|
|
79
|
+
result = if (configuration_failure = check_key_type_configuration(api_key, config) ||
|
|
80
|
+
check_environment_configuration(api_key, config))
|
|
81
|
+
configuration_failure
|
|
82
|
+
elsif api_key&.active?
|
|
65
83
|
log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}"
|
|
66
84
|
|
|
67
85
|
# Check environment isolation if enabled
|
|
@@ -83,22 +101,21 @@ module ApiKeys
|
|
|
83
101
|
Result.failure(error_code: :invalid_token, message: "API token is invalid")
|
|
84
102
|
end
|
|
85
103
|
|
|
86
|
-
log_debug "[ApiKeys Auth]
|
|
87
|
-
config.after_authentication&.call(result)
|
|
104
|
+
log_debug "[ApiKeys Auth] Authentication finished. Success: #{result.success?}; error code: #{result.error_code || 'none'}"
|
|
88
105
|
result
|
|
89
106
|
end
|
|
90
107
|
|
|
91
|
-
private
|
|
92
|
-
|
|
93
108
|
# Extracts the token string from the request headers or query parameters.
|
|
94
109
|
def self.extract_token(request, config)
|
|
95
110
|
# Check header first (preferred)
|
|
96
111
|
if config.header.present?
|
|
97
112
|
header_value = request.headers[config.header]
|
|
98
|
-
log_debug "[ApiKeys Auth]
|
|
99
|
-
|
|
113
|
+
log_debug "[ApiKeys Auth] Checked configured authentication header. Present: #{!header_value.nil?}"
|
|
114
|
+
unless header_value.nil?
|
|
115
|
+
return header_value unless header_value.is_a?(String)
|
|
116
|
+
|
|
100
117
|
# Handle "Bearer <token>" scheme
|
|
101
|
-
match = header_value.match(
|
|
118
|
+
match = header_value.match(/\ABearer[ \t]+(.+)\z/i)
|
|
102
119
|
if match
|
|
103
120
|
log_debug "[ApiKeys Auth] Extracted token from Bearer scheme."
|
|
104
121
|
return match[1]
|
|
@@ -112,7 +129,7 @@ module ApiKeys
|
|
|
112
129
|
# Check query parameter as fallback (if configured)
|
|
113
130
|
if config.query_param.present?
|
|
114
131
|
param_value = request.query_parameters[config.query_param]
|
|
115
|
-
log_debug "[ApiKeys Auth]
|
|
132
|
+
log_debug "[ApiKeys Auth] Checked configured query parameter. Present: #{param_value.present?}"
|
|
116
133
|
if param_value.present?
|
|
117
134
|
log_debug "[ApiKeys Auth] Extracted token from query parameter."
|
|
118
135
|
return param_value
|
|
@@ -129,170 +146,298 @@ module ApiKeys
|
|
|
129
146
|
# @param config [ApiKeys::Configuration] The current configuration.
|
|
130
147
|
# @return [ApiKeys::ApiKey, nil] The verified ApiKey instance or nil.
|
|
131
148
|
def self.find_and_verify_key(token, config)
|
|
132
|
-
cache_key = "
|
|
133
|
-
cache_ttl = config.cache_ttl
|
|
134
|
-
log_debug "[ApiKeys Auth] Verifying token. Cache
|
|
149
|
+
cache_key = "#{TOKEN_CACHE_NAMESPACE}:#{Digest::SHA256.hexdigest(token)}"
|
|
150
|
+
cache_ttl = normalized_cache_ttl(config.cache_ttl)
|
|
151
|
+
log_debug "[ApiKeys Auth] Verifying token. Cache enabled: #{cache_ttl.positive?}"
|
|
135
152
|
|
|
136
153
|
if cache_ttl > 0
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
log_debug "[ApiKeys Auth] Cache MISS. Proceeding to DB lookup."
|
|
145
|
-
# Continue execution if it's a cache miss (nil)
|
|
146
|
-
else
|
|
147
|
-
# Handle unexpected cache values (e.g., old symbol :not_found)
|
|
148
|
-
log_warn "[ApiKeys Auth] Invalid cache value found: #{cached_result.inspect}. Proceeding to DB lookup."
|
|
154
|
+
cached_id = safe_cache_read(cache_key)
|
|
155
|
+
if cache_identifier?(cached_id)
|
|
156
|
+
cached_key = safe_find_by_id(cached_id)
|
|
157
|
+
if cached_key && verify_record_token(cached_key, token)
|
|
158
|
+
log_debug "[ApiKeys Auth] Cache lookup hint verified for key ID: #{cached_key.id}"
|
|
159
|
+
return cached_key
|
|
160
|
+
end
|
|
149
161
|
end
|
|
150
162
|
end
|
|
151
163
|
|
|
152
|
-
# --- Cache miss or TTL=0: Perform DB lookup & verification ---
|
|
153
164
|
log_debug "[ApiKeys Auth] Performing DB lookup and verification."
|
|
165
|
+
verified_key = find_sha256_key(token) || find_bcrypt_key(token, config)
|
|
154
166
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
# 2. Find and verify the key based on the strategy.
|
|
160
|
-
verified_key = nil
|
|
161
|
-
if strategy == :bcrypt
|
|
162
|
-
# Optimization: Check against the *configured* prefix first.
|
|
163
|
-
configured_prefix = config.token_prefix.call
|
|
164
|
-
matched_prefix = nil
|
|
165
|
-
|
|
166
|
-
if token.start_with?(configured_prefix)
|
|
167
|
-
log_debug "[ApiKeys Auth] Token matches configured prefix: #{configured_prefix}"
|
|
168
|
-
matched_prefix = configured_prefix
|
|
169
|
-
else
|
|
170
|
-
# Fallback: If no match, check against all known prefixes (cached).
|
|
171
|
-
log_debug "[ApiKeys Auth] Token does not match configured prefix. Checking known prefixes."
|
|
172
|
-
known_prefixes = fetch_known_prefixes(config)
|
|
173
|
-
# Sort by length descending to find the longest match first
|
|
174
|
-
matched_prefix = known_prefixes.sort_by(&:length).reverse.find { |p| token.start_with?(p) }
|
|
175
|
-
log_debug "[ApiKeys Auth] Known prefixes: #{known_prefixes}. Matched prefix for lookup: #{matched_prefix || 'None'}"
|
|
176
|
-
end
|
|
167
|
+
if cache_ttl > 0 && verified_key
|
|
168
|
+
safe_cache_write(cache_key, verified_key.id, expires_in: cache_ttl)
|
|
169
|
+
end
|
|
177
170
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
log_debug "[ApiKeys Auth] DB Query Scope SQL (bcrypt): #{possible_keys_scope.to_sql}" if possible_keys_scope.respond_to?(:to_sql)
|
|
187
|
-
possible_keys = possible_keys_scope.to_a
|
|
188
|
-
log_debug "[ApiKeys Auth] Found #{possible_keys.count} potential key(s) with matching prefix and algorithm for bcrypt."
|
|
189
|
-
|
|
190
|
-
# Securely compare the provided token against the digests of potential keys
|
|
191
|
-
verified_key = possible_keys.find do |key|
|
|
192
|
-
match_result = Digestor.match?(token: token, stored_digest: key.token_digest, strategy: :bcrypt)
|
|
193
|
-
log_debug "[ApiKeys Auth] Comparing with Key ID: #{key.id} (bcrypt). Match result: #{match_result}"
|
|
194
|
-
match_result
|
|
195
|
-
end
|
|
171
|
+
verified_key
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def self.find_sha256_key(token)
|
|
175
|
+
token_digest = Digest::SHA256.hexdigest(token)
|
|
176
|
+
key = ApiKeys::ApiKey.find_by(token_digest: token_digest, digest_algorithm: "sha256")
|
|
177
|
+
key if key && verify_record_token(key, token)
|
|
178
|
+
end
|
|
196
179
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
# Note: Prefix lookup isn't useful here as the full hash is needed for the query.
|
|
200
|
-
token_digest = Digest::SHA256.hexdigest(token)
|
|
201
|
-
log_debug "[ApiKeys Auth] Calculated SHA256 digest for lookup: #{token_digest}"
|
|
180
|
+
def self.find_bcrypt_key(token, config)
|
|
181
|
+
return nil if token.bytesize > Digestor::BCRYPT_MAX_SECRET_BYTESIZE
|
|
202
182
|
|
|
203
|
-
|
|
204
|
-
|
|
183
|
+
cached_prefixes = fetch_known_prefixes(config)
|
|
184
|
+
return find_bcrypt_key_by_last4(token) unless cached_prefixes
|
|
205
185
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
else
|
|
209
|
-
log_debug "[ApiKeys Auth] No key found matching the SHA256 digest."
|
|
210
|
-
end
|
|
186
|
+
key = find_bcrypt_key_for_prefixes(token, cached_prefixes)
|
|
187
|
+
return key if key
|
|
211
188
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
189
|
+
# Prefix caching is only a hint. A stale or poisoned cache entry must not
|
|
190
|
+
# strand a valid bcrypt key, so retry once with authoritative DB values.
|
|
191
|
+
fresh_prefixes = fetch_known_prefixes_from_database
|
|
192
|
+
return find_bcrypt_key_by_last4(token) unless fresh_prefixes
|
|
193
|
+
return nil if fresh_prefixes == cached_prefixes
|
|
194
|
+
|
|
195
|
+
cache_ttl = normalized_cache_ttl(config.cache_ttl)
|
|
196
|
+
safe_cache_write(KNOWN_PREFIXES_CACHE_KEY, fresh_prefixes, expires_in: cache_ttl) if cache_ttl > 0
|
|
197
|
+
find_bcrypt_key_for_prefixes(token, fresh_prefixes)
|
|
198
|
+
end
|
|
216
199
|
|
|
217
|
-
|
|
218
|
-
|
|
200
|
+
def self.find_bcrypt_key_for_prefixes(token, prefixes)
|
|
201
|
+
matched_prefix = prefixes.sort_by(&:bytesize).reverse_each.find { |prefix| token.start_with?(prefix) }
|
|
202
|
+
return nil unless matched_prefix
|
|
219
203
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
204
|
+
random_part = token.delete_prefix(matched_prefix)
|
|
205
|
+
return nil if random_part.length < 4
|
|
206
|
+
|
|
207
|
+
candidates = ApiKeys::ApiKey.where(
|
|
208
|
+
prefix: matched_prefix,
|
|
209
|
+
last4: random_part.last(4),
|
|
210
|
+
digest_algorithm: "bcrypt"
|
|
211
|
+
)
|
|
212
|
+
find_verified_bcrypt_candidate(candidates, token)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# If a deployment has an unusually large number of historical prefixes,
|
|
216
|
+
# avoid materializing them all in a request. `last4` is the last four
|
|
217
|
+
# characters of the complete generated token as well as of its random
|
|
218
|
+
# component, and every install has an index on that column.
|
|
219
|
+
def self.find_bcrypt_key_by_last4(token)
|
|
220
|
+
return nil if token.length < 4
|
|
221
|
+
|
|
222
|
+
candidates = ApiKeys::ApiKey.where(last4: token.last(4), digest_algorithm: "bcrypt")
|
|
223
|
+
find_verified_bcrypt_candidate(candidates, token)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def self.find_verified_bcrypt_candidate(relation, token)
|
|
227
|
+
candidates = relation.limit(MAX_BCRYPT_CANDIDATES + 1).to_a
|
|
228
|
+
|
|
229
|
+
if candidates.length > MAX_BCRYPT_CANDIDATES
|
|
230
|
+
log_warn "[ApiKeys Security] Rejected an overfull bcrypt authentication candidate set."
|
|
231
|
+
return nil
|
|
224
232
|
end
|
|
225
233
|
|
|
226
|
-
|
|
234
|
+
candidates.find do |candidate|
|
|
235
|
+
candidate.prefix.is_a?(String) && token.start_with?(candidate.prefix) &&
|
|
236
|
+
verify_record_token(candidate, token)
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def self.verify_record_token(api_key, token)
|
|
241
|
+
strategy = api_key.digest_algorithm.to_s
|
|
242
|
+
return false unless %w[sha256 bcrypt].include?(strategy)
|
|
243
|
+
|
|
244
|
+
Digestor.match?(token: token, stored_digest: api_key.token_digest, strategy: strategy.to_sym)
|
|
227
245
|
end
|
|
228
246
|
|
|
229
247
|
# Helper to fetch (and cache) the distinct prefixes stored in the ApiKey table.
|
|
230
248
|
def self.fetch_known_prefixes(config)
|
|
231
|
-
|
|
232
|
-
cache_ttl = config.cache_ttl.to_i # Use the same TTL as key lookup for consistency
|
|
249
|
+
cache_ttl = normalized_cache_ttl(config.cache_ttl)
|
|
233
250
|
|
|
234
251
|
if cache_ttl > 0
|
|
235
|
-
cached_prefixes =
|
|
236
|
-
|
|
252
|
+
cached_prefixes = safe_cache_read(KNOWN_PREFIXES_CACHE_KEY)
|
|
253
|
+
if cached_prefixes.is_a?(Array)
|
|
254
|
+
sanitized_prefixes = sanitize_prefixes(cached_prefixes)
|
|
255
|
+
return sanitized_prefixes if sanitized_prefixes
|
|
256
|
+
|
|
257
|
+
log_warn "[ApiKeys Security] Ignored an overfull known-prefix cache entry."
|
|
258
|
+
return nil
|
|
259
|
+
end
|
|
237
260
|
log_debug "[ApiKeys Auth] Known prefixes cache MISS. Fetching from DB."
|
|
238
261
|
end
|
|
239
262
|
|
|
240
263
|
# Fetch distinct, non-null prefixes from the database
|
|
241
|
-
prefixes =
|
|
264
|
+
prefixes = fetch_known_prefixes_from_database
|
|
242
265
|
|
|
243
|
-
if cache_ttl > 0 &&
|
|
244
|
-
|
|
245
|
-
rails_cache.write(cache_key, prefixes, expires_in: cache_ttl)
|
|
266
|
+
if cache_ttl > 0 && prefixes
|
|
267
|
+
safe_cache_write(KNOWN_PREFIXES_CACHE_KEY, prefixes, expires_in: cache_ttl)
|
|
246
268
|
end
|
|
247
269
|
|
|
248
270
|
prefixes
|
|
249
271
|
end
|
|
250
272
|
|
|
273
|
+
def self.fetch_known_prefixes_from_database
|
|
274
|
+
prefixes = ApiKeys::ApiKey.distinct.limit(MAX_KNOWN_PREFIXES + 1).pluck(:prefix)
|
|
275
|
+
sanitize_prefixes(prefixes)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def self.clear_known_prefixes_cache
|
|
279
|
+
cache = rails_cache
|
|
280
|
+
return unless cache
|
|
281
|
+
|
|
282
|
+
cache.delete(KNOWN_PREFIXES_CACHE_KEY)
|
|
283
|
+
rescue StandardError => error
|
|
284
|
+
log_warn "[ApiKeys Auth] Cache delete failed (#{error.class}); continuing safely."
|
|
285
|
+
end
|
|
286
|
+
|
|
251
287
|
# Helper for accessing Rails cache safely
|
|
252
288
|
def self.rails_cache
|
|
253
289
|
defined?(Rails) ? Rails.cache : nil
|
|
254
290
|
end
|
|
255
291
|
|
|
292
|
+
def self.safe_cache_read(key)
|
|
293
|
+
rails_cache&.read(key)
|
|
294
|
+
rescue StandardError => error
|
|
295
|
+
log_warn "[ApiKeys Auth] Cache read failed (#{error.class}); falling back to the database."
|
|
296
|
+
nil
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def self.safe_cache_write(key, value, expires_in:)
|
|
300
|
+
rails_cache&.write(key, value, expires_in: expires_in)
|
|
301
|
+
rescue StandardError => error
|
|
302
|
+
log_warn "[ApiKeys Auth] Cache write failed (#{error.class}); authentication result was not cached."
|
|
303
|
+
false
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def self.normalized_cache_ttl(value)
|
|
307
|
+
ttl = value.nil? ? 0 : value.to_f
|
|
308
|
+
ttl.positive? ? ttl : 0
|
|
309
|
+
rescue ArgumentError, TypeError
|
|
310
|
+
0
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def self.cache_identifier?(value)
|
|
314
|
+
(value.is_a?(Integer) && value >= 0 && value.to_s.bytesize <= 128) ||
|
|
315
|
+
(value.is_a?(String) && value.bytesize <= 128 && value.match?(/\A[[:alnum:]_-]+\z/))
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def self.safe_find_by_id(id)
|
|
319
|
+
ApiKeys::ApiKey.find_by(id: id)
|
|
320
|
+
rescue StandardError => error
|
|
321
|
+
log_warn "[ApiKeys Auth] Ignored an invalid cached key identifier (#{error.class})."
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def self.sanitize_prefixes(prefixes)
|
|
326
|
+
return nil unless prefixes.is_a?(Array)
|
|
327
|
+
return nil if prefixes.length > MAX_KNOWN_PREFIXES
|
|
328
|
+
|
|
329
|
+
prefixes.filter_map do |prefix|
|
|
330
|
+
prefix if prefix.is_a?(String) && prefix.present? && prefix.valid_encoding? && prefix.bytesize <= 64
|
|
331
|
+
end.uniq
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def self.valid_token?(token)
|
|
335
|
+
return false unless token.is_a?(String)
|
|
336
|
+
return false if token.empty? || token.bytesize > MAX_TOKEN_BYTESIZE || !token.valid_encoding?
|
|
337
|
+
|
|
338
|
+
token.each_codepoint.none? { |codepoint| codepoint <= 0x20 || codepoint == 0x7f }
|
|
339
|
+
rescue ArgumentError
|
|
340
|
+
false
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def self.production_environment?
|
|
344
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
def self.secure_request?(request)
|
|
348
|
+
return request.ssl? if request.respond_to?(:ssl?)
|
|
349
|
+
|
|
350
|
+
request.respond_to?(:protocol) && request.protocol == "https://"
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def self.check_key_type_configuration(api_key, config)
|
|
354
|
+
return nil unless api_key&.key_type.present?
|
|
355
|
+
|
|
356
|
+
configured = config.key_types&.keys&.any? { |type| type.to_s == api_key.key_type.to_s }
|
|
357
|
+
return nil if configured
|
|
358
|
+
|
|
359
|
+
log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its key type is not configured."
|
|
360
|
+
Result.failure(error_code: :unknown_key_type, message: "API key type is not configured")
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def self.check_environment_configuration(api_key, config)
|
|
364
|
+
return nil unless api_key&.key_type.present?
|
|
365
|
+
|
|
366
|
+
configured = if api_key.environment.present? && config.environments.present?
|
|
367
|
+
config.environments.keys.any? { |environment| environment.to_s == api_key.environment.to_s }
|
|
368
|
+
else
|
|
369
|
+
api_key.environment.present?
|
|
370
|
+
end
|
|
371
|
+
return nil if configured
|
|
372
|
+
|
|
373
|
+
log_warn "[ApiKeys Security] Rejected API key ID #{api_key.id} because its environment is not configured."
|
|
374
|
+
Result.failure(error_code: :unknown_environment, message: "API key environment is not configured")
|
|
375
|
+
end
|
|
376
|
+
|
|
256
377
|
# Check if the API key's environment matches the current environment
|
|
257
378
|
# Returns a failure Result if there's a mismatch and strict isolation is enabled
|
|
258
379
|
# Returns nil if the check passes or is not applicable
|
|
259
380
|
def self.check_environment_isolation(api_key, config)
|
|
260
381
|
return nil unless config.strict_environment_isolation
|
|
261
382
|
|
|
262
|
-
#
|
|
383
|
+
# Untyped legacy keys predate environment support and remain exempt.
|
|
263
384
|
key_env = api_key.environment
|
|
264
|
-
return nil if key_env.blank?
|
|
385
|
+
return nil if key_env.blank? && api_key.key_type.blank?
|
|
386
|
+
if key_env.blank?
|
|
387
|
+
return Result.failure(
|
|
388
|
+
error_code: :environment_misconfigured,
|
|
389
|
+
message: "API key environment could not be verified"
|
|
390
|
+
)
|
|
391
|
+
end
|
|
265
392
|
|
|
266
393
|
# Get current environment
|
|
267
394
|
current_env_config = config.current_environment
|
|
268
|
-
|
|
395
|
+
begin
|
|
396
|
+
current_env = current_env_config.respond_to?(:call) ? current_env_config.call : current_env_config
|
|
397
|
+
rescue StandardError => error
|
|
398
|
+
log_warn "[ApiKeys Security] Current environment resolution failed (#{error.class})."
|
|
399
|
+
return Result.failure(
|
|
400
|
+
error_code: :environment_misconfigured,
|
|
401
|
+
message: "API key environment could not be verified"
|
|
402
|
+
)
|
|
403
|
+
end
|
|
269
404
|
|
|
270
405
|
# Normalize to string first, then check if blank
|
|
271
406
|
# This ensures consistent string comparison and prevents edge cases with empty strings
|
|
272
407
|
current_env = current_env.to_s
|
|
273
408
|
key_env = key_env.to_s
|
|
274
409
|
|
|
275
|
-
#
|
|
410
|
+
# Strict isolation must fail closed when the current environment cannot be resolved.
|
|
276
411
|
if current_env.blank?
|
|
277
|
-
|
|
278
|
-
return
|
|
412
|
+
log_warn "[ApiKeys Security] Strict environment isolation is enabled, but current_environment resolved to blank."
|
|
413
|
+
return Result.failure(
|
|
414
|
+
error_code: :environment_misconfigured,
|
|
415
|
+
message: "API key environment could not be verified"
|
|
416
|
+
)
|
|
279
417
|
end
|
|
280
418
|
|
|
281
419
|
if current_env != key_env
|
|
282
|
-
log_debug "[ApiKeys Auth] Environment mismatch
|
|
420
|
+
log_debug "[ApiKeys Auth] Environment mismatch for key ID #{api_key.id}."
|
|
283
421
|
return Result.failure(
|
|
284
422
|
error_code: :environment_mismatch,
|
|
285
|
-
message: "API key
|
|
423
|
+
message: "API key cannot be used in this environment"
|
|
286
424
|
)
|
|
287
425
|
end
|
|
288
426
|
|
|
289
427
|
nil # Check passed
|
|
290
428
|
end
|
|
291
429
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
430
|
+
private_class_method :extract_token, :find_and_verify_key, :find_sha256_key,
|
|
431
|
+
:find_bcrypt_key, :find_bcrypt_key_for_prefixes,
|
|
432
|
+
:find_bcrypt_key_by_last4, :find_verified_bcrypt_candidate,
|
|
433
|
+
:verify_record_token, :fetch_known_prefixes,
|
|
434
|
+
:fetch_known_prefixes_from_database, :rails_cache,
|
|
435
|
+
:safe_cache_read, :safe_cache_write,
|
|
436
|
+
:normalized_cache_ttl, :cache_identifier?,
|
|
437
|
+
:safe_find_by_id, :sanitize_prefixes, :valid_token?,
|
|
438
|
+
:production_environment?, :secure_request?,
|
|
439
|
+
:check_key_type_configuration, :check_environment_configuration,
|
|
440
|
+
:check_environment_isolation
|
|
296
441
|
end
|
|
297
442
|
end
|
|
298
443
|
end
|
|
@@ -7,6 +7,17 @@ module ApiKeys
|
|
|
7
7
|
module Services
|
|
8
8
|
# Handles hashing (digesting) and verifying tokens based on configured strategy.
|
|
9
9
|
class Digestor
|
|
10
|
+
BCRYPT_MAX_SECRET_BYTESIZE = if BCrypt::Engine.const_defined?(:MAX_SECRET_BYTESIZE)
|
|
11
|
+
BCrypt::Engine::MAX_SECRET_BYTESIZE
|
|
12
|
+
else
|
|
13
|
+
72
|
|
14
|
+
end
|
|
15
|
+
# Costs above this value can turn a malformed/imported database row into
|
|
16
|
+
# a multi-second (or worse) CPU denial of service during authentication.
|
|
17
|
+
# The default bcrypt cost is comfortably below this ceiling.
|
|
18
|
+
BCRYPT_MAX_SAFE_COST = 16
|
|
19
|
+
MAX_TOKEN_BYTESIZE = 512
|
|
20
|
+
|
|
10
21
|
# Creates a digest of the given token using the configured strategy.
|
|
11
22
|
#
|
|
12
23
|
# @param token [String] The plaintext token.
|
|
@@ -14,8 +25,20 @@ module ApiKeys
|
|
|
14
25
|
# @return [Hash] A hash containing the digest and the algorithm used.
|
|
15
26
|
# e.g., { digest: "...", algorithm: "bcrypt" }
|
|
16
27
|
def self.digest(token:, strategy: ApiKeys.configuration.hash_strategy)
|
|
28
|
+
validate_token!(token)
|
|
29
|
+
|
|
17
30
|
case strategy
|
|
18
31
|
when :bcrypt
|
|
32
|
+
if token.bytesize > BCRYPT_MAX_SECRET_BYTESIZE
|
|
33
|
+
raise ArgumentError,
|
|
34
|
+
"BCrypt tokens must not exceed #{BCRYPT_MAX_SECRET_BYTESIZE} bytes because BCrypt truncates longer inputs."
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
unless safe_bcrypt_cost?(BCrypt::Engine.cost)
|
|
38
|
+
raise ArgumentError,
|
|
39
|
+
"BCrypt cost must be between #{BCrypt::Engine::MIN_COST} and #{BCRYPT_MAX_SAFE_COST}."
|
|
40
|
+
end
|
|
41
|
+
|
|
19
42
|
# BCrypt handles salt generation internally
|
|
20
43
|
digest = BCrypt::Password.create(token, cost: BCrypt::Engine.cost)
|
|
21
44
|
{ digest: digest.to_s, algorithm: "bcrypt" }
|
|
@@ -38,21 +61,24 @@ module ApiKeys
|
|
|
38
61
|
# @param comparison_proc [Proc] The secure comparison function.
|
|
39
62
|
# @return [Boolean] True if the token matches the digest, false otherwise.
|
|
40
63
|
def self.match?(token:, stored_digest:, strategy: ApiKeys.configuration.hash_strategy, comparison_proc: ApiKeys.configuration.secure_compare_proc)
|
|
41
|
-
return false
|
|
64
|
+
return false unless valid_match_inputs?(token, stored_digest)
|
|
42
65
|
|
|
43
66
|
case strategy
|
|
44
67
|
when :bcrypt
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
end
|
|
68
|
+
return false if token.bytesize > BCRYPT_MAX_SECRET_BYTESIZE
|
|
69
|
+
|
|
70
|
+
bcrypt_object = validated_bcrypt_password(stored_digest)
|
|
71
|
+
return false unless bcrypt_object
|
|
72
|
+
|
|
73
|
+
# BCrypt's `==` operator is designed for secure comparison.
|
|
74
|
+
bcrypt_object == token
|
|
53
75
|
when :sha256
|
|
54
76
|
# Directly compare the SHA256 hash of the input token with the stored digest
|
|
55
|
-
|
|
77
|
+
return false unless stored_digest.match?(/\A\h{64}\z/)
|
|
78
|
+
|
|
79
|
+
# A custom comparator is security-sensitive. Accept only the literal
|
|
80
|
+
# boolean true so truthy sentinel/error values can never authenticate.
|
|
81
|
+
comparison_proc.call(stored_digest, Digest::SHA256.hexdigest(token)) == true
|
|
56
82
|
else
|
|
57
83
|
# Strategy mismatch or unsupported strategy should fail comparison safely
|
|
58
84
|
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
@@ -60,13 +86,50 @@ module ApiKeys
|
|
|
60
86
|
end
|
|
61
87
|
false
|
|
62
88
|
end
|
|
63
|
-
rescue
|
|
64
|
-
#
|
|
89
|
+
rescue StandardError => error
|
|
90
|
+
# A malformed digest or application-supplied comparison proc must never
|
|
91
|
+
# turn an authentication failure into an exception or an availability issue.
|
|
65
92
|
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
66
|
-
Rails.logger.error "[ApiKeys] Digestor comparison error
|
|
93
|
+
Rails.logger.error "[ApiKeys] Digestor comparison error (#{error.class})."
|
|
67
94
|
end
|
|
68
95
|
false
|
|
69
96
|
end
|
|
97
|
+
|
|
98
|
+
# Returns whether a stored bcrypt digest is structurally valid and has a
|
|
99
|
+
# bounded cost that is safe to evaluate on an authentication request.
|
|
100
|
+
def self.valid_bcrypt_digest?(stored_digest)
|
|
101
|
+
!validated_bcrypt_password(stored_digest).nil?
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.validate_token!(token)
|
|
105
|
+
valid = token.is_a?(String) && token.present? && token.valid_encoding? && token.bytesize <= MAX_TOKEN_BYTESIZE
|
|
106
|
+
return if valid
|
|
107
|
+
|
|
108
|
+
raise ArgumentError, "Token must be a non-blank valid string of at most #{MAX_TOKEN_BYTESIZE} bytes."
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def self.valid_match_inputs?(token, stored_digest)
|
|
112
|
+
token.is_a?(String) && token.present? && token.valid_encoding? && token.bytesize <= MAX_TOKEN_BYTESIZE &&
|
|
113
|
+
stored_digest.is_a?(String) && stored_digest.present? && stored_digest.valid_encoding? &&
|
|
114
|
+
stored_digest.bytesize <= 128
|
|
115
|
+
rescue ArgumentError
|
|
116
|
+
false
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.validated_bcrypt_password(stored_digest)
|
|
120
|
+
return nil unless stored_digest.is_a?(String) && stored_digest.valid_encoding? && stored_digest.bytesize <= 128
|
|
121
|
+
|
|
122
|
+
password = BCrypt::Password.new(stored_digest)
|
|
123
|
+
password if safe_bcrypt_cost?(password.cost)
|
|
124
|
+
rescue BCrypt::Error, ArgumentError
|
|
125
|
+
nil
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def self.safe_bcrypt_cost?(cost)
|
|
129
|
+
cost.is_a?(Integer) && cost.between?(BCrypt::Engine::MIN_COST, BCRYPT_MAX_SAFE_COST)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
private_class_method :validate_token!, :valid_match_inputs?, :validated_bcrypt_password, :safe_bcrypt_cost?
|
|
70
133
|
end
|
|
71
134
|
end
|
|
72
135
|
end
|