Class: ApiKeys::Services::Authenticator

Inherits:
Object
  • Object
show all
Extended by:
Logging
Defined in:
lib/api_keys/services/authenticator.rb

Overview

Authenticates an incoming request by extracting and verifying an API key.

Defined Under Namespace

Classes: Result

Class Method Summary collapse

Class Method Details

.call(request) ⇒ ApiKeys::Services::Authenticator::Result

Authenticates the request.

Parameters:

  • request (ActionDispatch::Request)

    The incoming request object.

Returns:



31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# File 'lib/api_keys/services/authenticator.rb', line 31

def self.call(request)
  log_debug "[ApiKeys Auth] Authenticator.call started for request: #{request.uuid}"
  config = ApiKeys.configuration
  config.before_authentication&.call(request)

  # === HTTPS Check (Production Only) ===
  if defined?(Rails.env) && Rails.env.production? && config.https_only_production
    if request.protocol == "http://"
      warning_message = "[ApiKeys Security] API key authentication attempted over insecure HTTP connection in production."
      log_warn warning_message
      if config.https_strict_mode
        log_warn "[ApiKeys Security] Strict mode enabled: Aborting authentication."
        result = Result.failure(error_code: :insecure_connection, message: "API requests must be made over HTTPS in production.")
        config.after_authentication&.call(result)
        return result # Halt execution due to strict mode
      end
    end
  end
  # === End HTTPS Check ===

  token = extract_token(request, config)

  unless token
    log_debug "[ApiKeys Auth] Token extraction failed."
    result = Result.failure(error_code: :missing_token, message: "API token is missing")
    config.after_authentication&.call(result)
    return result
  end

  log_debug "[ApiKeys Auth] Token extracted successfully. Verifying..."
  # Pass the original token AND config to find_and_verify_key
  api_key = find_and_verify_key(token, config)

  result = if api_key&.active?
             log_debug "[ApiKeys Auth] Verification successful. Key ID: #{api_key.id}"

             # Check environment isolation if enabled
             env_check_result = check_environment_isolation(api_key, config)
             if env_check_result
               env_check_result  # Return failure result
             else
               # TODO: Optionally update last_used_at and requests_count
               Result.success(api_key)
             end
           elsif api_key&.revoked?
             log_debug "[ApiKeys Auth] Verification failed: Key revoked. Key ID: #{api_key.id}"
             Result.failure(error_code: :revoked_key, message: "API key has been revoked")
           elsif api_key&.expired?
             log_debug "[ApiKeys Auth] Verification failed: Key expired. Key ID: #{api_key.id}"
             Result.failure(error_code: :expired_key, message: "API key has expired")
           else # Not found, mismatch, or inactive
             log_debug "[ApiKeys Auth] Verification failed: Token invalid or key not found."
             Result.failure(error_code: :invalid_token, message: "API token is invalid")
           end

  log_debug "[ApiKeys Auth] Authenticator.call finished. Result: #{result.inspect}"
  config.after_authentication&.call(result)
  result
end

.check_environment_isolation(api_key, config) ⇒ Object

Check if the API key's environment matches the current environment Returns a failure Result if there's a mismatch and strict isolation is enabled Returns nil if the check passes or is not applicable



259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/api_keys/services/authenticator.rb', line 259

def self.check_environment_isolation(api_key, config)
  return nil unless config.strict_environment_isolation

  # Skip check if key doesn't have an environment (legacy keys)
  key_env = api_key.environment
  return nil if key_env.blank?

  # Get current environment
  current_env_config = config.current_environment
  current_env = current_env_config.respond_to?(:call) ? current_env_config.call : current_env_config

  # Normalize to string first, then check if blank
  # This ensures consistent string comparison and prevents edge cases with empty strings
  current_env = current_env.to_s
  key_env = key_env.to_s

  # Skip isolation check if current environment is blank (not properly configured)
  if current_env.blank?
    log_debug "[ApiKeys Auth] Current environment is blank, skipping environment isolation check"
    return nil
  end

  if current_env != key_env
    log_debug "[ApiKeys Auth] Environment mismatch: Key environment '#{key_env}' does not match current environment '#{current_env}'"
    return Result.failure(
      error_code: :environment_mismatch,
      message: "API key environment (#{key_env}) does not match current environment (#{current_env})"
    )
  end

  nil # Check passed
