diff --git a/.env.local.example b/.env.local.example index 92a2d7e..27e1e11 100644 --- a/.env.local.example +++ b/.env.local.example @@ -7,8 +7,14 @@ NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key # Supabase service-role key. Bypasses RLS; used only by server-side -# routes (the webhook and automation engine). Keep this secret — -# never paste it into client code. +# routes (the webhook, automation engine, and the public API key +# auth path — see docs/public-api.md). Keep this secret — never +# paste it into client code. +# +# Note: the public API (/api/v1) needs no new env var. API keys are +# created in the dashboard (Settings → API keys) and stored hashed +# in the database; this service-role key is what lets the auth path +# look a presented key up without a user session. SUPABASE_SERVICE_ROLE_KEY=your-service-role-key # WhatsApp token encryption (64 hex chars = 32 bytes, AES-256-GCM). diff --git a/CHANGELOG.md b/CHANGELOG.md index 8da7bf3..0cc06ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,20 @@ as the sole owner of their own account and sees identical data, and a solo owner who never invites anyone sees the same single-user app they always did. +### Added + +- **Public REST API (`/api/v1`) — groundwork.** A scoped, revocable + **API key** system so you can drive wacrm from your own scripts and + automations. Create keys under **Settings → API keys** (admin+), + grant only the scopes each integration needs, and authenticate with + `Authorization: Bearer `. Keys are account-scoped and stored + hashed (plaintext shown once). This release ships the auth layer, + scopes, per-key rate limiting, the management UI, and a + `GET /api/v1/me` probe to verify a key; the data endpoints + (`messages`, `contacts`, …) follow one at a time. See + `docs/public-api.md`. **Migration required:** apply + `supabase/migrations/026_api_keys.sql`. ([#245](https://github.com/ArnasDon/wacrm/issues/245)) + ### Changed - **Tenancy moves from per-user to per-account.** RLS on every diff --git a/README.md b/README.md index f9dbb0a..0696fcd 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ clone or fork it to run your own CRM. is account-scoped, so one shared inbox can be staffed by a whole team. Solo use stays single-user with zero setup. - **Account management** — email, password, avatar, global sign-out. +- **Public REST API** (`/api/v1`) with scoped, revocable API keys — + build your own automations on top of your CRM. See + [docs/public-api.md](./docs/public-api.md). ## Why fork this? diff --git a/docs/public-api.md b/docs/public-api.md new file mode 100644 index 0000000..c678d5b --- /dev/null +++ b/docs/public-api.md @@ -0,0 +1,130 @@ +# Public API (`/api/v1`) + +The public API lets you drive your wacrm instance from your own +scripts and automations — send messages, manage contacts, launch +broadcasts — without going through the dashboard UI. + +> **Status:** groundwork release. Authentication, scopes, rate +> limiting, and the `GET /api/v1/me` probe ship now. The data +> endpoints (`messages`, `contacts`, …) land one at a time in +> follow-up releases — see [Roadmap](#roadmap). + +## Authentication + +Every request authenticates with an **API key**, sent as a bearer +token: + +``` +Authorization: Bearer wacrm_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +``` + +Keys are **account-scoped**: a key acts on exactly one account, the +one it was created in. There is no cross-account access. + +### Creating a key + +In the dashboard: **Settings → API keys → New API key**. Only +**admins and owners** can create keys. + +1. Give the key a name (after the integration that will use it). +2. Grant the **scopes** it needs — nothing more (see below). +3. Copy the key. **The full key is shown exactly once.** wacrm + stores only a SHA-256 hash, so it can never be shown again. If you + lose it, revoke it and create a new one. + +### Revoking a key + +**Settings → API keys → Revoke.** Revocation is effective on the +key's next request. Revoked keys stay in the list as an audit trail. + +## Scopes + +A key can do only what its scopes allow — independent of who created +it. Grant the minimum. + +| Scope | Allows | +| -------------------- | ---------------------------------------- | +| `messages:send` | Send WhatsApp messages | +| `messages:read` | Read messages and delivery status | +| `contacts:read` | List and read contacts | +| `contacts:write` | Create and update contacts | +| `conversations:read` | List and read conversations | +| `broadcasts:send` | Launch broadcast campaigns | + +A key with **no scopes** still authenticates and can call +`GET /api/v1/me` — useful for verifying a key works. + +## Response envelope + +Every response uses one of two shapes: + +```jsonc +// success +{ "data": { /* ... */ } } + +// failure +{ "error": { "code": "forbidden", "message": "This API key is missing the 'messages:send' scope" } } +``` + +Branch on `error.code` (stable); `error.message` is for humans and +may be reworded. + +| Status | `code` | Meaning | +| ------ | -------------- | ------------------------------------------------ | +| 401 | `unauthorized` | Missing / malformed / unknown / revoked / expired key | +| 403 | `forbidden` | Valid key, but missing the required scope | +| 429 | `rate_limited` | Per-key rate limit exceeded | +| 400 | `bad_request` | Malformed input | +| 404 | `not_found` | No such resource | +| 500 | `internal` | Server error | + +## Rate limits + +Requests are limited **per key**: **120 requests per minute**. On a +`429`, these headers tell you when to retry: + +- `Retry-After` — seconds until the window resets +- `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` + +> The limiter is in-memory and **per process**. A single-instance +> deploy (the common case for a self-hosted fork) is fine as-is. If +> you scale to multiple instances, swap the limiter for a shared +> store (Redis/Upstash) — see the note at the top of +> `src/lib/rate-limit.ts`. The limit is otherwise unenforced across +> instances. + +## Endpoints + +### `GET /api/v1/me` + +Returns the account a key is bound to and the scopes it carries. +Requires only a valid key (no scope). Use it to verify a key works +and to discover its scopes. + +```bash +curl https://your-crm.example.com/api/v1/me \ + -H "Authorization: Bearer wacrm_live_xxx" +``` + +```json +{ + "data": { + "account": { "id": "…", "name": "Acme Inc" }, + "key": { "id": "…", "scopes": ["messages:send"] } + } +} +``` + +## Roadmap + +Planned endpoints, shipping one per release (tracked in +[#245](https://github.com/ArnasDon/wacrm/issues/245)): + +- `POST /api/v1/messages` — send a message to a phone number + (`messages:send`) +- `GET/POST /api/v1/contacts`, `GET/PATCH /api/v1/contacts/{id}` + (`contacts:read` / `contacts:write`) +- `GET /api/v1/conversations` (`conversations:read`) +- `POST /api/v1/broadcasts` (`broadcasts:send`) +- Outbound event webhooks (so automations can react to inbound + messages) diff --git a/src/app/(dashboard)/settings/page.tsx b/src/app/(dashboard)/settings/page.tsx index 7f607b3..9f6971d 100644 --- a/src/app/(dashboard)/settings/page.tsx +++ b/src/app/(dashboard)/settings/page.tsx @@ -15,6 +15,7 @@ import { TemplateManager } from '@/components/settings/template-manager'; import { FieldsAndTagsPanel } from '@/components/settings/fields-and-tags-panel'; import { DealsSettings } from '@/components/settings/deals-settings'; import { MembersTab } from '@/components/settings/members-tab'; +import { ApiKeysSettings } from '@/components/settings/api-keys-settings'; import { resolveSection, type SettingsSection, @@ -59,6 +60,7 @@ export default function SettingsPage() { fields: , deals: , members: , + api: , }; return ( diff --git a/src/app/api/account/api-keys/[id]/route.ts b/src/app/api/account/api-keys/[id]/route.ts new file mode 100644 index 0000000..92065dd --- /dev/null +++ b/src/app/api/account/api-keys/[id]/route.ts @@ -0,0 +1,71 @@ +// ============================================================ +// DELETE /api/account/api-keys/[id] — revoke a key. +// +// Soft revoke: sets `revoked_at` rather than deleting the row, so +// the key's name/prefix stay visible in the roster as an audit +// trail ("this key existed and was turned off") and so the auth +// path's liveness check (`findActiveKeyByHash` filters revoked +// rows) starts rejecting it immediately. Admin+, enforced here and +// by the `api_keys_update` RLS policy. +// +// Revocation is effective on the next request: once `revoked_at` is +// set, `findActiveKeyByHash` returns null and the key 401s. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { requireRole, toErrorResponse } from '@/lib/auth/account'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const ctx = await requireRole('admin'); + + const limit = checkRateLimit( + `admin:apiKeyRevoke:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const { id } = await params; + + // Scope the update by account_id as well as id so an admin can + // never revoke another account's key by guessing a UUID. (RLS + // already enforces this; the explicit filter is belt-and-braces + // and makes the "0 rows updated → 404" path precise.) + const { data, error } = await ctx.supabase + .from('api_keys') + .update({ revoked_at: new Date().toISOString() }) + .eq('id', id) + .eq('account_id', ctx.accountId) + .is('revoked_at', null) + .select('id') + .maybeSingle(); + + if (error) { + console.error('[DELETE /api/account/api-keys/[id]] error:', error); + return NextResponse.json( + { error: 'Failed to revoke API key' }, + { status: 500 } + ); + } + if (!data) { + // Either no such key in this account, or it was already revoked. + return NextResponse.json( + { error: 'API key not found or already revoked' }, + { status: 404 } + ); + } + + return NextResponse.json({ success: true }); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/account/api-keys/route.ts b/src/app/api/account/api-keys/route.ts new file mode 100644 index 0000000..015faeb --- /dev/null +++ b/src/app/api/account/api-keys/route.ts @@ -0,0 +1,159 @@ +// ============================================================ +// /api/account/api-keys +// +// GET — list this account's API keys (safe columns only). +// POST — mint a new key. +// +// These are the *dashboard* endpoints for managing keys, so they +// authenticate the normal way (cookie session) and go through the +// RLS client. Listing is open to any member (viewer+) — the roster +// is not secret; the secret (the key itself) is never in it. Minting +// is admin+ (a key hands out capabilities), enforced by both +// `requireRole('admin')` here and the `api_keys_insert` RLS policy. +// +// IMPORTANT: the plaintext key is returned exactly ONCE, in the POST +// response. We persist only its SHA-256 hash, so neither GET nor any +// future endpoint can resurface it — same one-time-reveal contract +// as invite links. If the admin loses it, they revoke and re-issue. +// ============================================================ + +import { NextResponse } from 'next/server'; + +import { + getCurrentAccount, + requireRole, + toErrorResponse, +} from '@/lib/auth/account'; +import { generateApiKey } from '@/lib/api-keys/keys'; +import { normalizeScopes } from '@/lib/api-keys/scopes'; +import { + checkRateLimit, + rateLimitResponse, + RATE_LIMITS, +} from '@/lib/rate-limit'; + +const MAX_NAME_LEN = 80; +// Hard ceiling on caller-supplied expiry (1 year), mirroring the +// invite-link clamp. NULL/absent = never expires. +const MAX_EXPIRY_DAYS = 365; + +// Columns safe to expose. `key_hash` is deliberately excluded — it +// never leaves the server. +const SAFE_COLUMNS = + 'id, name, key_prefix, scopes, last_used_at, expires_at, revoked_at, created_at'; + +export async function GET() { + try { + // Any member can view the roster (RLS allows it); we just need a + // resolved account context. + const ctx = await getCurrentAccount(); + + const { data, error } = await ctx.supabase + .from('api_keys') + .select(SAFE_COLUMNS) + .eq('account_id', ctx.accountId) + .order('created_at', { ascending: false }); + + if (error) { + console.error('[GET /api/account/api-keys] fetch error:', error); + return NextResponse.json( + { error: 'Failed to load API keys' }, + { status: 500 } + ); + } + + return NextResponse.json({ keys: data ?? [] }); + } catch (err) { + return toErrorResponse(err); + } +} + +export async function POST(request: Request) { + try { + const ctx = await requireRole('admin'); + + const limit = checkRateLimit( + `admin:apiKeyCreate:${ctx.userId}`, + RATE_LIMITS.adminAction + ); + if (!limit.success) return rateLimitResponse(limit); + + const body = (await request.json().catch(() => null)) as { + name?: unknown; + scopes?: unknown; + expiresInDays?: unknown; + } | null; + + const rawName = typeof body?.name === 'string' ? body.name.trim() : ''; + if (!rawName) { + return NextResponse.json( + { error: "'name' is required" }, + { status: 400 } + ); + } + if (rawName.length > MAX_NAME_LEN) { + return NextResponse.json( + { error: `Name must be ${MAX_NAME_LEN} characters or fewer` }, + { status: 400 } + ); + } + + // Scopes default to none if omitted — that yields a key that can + // only call the scope-free endpoints (e.g. GET /api/v1/me). + const scopes = normalizeScopes(body?.scopes ?? []); + if (scopes === null) { + return NextResponse.json( + { error: "'scopes' must be an array of known scope strings" }, + { status: 400 } + ); + } + + let expiresAt: string | null = null; + const rawExpiry = body?.expiresInDays; + if ( + typeof rawExpiry === 'number' && + Number.isFinite(rawExpiry) && + rawExpiry > 0 + ) { + const days = Math.min(Math.floor(rawExpiry), MAX_EXPIRY_DAYS); + expiresAt = new Date( + Date.now() + days * 24 * 60 * 60 * 1000 + ).toISOString(); + } + + const { plaintext, hash, prefix } = generateApiKey(); + + const { data, error } = await ctx.supabase + .from('api_keys') + .insert({ + account_id: ctx.accountId, + created_by: ctx.userId, + name: rawName, + key_prefix: prefix, + key_hash: hash, + scopes, + expires_at: expiresAt, + }) + .select(SAFE_COLUMNS) + .single(); + + if (error || !data) { + console.error('[POST /api/account/api-keys] insert error:', error); + return NextResponse.json( + { error: 'Failed to create API key' }, + { status: 500 } + ); + } + + return NextResponse.json( + { + key: data, + // Plaintext — shown to the admin exactly once. + plaintext, + }, + { status: 201 } + ); + } catch (err) { + return toErrorResponse(err); + } +} diff --git a/src/app/api/v1/me/route.ts b/src/app/api/v1/me/route.ts new file mode 100644 index 0000000..4c1aff1 --- /dev/null +++ b/src/app/api/v1/me/route.ts @@ -0,0 +1,31 @@ +// ============================================================ +// GET /api/v1/me — public API identity probe. +// +// The reference endpoint for the public API: it requires nothing +// but a valid key (no scope), and returns the account the key is +// bound to plus the scopes it carries. Integrators use it to verify +// their key works and to discover what it's allowed to do before +// wiring up real calls. +// +// It also exercises the entire public-API stack end to end — bearer +// parse → hash lookup → liveness → rate limit → envelope — so a +// green response here means the plumbing every future endpoint +// depends on is sound. +// ============================================================ + +import { requireApiKey } from '@/lib/auth/api-context'; +import { getAccountName } from '@/lib/api-keys/store'; +import { ok, toApiErrorResponse } from '@/lib/api/v1/respond'; + +export async function GET(request: Request) { + try { + const ctx = await requireApiKey(request); + const name = await getAccountName(ctx.accountId); + return ok({ + account: { id: ctx.accountId, name }, + key: { id: ctx.keyId, scopes: ctx.scopes }, + }); + } catch (err) { + return toApiErrorResponse(err); + } +} diff --git a/src/components/settings/api-keys-settings.tsx b/src/components/settings/api-keys-settings.tsx new file mode 100644 index 0000000..0efe9d3 --- /dev/null +++ b/src/components/settings/api-keys-settings.tsx @@ -0,0 +1,481 @@ +'use client'; + +// ============================================================ +// ApiKeysSettings — Settings → API keys +// +// Manage the credentials that authenticate the public REST API +// (`/api/v1/*`). Any member sees the roster (read-only); admin+ can +// mint and revoke (gated by here and the +// admin-only API routes + RLS on the server). +// +// One-time reveal: a freshly-minted key's plaintext is shown ONCE in +// the creation dialog. After it closes, only the prefix remains — +// the server stores just the hash. The UI states this explicitly so +// the absence of a "copy again" button reads as intentional, not a +// bug (same lesson as the invite-link flow). +// ============================================================ + +import { useCallback, useEffect, useState } from 'react'; +import { toast } from 'sonner'; +import { Copy, KeyRound, Loader2, Plus, Trash2 } from 'lucide-react'; + +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { RequireRole } from '@/components/auth/require-role'; +import { useAuth } from '@/hooks/use-auth'; +import { + API_SCOPES, + SCOPE_DESCRIPTIONS, + type ApiScope, +} from '@/lib/api-keys/scopes'; +import { SettingsPanelHead } from './settings-panel-head'; + +interface ApiKey { + id: string; + name: string; + key_prefix: string; + scopes: string[]; + last_used_at: string | null; + expires_at: string | null; + revoked_at: string | null; + created_at: string; +} + +function fmtDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + }); +} + +function keyStatus(k: ApiKey): 'active' | 'revoked' | 'expired' { + if (k.revoked_at) return 'revoked'; + if (k.expires_at && new Date(k.expires_at).getTime() <= Date.now()) + return 'expired'; + return 'active'; +} + +export function ApiKeysSettings() { + const { canEditSettings } = useAuth(); + + const [keys, setKeys] = useState([]); + const [loading, setLoading] = useState(true); + const [createOpen, setCreateOpen] = useState(false); + const [revoking, setRevoking] = useState(null); + + const load = useCallback(async () => { + try { + const res = await fetch('/api/account/api-keys', { cache: 'no-store' }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + toast.error(payload.error || 'Failed to load API keys'); + return; + } + const data = (await res.json()) as { keys: ApiKey[] }; + setKeys(data.keys); + } catch (err) { + console.error('[ApiKeysSettings] load error:', err); + toast.error('Could not reach the server'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + async function handleRevoke(key: ApiKey) { + setRevoking(key.id); + try { + const res = await fetch(`/api/account/api-keys/${key.id}`, { + method: 'DELETE', + }); + if (!res.ok) { + const payload = await res.json().catch(() => ({})); + toast.error(payload.error || 'Failed to revoke key'); + return; + } + toast.success(`Revoked "${key.name}"`); + // Reflect the revoke locally without a refetch. + setKeys((prev) => + prev.map((k) => + k.id === key.id ? { ...k, revoked_at: new Date().toISOString() } : k + ) + ); + } catch (err) { + console.error('[ApiKeysSettings] revoke error:', err); + toast.error('Could not reach the server'); + } finally { + setRevoking(null); + } + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ + Keys authenticate the public REST API ( + /api/v1) so you can build your own + automations. Send them as{' '} + Authorization: Bearer <key>. + + } + action={ + + + + } + /> + + {keys.length === 0 ? ( + + + +

