api_keys 0.2.1 → 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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +851 -25
  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 +77 -23
  7. data/app/controllers/api_keys/security_controller.rb +8 -0
  8. data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
  9. data/app/views/api_keys/keys/_form.html.erb +33 -4
  10. data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
  11. data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
  12. data/app/views/api_keys/keys/_key_row.html.erb +21 -35
  13. data/app/views/api_keys/keys/_key_status.html.erb +10 -0
  14. data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
  15. data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
  16. data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
  17. data/app/views/api_keys/keys/_show_token.html.erb +10 -47
  18. data/app/views/api_keys/keys/_token_display.html.erb +11 -0
  19. data/app/views/api_keys/keys/index.html.erb +40 -8
  20. data/app/views/api_keys/keys/show.html.erb +2 -2
  21. data/app/views/api_keys/security/best_practices.html.erb +73 -47
  22. data/app/views/layouts/api_keys/application.html.erb +267 -14
  23. data/lib/api_keys/authentication.rb +39 -11
  24. data/lib/api_keys/configuration.rb +444 -17
  25. data/lib/api_keys/engine.rb +5 -20
  26. data/lib/api_keys/errors.rb +73 -0
  27. data/lib/api_keys/form_builder_extensions.rb +168 -0
  28. data/lib/api_keys/helpers/expiration_options.rb +139 -0
  29. data/lib/api_keys/helpers/token_session.rb +203 -0
  30. data/lib/api_keys/helpers/view_helpers.rb +220 -0
  31. data/lib/api_keys/jobs/callbacks_job.rb +10 -17
  32. data/lib/api_keys/jobs/update_stats_job.rb +27 -12
  33. data/lib/api_keys/models/api_key.rb +452 -21
  34. data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
  35. data/lib/api_keys/services/authenticator.rb +300 -112
  36. data/lib/api_keys/services/digestor.rb +81 -14
  37. data/lib/api_keys/services/token_generator.rb +41 -1
  38. data/lib/api_keys/tenant_resolution.rb +4 -4
  39. data/lib/api_keys/version.rb +1 -1
  40. data/lib/api_keys.rb +12 -0
  41. data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
  42. data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
  43. data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
  44. data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
  45. data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
  46. data/lib/generators/api_keys/templates/initializer.rb +261 -120
  47. metadata +29 -63
  48. data/Rakefile +0 -32
@@ -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,19 +64,32 @@ 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
- # TODO: Optionally update last_used_at and requests_count
67
- Result.success(api_key)
84
+
85
+ # Check environment isolation if enabled
86
+ env_check_result = check_environment_isolation(api_key, config)
87
+ if env_check_result
88
+ env_check_result # Return failure result
89
+ else
90
+ # TODO: Optionally update last_used_at and requests_count
91
+ Result.success(api_key)
92
+ end
68
93
  elsif api_key&.revoked?
69
94
  log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}"
70
95
  Result.failure(error_code: :revoked_key, message: "API key has been revoked")
@@ -76,22 +101,21 @@ module ApiKeys
76
101
  Result.failure(error_code: :invalid_token, message: "API token is invalid")
77
102
  end
78
103
 
79
- log_debug "[ApiKeys Auth] Authenticator.call finished. Result: #{result.inspect}"
80
- config.after_authentication&.call(result)
104
+ log_debug "[ApiKeys Auth] Authentication finished. Success: #{result.success?}; error code: #{result.error_code || 'none'}"
81
105
  result
82
106
  end
83
107
 
84
- private
85
-
86
108
  # Extracts the token string from the request headers or query parameters.
87
109
  def self.extract_token(request, config)
88
110
  # Check header first (preferred)
89
111
  if config.header.present?
90
112
  header_value = request.headers[config.header]
91
- log_debug "[ApiKeys Auth] Checking header '#{config.header}': '#{header_value}'"
92
- 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
+
93
117
  # Handle "Bearer <token>" scheme
94
- match = header_value.match(/^Bearer\s+(.*)$/i)
118
+ match = header_value.match(/\ABearer[ \t]+(.+)\z/i)
95
119
  if match
96
120
  log_debug "[ApiKeys Auth] Extracted token from Bearer scheme."
97
121
  return match[1]
@@ -105,7 +129,7 @@ module ApiKeys
105
129
  # Check query parameter as fallback (if configured)
106
130
  if config.query_param.present?
107
131
  param_value = request.query_parameters[config.query_param]
108
- 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?}"
109
133
  if param_value.present?
110
134
  log_debug "[ApiKeys Auth] Extracted token from query parameter."
111
135
  return param_value
@@ -122,134 +146,298 @@ module ApiKeys
122
146
  # @param config [ApiKeys::Configuration] The current configuration.
123
147
  # @return [ApiKeys::ApiKey, nil] The verified ApiKey instance or nil.
124
148
  def self.find_and_verify_key(token, config)