end

.extract_token(request, config) ⇒ Object

Extracts the token string from the request headers or query parameters.



94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/api_keys/services/authenticator.rb', line 94

def self.extract_token(request, config)
  # Check header first (preferred)
  if config.header.present?
    header_value = request.headers[config.header]
    log_debug "[ApiKeys Auth] Checking header '#{config.header}': '#{header_value}'"
    if header_value
      # Handle "Bearer <token>" scheme
      match = header_value.match(/^Bearer\s+(.*)$/i)
      if match
        log_debug "[ApiKeys Auth] Extracted token from Bearer scheme."
        return match[1]
      end
      # Fallback: return the raw header value if no Bearer scheme
      log_debug "[ApiKeys Auth] No Bearer scheme, using raw header value as token."
      return header_value
    end
  end

  # Check query parameter as fallback (if configured)
  if config.query_param.present?
    param_value = request.query_parameters[config.query_param]
    log_debug "[ApiKeys Auth] Checking query param '#{config.query_param}': '#{param_value}'"
    if param_value.present?
      log_debug "[ApiKeys Auth] Extracted token from query parameter."
      return param_value
    end
  end

  log_debug "[ApiKeys Auth] No token found in headers or query parameters."
  nil # No token found
end

.fetch_known_prefixes(config) ⇒ Object

Helper to fetch (and cache) the distinct prefixes stored in the ApiKey table.



230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# File 'lib/api_keys/services/authenticator.rb', line 230

def self.fetch_known_prefixes(config)
  cache_key = "api_keys:known_prefixes"
  cache_ttl = config.cache_ttl.to_i # Use the same TTL as key lookup for consistency

  if cache_ttl > 0
    cached_prefixes = rails_cache&.read(cache_key)
    return cached_prefixes if cached_prefixes.is_a?(Array)
    log_debug "[ApiKeys Auth] Known prefixes cache MISS. Fetching from DB."
  end

  # Fetch distinct, non-null prefixes from the database
  prefixes = ApiKeys::ApiKey.distinct.pluck(:prefix).compact

  if cache_ttl > 0 && rails_cache
    log_debug "[ApiKeys Auth] Writing known prefixes to cache. Key: #{cache_key}, Value: #{prefixes.inspect}"
    rails_cache.write(cache_key, prefixes, expires_in: cache_ttl)
  end

  prefixes
end

.find_and_verify_key(token, config) ⇒ ApiKeys::ApiKey?

Finds the ApiKey record corresponding to the token and verifies it securely. Uses caching if enabled.

Parameters:

  • token (String)

    The plaintext token from the request.

  • config (ApiKeys::Configuration)

    The current configuration.

Returns:



131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
# File 'lib/api_keys/services/authenticator.rb', line 131

