Skip to content

feat(api-token): personal access tokens with an opt-in public api surface - #59

Merged
axelhamil merged 26 commits into
devfrom
feat/api-tokens
Aug 6, 2026
Merged

feat(api-token): personal access tokens with an opt-in public api surface#59
axelhamil merged 26 commits into
devfrom
feat/api-tokens

Conversation

@axelhamil

Copy link
Copy Markdown
Owner

Phase C.4 — Personal Access Tokens, plus the two public-surface decisions that came with them.

What ships

Tokens. clean_ + 44 base58 chars + a 6-char CRC32 checksum. The prefix makes the token findable by GitHub's scanner, the checksum makes it verifiable offline (a malformed token is rejected before touching the database), and 256 bits of entropy make it unguessable. Stored as HMAC-SHA256(pepper, token) in a unique-indexed column — not sha256 + per-row salt, which would have destroyed the O(1) lookup while buying nothing against a 256-bit secret. The pepper lives in the environment, so a database dump alone yields no usable token.

Pepper rotation runs without downtime: API_TOKEN_PEPPER_PREVIOUS is tried on miss and the row is re-hashed in place, with pepper_version telling you exactly what is left to migrate before you drop the old value.

The public surface is opt-in by construction. /api/v1 is a separate sub-app that never mounts sessionMiddleware; every other route stays session-only. A cookie opens nothing there, a token opens nothing elsewhere, and the boundary is physical rather than a convention re-checked at every PR. A useful side effect: token management lives outside /api/v1, so a token can never mint a token — the scope-escalation vector simply doesn't exist in this topology.

The event catalogue is now curated. Publishing an event makes it a contract we can no longer rename, so visibility-map.ts declares each one public or internal, exhaustively enough that a new event won't compile without a decision. 65 events: 28 public, 37 internal.

Also: cascade revocation when a token's creator loses their membership, and POST /api/token-scanning/github — ECDSA P-256, not the HMAC used by ordinary webhooks — which revokes leaked tokens within seconds of a bad push and emails the owner.

Notable catches during review

  • denyImpersonated was missing on token creation: an admin impersonating a user could mint a token that outlived the session, attributed to the victim in the audit log.
  • A banned user kept full API access — banning revokes BetterAuth sessions, and tokens don't go through the session cycle. Fixed with a per-request check rather than cascade revocation, so a temporary ban stays temporary.
  • The secret-scanning endpoint ignored the previous pepper, leaving exactly the riskiest tokens (old, rarely used, therefore not yet re-hashed) unrevoked during a rotation.
  • pepper_version was incremented relatively, which broke on the second rotation.

Verification

pnpm test green (16/16 Turbo tasks) · pnpm ci:check exit 0, zero warnings · 641 API tests, 112 app tests.

New required env var in production: API_TOKEN_PEPPER (min 32 chars, fail-hard at boot). Rotation procedure documented in docs/DEPLOY-RAILWAY.md.

https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk

PAT org-scopés, HMAC-SHA256 + pepper serveur, format
clean_<base58url-32> + checksum CRC32, surface publique opt-in via une
sous-app /api/v1 dédiée.

Trois corrections :
- le per-row salt détruisait le lookup O(1) sans rien apporter contre
  256 bits d'entropie ; remplacé par HMAC + pepper (frontière DB/secret,
  argument SOC2 pour D.4)
- read:uploads abandonné : le module uploads n'a ni route de listing ni
  table de métadonnées, et le storage est un profil docker opt-in
- @better-auth/api-key évalué puis écarté : aucun hook de cycle de vie,
  donc pas d'event dans la même TX (règle §6)

Ajoute à C.5 la curation du catalogue public : les 57 events
subscribables sont aujourd'hui exposés en permanence, alors qu'une
publication est un contrat qu'on ne peut plus renommer.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
- IApiTokenRepository port with ApiTokenRecord, ApiTokenError, TokenOwner
- DrizzleApiTokenRepository: §8-instrumented, all 8 methods
- touchLastUsed bucket-check lives in WHERE clause (no prior read)
- findByIdForOwner returns Option.none for wrong owner (no 403 leak)
- Integration tests against real DB: 4/4 pass

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
- requireApiToken: bearer-only, parseToken gate before any DB call,
  transparent pepper rotation with rehash, revoke/expiry/scope checks,
  touchLastUsed + API_TOKEN_USED event gated on actual bucket write
- API_TOKEN_POLICY (per-token, 600 req/min) and API_TOKEN_IP_POLICY
  (per-IP, 1200 req/min) as two sequential policies — never composite
  to prevent IP-limit bypass by token rotation
- touchLastUsed: Result<void> → Result<boolean> (returning) so the
  middleware can gate event emission on actual DB writes
