Skip to content

feat(api): add API key auth and per-key rate quotas#69

Merged
Miracle656 merged 1 commit into
Miracle656:mainfrom
Salmatcre8:feat/api-key-auth
Jun 1, 2026
Merged

feat(api): add API key auth and per-key rate quotas#69
Miracle656 merged 1 commit into
Miracle656:mainfrom
Salmatcre8:feat/api-key-auth

Conversation

@Salmatcre8
Copy link
Copy Markdown
Contributor

@Salmatcre8 Salmatcre8 commented Jun 1, 2026

Summary

Adds API key authentication with per-key rate quotas, plus an admin surface for minting and revoking keys. Lens previously had no auth — every endpoint was open.

Closes #54

What was built

api_keys table (Prisma model + raw sql/schema.sql)

  • id, hash, label, rate_per_min, rate_per_day, revoked_at, created_at.
  • Only the SHA-256 hash of a key is stored — the plaintext is never persisted.

src/api/auth.ts — auth preHandler/onRequest hook

  • Validates Authorization: Bearer <key>, hashes it, looks it up, and attaches req.apiKey (id + quotas).
  • Missing / invalid / revoked key → 401.
  • Registered as an onRequest hook before @fastify/rate-limit so the limiter can read req.apiKey.
  • Routes opt out via config.public = true (used by /status, /metrics, and the admin routes).

Per-key rate quotas (src/index.ts)

  • @fastify/rate-limit now uses a dynamic max = the key's ratePerMin, keyed by the key id (falls back to a shared IP-based limit for public/anonymous traffic).

src/api/admin.ts — key issuance/revocation

  • POST /admin/keys mints a key and returns the plaintext once; DELETE /admin/keys/:id revokes.
  • Guarded by a shared ADMIN_TOKEN env var (X-Admin-Token header).

scripts/issue-api-key.ts — CLI for minting the first key: npm run key:issue -- --label "Acme" --per-min 120 --per-day 50000.

Env vars documented in .env.example (ADMIN_TOKEN, REQUIRE_API_KEY).

Verification

New test suite src/__tests__/auth.test.ts (17 tests) plus the existing suite:

Test Files  10 passed (10)
Tests       79 passed (79)

Covers: SHA-256 hashing, bearer extraction, lookup (valid/unknown/revoked), 401 paths, public-route bypass, per-key quota enforcement (429 after quota, independent buckets per key), and the admin mint/revoke endpoints (incl. asserting only the hash is stored).

npx tsc --noEmit: no new errors (the 2 pre-existing webhookDispatcher.ts errors exist on main and are unrelated).

Acceptance criteria

  • Requests without a valid key get 401
  • Each key honors its own quota
  • Revoked keys return 401
  • Admin endpoint can mint a new key
  • Keys are stored as hashes only

@drips-wave
Copy link
Copy Markdown

drips-wave Bot commented Jun 1, 2026

@Salmatcre8 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

Copy link
Copy Markdown
Owner

@Miracle656 Miracle656 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really solid work — only SHA-256 hashes are stored, auth is fail-secure by default (REQUIRE_API_KEY must be explicitly false to disable), routes opt out cleanly via config.public, revoke is idempotent, and the 17-test suite covers the important paths. The typecheck failure in CI is the two pre-existing webhookDispatcher.ts errors on main — your code adds none. Two things to address before merge, plus a couple of nits.

🟠 The WebSocket endpoint will break under auth

/ws is registered in src/api/websocket.ts as app.get('/ws', { websocket: true }, ...) with no config.public. Since the auth hook is a global onRequest, the WS upgrade request now requires Authorization: Bearer <key> — but browser WebSocket clients can't set custom headers on the handshake. With REQUIRE_API_KEY defaulting to on, the live feed stops working for browser clients.

Pick one:

  • Mark /ws config.public = true and authenticate inside the handler, or
  • Accept the key as a query param (/ws?token=...) and validate it in the onRequest/handler, or
  • Use the Sec-WebSocket-Protocol subprotocol header to carry the key.

Please also sanity-check /graphql and the x402 routes — those are fine to gate (clients can send headers), just confirm it's intended.

🟠 ratePerDay is stored and returned but never enforced

The model, key context, mint response, and CLI all carry ratePerDay, and the PR/acceptance criteria say "each key honors its own quota" — but @fastify/rate-limit is only wired for the per-minute window (max: req.apiKey?.ratePerMin, timeWindow: '1 minute'). There's no daily limiter, so the daily quota silently does nothing. Either add a second daily-window limiter keyed on ratePerDay, or drop the field + claim for now and file a follow-up.

🟢 Nits (non-blocking)

  • isAdminAuthorized: supplied === adminToken is a non-constant-time comparison of a shared secret. Use crypto.timingSafeEqual (guard for length mismatch) to avoid the timing side-channel.
  • POST /admin/keys doesn't validate ratePerMin/ratePerDay are positive integers — admin-only, so low risk, but a negative/NaN value would land in the DB and feed the limiter.

Note (not a blocker)

This is a breaking change for any current consumer: deployments must set ADMIN_TOKEN and mint keys (npm run key:issue) before non-public routes are usable. Worth a line in the README / release notes. Closes #54 nicely otherwise.

Happy to merge once /ws is unblocked and the daily quota is either enforced or removed.

Copy link
Copy Markdown
Owner

@Miracle656 Miracle656 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Merging — second-account PR. The pre-existing webhookDispatcher.ts CI failure is being deferred until after the Wave. Applying the WebSocket-auth fix + admin timing-safe comparison directly on main as a follow-up.

@Miracle656 Miracle656 merged commit 038ec37 into Miracle656:main Jun 1, 2026
1 of 2 checks passed
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.

Add API key auth and per-key rate quotas

2 participants