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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -0
  3. data/README.md +101 -39
  4. data/SECURITY.md +33 -0
  5. data/app/controllers/api_keys/application_controller.rb +58 -10
  6. data/app/controllers/api_keys/keys_controller.rb +40 -18
  7. data/app/views/api_keys/keys/_empty_state.html.erb +1 -1
  8. data/app/views/api_keys/keys/_form.html.erb +3 -3
  9. data/app/views/api_keys/keys/_key_actions.html.erb +3 -3
  10. data/app/views/api_keys/keys/_key_badges.html.erb +2 -2
  11. data/app/views/api_keys/keys/_key_row.html.erb +1 -1
  12. data/app/views/api_keys/keys/_key_status.html.erb +3 -3
  13. data/app/views/api_keys/keys/_keys_table.html.erb +1 -4
  14. data/app/views/api_keys/keys/_show_token.html.erb +5 -46
  15. data/app/views/api_keys/keys/_token_display.html.erb +3 -3
  16. data/app/views/api_keys/keys/index.html.erb +2 -2
  17. data/app/views/api_keys/keys/show.html.erb +2 -2
  18. data/app/views/api_keys/security/best_practices.html.erb +7 -7
  19. data/app/views/layouts/api_keys/application.html.erb +159 -12
  20. data/lib/api_keys/authentication.rb +39 -11
  21. data/lib/api_keys/configuration.rb +374 -24
  22. data/lib/api_keys/engine.rb +5 -20
  23. data/lib/api_keys/form_builder_extensions.rb +12 -2
  24. data/lib/api_keys/helpers/expiration_options.rb +11 -3
  25. data/lib/api_keys/helpers/token_session.rb +143 -8
  26. data/lib/api_keys/helpers/view_helpers.rb +5 -1
  27. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  28. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  29. data/lib/api_keys/models/api_key.rb +244 -25
  30. data/lib/api_keys/models/concerns/has_api_keys.rb +95 -32
  31. data/lib/api_keys/services/authenticator.rb +263 -118
  32. data/lib/api_keys/services/digestor.rb +76 -13
  33. data/lib/api_keys/services/token_generator.rb +41 -1
  34. data/lib/api_keys/tenant_resolution.rb +2 -4
  35. data/lib/api_keys/version.rb +1 -1
  36. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  37. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
  38. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +2 -3
  39. data/lib/generators/api_keys/templates/initializer.rb +36 -17
  40. metadata +16 -16
  41. data/.simplecov +0 -36
  42. data/AGENTS.md +0 -5
  43. data/Appraisals +0 -17
  44. data/CLAUDE.md +0 -5
  45. data/Rakefile +0 -37
  46. data/context7.json +0 -4
  47. data/gemfiles/rails_7.2.gemfile +0 -21
  48. data/gemfiles/rails_8.0.gemfile +0 -21
  49. 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
- log_debug "[ApiKeys Auth] Authenticator.call started for request: #{request.uuid}"
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 defined?(Rails.env) && Rails.env.production? && config.https_only_production
38
- if request.protocol == "http://"
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
- result = Result.failure(error_code: :insecure_connection, message: "API requests must be made over HTTPS in production.")
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
- result = Result.failure(error_code: :missing_token, message: "API token is missing")
56
- config.after_authentication&.call(result)
57
- return result
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&.active?
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] Authenticator.call finished. Result: #{result.inspect}"
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] Checking header '#{config.header}': '#{header_value}'"
99
- if header_value
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(/^Bearer\s+(.*)$/i)
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] Checking query param '#{config.query_param}': '#{param_value}'"
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 = "api_keys:token:#{Digest::SHA1.hexdigest(token)}" # Cache key based on token hash
133
- cache_ttl = config.cache_ttl.to_i
134
- log_debug "[ApiKeys Auth] Verifying token. Cache key: #{cache_key}, TTL: #{cache_ttl}"
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
- cached_result = rails_cache&.read(cache_key)
138
- log_debug "[ApiKeys Auth] Cache check: Result=#{cached_result.inspect}"
139
- # Return cached result ONLY if it's a valid ApiKey instance (a true cache hit)
140
- if cached_result.is_a?(ApiKeys::ApiKey)
141
- log_debug "[ApiKeys Auth] Cache HIT. Returning cached ApiKey ID: #{cached_result.id}"
142
- return cached_result
143
- elsif cached_result.nil?
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
- # 1. Determine the expected hashing strategy (assuming single strategy for now)
156
- strategy = config.hash_strategy.to_sym
157
- log_debug "[ApiKeys Auth] Using strategy: #{strategy}"
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
- possible_keys_scope = if matched_prefix
179
- ApiKeys::ApiKey.where(prefix: matched_prefix, digest_algorithm: 'bcrypt')
180
- else
181
- # This path is now less likely but covers cases where token matches no known prefix.
182
- log_warn "[ApiKeys Auth] Token does not start with the configured prefix or any known prefix. Cannot perform DB lookup."
183
- ApiKeys::ApiKey.none # Return an empty relation
184
- end
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
- elsif strategy == :sha256
198
- # For sha256, we hash the incoming token and look for an exact match
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
- # Find the key directly by the calculated digest and algorithm
204
- verified_key = ApiKeys::ApiKey.find_by(token_digest: token_digest, digest_algorithm: 'sha256')
183
+ cached_prefixes = fetch_known_prefixes(config)
184
+ return find_bcrypt_key_by_last4(token) unless cached_prefixes
205
185
 