125
- cache_key = "api_keys:token:#{Digest::SHA1.hexdigest(token)}" # Cache key based on token hash
126
- cache_ttl = config.cache_ttl.to_i
127
- 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?}"
128
152
 
129
153
  if cache_ttl > 0
130
- cached_result = rails_cache&.read(cache_key)
131
- log_debug "[ApiKeys Auth] Cache check: Result=#{cached_result.inspect}"
132
- # Return cached result ONLY if it's a valid ApiKey instance (a true cache hit)
133
- if cached_result.is_a?(ApiKeys::ApiKey)
134
- log_debug "[ApiKeys Auth] Cache HIT. Returning cached ApiKey ID: #{cached_result.id}"
135
- return cached_result
136
- elsif cached_result.nil?
137
- log_debug "[ApiKeys Auth] Cache MISS. Proceeding to DB lookup."
138
- # Continue execution if it's a cache miss (nil)
139
- else
140
- # Handle unexpected cache values (e.g., old symbol :not_found)
141
- 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
142
161
  end
143
162
  end
144
163
 
145
- # --- Cache miss or TTL=0: Perform DB lookup & verification ---
146
164
  log_debug "[ApiKeys Auth] Performing DB lookup and verification."
165
+ verified_key = find_sha256_key(token) || find_bcrypt_key(token, config)
147
166
 
148
- # 1. Determine the expected hashing strategy (assuming single strategy for now)
149
- strategy = config.hash_strategy.to_sym
150
- log_debug "[ApiKeys Auth] Using strategy: #{strategy}"
151
-
152
- # 2. Find and verify the key based on the strategy.
153
- verified_key = nil
154
- if strategy == :bcrypt
155
- # Optimization: Check against the *configured* prefix first.
156
- configured_prefix = config.token_prefix.call
157
- matched_prefix = nil
158
-
159
- if token.start_with?(configured_prefix)
160
- log_debug "[ApiKeys Auth] Token matches configured prefix: #{configured_prefix}"
161
- matched_prefix = configured_prefix
162
- else
163
- # Fallback: If no match, check against all known prefixes (cached).
164
- log_debug "[ApiKeys Auth] Token does not match configured prefix. Checking known prefixes."
165
- known_prefixes = fetch_known_prefixes(config)
166
- # Sort by length descending to find the longest match first
167
- matched_prefix = known_prefixes.sort_by(&:length).reverse.find { |p| token.start_with?(p) }
168
- log_debug "[ApiKeys Auth] Known prefixes: #{known_prefixes}. Matched prefix for lookup: #{matched_prefix || 'None'}"
169
- end
167
+ if cache_ttl > 0 && verified_key
168
+ safe_cache_write(cache_key, verified_key.id, expires_in: cache_ttl)
169
+ end
170
170
 
171
- possible_keys_scope = if matched_prefix
172
- ApiKeys::ApiKey.where(prefix: matched_prefix, digest_algorithm: 'bcrypt')
173
- else
174
- # This path is now less likely but covers cases where token matches no known prefix.
175
- log_warn "[ApiKeys Auth] Token does not start with the configured prefix or any known prefix. Cannot perform DB lookup."
176
- ApiKeys::ApiKey.none # Return an empty relation
177
- end
178
-
179
- log_debug "[ApiKeys Auth] DB Query Scope SQL (bcrypt): #{possible_keys_scope.to_sql}" if possible_keys_scope.respond_to?(:to_sql)
180
- possible_keys = possible_keys_scope.to_a
181
- log_debug "[ApiKeys Auth] Found #{possible_keys.count} potential key(s) with matching prefix and algorithm for bcrypt."
182
-
183
- # Securely compare the provided token against the digests of potential keys
184
- verified_key = possible_keys.find do |key|
185
- match_result = Digestor.match?(token: token, stored_digest: key.token_digest, strategy: :bcrypt)
186
- log_debug "[ApiKeys Auth] Comparing with Key ID: #{key.id} (bcrypt). Match result: #{match_result}"
187
- match_result
188
- end
171
+ verified_key
172
+ end
189
173
 
190
- elsif strategy == :sha256
191
- # For sha256, we hash the incoming token and look for an exact match
192
- # Note: Prefix lookup isn't useful here as the full hash is needed for the query.
193
- token_digest = Digest::SHA256.hexdigest(token)
194
- log_debug "[ApiKeys Auth] Calculated SHA256 digest for lookup: #{token_digest}"
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
195
179
 
196
- # Find the key directly by the calculated digest and algorithm
197
- verified_key = ApiKeys::ApiKey.find_by(token_digest: token_digest, digest_algorithm: 'sha256')
180
+ def self.find_bcrypt_key(token, config)
181
+ return nil if token.bytesize > Digestor::BCRYPT_MAX_SECRET_BYTESIZE
198
182
 
