2026-05-19-integration-api-keys

Tue May 19 2026 07:00:00 GMT+0700 (Western Indonesia Time)type/changelogtopic/authtopic/integrationstopic/migration

Per-user integration API keys — Phase C ship #1 (01d9ac3)

The integration API piece of Phase C, shipped on its own. Remaining hardening (H1 CSP, H2 timing, M2 role, M5 logger, test rewrite) later shipped in [[Projects/personal-finance-notion/backlog/done/p0-authjs-phase-d-pre-launch-hardening|Phase D]] (2026-05-24).

Why this lands first

The legacy integration API auth (Authorization: Bearer ${PREFIX}${User._id}) reused the user's Mongo _id as a long-lived secret. Under the bespoke JWT stack that was already weak; under Auth.js it had no anchor at all — there was no longer a reason for the user's _id to be a secret. The route handlers were left in place after Phase B but were effectively dark (any key issued via the old getIntegrationBearerPreview action stopped being trustworthy). This ship re-enables integration access on a proper key model.

What changed

New model — src/lib/models/integrationKeyModel.ts

  • Per-user keys: { userId, keyHash (sha256, unique), label, scopes, expiresAt, revokedAt, lastUsedAt, timestamps }.
  • Key format: pfn_live_<32-byte base64url> (~52 chars).
  • Raw key shown exactly once at creation; only the sha256 hash is persisted.
  • Indexes: { keyHash } unique, { userId, createdAt: -1 }.

Auth rewrite — src/lib/integrations/requireIntegrationUser.ts

  • Parse Authorization: Bearer <raw> → sha256 → IntegrationKey.findOne({ keyHash }).
  • Reject 401 on missing / revoked / expired.
  • Fire-and-forget lastUsedAt bump (.catch(() => {}) — telemetry only, never blocks the request).
  • Returns { userId, keyId } so downstream actions can audit which key made the call.

Server actions — src/lib/actions/integrationKeys.ts

  • createIntegrationKey({ label, expiresInDays? }){ key, rawKey }. The rawKey is the only place the raw token is ever returned.
  • listIntegrationKeys() → status fields without the hash.
  • revokeIntegrationKey(keyId) → sets revokedAt; idempotent.

UI — src/components/settings/IntegrationKeysSection.tsx

  • Wired into /config/user (replaces the inline getIntegrationBearerPreview block).
  • Create form: label + optional expiry in days.
  • One-time reveal: amber banner with "Save this key now — you won't see it again", copy button, dismiss.
  • Table: label, status (Active / Expired / Revoked), created, last used, expires, revoke button.
  • Confirmation dialog on revoke.

Removed

  • src/lib/actions/integrationPreview.ts — deleted.
  • INTEGRATION_API_TOKEN_PREFIX env var — dropped from .env.example with a migration note.
  • Legacy 503-on-unset behaviour — integration routes now always serve as long as the user has a valid key.

Docs

  • docs/integrations/ai-agent-push-transactions.mdPERSONAL_FINANCE_API_KEY row now points operators to /config/user → External integrations · API keys.

Tests

  • src/lib/integrations/requireIntegrationUser.test.ts — rewritten for the new model. 8 tests green:
    • Missing header → 401
    • Non-Bearer scheme → 401
    • Too-short token → 401 (no DB hit)
    • No key matches hash → 401
    • Revoked key → 401
    • Expired key → 401
    • Valid key → returns { userId, keyId } + fires lastUsedAt update
    • Future-expiry key → accepted
  • src/app/api/integrations/v1/integrations.routes.test.ts — unchanged (it mocks requireIntegrationUser); 9 tests still green.

What's next

Phase D. The four remaining hardening items (CSP, timing, role decision, logger) + the test rewrite are all that's between us and public launch.

As-built reference

  • [[Projects/personal-finance-notion/context/auth-as-built-2026-05-19]] — cross-file picture of the migrated stack as of 2026-05-19; the integration API surface shipped in this commit is documented there alongside the rest of the auth stack.