206
- if verified_key
207
- log_debug "[ApiKeys Auth] Found matching key by SHA256 digest. Key ID: #{verified_key.id}"
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
- else
213
- # Log unsupported strategy
214
- log_warn "[ApiKeys Auth] Authentication attempt with unsupported hash strategy: #{strategy}"
215
- end
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
- log_debug "[ApiKeys Auth] DB Verification result: #{verified_key ? "Key ID: #{verified_key.id}" : 'No match'}"
218
- # --- End DB Lookup ---
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
- # Cache the result (either the found ApiKey instance or nil for a miss)
221
- if cache_ttl > 0 && rails_cache
222
- log_debug "[ApiKeys Auth] Writing result to cache. Key: #{cache_key}, Value: #{verified_key.inspect}"
223
- rails_cache.write(cache_key, verified_key, expires_in: cache_ttl)
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
- verified_key
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
- cache_key = "api_keys:known_prefixes"
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 = rails_cache&.read(cache_key)
236
- return cached_prefixes if cached_prefixes.is_a?(Array)
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 = ApiKeys::ApiKey.distinct.pluck(:prefix).compact
264
+ prefixes = fetch_known_prefixes_from_database
242
265
 
243
- if cache_ttl > 0 && rails_cache
244
- log_debug "[ApiKeys Auth] Writing known prefixes to cache. Key: #{cache_key}, Value: #{prefixes.inspect}"
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
- # Skip check if key doesn't have an environment (legacy keys)
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
- current_env = current_env_config.respond_to?(:call) ? current_env_config.call : current_env_config
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
- # Skip isolation check if current environment is blank (not properly configured)
410
+ # Strict isolation must fail closed when the current environment cannot be resolved.
276
411
  if current_env.blank?
277
- log_debug "[ApiKeys Auth] Current environment is blank, skipping environment isolation check"
278
- return nil
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: Key environment '#{key_env}' does not match current environment '#{current_env}'"
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 environment (#{key_env}) does not match current environment (#{current_env})"
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
- # NOTE: Removing the incorrect private `find_key_by_token` method.
293
- # def find_key_by_token(token)
294
- # ...
295
- # end
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 if token.blank? || stored_digest.blank?
64
+ return false unless valid_match_inputs?(token, stored_digest)
42
65
 
43
66
  case strategy
44
67
  when :bcrypt
45
- begin
46
- bcrypt_object = BCrypt::Password.new(stored_digest)
47
- # BCrypt's `==` operator is designed for secure comparison
48
- bcrypt_object == token
49
- rescue BCrypt::Errors::InvalidHash
50
- # If the stored digest isn't a valid BCrypt hash, comparison fails
51
- false
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
- comparison_proc.call(stored_digest, Digest::SHA256.hexdigest(token))
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 ArgumentError => e
64
- # Catch potential errors from Digest or comparison proc
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: #{e.message}"
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