199
- if verified_key
200
- log_debug "[ApiKeys Auth] Found matching key by SHA256 digest. Key ID: #{verified_key.id}"
201
- else
202
- log_debug "[ApiKeys Auth] No key found matching the SHA256 digest."
203
- end
183
+ cached_prefixes = fetch_known_prefixes(config)
184
+ return find_bcrypt_key_by_last4(token) unless cached_prefixes
204
185
 
205
- else
206
- # Log unsupported strategy
207
- log_warn "[ApiKeys Auth] Authentication attempt with unsupported hash strategy: #{strategy}"
208
- end
186
+ key = find_bcrypt_key_for_prefixes(token, cached_prefixes)
187
+ return key if key
188
+
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
199
+
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
203
+
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
209
214
 
210
- log_debug "[ApiKeys Auth] DB Verification result: #{verified_key ? "Key ID: #{verified_key.id}" : 'No match'}"
211
- # --- End DB Lookup ---
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
212
221
 
213
- # Cache the result (either the found ApiKey instance or nil for a miss)
214
- if cache_ttl > 0 && rails_cache
215
- log_debug "[ApiKeys Auth] Writing result to cache. Key: #{cache_key}, Value: #{verified_key.inspect}"
216
- rails_cache.write(cache_key, verified_key, expires_in: cache_ttl)
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
217
232
  end
218
233
 
219
- 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)
220
245
  end
221
246
 
222
247
  # Helper to fetch (and cache) the distinct prefixes stored in the ApiKey table.
223
248
  def self.fetch_known_prefixes(config)
224
- cache_key = "api_keys:known_prefixes"
225
- 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)
226
250
 
227
251
  if cache_ttl > 0
228
- cached_prefixes = rails_cache&.read(cache_key)
229
- 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
230
260
  log_debug "[ApiKeys Auth] Known prefixes cache MISS. Fetching from DB."
231
261
  end
232
262
 
233
263
  # Fetch distinct, non-null prefixes from the database
234
- prefixes = ApiKeys::ApiKey.distinct.pluck(:prefix).compact
264
+ prefixes = fetch_known_prefixes_from_database
235
265
 
236
- if cache_ttl > 0 && rails_cache
237
- log_debug "[ApiKeys Auth] Writing known prefixes to cache. Key: #{cache_key}, Value: #{prefixes.inspect}"
238
- 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)
239
268
  end
240
269
 
241
270
  prefixes
242
271
  end
243
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
+
244
287
  # Helper for accessing Rails cache safely
245
288
  def self.rails_cache
246
289
  defined?(Rails) ? Rails.cache : nil
247
290
  end
248
291
 
249
- # NOTE: Removing the incorrect private `find_key_by_token` method.
250
- # def find_key_by_token(token)
251
- # ...
252
- # end
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
+
377
+ # Check if the API key's environment matches the current environment
378
+ # Returns a failure Result if there's a mismatch and strict isolation is enabled
379
+ # Returns nil if the check passes or is not applicable
380
+ def self.check_environment_isolation(api_key, config)
381
+ return nil unless config.strict_environment_isolation
382
+
383
+ # Untyped legacy keys predate environment support and remain exempt.
384
+ key_env = api_key.environment
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
392
+
393
+ # Get current environment
394
+ current_env_config = config.current_environment
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
404
+
405
+ # Normalize to string first, then check if blank
406
+ # This ensures consistent string comparison and prevents edge cases with empty strings
407
+ current_env = current_env.to_s
408
+ key_env = key_env.to_s
409
+
410
+ # Strict isolation must fail closed when the current environment cannot be resolved.
411
+ if current_env.blank?
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
+ )
417
+ end
418
+
419
+ if current_env != key_env
420
+ log_debug "[ApiKeys Auth] Environment mismatch for key ID #{api_key.id}."
421
+ return Result.failure(
422
+ error_code: :environment_mismatch,
423
+ message: "API key cannot be used in this environment"
424
+ )
425
+ end
426
+
427
+ nil # Check passed
428
+ end
429
+
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
253
441
  end
254
442
  end
255
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,31 +61,75 @@ 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
- Rails.logger.error "[ApiKeys] Digestor comparison failed: Unsupported hash strategy '#{strategy}' for digest check." if defined?(Rails.logger)
84
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
85
+ Rails.logger.error "[ApiKeys] Digestor comparison failed: Unsupported hash strategy '#{strategy}' for digest check."
86
+ end
59
87
  false
60
88
  end
61
- rescue ArgumentError => e
62
- # Catch potential errors from Digest or comparison proc
63
- Rails.logger.error "[ApiKeys] Digestor comparison error: #{e.message}" if defined?(Rails.logger)
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.
92
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
93
+ Rails.logger.error "[ApiKeys] Digestor comparison error (#{error.class})."
94
+ end
95
+ false
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
64
116
  false
65
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?
66
133
  end
67
134
  end
68
135
  end