- findUserById added to auth-queries.ts following established pattern

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
- revoked/expired guards now run before rehash: a dead token found via
  the previous pepper is rejected without any DB write (finding #1)
- pepper version is no longer relative (record version + 1) but sourced
  from API_TOKEN_PEPPER_VERSION env (default 1), threaded through the
  service config, module wiring, and middleware deps so both creation
  and rotation always write the same canonical version (finding #2)
- two new tests: revoked-via-prev-pepper does not call rehash; prev-
  pepper rehash uses deps.pepperVersion (3) not record version + 1 (2)

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
POST / returns raw secret once; GET and DELETE strip tokenHmac and
pepperVersion from responses. Org-scoped creation applies requireOrg +
requireOrgPermission inline — body scope drives auth, not path. Wrong-owner
revoke returns 404 via AppErrorException(API_TOKEN_NOT_FOUND). Also renames
API_TOKEN_EXPIRY_TOO_LONG → API_TOKEN_EXPIRY_INVALID to satisfy ErrorCode suffix
constraint required by AppErrorException.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
an admin impersonating a user could call POST /settings/tokens, obtain a
persistent API token scoped to the impersonated account, then end the
session — the token would survive indefinitely with the audit log
attributing it to the victim, not the admin. adding denyImpersonated after
requireAuth on both mutating routes closes this escalation path. GET is
left open (read-only support access is legitimate). test covers the 403
on impersonated POST.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
Implements POST /api/token-scanning/github which receives GitHub Secret
Scanning notifications, verifies the ECDSA P-256 signature (DER→raw
conversion required — WebCrypto expects IEEE P1363, not DER), revokes
each matching token with reason "leaked", emits api_token.revoked, and
notifies the owner via email.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
…an op

the resolveKey call was outside the try/catch in verify() — a network
error to api.github.com at cold start propagated as 500 instead of a
clean 403 refusal, leaving a leaked token active during the outage window.
moved it inside the try so any key-resolution failure captures and returns
false.

the crypto.subtle.verify span carried op: "http.client" — pure cpu
compute, no outbound request. removed op so otel/sentry do not classify
it as an http call in latency metrics.

test added: github endpoint unreachable → verify() returns false, no throw.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
- promote SecretRevealDialog to shared/components with optional title/description props
- add features/api-tokens: queries, mutations, TokenRow, TokenForm, page, route, tests
- register /settings/api-tokens tab (no requiresOrg) and route under settingsLayout
- org/personal scope selector renders only when hasMembership + apiToken:create

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
Missing test fixture added after template type was declared in templates.ts
but stub not added to the exhaustive STUB_VARS map.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
mock.module is process-global in Bun, so the module mocks boundary.test.ts
needed to import publicApiV1 leaked into every file that ran after it —
a mocked env stripped the internal routes of their signing key and turned
39 unrelated assertions red.

createPublicApiV1(deps) takes the repository, outbox, pepper config,
limiter and ip resolver, so the test injects stubs instead of rewriting
the module registry. The middleware order still lives in the factory, so
the boundary tests keep exercising the real mount.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
The stub was changed from success:true to success:false to prevent
destructuring crashes on leaked mocks, but assertPayloadValid throws
on !success, breaking 3 enqueue tests.

The correct stub is { success: true, data: {} } — assertPayloadValid
passes, leaked code doing const { x } = parsed.data gets {} not undefined.
Includes scanning.routes factory refactor and associated type fixes.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
Aligns four test files with the project's established pattern:
mock.module() before await import(), full export superset.

- drizzle-api-token.repository.test.ts: full rewrite as mock-based unit
  test (was integration test using real Postgres). mock.module(@packages/drizzle)
  with controlled dbBehavior + spyOn pattern, same as webhook repo tests.
- revoke-on-membership-lost.test.ts: mock.module(@packages/events) with real
  OrgMemberRemovedPayload Zod schema + mock.module(event-emitter) restoring
  outbox.enqueue call; dynamic import of handler after mocks.
- notify-impersonated-user.test.ts: mock.module(@packages/events) with real
  AdminImpersonationStartedPayload before existing dynamic import.
- github-key-verifier.test.ts: mock.module(event-emitter) restoring real
  outbox.enqueue behaviour before dynamic import of scanning.routes.

All mocks use the full EventTypes superset to avoid partial-mock leaks to
subsequent files running in the same Bun worker.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
EVENTS.md documents the visibility allowlist and its three consumers:
publishing an event makes it a contract we can no longer rename, so the
decision lives in code and gets reviewed in a PR. Catalogue: 65 events,
28 public / 37 internal.

apps/api/CLAUDE.md gains the public-api/ layout entry and, more
importantly, writes down the AppType exception — /api/v1 stays outside
the chained routes because it serves external consumers, not the typed
internal client. Without that note the next reader applies the general
rule and collapses the boundary.

Also translates the seven admin.* event descriptions to English (they
feed the integrator-facing catalogue) and updates the webhooks group
test, which now asserts the absence of a "webhook" group since every
webhook.* event became internal.

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
- banned user bypass: check banned+banExpires in api-token middleware at
  request time (not on ban event — ban may be temporary)
- secret scanning pepper gap: fall back to pepperPrevious in scanning
  routes so tokens rehashed mid-rotation are not false-positived
- bulk revoke disable: restrict isRevoking to the mutating row via
  revoke.variables === token.id

Claude-Session: https://claude.ai/code/session_01VTzaSyvpBCoWAocwbiywpk
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

🎉 This PR is included in version 1.24.0 🎉

The release is available on GitHub release

Your semantic-release bot 📦🚀

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant