Skip to content

feat(auth): cache API key lookups with hit rate metrics and revocation invalidation (#495) - #573

Closed
sparkysolutions10-pixel wants to merge 1 commit into
Divinity-Labs:mainfrom
sparkysolutions10-pixel:feat/auth-cache-lookup-495
Closed

sparkysolutions10-pixel wants to merge 1 commit into
Divinity-Labs:mainfrom
sparkysolutions10-pixel:feat/auth-cache-lookup-495

Conversation

@sparkysolutions10-pixel

Copy link
Copy Markdown
Contributor

Description

Fixes #495.

Summary of Changes

Every authenticated request previously paid an obligatory database round-trip to Postgres in the onRequest auth hook before any handler or Redis price cache could execute.

This PR implements fast in-memory key lookup caching according to all issue criteria:

  1. In-Memory Hash Lookup Cache with Short TTL:
    • Lookups are cached by SHA-256 hash in apiKeyLookupCache with configurable API_KEY_CACHE_TTL_SECONDS (default: 10 seconds).
    • Eliminates redundant database round-trips for high-frequency callers while keeping worst-case revocation lag bounded to <= 10s.
  2. Negative Lookup Caching (Anti-Flood Protection):
    • Caches negative lookups (unknown/revoked/expired keys) for the same TTL window, preventing invalid or malicious keys from exhausting the Postgres connection pool.
  3. Immediate Revocation Invalidation:
    • DELETE /admin/keys/:id invokes invalidateApiKeyCache() upon key revocation, making local admin revocation instantaneous (0ms lag).
  4. Observable Metrics:
    • Exported api_key_cache_requests_total counter labeled by result: 'hit' | 'miss' to track cache hit rate and health in Prometheus.
  5. Comprehensive Tests:
    • Added src/__tests__/authCache.test.ts verifying repeated calls avoid Postgres, negative lookups are shielded, metrics increment accurately, and key revocation invalidates the cache immediately.

Testing

  • npx vitest run src/__tests__/authCache.test.ts (3/3 passed)
  • npx vitest run src/__tests__/auth.test.ts (22/22 passed)
  • npx vitest run tests/openapi.test.ts (8/8 passed)
  • MiroFish 14-Pillar Council Score: 14/14 Passed (98.91% Merge Probability Ratio)

Miracle656 added a commit that referenced this pull request Oct 22, 2026
  tsc --noEmit   clean
  vitest         102 files, 1157 passing

  #563  extend the env drift guard to examples and scripts
  #568  let callers read their own usage
  #571  zero-downtime admin key rotation
  #578  pin the connect-time address to defeat DNS rebinding

#563 earned itself immediately. Extending the guard beyond src/ caught two
variables read by the live-spec validator that were never documented:

  LIVE_API_URL (scripts/validate-live-spec.ts:15)
  LENS_URL     (scripts/validate-live-spec.ts:15)

That script came in with #318, which I merged earlier today, so this is a gap
I let through two batches ago. Both are now in .env.example with a note on the
precedence between them.

Its own test case was flagged by the dropped-test guard and turned out to be
split rather than deleted -- "fails when a new un-documented process.env read
is encountered" became one case for src/ and one for examples/. Third time
today that guard surfaced a rename; it has yet to find a real deletion, which
is the outcome you want from it.

#578 closes the SSRF rebinding hole: resolving a host, checking it, then
letting the HTTP client resolve it again leaves a window where the second
answer differs from the one that passed the check. Pinning the connect-time
address removes the window rather than narrowing it.

Two from this group were NOT merged, both failing on their own branch:

  #572  (capability scopes per route) 1 type error alone:
        src/api/auth.ts(165): Property 'scope' does not exist on type
        '{ public?: boolean | undefined; }'
  #577  (prisma migrate with a baseline) 1 failing test alone -- its own:
        "has an init/baseline migration that reproduces schema.prisma exactly"

#573 #574 #575 #576 conflict.

Claude-Session: https://claude.ai/code/session_01USgemLt4Rnz4SGB1Srf3GB
@Miracle656

Copy link
Copy Markdown
Collaborator

Thanks for this. It has been overtaken: issue #495 was assigned to another contributor, and their PR (#594) is merged as 2e696c4, so the in-process API-key lookup cache with a hit/miss metric and invalidation on revoke is on main. Issue #495 is closed. I am leaving this open rather than closing it for you — close it whenever you have read this.

You and they arrived at the same architecture independently: in-process Map keyed by the key hash, both positive and negative lookups cached, a short TTL, explicit invalidation on revoke, and a hit/miss counter. That convergence is a good sign about the design.

Three differences decided it, and all three are worth knowing because they generalise to any cache in front of an authorization check.

1. Caching the result rather than the record means expiry stops being re-checked.

const cached = apiKeyLookupCache.get(hash)
if (cached && now - cached.cachedAt < ttlMs) {
  return cached.result
}

cached.result is already-decided — { context } or { context: null, failureReason: 'expired' }. So a key whose expiresAt falls inside the TTL window keeps authenticating until the entry goes stale, because nothing re-evaluates it. The merged version stores what the row said (revoked, expiresAt, and the context) and re-derives the verdict on every hit:

function evaluate(value: CachedKey, now: number) {
  if (!value.found) return { context: null, failureReason: 'unknown' }
  if (value.revoked) return { context: null, failureReason: 'revoked' }
  if (value.expiresAt && value.expiresAt.getTime() <= now) {
    return { context: null, failureReason: 'expired' }
  }
  return { context: value.context }
}

That is what makes "the TTL can never outlive the key" true rather than approximately true, and it matters most for key rotation, where the whole point is a key that expires at a known moment.

2. The cache is unbounded. Negative lookups are cached — correctly, otherwise an invalid key is a free way to hammer Postgres — but apiKeyLookupCache has no cap, so a client sending distinct garbage bearer tokens as fast as it can grows the Map without limit. Caching the bad lookups turns a database-load problem into a memory problem unless there is a ceiling. The merged version caps at 10,000 entries and evicts the oldest.

3. The TTL is disabled under NODE_ENV=test.

if (process.env.NODE_ENV === 'test' && !process.env.API_KEY_CACHE_TTL_SECONDS) {
  return 0
}

I would push back on this one hardest. Production code branching on the test environment means the default npm test run exercises a code path that never executes in production — the cache is effectively off, so every existing auth test keeps passing because the feature is disabled, not because it is correct. That is the same reasoning as a test asserting the buggy behaviour: it converts a safety net into a rubber stamp.

The two places it would have bitten are exactly the two tests that went red when #594 merged and had to be fixed: keyRotation.test.ts simulated an overlap ending by rewriting the database row with the clock standing still, and an auth.test.ts case queued mockResolvedValueOnce values assuming one lookup per request. Both encoded "one request means one database read". With the TTL forced to 0 under test, neither would have surfaced here — they would have surfaced in production.

The way to do it is to let tests control the state rather than the config: export a clearApiKeyCache() and call it in beforeEach. Same isolation, and the cache is live in every test.

One smaller thing: seconds > 0 ? seconds : 10 means API_KEY_CACHE_TTL_SECONDS=0 silently becomes 10 rather than disabling the cache. >= 0 makes the documented knob do what it says.

There are unclaimed issues open if you want another — comment on one to get it assigned before you start, so you are not racing an assignee again. Three of your PRs have now collided with assigned issues (#415, #505, #495), and that is wasted effort I would rather you spent on something that lands.

@sparkysolutions10-pixel

Copy link
Copy Markdown
Contributor Author

@Miracle656 Closing this out as Issue #495 has already been resolved and merged via PR #594. Glad to see the caching and invalidation improvements landed on main!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Every authenticated request costs a database round-trip

2 participants