asobi_auth_cache (asobi v0.84.0)

View Source

In-memory cache for access-token → player resolution.

The WS connect path and every authenticated HTTP request hit nova_auth_refresh:get_user_by_access_token/2, which costs two kura queries (one for the token row, one for the user). Under mobile-reconnect storms this is the dominant per-connect cost. The cache turns a hit into one ETS lookup.

Lifetime and freshness

Entries TTL after asobi.auth_cache_ttl_ms (default 60_000ms) so a revoked token's fallout is bounded. Invalidation must be wired into every code path that deletes or replaces a token; see invalidate/1.

Negative results (token not found, expired) are also cached but with a much shorter TTL (asobi.auth_cache_negative_ttl_ms, default 5_000ms) so a fresh-token race doesn't keep returning errors after the token actually exists.

Revocation SLA (asobi#215)

A revoked token's cache entry can outlive its DB row by up to auth_cache_ttl_ms unless the revoking code path also touches this cache. Audited as of asobi#215:

  • Single-device logout (asobi_auth_tokens:revoke_access/1) calls invalidate/1 for the one access token being logged out. Immediate.
  • Mass revoke keyed by player (any path that deletes more tokens than the caller holds a single token for - e.g. asobi_guest_controller's guest-to-real upgrade, which calls nova_auth_refresh:revoke_all/2 for every token a player holds, or a future ban/admin-suspend path) MUST call revoke_player/1 right after the DB-level revoke, passing the player id. The stored value carries id (see cacheable/1), so a match-spec scan can target every cached entry for one player without evicting every other player's session; there is no need to reach for clear/0 here. "Targeted" is about blast radius, not cost: there is no secondary index on id, so revoke_player/1 is still an O(total cache size) scan, same shape as clear/0's delete_all_objects/1, not O(that player's token count).
  • clear/0 remains available for whole-table maintenance (test teardown, an operator-triggered flush) but is intentionally NOT the revocation primitive - a full clear scales with total cached sessions rather than the one player being revoked, and is an easy denial-of-service amplifier if wired to a client-reachable action.
  • Refresh-token rotation (nova_auth_refresh:refresh/2) does NOT revoke the access token that was live before the rotation - by nova_auth's design, access tokens are short-lived bearer credentials (60 min default, asobi_auth:config/0) that aren't tied to their refresh token's rotation state, so there is nothing to invalidate here even in principle. This is a nova_auth design characteristic, not an asobi_auth_cache gap.

Revoke/read race

invalidate/1 and revoke_player/1 only delete what's in the table right now. A miss/1 already in flight (DB read returned, put_positive/2 not yet called) can still land its write after the revoke, caching the stale row for a fresh TTL. epoch/0 closes this: every revoke bumps a counter first, and miss/1 only caches its read if the epoch it read before querying the DB is still current when the DB answers - otherwise a revoke happened mid-read and the result is served once, uncached.

Process model

A single named gen_server owns the ETS table. Lookups are direct ETS reads from any process; writes go through the gen_server only for expiry-sweep coordination — put/2,3, invalidate/1 and revoke_player/1 write directly via public ETS for latency. The gen_server runs a periodic sweep (every TTL/2) that removes expired rows so the table doesn't grow unbounded under attack.

Summary

Functions

clear()

-spec clear() -> ok.

handle_call(Req, From, State)

-spec handle_call(term(), gen_server:from(), map()) -> {reply, ok, map()}.

handle_cast(Msg, State)

-spec handle_cast(term(), map()) -> {noreply, map()}.

handle_info/2

-spec handle_info(term(), map()) -> {noreply, map()}.

info()

-spec info() -> #{size := non_neg_integer(), memory_words := non_neg_integer()}.

init/1

-spec init([]) -> {ok, map()}.

invalidate/1

-spec invalidate(binary()) -> ok.

put_negative(Token)

-spec put_negative(binary()) -> ok.

put_positive(Token, Player)

-spec put_positive(binary(), map()) -> ok.

resolve_token/1

-spec resolve_token(binary()) -> {ok, map()} | {error, term()}.

revoke_player(PlayerId)

-spec revoke_player(binary()) -> ok.

start_link()

-spec start_link() -> gen_server:start_ret().