Class: Otto::Security::Authentication::Strategies::APIKeyStrategy

Inherits:
AuthStrategy
  • Object
show all
Defined in:
lib/otto/security/authentication/strategies/api_key_strategy.rb

Overview

API key authentication strategy.

Accepts exactly one key source: a static list (api_keys:), a callable (resolver:), or a block. A resolver receives the presented key (a non-empty String, never env) and returns the account behind it, or nil/false when the key is unknown. Any other return value, including an empty container, counts as a match: an ORM relation from where, an empty Array from select, or {} from a cache miss is truthy and authenticates the caller. Return exactly one record (find_by, first) or nil. The strategy never branches on mode: a static list is wrapped in a resolver internally.

Fails closed: a strategy with no source, or a static list with no non-empty key, is a misconfiguration and raises ArgumentError at construction rather than authenticating every caller. Only a truthy resolver return (for a static list: a constant-time match) grants success.

A credential that was presented and rejected, including a non-String credential such as an array query parameter, fails TERMINALLY, so a bad key halts the strategy chain instead of falling through to a later anonymous-capable strategy. A missing credential fails non-terminally. Blank and non-String credentials are rejected before the resolver runs.

Exceptions raised by a resolver propagate. A database outage must surface as an error, not as a silent 401 and never as success.

Timing: the static list is compared in constant time against every configured key. Both sides are reduced to fixed-width SHA-256 digests first, so the comparison never short-circuits on a length mismatch and the lengths of configured keys are not observable. A black-box lookup cannot be made constant-time by the strategy; the documented pattern is to store SHA-256 digests and look up by APIKeyStrategy.digest, which is constant-time by construction and keeps raw keys out of the database.

The strategy never places the raw key in the result itself: the only strategy-generated field derived from the key is a short SHA-256 fingerprint. With a resolver, user is whatever the resolver returns, verbatim; the result is stored in env and exposed to handlers, so anything the application serializes or logs from it carries user. It is the resolver's responsibility not to return an object that holds the raw key: return the account, not the ApiKey row that stores the key, and store digests. A resolver that returns the presented key String itself as the user raises ArgumentError.

The query/form parameter path is opt-in (param_name:), because keys in URLs are recorded by access logs, proxies, and browser history.

Scope: this is a small static-allowlist authenticator shipped as a low-dependency convenience and reference implementation. It has no native support for runtime addition or revocation, expiration, roles or scopes, key metadata, quotas, a management API, audit history, or hashed verifier storage. The resolver form delegates those concerns to the application's key store; see docs/guides/authentication.md.

Examples:

Static list, header only (recommended)

APIKeyStrategy.new(api_keys: ['secret123'])

Block resolver looking up a stored digest

APIKeyStrategy.new do |presented_key|
  ApiKey.find_by(digest: APIKeyStrategy.digest(presented_key))&.
end

Callable resolver (anything responding to #call)

APIKeyStrategy.new(resolver: repo.method(:find_by_key))

Also accept ?api_key= (logged in URLs; prefer the header)

APIKeyStrategy.new(api_keys: ['secret123'], param_name: 'api_key')

Class Method Summary collapse

Instance Method Summary collapse

Methods inherited from AuthStrategy

#authorization_failure, #failure, #success

Constructor Details

#initialize(api_keys: nil, resolver: nil, header_name: 'X-API-Key', param_name: nil) {|presented_key| ... } ⇒ APIKeyStrategy

Returns a new instance of APIKeyStrategy.

Parameters:

  • api_keys (String, Array<String>, nil) (defaults to: nil)

    static list of valid API keys

  • resolver (#call, nil) (defaults to: nil)

    callable receiving the presented key and returning the account (truthy) or nil/false when unknown

  • header_name (String) (defaults to: 'X-API-Key')

    request header carrying the key

  • param_name (String, nil) (defaults to: nil)

    query/form parameter carrying the key. Defaults to nil (header only): keys placed in a URL are captured by access logs, proxies, and browser history. Pass 'api_key' to opt in.

Yield Parameters:

  • presented_key (String)

    the non-empty presented key

Yield Returns:

  • (Object, nil, false)

    the account behind the key, or nil/false when the key is unknown

Raises:

  • (ArgumentError)

    if no source, or more than one, is given

  • (ArgumentError)

    if resolver: does not respond to #call

  • (ArgumentError)

    if api_keys: contains no non-empty API key



103
104
105
106
107
108
# File 'lib/otto/security/authentication/strategies/api_key_strategy.rb', line 103

def initialize(api_keys: nil, resolver: nil, header_name: 'X-API-Key', param_name: nil, &block)
  super()
  @resolver = build_resolver(api_keys, resolver, block)
  @header_name = header_name
  @param_name = param_name
end

Class Method Details

.digest(key) ⇒ String

Full SHA-256 hex digest of a key. Store this instead of the raw key and look up presented keys by their digest.

Parameters:

  • key (String)

Returns:

  • (String)

    64-char hex digest



86
87
88
# File 'lib/otto/security/authentication/strategies/api_key_strategy.rb', line 86

def self.digest(key)
  Digest::SHA256.hexdigest(key)
end

Instance Method Details

#authenticate(env, _requirement) ⇒ Object



110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
# File 'lib/otto/security/authentication/strategies/api_key_strategy.rb', line 110

def authenticate(env, _requirement)
  # Header first; the parameter path is consulted only when opted in.
  api_key = env["HTTP_#{@header_name.upcase.tr('-', '_')}"]

  if api_key.nil? && @param_name
    request = Otto::Request.new(env)
    api_key = request.params[@param_name]
  end

  # '' is truthy in Ruby; treat it, and a whitespace-only value, as a
  # missing credential. The static list already refuses blank keys, so
  # the resolver must never be asked about one either.
  return failure('No API key provided') if api_key.nil? || (api_key.is_a?(String) && api_key.strip.empty?)

  # A non-String credential (e.g. `?api_key[]=k` yields an Array) was
  # still presented, so reject it terminally rather than handing it to
  # the resolver. Credentials were explicitly presented and rejected:
  # fail closed.
  return failure('Invalid API key', terminal: true) unless api_key.is_a?(String)

  # Resolver exceptions propagate deliberately (see class docs).
  user = @resolver.call(api_key)
  return failure('Invalid API key', terminal: true) unless user

  # The most likely naive misuse: `->(k) { k if keys.include?(k) }`.
  # Fail loud before the key can reach the result and env.
  if user.is_a?(String) && constant_time_equal?(user, api_key)
    raise ArgumentError,
          'APIKeyStrategy resolver returned the presented key as the user; ' \
          'return the account behind the key, not the key'
  end

  # Identify the credential by a non-reversible fingerprint. The strategy
  # itself never places the raw key in the result; `user` is the
  # resolver's return value, verbatim (see class docs).
  success(user: user,
          auth_method: 'api_key',
          api_key_fingerprint: key_fingerprint(api_key))
end