+ No API keys yet. +

+ {canEditSettings ? ( +

+ Click New API key to + create one. +

+ ) : ( +

+ Ask an admin to create one. +

+ )} +
+
+ ) : ( + + +
    + {keys.map((k) => { + const status = keyStatus(k); + const inactive = status !== 'active'; + return ( +
  • +
    +
    + + {k.name} + + {status === 'revoked' && ( + + Revoked + + )} + {status === 'expired' && ( + + Expired + + )} +
    +

    + {k.key_prefix}… +

    +
    + {k.scopes.length === 0 ? ( + + No scopes + + ) : ( + k.scopes.map((s) => ( + + {s} + + )) + )} +
    +

    + Created {fmtDate(k.created_at)} + {' · '} + {k.last_used_at + ? `last used ${fmtDate(k.last_used_at)}` + : 'never used'} + {k.expires_at && status !== 'expired' + ? ` · expires ${fmtDate(k.expires_at)}` + : ''} +

    +
    + + {status === 'active' && ( + + + + )} +
  • + ); + })} +
+
+
+ )} + + +
+ ); +} + +// ------------------------------------------------------------ +// Create dialog — form → one-time plaintext reveal. +// ------------------------------------------------------------ + +function CreateKeyDialog({ + open, + onOpenChange, + onCreated, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + onCreated: () => void; +}) { + const [name, setName] = useState(''); + const [scopes, setScopes] = useState([]); + const [submitting, setSubmitting] = useState(false); + // Once set, we switch from the form to the reveal view. + const [createdKey, setCreatedKey] = useState(null); + + function reset() { + setName(''); + setScopes([]); + setSubmitting(false); + setCreatedKey(null); + } + + function toggleScope(scope: ApiScope, checked: boolean) { + setScopes((prev) => + checked ? [...prev, scope] : prev.filter((s) => s !== scope) + ); + } + + async function handleCreate() { + const trimmed = name.trim(); + if (!trimmed) { + toast.error('Give the key a name'); + return; + } + setSubmitting(true); + try { + const res = await fetch('/api/account/api-keys', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: trimmed, scopes }), + }); + const payload = await res.json().catch(() => ({})); + if (!res.ok) { + toast.error(payload.error || 'Failed to create key'); + return; + } + setCreatedKey(payload.plaintext as string); + onCreated(); + } catch (err) { + console.error('[CreateKeyDialog] create error:', err); + toast.error('Could not reach the server'); + } finally { + setSubmitting(false); + } + } + + async function copyKey() { + if (!createdKey) return; + try { + await navigator.clipboard.writeText(createdKey); + toast.success('API key copied'); + } catch { + toast.error('Copy failed — select and copy manually'); + } + } + + return ( + { + if (!next) reset(); + onOpenChange(next); + }} + > + + {createdKey ? ( + <> + + + Copy your API key + + + This is the only time the full key is shown. Store it somewhere + safe — if you lose it, revoke it and create a new one. + + + +
+ +
+ e.currentTarget.select()} + /> + +
+
+ + + + + + ) : ( + <> + + + New API key + + + Name it after the integration that will use it, and grant only + the scopes it needs. + + + +
+
+ + setName(e.target.value)} + /> +
+ +
+ +
+ {API_SCOPES.map((scope) => ( + + ))} +
+