def self.find_and_verify_key(token, config)
  cache_key = "api_keys:token:#{Digest::SHA1.hexdigest(token)}" # Cache key based on token hash
  cache_ttl = config.cache_ttl.to_i
  log_debug "[ApiKeys Auth] Verifying token. Cache key: #{cache_key}, TTL: #{cache_ttl}"

  if cache_ttl > 0
    cached_result = rails_cache&.read(cache_key)
    log_debug "[ApiKeys Auth] Cache check: Result=#{cached_result.inspect}"
    # Return cached result ONLY if it's a valid ApiKey instance (a true cache hit)
    if cached_result.is_a?(ApiKeys::ApiKey)
       log_debug "[ApiKeys Auth] Cache HIT. Returning cached ApiKey ID: #{cached_result.id}"
       return cached_result
    elsif cached_result.nil?
       log_debug "[ApiKeys Auth] Cache MISS. Proceeding to DB lookup."
       # Continue execution if it's a cache miss (nil)
    else
       # Handle unexpected cache values (e.g., old symbol :not_found)
       log_warn "[ApiKeys Auth] Invalid cache value found: #{cached_result.inspect}. Proceeding to DB lookup."
    end
  end

  # --- Cache miss or TTL=0: Perform DB lookup & verification ---
  log_debug "[ApiKeys Auth] Performing DB lookup and verification."

  # 1. Determine the expected hashing strategy (assuming single strategy for now)
  strategy = config.hash_strategy.to_sym
  log_debug "[ApiKeys Auth] Using strategy: #{strategy}"

  # 2. Find and verify the key based on the strategy.
  verified_key = nil
  if strategy == :bcrypt
    # Optimization: Check against the *configured* prefix first.
    configured_prefix = config.token_prefix.call
    matched_prefix = nil

    if token.start_with?(configured_prefix)
      log_debug "[ApiKeys Auth] Token matches configured prefix: #{configured_prefix}"
      matched_prefix = configured_prefix
    else
      # Fallback: If no match, check against all known prefixes (cached).
      log_debug "[ApiKeys Auth] Token does not match configured prefix. Checking known prefixes."
      known_prefixes = fetch_known_prefixes(config)
      # Sort by length descending to find the longest match first
      matched_prefix = known_prefixes.sort_by(&:length).reverse.find { |p| token.start_with?(p) }
      log_debug "[ApiKeys Auth] Known prefixes: #{known_prefixes}. Matched prefix for lookup: #{matched_prefix || 'None'}"
    end

    possible_keys_scope = if matched_prefix
                            ApiKeys::ApiKey.where(prefix: matched_prefix, digest_algorithm: 'bcrypt')
                          else
                            # This path is now less likely but covers cases where token matches no known prefix.
                            log_warn "[ApiKeys Auth] Token does not start with the configured prefix or any known prefix. Cannot perform DB lookup."
                            ApiKeys::ApiKey.none # Return an empty relation
                          end

    log_debug "[ApiKeys Auth] DB Query Scope SQL (bcrypt): #{possible_keys_scope.to_sql}" if possible_keys_scope.respond_to?(:to_sql)
    possible_keys = possible_keys_scope.to_a
    log_debug "[ApiKeys Auth] Found #{possible_keys.count} potential key(s) with matching prefix and algorithm for bcrypt."

    # Securely compare the provided token against the digests of potential keys
    verified_key = possible_keys.find do |key|
      match_result = Digestor.match?(token: token, stored_digest: key.token_digest, strategy: :bcrypt)
      log_debug "[ApiKeys Auth] Comparing with Key ID: #{key.id} (bcrypt). Match result: #{match_result}"
      match_result
    end

  elsif strategy == :sha256
    # For sha256, we hash the incoming token and look for an exact match
    # Note: Prefix lookup isn't useful here as the full hash is needed for the query.
    token_digest = Digest::SHA256.hexdigest(token)
    log_debug "[ApiKeys Auth] Calculated SHA256 digest for lookup: #{token_digest}"

    # Find the key directly by the calculated digest and algorithm
    verified_key = ApiKeys::ApiKey.find_by(token_digest: token_digest, digest_algorithm: 'sha256')

    if verified_key
      log_debug "[ApiKeys Auth] Found matching key by SHA256 digest. Key ID: #{verified_key.id}"
    else
      log_debug "[ApiKeys Auth] No key found matching the SHA256 digest."
    end

  else
    # Log unsupported strategy
    log_warn "[ApiKeys Auth] Authentication attempt with unsupported hash strategy: #{strategy}"
  end

  log_debug "[ApiKeys Auth] DB Verification result: #{verified_key ? "Key ID: #{verified_key.id}" : 'No match'}"
  # --- End DB Lookup ---

  # Cache the result (either the found ApiKey instance or nil for a miss)
  if cache_ttl > 0 && rails_cache
    log_debug "[ApiKeys Auth] Writing result to cache. Key: #{cache_key}, Value: #{verified_key.inspect}"
    rails_cache.write(cache_key, verified_key, expires_in: cache_ttl)
  end

  verified_key
end

.rails_cacheObject

Helper for accessing Rails cache safely



252
253
254
# File 'lib/api_keys/services/authenticator.rb', line 252

def self.rails_cache
  defined?(Rails) ? Rails.cache : nil
end