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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +851 -25
- data/SECURITY.md +33 -0
- data/app/controllers/api_keys/application_controller.rb +58 -10
- data/app/controllers/api_keys/keys_controller.rb +77 -23
- data/app/controllers/api_keys/security_controller.rb +8 -0
- data/app/views/api_keys/keys/_empty_state.html.erb +9 -0
- data/app/views/api_keys/keys/_form.html.erb +33 -4
- data/app/views/api_keys/keys/_key_actions.html.erb +20 -0
- data/app/views/api_keys/keys/_key_badges.html.erb +17 -0
- data/app/views/api_keys/keys/_key_row.html.erb +21 -35
- data/app/views/api_keys/keys/_key_status.html.erb +10 -0
- data/app/views/api_keys/keys/_keys_table.html.erb +3 -11
- data/app/views/api_keys/keys/_publishable_keys.html.erb +40 -0
- data/app/views/api_keys/keys/_secret_keys.html.erb +39 -0
- data/app/views/api_keys/keys/_show_token.html.erb +10 -47
- data/app/views/api_keys/keys/_token_display.html.erb +11 -0
- data/app/views/api_keys/keys/index.html.erb +40 -8
- data/app/views/api_keys/keys/show.html.erb +2 -2
- data/app/views/api_keys/security/best_practices.html.erb +73 -47
- data/app/views/layouts/api_keys/application.html.erb +267 -14
- data/lib/api_keys/authentication.rb +39 -11
- data/lib/api_keys/configuration.rb +444 -17
- data/lib/api_keys/engine.rb +5 -20
- data/lib/api_keys/errors.rb +73 -0
- data/lib/api_keys/form_builder_extensions.rb +168 -0
- data/lib/api_keys/helpers/expiration_options.rb +139 -0
- data/lib/api_keys/helpers/token_session.rb +203 -0
- data/lib/api_keys/helpers/view_helpers.rb +220 -0
- 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 +452 -21
- data/lib/api_keys/models/concerns/has_api_keys.rb +269 -26
- data/lib/api_keys/services/authenticator.rb +300 -112
- data/lib/api_keys/services/digestor.rb +81 -14
- data/lib/api_keys/services/token_generator.rb +41 -1
- data/lib/api_keys/tenant_resolution.rb +4 -4
- data/lib/api_keys/version.rb +1 -1
- data/lib/api_keys.rb +12 -0
- data/lib/generators/api_keys/add_authentication_index_generator.rb +36 -0
- data/lib/generators/api_keys/add_key_types_generator.rb +68 -0
- data/lib/generators/api_keys/templates/add_authentication_index_to_api_keys.rb.erb +32 -0
- data/lib/generators/api_keys/templates/add_key_types_to_api_keys.rb.erb +18 -0
- data/lib/generators/api_keys/templates/create_api_keys_table.rb.erb +11 -3
- data/lib/generators/api_keys/templates/initializer.rb +261 -120
- metadata +29 -63
- 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
|
-
|
|
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,19 +64,32 @@ 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
|
-
|
|
67
|
-
|
|
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]
|
|
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]
|
|
92
|
-
|
|
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(
|
|
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]
|
|
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 = "
|
|
126
|
-
cache_ttl = config.cache_ttl
|
|
127
|
-
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?}"
|
|
128
152
|
|
|
129
153
|
if cache_ttl > 0
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
197
|
-
|
|
180
|
+
def self.find_bcrypt_key(token, config)
|
|
181
|
+
return nil if token.bytesize > Digestor::BCRYPT_MAX_SECRET_BYTESIZE
|
|
198
182
|
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
229
|
-
|
|
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 =
|
|
264
|
+
prefixes = fetch_known_prefixes_from_database
|
|
235
265
|
|
|
236
|
-
if cache_ttl > 0 &&
|
|
237
|
-
|
|
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
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
|
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
|
-
Rails
|
|
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
|
|
62
|
-
#
|
|
63
|
-
|
|
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
|