+ A key with no scopes can still call{' '} + GET /api/v1/me to verify + it works. +

+
+
+ + + + + + + )} +
+
+ ); +} diff --git a/src/components/settings/settings-sections.ts b/src/components/settings/settings-sections.ts index 6aca9ca..afc5722 100644 --- a/src/components/settings/settings-sections.ts +++ b/src/components/settings/settings-sections.ts @@ -1,6 +1,7 @@ import { Coins, FileText, + KeyRound, LayoutGrid, Palette, PlugZap, @@ -29,6 +30,7 @@ export const SETTINGS_SECTIONS = [ 'fields', 'deals', 'members', + 'api', ] as const; export type SettingsSection = (typeof SETTINGS_SECTIONS)[number]; @@ -53,6 +55,7 @@ export const SECTION_META: Record = { fields: { id: 'fields', label: 'Fields & tags', icon: Tags, group: 'workspace' }, deals: { id: 'deals', label: 'Deals & currency', icon: Coins, group: 'workspace' }, members: { id: 'members', label: 'Team members', icon: UsersRound, group: 'workspace' }, + api: { id: 'api', label: 'API keys', icon: KeyRound, group: 'workspace' }, }; export const RAIL_GROUPS: { label: string | null; group: SectionMeta['group'] }[] = [ diff --git a/src/lib/api-keys/keys.test.ts b/src/lib/api-keys/keys.test.ts new file mode 100644 index 0000000..2efe01e --- /dev/null +++ b/src/lib/api-keys/keys.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { + API_KEY_PREFIX, + generateApiKey, + hashApiKey, + looksLikeApiKey, + timingSafeHexEqual, +} from './keys'; + +describe('generateApiKey', () => { + it('returns a prefixed plaintext, a hash, and a display prefix', () => { + const { plaintext, hash, prefix } = generateApiKey(); + expect(plaintext.startsWith(API_KEY_PREFIX)).toBe(true); + expect(plaintext.length).toBeGreaterThan(API_KEY_PREFIX.length + 20); + // SHA-256 hex is 64 chars. + expect(hash).toMatch(/^[0-9a-f]{64}$/); + // Display prefix is the literal prefix + 8 body chars. + expect(prefix.startsWith(API_KEY_PREFIX)).toBe(true); + expect(prefix.length).toBe(API_KEY_PREFIX.length + 8); + // The display prefix is a true prefix of the plaintext. + expect(plaintext.startsWith(prefix)).toBe(true); + }); + + it('never repeats a key (entropy sanity check)', () => { + const seen = new Set(); + for (let i = 0; i < 200; i++) seen.add(generateApiKey().plaintext); + expect(seen.size).toBe(200); + }); + + it('hash matches an independent hashApiKey of the plaintext', () => { + const { plaintext, hash } = generateApiKey(); + expect(hashApiKey(plaintext)).toBe(hash); + }); +}); + +describe('hashApiKey', () => { + it('is deterministic', () => { + expect(hashApiKey('wacrm_live_abc')).toBe(hashApiKey('wacrm_live_abc')); + }); + + it('differs for different inputs', () => { + expect(hashApiKey('wacrm_live_abc')).not.toBe(hashApiKey('wacrm_live_abd')); + }); +}); + +describe('looksLikeApiKey', () => { + it('accepts a well-formed key', () => { + expect(looksLikeApiKey(generateApiKey().plaintext)).toBe(true); + }); + + it('rejects the bare prefix, empty, and foreign tokens', () => { + expect(looksLikeApiKey(API_KEY_PREFIX)).toBe(false); + expect(looksLikeApiKey('')).toBe(false); + expect(looksLikeApiKey('some-invite-token')).toBe(false); + }); +}); + +describe('timingSafeHexEqual', () => { + it('is true for identical digests', () => { + const h = hashApiKey('wacrm_live_xyz'); + expect(timingSafeHexEqual(h, h)).toBe(true); + }); + + it('is false for different digests', () => { + expect(timingSafeHexEqual(hashApiKey('a'), hashApiKey('b'))).toBe(false); + }); + + it('is false (not throwing) on length mismatch', () => { + expect(timingSafeHexEqual('ab', 'abcd')).toBe(false); + }); +}); diff --git a/src/lib/api-keys/keys.ts b/src/lib/api-keys/keys.ts new file mode 100644 index 0000000..fa75a13 --- /dev/null +++ b/src/lib/api-keys/keys.ts @@ -0,0 +1,93 @@ +// ============================================================ +// API key generation + hashing — pure, server-side, no Supabase. +// +// Mirrors the invite-token utilities in `src/lib/auth/invitations.ts`: +// the DB stores only the SHA-256 hash, the plaintext is shown to the +// creator exactly once. See migration 026 for the rationale. +// +// Why SHA-256 (not bcrypt/argon2) +// API keys are full-entropy random strings (32 CSPRNG bytes), not +// user-chosen passwords. There is no dictionary to attack and no +// rainbow table that helps, so a slow KDF buys nothing — it would +// only slow the per-request auth lookup. A fast hash with a UNIQUE +// index is the correct, indexable choice for opaque secrets. +// +// Why the `wacrm_live_` prefix +// - Self-identifying: a leaked string is instantly recognisable as +// a wacrm key (handy for secret-scanners like GitGuardian). +// - Forward-compatible: leaves room for a `wacrm_test_` variant if +// a sandbox mode is ever added, without reshaping the format. +// ============================================================ + +import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'; + +/** Secret prefix on every key. Part of the plaintext, not a secret. */ +export const API_KEY_PREFIX = 'wacrm_live_'; + +/** + * Length of the non-secret display prefix stored in `key_prefix` and + * shown in the dashboard: the literal prefix plus the first 8 chars + * of the random body. Enough to tell two keys apart at a glance, + * far too little to brute-force the remaining ~248 bits. + */ +const DISPLAY_BODY_CHARS = 8; + +export interface GeneratedApiKey { + /** Plaintext key — return to the creator ONCE, never persist. */ + plaintext: string; + /** SHA-256 hex digest. Persist this in `api_keys.key_hash`. */ + hash: string; + /** Non-secret display string. Persist this in `api_keys.key_prefix`. */ + prefix: string; +} + +/** + * Generate a fresh API key + its hash + its display prefix. Call + * once per key creation; the plaintext is shown to the admin in the + * creation modal and never again. + */ +export function generateApiKey(): GeneratedApiKey { + // 32 bytes of CSPRNG entropy. base64url keeps it URL/header-safe + // and shorter than hex (43 vs 64 chars). + const body = randomBytes(32).toString('base64url'); + const plaintext = `${API_KEY_PREFIX}${body}`; + return { + plaintext, + hash: hashApiKey(plaintext), + prefix: `${API_KEY_PREFIX}${body.slice(0, DISPLAY_BODY_CHARS)}`, + }; +} + +/** + * Deterministic SHA-256 of a plaintext key. Used at auth time to + * look up the matching `api_keys` row by `key_hash`. Pure — same + * input always produces the same output. + */ +export function hashApiKey(plaintext: string): string { + return createHash('sha256').update(plaintext).digest('hex'); +} + +/** + * Structural check that a string looks like one of our keys before + * we bother hashing + hitting the DB. Cheap reject for obviously + * malformed `Authorization` headers (e.g. a stale invite token). + */ +export function looksLikeApiKey(value: string): boolean { + return ( + value.startsWith(API_KEY_PREFIX) && value.length > API_KEY_PREFIX.length + ); +} + +/** + * Constant-time comparison of two hex digests. The lookup is by an + * indexed UNIQUE column so an attacker can't easily probe timing, + * but comparing the hashes in constant time anyway costs nothing and + * removes the question. Returns false on any length mismatch (the + * underlying `timingSafeEqual` throws on unequal lengths). + */ +export function timingSafeHexEqual(a: string, b: string): boolean { + const bufA = Buffer.from(a, 'hex'); + const bufB = Buffer.from(b, 'hex'); + if (bufA.length !== bufB.length) return false; + return timingSafeEqual(bufA, bufB); +} diff --git a/src/lib/api-keys/scopes.test.ts b/src/lib/api-keys/scopes.test.ts new file mode 100644 index 0000000..d05d2a3 --- /dev/null +++ b/src/lib/api-keys/scopes.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest'; +import { + API_SCOPES, + SCOPE_DESCRIPTIONS, + hasScope, + isApiScope, + normalizeScopes, +} from './scopes'; + +describe('isApiScope', () => { + it('accepts every declared scope', () => { + for (const s of API_SCOPES) expect(isApiScope(s)).toBe(true); + }); + + it('rejects unknown strings and non-strings', () => { + expect(isApiScope('messages:delete')).toBe(false); + expect(isApiScope('')).toBe(false); + expect(isApiScope(null)).toBe(false); + expect(isApiScope(42)).toBe(false); + }); +}); + +describe('normalizeScopes', () => { + it('passes a valid list through, de-duplicated', () => { + expect( + normalizeScopes(['messages:send', 'messages:send', 'contacts:read']) + ).toEqual(['messages:send', 'contacts:read']); + }); + + it('treats an empty array as valid (key with no scopes)', () => { + expect(normalizeScopes([])).toEqual([]); + }); + + it('returns null if any entry is not a known scope', () => { + expect(normalizeScopes(['messages:send', 'bogus'])).toBeNull(); + }); + + it('returns null for non-array input', () => { + expect(normalizeScopes('messages:send')).toBeNull(); + expect(normalizeScopes(undefined)).toBeNull(); + }); +}); + +describe('hasScope', () => { + it('is true when the scope is present', () => { + expect(hasScope(['messages:send', 'contacts:read'], 'contacts:read')).toBe( + true + ); + }); + + it('is false when the scope is absent or the list is empty', () => { + expect(hasScope(['messages:send'], 'contacts:read')).toBe(false); + expect(hasScope([], 'messages:send')).toBe(false); + }); +}); + +describe('SCOPE_DESCRIPTIONS', () => { + it('has a description for every scope', () => { + for (const s of API_SCOPES) { + expect(SCOPE_DESCRIPTIONS[s]).toBeTruthy(); + } + }); +}); diff --git a/src/lib/api-keys/scopes.ts b/src/lib/api-keys/scopes.ts new file mode 100644 index 0000000..e274063 --- /dev/null +++ b/src/lib/api-keys/scopes.ts @@ -0,0 +1,73 @@ +// ============================================================ +// API key scopes — pure, unit-testable, no I/O. +// +// Authorization for the public API is *scopes-only*: a key's +// capabilities are defined entirely by the scopes granted to it at +// creation, independent of the role of the user who minted it. (We +// still gate *key creation* at admin+, so only trusted members can +// hand out capabilities — see the management routes.) +// +// A scope is `:`. Endpoints declare the single +// scope they require; `requireApiKey(request, scope)` enforces it. +// Adding a capability = one entry here + the endpoint that checks +// it. No migration needed (the DB stores scopes as a free `text[]`). +// ============================================================ + +export const API_SCOPES = [ + 'messages:send', + 'messages:read', + 'contacts:read', + 'contacts:write', + 'conversations:read', + 'broadcasts:send', +] as const; + +export type ApiScope = (typeof API_SCOPES)[number]; + +/** Human-readable descriptions, surfaced in the key-creation UI. */ +export const SCOPE_DESCRIPTIONS: Record = { + 'messages:send': 'Send WhatsApp messages', + 'messages:read': 'Read messages and their delivery status', + 'contacts:read': 'List and read contacts', + 'contacts:write': 'Create and update contacts', + 'conversations:read': 'List and read conversations', + 'broadcasts:send': 'Launch broadcast campaigns', +}; + +/** Type-narrow an unknown value into a valid `ApiScope`. */ +export function isApiScope(value: unknown): value is ApiScope { + return ( + typeof value === 'string' && + (API_SCOPES as readonly string[]).includes(value) + ); +} + +/** + * Validate and de-duplicate a caller-supplied scope list. Returns + * the cleaned list, or `null` if any entry is not a known scope + * (callers turn that into a 400). An empty input is valid — it + * yields a key that authenticates but can't do anything beyond the + * scope-free endpoints (e.g. `GET /api/v1/me`). + */ +export function normalizeScopes(input: unknown): ApiScope[] | null { + if (!Array.isArray(input)) return null; + const out: ApiScope[] = []; + for (const entry of input) { + if (!isApiScope(entry)) return null; + if (!out.includes(entry)) out.push(entry); + } + return out; +} + +/** + * True iff `granted` contains `required`. The single source of + * truth for "is this key allowed to do X?" — both `requireApiKey` + * and any future inline check should call this rather than poking + * at the array directly. + */ +export function hasScope( + granted: readonly string[], + required: ApiScope +): boolean { + return granted.includes(required); +} diff --git a/src/lib/api-keys/store.ts b/src/lib/api-keys/store.ts new file mode 100644 index 0000000..0cf7439 --- /dev/null +++ b/src/lib/api-keys/store.ts @@ -0,0 +1,94 @@ +// ============================================================ +// API key store — the *auth-path* data access for public API keys. +// +// Only the read side lives here, and deliberately so: it runs with +// the service-role client because a public-API caller has no Supabase +// session, so RLS (which keys off `auth.uid()`) can't scope the +// lookup. The management side (list / create / revoke) runs in the +// dashboard under a real cookie session and goes through the RLS +// client *inline* in the route handlers — same pattern as +// `/api/account/invitations`. Keeping the RLS-bypassing surface tiny +// and read-only here makes it easy to audit. +// ============================================================ + +import { supabaseAdmin } from '@/lib/flows/admin-client'; + +/** Shape of an `api_keys` row as the auth path consumes it. */ +export interface ApiKeyRow { + id: string; + account_id: string; + created_by: string | null; + name: string; + scopes: string[]; + expires_at: string | null; + revoked_at: string | null; +} + +/** + * Look up an *active* key by its SHA-256 hash. Returns null if no + * row matches, or if the matching row is revoked or expired — so + * callers never have to re-check liveness. Uses the service-role + * client (RLS-bypassing); the hash is the only credential, so this + * is the moment that establishes the caller's account. + */ +export async function findActiveKeyByHash( + hash: string +): Promise { + const { data, error } = await supabaseAdmin() + .from('api_keys') + .select('id, account_id, created_by, name, scopes, expires_at, revoked_at') + .eq('key_hash', hash) + .maybeSingle(); + + if (error) { + console.error('[api-keys/store] lookup error:', error.message); + return null; + } + if (!data) return null; + + // Liveness checks in JS rather than SQL so the failure modes are + // explicit and the index stays a simple equality lookup. + if (data.revoked_at) return null; + if (data.expires_at && new Date(data.expires_at).getTime() <= Date.now()) { + return null; + } + + return data as ApiKeyRow; +} + +/** + * Fetch the account name for a resolved key, so `/api/v1/me` and any + * future endpoint can echo it without a second round trip in the + * route. Service-role; the key already proved account membership. + */ +export async function getAccountName( + accountId: string +): Promise { + const { data, error } = await supabaseAdmin() + .from('accounts') + .select('name') + .eq('id', accountId) + .maybeSingle(); + if (error || !data) return null; + return (data.name as string) ?? null; +} + +/** + * Best-effort `last_used_at` bump. Fire-and-forget from the auth + * path — a failed update just means the "last used" column lags; + * it must never fail the request the caller is actually making. + */ +export function touchLastUsed(id: string): void { + void supabaseAdmin() + .from('api_keys') + .update({ last_used_at: new Date().toISOString() }) + .eq('id', id) + .then(({ error }) => { + if (error) { + console.warn( + '[api-keys/store] last_used_at bump failed:', + error.message + ); + } + }); +} diff --git a/src/lib/api/v1/respond.ts b/src/lib/api/v1/respond.ts new file mode 100644 index 0000000..49776f2 --- /dev/null +++ b/src/lib/api/v1/respond.ts @@ -0,0 +1,106 @@ +// ============================================================ +// Public API (v1) response envelope. +// +// Every `/api/v1/*` route speaks one shape so external integrators +// can write a single response parser: +// +// success → { "data": } +// failure → { "error": { "code": "", "message": "" } } +// +// `code` is a stable, machine-matchable string (clients branch on +// it); `message` is human-facing and may be reworded freely. This is +// intentionally distinct from the internal `{ error: string }` shape +// used by the dashboard's own `/api/*` routes — the public contract +// is versioned and shouldn't inherit internal wording changes. +// ============================================================ + +import { NextResponse } from 'next/server'; +import type { RateLimitResult } from '@/lib/rate-limit'; + +export type ApiErrorCode = + | 'unauthorized' // missing / malformed / unknown / revoked / expired key + | 'forbidden' // valid key, but missing the required scope + | 'rate_limited' // per-key budget exhausted + | 'bad_request' // malformed input + | 'not_found' + | 'internal'; + +/** + * Typed error a route (or `requireApiKey`) can throw and have mapped + * to the envelope by `toApiErrorResponse`. Carries an HTTP status, a + * machine code, and optional extra headers (used for the rate-limit + * `Retry-After` / `X-RateLimit-*` set). + */ +export class ApiError extends Error { + readonly code: ApiErrorCode; + readonly status: number; + readonly headers?: Record; + + constructor( + code: ApiErrorCode, + message: string, + status: number, + headers?: Record + ) { + super(message); + this.name = 'ApiError'; + this.code = code; + this.status = status; + this.headers = headers; + } +} + +/** 401 — no usable credential. */ +export function unauthorized(message = 'Missing or invalid API key'): ApiError { + return new ApiError('unauthorized', message, 401); +} + +/** 403 — authenticated, but the key lacks the scope this route needs. */ +export function forbidden(message: string): ApiError { + return new ApiError('forbidden', message, 403); +} + +/** 400 — bad input. */ +export function badRequest(message: string): ApiError { + return new ApiError('bad_request', message, 400); +} + +/** 429 — built from a `checkRateLimit` miss, with the standard headers. */ +export function rateLimited(result: RateLimitResult): ApiError { + const retryAfter = Math.max(1, Math.ceil((result.reset - Date.now()) / 1000)); + return new ApiError( + 'rate_limited', + 'Rate limit exceeded for this API key', + 429, + { + 'Retry-After': String(retryAfter), + 'X-RateLimit-Limit': String(result.limit), + 'X-RateLimit-Remaining': String(result.remaining), + 'X-RateLimit-Reset': String(Math.ceil(result.reset / 1000)), + } + ); +} + +/** Success envelope: `{ data: }`. */ +export function ok(data: T, status = 200): NextResponse { + return NextResponse.json({ data }, { status }); +} + +/** + * Map any thrown value to the failure envelope. `ApiError` keeps its + * code/status/headers; anything else collapses to a generic 500 so we + * never leak internal error text onto the public wire. + */ +export function toApiErrorResponse(err: unknown): NextResponse { + if (err instanceof ApiError) { + return NextResponse.json( + { error: { code: err.code, message: err.message } }, + { status: err.status, headers: err.headers } + ); + } + console.error('[api/v1] uncategorized error:', err); + return NextResponse.json( + { error: { code: 'internal', message: 'Internal server error' } }, + { status: 500 } + ); +} diff --git a/src/lib/auth/api-context.test.ts b/src/lib/auth/api-context.test.ts new file mode 100644 index 0000000..64f00f8 --- /dev/null +++ b/src/lib/auth/api-context.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { generateApiKey } from "@/lib/api-keys/keys"; +import type { ApiKeyRow } from "@/lib/api-keys/store"; +import { ApiError } from "@/lib/api/v1/respond"; +import { __resetRateLimitForTests, RATE_LIMITS } from "@/lib/rate-limit"; + +// Mock the service-role client factory — requireApiKey only stashes +// the returned client in the context; tests never call through it. +vi.mock("@/lib/flows/admin-client", () => ({ + supabaseAdmin: () => ({ __isMockAdminClient: true }), +})); + +// Mock the store so we control which row a hash resolves to. +const findActiveKeyByHash = vi.fn<(hash: string) => Promise>(); +const touchLastUsed = vi.fn(); +vi.mock("@/lib/api-keys/store", () => ({ + findActiveKeyByHash: (hash: string) => findActiveKeyByHash(hash), + touchLastUsed: (id: string) => touchLastUsed(id), +})); + +// Import AFTER the mocks are registered. +const { requireApiKey } = await import("./api-context"); + +const KEY = generateApiKey().plaintext; + +function reqWith(authHeader?: string): Request { + return new Request("https://crm.example.com/api/v1/me", { + headers: authHeader ? { authorization: authHeader } : {}, + }); +} + +function row(overrides: Partial = {}): ApiKeyRow { + return { + id: "key-1", + account_id: "acct-1", + created_by: "user-1", + name: "Test key", + scopes: ["messages:send"], + expires_at: null, + revoked_at: null, + ...overrides, + }; +} + +beforeEach(() => { + __resetRateLimitForTests(); + findActiveKeyByHash.mockReset(); + touchLastUsed.mockReset(); +}); + +afterEach(() => { + __resetRateLimitForTests(); +}); + +async function expectApiError(p: Promise, code: string, status: number) { + await expect(p).rejects.toBeInstanceOf(ApiError); + await p.catch((e: unknown) => { + const err = e as ApiError; + expect(err.code).toBe(code); + expect(err.status).toBe(status); + }); +} + +describe("requireApiKey", () => { + it("401s when no Authorization header is present", async () => { + await expectApiError(requireApiKey(reqWith()), "unauthorized", 401); + expect(findActiveKeyByHash).not.toHaveBeenCalled(); + }); + + it("401s on a token that doesn't look like a wacrm key", async () => { + await expectApiError( + requireApiKey(reqWith("Bearer some-invite-token")), + "unauthorized", + 401, + ); + expect(findActiveKeyByHash).not.toHaveBeenCalled(); + }); + + it("401s when the key is unknown / revoked / expired (store returns null)", async () => { + findActiveKeyByHash.mockResolvedValue(null); + await expectApiError( + requireApiKey(reqWith(`Bearer ${KEY}`)), + "unauthorized", + 401, + ); + }); + + it("returns a context for a valid key with no scope required", async () => { + findActiveKeyByHash.mockResolvedValue(row()); + const ctx = await requireApiKey(reqWith(`Bearer ${KEY}`)); + expect(ctx.authType).toBe("api_key"); + expect(ctx.accountId).toBe("acct-1"); + expect(ctx.keyId).toBe("key-1"); + expect(ctx.scopes).toEqual(["messages:send"]); + expect(touchLastUsed).toHaveBeenCalledWith("key-1"); + }); + + it("accepts a bare key without the 'Bearer ' prefix", async () => { + findActiveKeyByHash.mockResolvedValue(row()); + const ctx = await requireApiKey(reqWith(KEY)); + expect(ctx.accountId).toBe("acct-1"); + }); + + it("403s when the key lacks the required scope", async () => { + findActiveKeyByHash.mockResolvedValue(row({ scopes: ["contacts:read"] })); + await expectApiError( + requireApiKey(reqWith(`Bearer ${KEY}`), "messages:send"), + "forbidden", + 403, + ); + }); + + it("passes when the key has the required scope", async () => { + findActiveKeyByHash.mockResolvedValue(row({ scopes: ["messages:send"] })); + const ctx = await requireApiKey(reqWith(`Bearer ${KEY}`), "messages:send"); + expect(ctx.accountId).toBe("acct-1"); + }); + + it("429s once the per-key budget is exhausted", async () => { + findActiveKeyByHash.mockResolvedValue(row()); + // Burn the whole window. + for (let i = 0; i < RATE_LIMITS.publicApi.limit; i++) { + await requireApiKey(reqWith(`Bearer ${KEY}`)); + } + await expectApiError( + requireApiKey(reqWith(`Bearer ${KEY}`)), + "rate_limited", + 429, + ); + }); +}); diff --git a/src/lib/auth/api-context.ts b/src/lib/auth/api-context.ts new file mode 100644 index 0000000..a8c5ba2 --- /dev/null +++ b/src/lib/auth/api-context.ts @@ -0,0 +1,118 @@ +// ============================================================ +// Public API authentication — resolve a request's API key into an +// account context. +// +// This is the machine-to-machine counterpart of `getCurrentAccount` +// (cookie session → account). Where the dashboard authenticates a +// human via Supabase cookies, the public API authenticates a caller +// via `Authorization: Bearer wacrm_live_…`. +// +// Calling convention — every `/api/v1` route does: +// +// try { +// const ctx = await requireApiKey(request, "messages:send"); +// // ctx.supabase — service-role client (no user session exists) +// // ctx.accountId — the key's account; scope every query by it +// // ctx.scopes — granted scopes +// // ctx.keyId — for logging / the rate-limit bucket +// } catch (err) { +// return toApiErrorResponse(err); // maps ApiError → envelope +// } +// +// Why a service-role client: an API caller has no Supabase session, +// so there's no `auth.uid()` for RLS to match. The key lookup itself +// establishes the account; from there every downstream query MUST be +// explicitly filtered by `ctx.accountId` (the same discipline the +// dashboard's send route already follows). The key never escalates +// past its own account because the account is fixed at lookup time. +// ============================================================ + +import type { SupabaseClient } from '@supabase/supabase-js'; + +import { supabaseAdmin } from '@/lib/flows/admin-client'; +import { findActiveKeyByHash, touchLastUsed } from '@/lib/api-keys/store'; +import { hashApiKey, looksLikeApiKey } from '@/lib/api-keys/keys'; +import { hasScope, type ApiScope } from '@/lib/api-keys/scopes'; +import { forbidden, rateLimited, unauthorized } from '@/lib/api/v1/respond'; +import { checkRateLimit, RATE_LIMITS } from '@/lib/rate-limit'; + +export interface ApiKeyContext { + /** Discriminant — lets shared logic tell key auth from cookie auth. */ + authType: 'api_key'; + /** Service-role Supabase client. RLS-bypassing; scope by accountId. */ + supabase: SupabaseClient; + /** The account this key belongs to. */ + accountId: string; + /** The key row id — for audit logging and the rate-limit bucket. */ + keyId: string; + /** Scopes granted to this key. */ + scopes: string[]; + /** Who minted the key (null if that user was later removed). */ + createdBy: string | null; +} + +/** + * Extract the bearer token from the `Authorization` header. + * Tolerates the `Bearer ` prefix being absent (some clients send the + * bare key) but requires the value to look like one of our keys. + */ +function extractKey(request: Request): string | null { + const header = request.headers.get('authorization'); + if (!header) return null; + const value = header.startsWith('Bearer ') + ? header.slice('Bearer '.length).trim() + : header.trim(); + return value.length > 0 ? value : null; +} + +/** + * Authenticate a public-API request and (optionally) enforce a + * single scope. Throws an `ApiError` (mapped to the envelope by + * `toApiErrorResponse`) on any failure: + * + * 401 unauthorized — no key, malformed, unknown, revoked, expired + * 403 forbidden — valid key without the required scope + * 429 rate_limited — per-key budget exhausted + * + * On success, bumps `last_used_at` (fire-and-forget) and returns the + * account context. + */ +export async function requireApiKey( + request: Request, + scope?: ApiScope +): Promise { + const presented = extractKey(request); + if (!presented || !looksLikeApiKey(presented)) { + throw unauthorized(); + } + + const row = await findActiveKeyByHash(hashApiKey(presented)); + if (!row) { + // Covers unknown, revoked, and expired keys alike — we don't + // distinguish them on the wire so a probe can't learn whether a + // key ever existed. + throw unauthorized(); + } + + // Rate-limit per key, before the scope check, so an unauthorized- + // scope caller still can't hammer the endpoint for free. + const limit = checkRateLimit(`apikey:${row.id}`, RATE_LIMITS.publicApi); + if (!limit.success) { + throw rateLimited(limit); + } + + if (scope && !hasScope(row.scopes, scope)) { + throw forbidden(`This API key is missing the '${scope}' scope`); + } + + touchLastUsed(row.id); + + return { + authType: 'api_key', + supabase: supabaseAdmin(), + accountId: row.account_id, + keyId: row.id, + scopes: row.scopes, + createdBy: row.created_by, + }; +} diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 9fc737e..fd37211 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -141,6 +141,13 @@ export const RATE_LIMITS = { * while still bounding accidental abuse from a script run in a * loop or a compromised admin session spamming role flips. */ adminAction: { limit: 30, windowMs: 60_000 }, + /** Public REST API (`/api/v1/*`), keyed per API key. 120/min ≈ 2 + * req/s sustained — comfortable for a polling integration or an + * automation firing on inbound events, while bounding a runaway + * script. Like every bucket here it's per-process; a multi- + * instance deploy needs the Redis swap described at the top of + * this file (the per-key call sites don't change). */ + publicApi: { limit: 120, windowMs: 60_000 }, } as const; /** Test-only helper. Clears the in-memory state so unit tests don't diff --git a/supabase/migrations/026_api_keys.sql b/supabase/migrations/026_api_keys.sql new file mode 100644 index 0000000..81395f3 --- /dev/null +++ b/supabase/migrations/026_api_keys.sql @@ -0,0 +1,84 @@ +-- ============================================================ +-- 026_api_keys.sql — Public API credentials (groundwork) +-- +-- Adds the `api_keys` table backing the public REST API +-- (`/api/v1/*`). A key authenticates a *machine* caller (a script, +-- an n8n/Zapier-style automation, a cron) against one account, the +-- same way the cookie session authenticates a *human* in the +-- dashboard. +-- +-- Design notes +-- - Account-scoped, never user-scoped. A key belongs to the +-- account; `created_by` only records who minted it (audit), and +-- is ON DELETE SET NULL so removing a teammate doesn't cascade- +-- delete the keys their automations still depend on. +-- - We store only the SHA-256 *hash* of the key, never plaintext. +-- A leaked DB snapshot (backup, log, support export) therefore +-- can't be replayed against the API — the caller would need the +-- original key, which is returned exactly once at creation. Same +-- pattern as `account_invitations.token_hash` (migration 017/019). +-- - `key_prefix` is a short, non-secret display string +-- (`wacrm_live_a1b2c3d4`) so the dashboard can show "which key +-- is this" in a list without ever resurfacing the secret. +-- - Authorization is by `scopes[]` (scopes-only model), resolved +-- in the application layer (`src/lib/api-keys/scopes.ts`). The +-- DB doesn't constrain the scope vocabulary — a future scope is +-- a code change, not a migration. +-- +-- RLS +-- `api_keys` is a settings-class table: any member may *read* the +-- roster of keys for their account; only admin+ may create/revoke +-- (mirrors the `tags` / `custom_fields` policies in 017). The +-- public-API auth path itself reads keys with the service-role +-- client (RLS-bypassing) because an API caller has no Supabase +-- session and therefore no `auth.uid()` for a policy to match. +-- +-- Idempotent — safe to run multiple times. Table uses IF NOT +-- EXISTS; policies are dropped before recreate (Postgres has no +-- CREATE POLICY IF NOT EXISTS). +-- ============================================================ + +CREATE TABLE IF NOT EXISTS api_keys ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + account_id uuid NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, + created_by uuid REFERENCES auth.users(id) ON DELETE SET NULL, + name text NOT NULL, + key_prefix text NOT NULL, -- display only, e.g. "wacrm_live_a1b2c3d4" + key_hash text NOT NULL UNIQUE, -- SHA-256 hex of the full plaintext key + scopes text[] NOT NULL DEFAULT '{}', + last_used_at timestamptz, + expires_at timestamptz, -- NULL = never expires + revoked_at timestamptz, -- NULL = active + created_at timestamptz NOT NULL DEFAULT now() +); + +-- account_id: every "list this account's keys" query filters on it. +CREATE INDEX IF NOT EXISTS api_keys_account_id_idx ON api_keys (account_id); +-- key_hash: the hot path is the per-request auth lookup by hash. The +-- UNIQUE constraint already creates an index, but spell it out so the +-- intent (this is the lookup key) is documented and survives a future +-- drop of the UNIQUE constraint. +CREATE INDEX IF NOT EXISTS api_keys_key_hash_idx ON api_keys (key_hash); + +ALTER TABLE api_keys ENABLE ROW LEVEL SECURITY; + +-- SELECT: any member of the account (viewer+) can see the roster. +-- key_hash is in the table but the dashboard never selects it. +DROP POLICY IF EXISTS api_keys_select ON api_keys; +CREATE POLICY api_keys_select ON api_keys FOR SELECT + USING (is_account_member(account_id)); + +-- INSERT / UPDATE / DELETE: admin+ only (settings-class). Revoking a +-- key is an UPDATE that sets `revoked_at`; we keep DELETE available +-- too for operators who'd rather hard-delete. +DROP POLICY IF EXISTS api_keys_insert ON api_keys; +CREATE POLICY api_keys_insert ON api_keys FOR INSERT + WITH CHECK (is_account_member(account_id, 'admin')); + +DROP POLICY IF EXISTS api_keys_update ON api_keys; +CREATE POLICY api_keys_update ON api_keys FOR UPDATE + USING (is_account_member(account_id, 'admin')); + +DROP POLICY IF EXISTS api_keys_delete ON api_keys; +CREATE POLICY api_keys_delete ON api_keys FOR DELETE + USING (is_account_member(account_id, 'admin'));