diff --git a/.changeset/user-api-keys-provisioning.md b/.changeset/user-api-keys-provisioning.md new file mode 100644 index 000000000..fb455611d --- /dev/null +++ b/.changeset/user-api-keys-provisioning.md @@ -0,0 +1,6 @@ +--- +"@anticapture/authful": minor +"@anticapture/user-api": minor +--- + +Add self-service API keys (DEV-950). Authful gains an optional scoped provisioning key that may only mint/revoke `user:*` tenants and cannot list all tenants (the admin key stays unrestricted). The User API brokers end-user keys through it: `POST/GET/DELETE /me/api-keys` (session-authenticated) mint into Authful under tenant `user:`, return the plaintext exactly once, and store only ownership (never the secret) — with a per-user quota and Authful-first revocation. Both surfaces stay disabled until their env is configured. diff --git a/apps/authful/src/app.ts b/apps/authful/src/app.ts index 38869a0af..7842362ae 100644 --- a/apps/authful/src/app.ts +++ b/apps/authful/src/app.ts @@ -9,6 +9,7 @@ import { validateController } from "@/controllers/validate"; import { exporter } from "@/instrumentation"; import { metricsMiddleware } from "@/middlewares/metrics"; import { requestLogger } from "@/middlewares/logger"; +import { scopedTokenAuth } from "@/middlewares/token-auth"; import { logger } from "@/logger"; import type { TokensService } from "@/services/tokens"; @@ -17,6 +18,7 @@ export type AppConfig = { db: AuthfulDrizzle; adminApiKey: string; internalApiKey: string; + provisioningApiKey?: string; }; export function createApp({ @@ -24,6 +26,7 @@ export function createApp({ db, adminApiKey, internalApiKey, + provisioningApiKey, }: AppConfig): Hono { const app = new Hono(); @@ -48,9 +51,11 @@ export function createApp({ return c.body(body, 200, { "Content-Type": contentType }); }); - // Admin surface: humans minting/listing/revoking tokens. - app.use("/tokens", bearerAuth({ token: adminApiKey })); - app.use("/tokens/*", bearerAuth({ token: adminApiKey })); + // Token-management surface: the admin key (unrestricted) or the optional + // provisioning key (scoped to `user:*`, enforced in the controller). + const tokenAuth = scopedTokenAuth({ adminApiKey, provisioningApiKey }); + app.use("/tokens", tokenAuth); + app.use("/tokens/*", tokenAuth); // Internal surface: Gateful validating tokens. app.use("/validate", bearerAuth({ token: internalApiKey })); diff --git a/apps/authful/src/controllers/authful.integration.test.ts b/apps/authful/src/controllers/authful.integration.test.ts index ced23c8fe..ca6f1054b 100644 --- a/apps/authful/src/controllers/authful.integration.test.ts +++ b/apps/authful/src/controllers/authful.integration.test.ts @@ -23,6 +23,7 @@ import { TokensService, hashToken } from "@/services/tokens"; const ADMIN_KEY = "test-admin-key-0123456789"; const INTERNAL_KEY = "test-internal-key-0123456789"; +const PROVISIONING_KEY = "test-provisioning-key-0123456789"; const adminHeaders = { Authorization: `Bearer ${ADMIN_KEY}`, @@ -32,6 +33,10 @@ const internalHeaders = { Authorization: `Bearer ${INTERNAL_KEY}`, "Content-Type": "application/json", }; +const provisioningHeaders = { + Authorization: `Bearer ${PROVISIONING_KEY}`, + "Content-Type": "application/json", +}; describe("authful app", () => { let client: PGlite; @@ -50,6 +55,7 @@ describe("authful app", () => { db, adminApiKey: ADMIN_KEY, internalApiKey: INTERNAL_KEY, + provisioningApiKey: PROVISIONING_KEY, }); }); @@ -105,6 +111,18 @@ describe("authful app", () => { expect(res.status).toBe(200); }); + it("rejects an unknown bearer token", async () => { + const res = await app.request("/tokens", { + method: "POST", + headers: { + Authorization: "Bearer not-a-real-key-000000000000", + "Content-Type": "application/json", + }, + body: JSON.stringify({ tenant: "user:1", name: "x" }), + }); + expect(res.status).toBe(401); + }); + it("returns 503 when the DB probe fails", async () => { const failingDb = { execute: () => Promise.reject(new Error("db down")), @@ -126,6 +144,88 @@ describe("authful app", () => { }); }); + describe("provisioning scope", () => { + const provMint = (body: Record) => + app.request("/tokens", { + method: "POST", + headers: provisioningHeaders, + body: JSON.stringify(body), + }); + + it("mints a user:* token", async () => { + const res = await provMint({ tenant: "user:abc", name: "my-agent" }); + expect(res.status).toBe(201); + const body = (await res.json()) as MintResponse; + expect(body.tenant).toBe("user:abc"); + expect(body.token).toMatch(/^act_/); + }); + + it("cannot mint a first-party (non-user) tenant", async () => { + const res = await provMint({ tenant: "uniswap", name: "sneaky" }); + expect(res.status).toBe(403); + const before = await app.request("/tokens", { headers: adminHeaders }); + const { items } = (await before.json()) as { items: unknown[] }; + expect(items).toHaveLength(0); + }); + + it("cannot list all tokens (no tenant filter)", async () => { + const res = await app.request("/tokens", { + headers: provisioningHeaders, + }); + expect(res.status).toBe(403); + }); + + it("cannot list a non-user tenant", async () => { + const res = await app.request("/tokens?tenant=uniswap", { + headers: provisioningHeaders, + }); + expect(res.status).toBe(403); + }); + + it("lists only its own user:* tenant", async () => { + await provMint({ tenant: "user:abc", name: "one" }); + await provMint({ tenant: "user:abc", name: "two" }); + await mint({ tenant: "uniswap" }); // must not leak into the result + + const res = await app.request("/tokens?tenant=user:abc", { + headers: provisioningHeaders, + }); + expect(res.status).toBe(200); + const { items } = (await res.json()) as { + items: { tenant: string; lastUsedAt: string | null }[]; + }; + expect(items).toHaveLength(2); + expect(items.every((t) => t.tenant === "user:abc")).toBe(true); + }); + + it("revokes its own user:* token", async () => { + const minted = (await ( + await provMint({ tenant: "user:abc", name: "temp" }) + ).json()) as MintResponse; + const res = await app.request(`/tokens/${minted.id}`, { + method: "DELETE", + headers: provisioningHeaders, + }); + expect(res.status).toBe(204); + }); + + it("cannot revoke a first-party token — 404, not 403 (no oracle)", async () => { + const firstParty = await mint({ tenant: "uniswap" }); + const res = await app.request(`/tokens/${firstParty.id}`, { + method: "DELETE", + headers: provisioningHeaders, + }); + expect(res.status).toBe(404); + + // The token is untouched — admin can still see it active. + const list = await app.request("/tokens", { headers: adminHeaders }); + const { items } = (await list.json()) as { + items: { id: string; revokedAt: string | null }[]; + }; + expect(items.find((t) => t.id === firstParty.id)?.revokedAt).toBeNull(); + }); + }); + describe("POST /tokens", () => { it("mints a token and returns the plaintext exactly once", async () => { const body = await mint({}); diff --git a/apps/authful/src/controllers/tokens/index.ts b/apps/authful/src/controllers/tokens/index.ts index 2ab5cf4f3..a9af40959 100644 --- a/apps/authful/src/controllers/tokens/index.ts +++ b/apps/authful/src/controllers/tokens/index.ts @@ -2,14 +2,21 @@ import { OpenAPIHono as Hono, createRoute } from "@hono/zod-openapi"; import { ErrorResponseSchema, + ListTokensQuerySchema, MintTokenBodySchema, MintTokenResponseSchema, TokenListResponseSchema, TokenParamsSchema, toTokenMetadata, } from "@/mappers/tokens"; +import { USER_TENANT_PREFIX } from "@/middlewares/token-auth"; import type { TokensService } from "@/services/tokens"; +const forbiddenResponse = { + description: "Scope not permitted for this operation", + content: { "application/json": { schema: ErrorResponseSchema } }, +}; + export function tokensController(app: Hono, service: TokensService) { app.openapi( createRoute({ @@ -33,10 +40,23 @@ export function tokensController(app: Hono, service: TokensService) { "application/json": { schema: MintTokenResponseSchema }, }, }, + 403: forbiddenResponse, }, }), async (c) => { const body = c.req.valid("json"); + // The provisioning key may only mint end-user keys. + if ( + c.get("authScope") === "provisioning" && + !body.tenant.startsWith(USER_TENANT_PREFIX) + ) { + return c.json( + { + error: `provisioning scope may only mint ${USER_TENANT_PREFIX}* tenants`, + }, + 403, + ); + } const { token, plaintext } = await service.mint(body); return c.json({ ...toTokenMetadata(token), token: plaintext }, 201); }, @@ -48,16 +68,36 @@ export function tokensController(app: Hono, service: TokensService) { operationId: "listTokens", path: "/tokens", summary: "List token metadata (never hashes or plaintext)", + description: + "Admin lists all tenants (optionally filtered by ?tenant). The " + + "provisioning scope may list only a single user:* tenant it owns.", tags: ["tokens"], + request: { query: ListTokensQuerySchema }, responses: { 200: { - description: "All tokens, newest first", + description: "Matching tokens, newest first", content: { "application/json": { schema: TokenListResponseSchema } }, }, + 403: forbiddenResponse, }, }), async (c) => { - const tokens = await service.list(); + const { tenant } = c.req.valid("query"); + // Unfiltered listing exposes every tenant's metadata — admin only. The + // provisioning key may list, but only its own user:* tenant (so the User + // API can read its users' keys, e.g. lastUsedAt). + if ( + c.get("authScope") !== "admin" && + (!tenant || !tenant.startsWith(USER_TENANT_PREFIX)) + ) { + return c.json( + { + error: `provisioning scope may only list a single ${USER_TENANT_PREFIX}* tenant`, + }, + 403, + ); + } + const tokens = await service.list(tenant); return c.json({ items: tokens.map(toTokenMetadata) }, 200); }, ); @@ -81,7 +121,11 @@ export function tokensController(app: Hono, service: TokensService) { }), async (c) => { const { id } = c.req.valid("param"); - const revoked = await service.revoke(id); + // Provisioning scope can only revoke `user:*` tokens; a non-matching id + // returns 404 (same as missing) to avoid a first-party-token oracle. + const requireTenantPrefix = + c.get("authScope") === "provisioning" ? USER_TENANT_PREFIX : undefined; + const revoked = await service.revoke(id, { requireTenantPrefix }); if (!revoked) return c.json({ error: "Token not found" }, 404); return c.body(null, 204); }, diff --git a/apps/authful/src/env.ts b/apps/authful/src/env.ts index 801f473a6..618ca1380 100644 --- a/apps/authful/src/env.ts +++ b/apps/authful/src/env.ts @@ -15,6 +15,13 @@ const envSchema = z ADMIN_API_KEY: z.string().min(16), // Guards service-facing endpoints (/validate), shared with Gateful. INTERNAL_API_KEY: z.string().min(16), + // Optional scoped key for the User API to broker end-user keys: restricted + // to `user:*` tenants (mint/revoke only, no listing). Left unset until the + // User API's key-provisioning feature ships. + PROVISIONING_API_KEY: z.preprocess( + (v) => (v === "" ? undefined : v), + z.string().min(16).optional(), + ), // CI/preview only: a fixed, known token seeded into the DB on boot so every // service in the same Railway PR preview shares a working API key. Required // in preview environments; ignored on dev/production. The seeded token's diff --git a/apps/authful/src/index.ts b/apps/authful/src/index.ts index bb5b8a558..f27591345 100644 --- a/apps/authful/src/index.ts +++ b/apps/authful/src/index.ts @@ -42,6 +42,7 @@ const app = createApp({ db, adminApiKey: env.ADMIN_API_KEY, internalApiKey: env.INTERNAL_API_KEY, + provisioningApiKey: env.PROVISIONING_API_KEY, }); app.doc("/docs/json", { diff --git a/apps/authful/src/mappers/tokens/index.ts b/apps/authful/src/mappers/tokens/index.ts index fba825ad3..96b9d0d5e 100644 --- a/apps/authful/src/mappers/tokens/index.ts +++ b/apps/authful/src/mappers/tokens/index.ts @@ -19,6 +19,15 @@ export const TokenListResponseSchema = z .object({ items: z.array(TokenMetadataSchema) }) .openapi("TokenListResponse"); +export const ListTokensQuerySchema = z + .object({ + tenant: z + .string() + .optional() + .openapi({ description: "Filter to a single tenant's tokens." }), + }) + .openapi("ListTokensQuery"); + export const MintTokenBodySchema = z .object({ tenant: z.string().min(1), diff --git a/apps/authful/src/middlewares/token-auth.ts b/apps/authful/src/middlewares/token-auth.ts new file mode 100644 index 000000000..b460d8a95 --- /dev/null +++ b/apps/authful/src/middlewares/token-auth.ts @@ -0,0 +1,57 @@ +import { createHash, timingSafeEqual } from "node:crypto"; + +import { createMiddleware } from "hono/factory"; + +/** + * Tenant prefix reserved for end-user API keys. The provisioning key (used by + * the User API to broker keys on behalf of signed-in users) may only mint and + * revoke tokens under this prefix — never a first-party tenant like "uniswap". + */ +export const USER_TENANT_PREFIX = "user:"; + +export type AuthScope = "admin" | "provisioning"; + +// Global context-variable typing so handlers can read c.get("authScope") +// without threading a custom Env generic through the whole app (which would +// break controllers that take the default-typed app). +declare module "hono" { + interface ContextVariableMap { + authScope: AuthScope; + } +} + +const digest = (value: string) => createHash("sha256").update(value).digest(); + +// Constant-time compare over fixed-length digests (raw strings differ in +// length, which timingSafeEqual rejects and which itself leaks length). +const safeEqual = (a: string, b: string) => + timingSafeEqual(digest(a), digest(b)); + +const bearer = (header: string | undefined): string | undefined => + header?.startsWith("Bearer ") ? header.slice("Bearer ".length) : undefined; + +/** + * Authenticates the token-management surface as one of two scopes: + * - `admin` — the full admin key; unrestricted (mint/list/revoke any tenant). + * - `provisioning` — the optional provisioning key; restricted to `user:*` + * tenants and forbidden from listing (enforced by the controller via + * `c.get("authScope")`). + */ +export const scopedTokenAuth = (opts: { + adminApiKey: string; + provisioningApiKey?: string; +}) => + createMiddleware(async (c, next) => { + const token = bearer(c.req.header("Authorization")); + if (!token) return c.json({ error: "unauthorized" }, 401); + + if (safeEqual(token, opts.adminApiKey)) { + c.set("authScope", "admin"); + return next(); + } + if (opts.provisioningApiKey && safeEqual(token, opts.provisioningApiKey)) { + c.set("authScope", "provisioning"); + return next(); + } + return c.json({ error: "unauthorized" }, 401); + }); diff --git a/apps/authful/src/repositories/tokens/index.ts b/apps/authful/src/repositories/tokens/index.ts index 429a93d49..0b2e2c4fe 100644 --- a/apps/authful/src/repositories/tokens/index.ts +++ b/apps/authful/src/repositories/tokens/index.ts @@ -12,6 +12,14 @@ export class TokensRepository { return this.db.select().from(tokens).orderBy(desc(tokens.createdAt)); } + async listByTenant(tenant: string): Promise { + return this.db + .select() + .from(tokens) + .where(eq(tokens.tenant, tenant)) + .orderBy(desc(tokens.createdAt)); + } + async create(token: NewToken): Promise { const [created] = await this.db.insert(tokens).values(token).returning(); return created!; @@ -23,6 +31,10 @@ export class TokensRepository { }); } + async findById(id: string): Promise { + return this.db.query.tokens.findFirst({ where: eq(tokens.id, id) }); + } + /** Sets revoked_at; idempotent. Returns false when the id doesn't exist. */ async revoke(id: string): Promise { const result = await this.db diff --git a/apps/authful/src/services/tokens/index.ts b/apps/authful/src/services/tokens/index.ts index 3d78a218d..3b9e5b20d 100644 --- a/apps/authful/src/services/tokens/index.ts +++ b/apps/authful/src/services/tokens/index.ts @@ -72,11 +72,25 @@ export class TokensService { return { created: true, token }; } - async list(): Promise { - return this.repo.list(); + async list(tenant?: string): Promise { + return tenant ? this.repo.listByTenant(tenant) : this.repo.list(); } - async revoke(id: string): Promise { + /** + * Revokes a token. When `requireTenantPrefix` is given (provisioning scope), + * a token whose tenant lacks that prefix is treated as not-found (404) rather + * than 403 — so the provisioning key can't probe for first-party token ids. + */ + async revoke( + id: string, + opts: { requireTenantPrefix?: string } = {}, + ): Promise { + if (opts.requireTenantPrefix !== undefined) { + const token = await this.repo.findById(id); + if (!token || !token.tenant.startsWith(opts.requireTenantPrefix)) { + return false; + } + } return this.repo.revoke(id); } diff --git a/apps/user-api/drizzle/0002_ordinary_lord_tyger.sql b/apps/user-api/drizzle/0002_ordinary_lord_tyger.sql new file mode 100644 index 000000000..b4bf77dca --- /dev/null +++ b/apps/user-api/drizzle/0002_ordinary_lord_tyger.sql @@ -0,0 +1,11 @@ +CREATE TABLE "user_api_keys" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" text NOT NULL, + "authful_token_id" uuid NOT NULL, + "label" text NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "revoked_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "user_api_keys" ADD CONSTRAINT "user_api_keys_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "user_api_keys_user_id_index" ON "user_api_keys" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/user-api/drizzle/meta/0002_snapshot.json b/apps/user-api/drizzle/meta/0002_snapshot.json new file mode 100644 index 000000000..c883205f4 --- /dev/null +++ b/apps/user-api/drizzle/meta/0002_snapshot.json @@ -0,0 +1,640 @@ +{ + "id": "da1db9e9-9975-4054-9b53-5bb66bf52def", + "prevId": "72f99871-3ad0-4bf5-8f9e-0a6ecdc1d68b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.drafts": { + "name": "drafts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_address": { + "name": "author_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dao_id": { + "name": "dao_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "discussion_url": { + "name": "discussion_url", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "actions": { + "name": "actions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "drafts_user_id_dao_id_index": { + "name": "drafts_user_id_dao_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "dao_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "drafts_author_address_index": { + "name": "drafts_author_address_index", + "columns": [ + { + "expression": "author_address", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "drafts_user_id_user_id_fk": { + "name": "drafts_user_id_user_id_fk", + "tableFrom": "drafts", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_api_keys": { + "name": "user_api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "authful_token_id": { + "name": "authful_token_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_api_keys_user_id_index": { + "name": "user_api_keys_user_id_index", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_api_keys_user_id_user_id_fk": { + "name": "user_api_keys_user_id_user_id_fk", + "tableFrom": "user_api_keys", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": ["token"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallet_address": { + "name": "wallet_address", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "walletAddress_userId_idx": { + "name": "walletAddress_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "wallet_address_user_id_user_id_fk": { + "name": "wallet_address_user_id_user_id_fk", + "tableFrom": "wallet_address", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/user-api/drizzle/meta/_journal.json b/apps/user-api/drizzle/meta/_journal.json index 9ec63f34b..c20aa2b36 100644 --- a/apps/user-api/drizzle/meta/_journal.json +++ b/apps/user-api/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1783638916278, "tag": "0001_needy_molly_hayes", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1783685472681, + "tag": "0002_ordinary_lord_tyger", + "breakpoints": true } ] } diff --git a/apps/user-api/src/app.ts b/apps/user-api/src/app.ts index a6e7328a8..2bec6f888 100644 --- a/apps/user-api/src/app.ts +++ b/apps/user-api/src/app.ts @@ -3,24 +3,30 @@ import { OpenAPIHono as Hono } from "@hono/zod-openapi"; import { sql } from "drizzle-orm"; import { forwardedHost, type AuthResolver } from "@/auth"; +import { apiKeysController } from "@/controllers/api-keys"; import { draftsController } from "@/controllers/drafts"; import type { UserApiDrizzle } from "@/database/types"; import { exporter } from "@/instrumentation"; import { logger } from "@/logger"; import { metricsMiddleware } from "@/middlewares/metrics"; import { requestLogger } from "@/middlewares/logger"; +import type { ApiKeysService } from "@/services/api-keys"; import type { DraftsService } from "@/services/drafts"; export type AppConfig = { db: UserApiDrizzle; authResolver: AuthResolver; draftsService: DraftsService; + // Present only when Authful provisioning is configured — the API-key surface + // stays absent otherwise (env-gated, like Google / magic link). + apiKeysService?: ApiKeysService; }; export function createApp({ db, authResolver, draftsService, + apiKeysService, }: AppConfig): Hono { const app = new Hono(); @@ -60,6 +66,9 @@ export function createApp({ }); draftsController(app, draftsService, authResolver); + if (apiKeysService) { + apiKeysController(app, apiKeysService, authResolver); + } return app; } diff --git a/apps/user-api/src/clients/authful/index.ts b/apps/user-api/src/clients/authful/index.ts new file mode 100644 index 000000000..5e875823b --- /dev/null +++ b/apps/user-api/src/clients/authful/index.ts @@ -0,0 +1,76 @@ +/** + * East-west client for Authful's token surface, authenticated with the scoped + * provisioning key (restricted to `user:*` tenants). Called directly over the + * private network — never through Gateful. + */ +export type MintedToken = { + id: string; + token: string; // plaintext, returned exactly once by Authful +}; + +export type TokenUsage = { + id: string; + lastUsedAt: string | null; +}; + +export interface AuthfulClient { + mint(tenant: string, name: string): Promise; + revoke(tokenId: string): Promise; + /** Token metadata for a single tenant (used to surface lastUsedAt). */ + listByTenant(tenant: string): Promise; +} + +export class AuthfulHttpClient implements AuthfulClient { + constructor( + private readonly baseUrl: string, + private readonly provisioningApiKey: string, + ) {} + + private headers() { + return { + Authorization: `Bearer ${this.provisioningApiKey}`, + "Content-Type": "application/json", + }; + } + + async mint(tenant: string, name: string): Promise { + const res = await fetch(`${this.baseUrl}/tokens`, { + method: "POST", + headers: this.headers(), + // Product decision: user API keys are unbounded for now (free, no + // metering); gateful treats rateLimitPerMin <= 0 as no rate limit. + // Monetization later will mint with a real per-plan limit. + body: JSON.stringify({ tenant, name, rateLimitPerMin: 0 }), + }); + if (!res.ok) { + throw new Error(`authful mint failed: ${res.status}`); + } + const body = (await res.json()) as { id: string; token: string }; + return { id: body.id, token: body.token }; + } + + async revoke(tokenId: string): Promise { + const res = await fetch(`${this.baseUrl}/tokens/${tokenId}`, { + method: "DELETE", + headers: this.headers(), + }); + // 404 means already gone (revoked or never existed) — idempotent success. + if (!res.ok && res.status !== 404) { + throw new Error(`authful revoke failed: ${res.status}`); + } + } + + async listByTenant(tenant: string): Promise { + const res = await fetch( + `${this.baseUrl}/tokens?tenant=${encodeURIComponent(tenant)}`, + { headers: this.headers() }, + ); + if (!res.ok) { + throw new Error(`authful list failed: ${res.status}`); + } + const body = (await res.json()) as { + items: { id: string; lastUsedAt: string | null }[]; + }; + return body.items.map((t) => ({ id: t.id, lastUsedAt: t.lastUsedAt })); + } +} diff --git a/apps/user-api/src/controllers/api-keys/api-keys.integration.test.ts b/apps/user-api/src/controllers/api-keys/api-keys.integration.test.ts new file mode 100644 index 000000000..5a1c3ce8c --- /dev/null +++ b/apps/user-api/src/controllers/api-keys/api-keys.integration.test.ts @@ -0,0 +1,259 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { PGlite } from "@electric-sql/pglite"; +import { pushSchema } from "drizzle-kit/api"; +import { drizzle } from "drizzle-orm/pglite"; +import { recoverMessageAddress } from "viem"; +import { privateKeyToAccount, generatePrivateKey } from "viem/accounts"; +import { createSiweMessage } from "viem/siwe"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { createApp } from "@/app"; +import { createAuthResolver } from "@/auth"; +import type { AuthfulClient } from "@/clients/authful"; +import * as fullSchema from "@/database/schema"; +import { + account, + drafts, + session, + user, + userApiKeys, + verification, + walletAddress, +} from "@/database/schema"; +import { ApiKeysRepository } from "@/repositories/api-keys"; +import { DraftsRepository } from "@/repositories/drafts"; +import { ApiKeysService } from "@/services/api-keys"; +import { DraftsService } from "@/services/drafts"; + +const HOST = "localhost:3000"; +const ORIGIN = `http://${HOST}`; + +const verifyMessage = async ({ + message, + signature, + address, +}: { + message: string; + signature: string; + address: string; +}) => { + const recovered = await recoverMessageAddress({ + message, + signature: signature as `0x${string}`, + }); + return recovered.toLowerCase() === address.toLowerCase(); +}; + +type TestApp = ReturnType; + +describe("api-keys + Authful brokering integration", () => { + let client: PGlite; + let app: TestApp; + + // Fake Authful: records mint/revoke calls, returns a deterministic plaintext. + // listByTenant reports a fixed lastUsedAt so the enrichment path is covered. + const authful: AuthfulClient & { + mint: ReturnType; + revoke: ReturnType; + listByTenant: ReturnType; + } = { + mint: vi.fn(async (tenant: string) => ({ + id: crypto.randomUUID(), + token: `act_${tenant}`, + })), + revoke: vi.fn(async () => undefined), + listByTenant: vi.fn(async () => []), + }; + + beforeAll(async () => { + client = new PGlite(); + const db = drizzle(client, { schema: fullSchema }); + const tables = { + user, + session, + account, + verification, + walletAddress, + drafts, + userApiKeys, + }; + const { apply } = await pushSchema(tables as any, db as any); + await apply(); + + const authResolver = createAuthResolver({ + db, + secret: "integration-test-secret-0123456789abcdef", + domains: [HOST], + verifyMessage, + }); + + app = createApp({ + db, + authResolver, + draftsService: new DraftsService(new DraftsRepository(db)), + // small quota to exercise the limit + apiKeysService: new ApiKeysService(new ApiKeysRepository(db), authful, 2), + }); + }); + + afterAll(async () => { + await client.close(); + }); + + const baseHeaders = { host: HOST, origin: ORIGIN }; + + const signIn = async () => { + const wallet = privateKeyToAccount(generatePrivateKey()); + const nonceRes = await app.request("/api/auth/siwe/nonce", { + method: "POST", + headers: { ...baseHeaders, "content-type": "application/json" }, + body: JSON.stringify({ walletAddress: wallet.address, chainId: 1 }), + }); + const { nonce } = (await nonceRes.json()) as any; + const message = createSiweMessage({ + domain: HOST, + address: wallet.address, + chainId: 1, + nonce, + uri: ORIGIN, + version: "1", + }); + const signature = await wallet.signMessage({ message }); + const verifyRes = await app.request("/api/auth/siwe/verify", { + method: "POST", + headers: { ...baseHeaders, "content-type": "application/json" }, + body: JSON.stringify({ + message, + signature, + walletAddress: wallet.address, + chainId: 1, + }), + }); + const cookie = verifyRes.headers + .getSetCookie() + .map((c) => c.split(";")[0]) + .join("; "); + return cookie; + }; + + const authed = (cookie: string, extra: Record = {}) => ({ + ...baseHeaders, + cookie, + ...extra, + }); + + const createKey = (cookie: string, label = "my-agent") => + app.request("/me/api-keys", { + method: "POST", + headers: authed(cookie, { "content-type": "application/json" }), + body: JSON.stringify({ label }), + }); + + it("requires a session", async () => { + const res = await app.request("/me/api-keys", { headers: baseHeaders }); + expect(res.status).toBe(401); + }); + + it("mints via Authful under the user's tenant and returns plaintext once", async () => { + const cookie = await signIn(); + const res = await createKey(cookie, "prod-agent"); + expect(res.status).toBe(201); + const body = (await res.json()) as any; + + expect(body.label).toBe("prod-agent"); + expect(body.token).toMatch(/^act_user:/); // tenant = user: + expect(authful.mint).toHaveBeenCalledWith( + expect.stringMatching(/^user:/), + "prod-agent", + ); + }); + + it("lists the session user's keys without the plaintext", async () => { + const cookie = await signIn(); + await createKey(cookie, "a"); + const res = await app.request("/me/api-keys", { headers: authed(cookie) }); + const { items } = (await res.json()) as any; + + expect(items.length).toBe(1); + expect(items[0].label).toBe("a"); + expect(items[0]).not.toHaveProperty("token"); + expect(items[0]).toHaveProperty("lastUsedAt"); + }); + + it("surfaces lastUsedAt from Authful for the listed keys", async () => { + const cookie = await signIn(); + await createKey(cookie, "used"); + const mintedId = (await authful.mint.mock.results.at(-1)!.value).id; + authful.listByTenant.mockResolvedValueOnce([ + { id: mintedId, lastUsedAt: "2026-01-02T03:04:05.000Z" }, + ]); + + const res = await app.request("/me/api-keys", { headers: authed(cookie) }); + const { items } = (await res.json()) as any; + const used = items.find((k: { label: string }) => k.label === "used"); + expect(used.lastUsedAt).toBe("2026-01-02T03:04:05.000Z"); + }); + + it("still lists keys when Authful is unreachable (lastUsedAt null)", async () => { + const cookie = await signIn(); + await createKey(cookie, "resilient"); + authful.listByTenant.mockRejectedValueOnce(new Error("authful down")); + + const res = await app.request("/me/api-keys", { headers: authed(cookie) }); + expect(res.status).toBe(200); + const { items } = (await res.json()) as any; + expect( + items.find((k: { label: string }) => k.label === "resilient"), + ).toBeTruthy(); + }); + + it("does not list another user's keys", async () => { + const alice = await signIn(); + const bob = await signIn(); + await createKey(alice, "alice-key"); + + const res = await app.request("/me/api-keys", { headers: authed(bob) }); + const { items } = (await res.json()) as any; + expect(items).toHaveLength(0); + }); + + it("revokes in Authful then locally, and 404s a foreign key", async () => { + const owner = await signIn(); + const attacker = await signIn(); + const created = (await (await createKey(owner, "temp")).json()) as any; + + // Attacker cannot revoke it — 404, and Authful is never called. + authful.revoke.mockClear(); + const foreign = await app.request(`/me/api-keys/${created.id}`, { + method: "DELETE", + headers: authed(attacker), + }); + expect(foreign.status).toBe(404); + expect(authful.revoke).not.toHaveBeenCalled(); + + // Owner revokes — Authful called, key drops from the list. + const res = await app.request(`/me/api-keys/${created.id}`, { + method: "DELETE", + headers: authed(owner), + }); + expect(res.status).toBe(204); + expect(authful.revoke).toHaveBeenCalledOnce(); + + const list = await app.request("/me/api-keys", { headers: authed(owner) }); + const { items } = (await list.json()) as any; + expect( + items.find((k: { id: string }) => k.id === created.id), + ).toBeUndefined(); + }); + + it("enforces the per-user key quota", async () => { + const cookie = await signIn(); + expect((await createKey(cookie)).status).toBe(201); + expect((await createKey(cookie)).status).toBe(201); + const third = await createKey(cookie); + expect(third.status).toBe(403); + await expect(third.json() as any).resolves.toEqual({ + error: "api_key_limit_reached", + }); + }); +}); diff --git a/apps/user-api/src/controllers/api-keys/index.ts b/apps/user-api/src/controllers/api-keys/index.ts new file mode 100644 index 000000000..8b86c6486 --- /dev/null +++ b/apps/user-api/src/controllers/api-keys/index.ts @@ -0,0 +1,148 @@ +import { createRoute, type OpenAPIHono } from "@hono/zod-openapi"; + +import type { AuthResolver } from "@/auth"; +import { + ApiKeyListResponseSchema, + ApiKeyParamsSchema, + CreateApiKeyBodySchema, + CreatedApiKeyResponseSchema, +} from "@/mappers/api-keys"; +import { ErrorResponseSchema } from "@/mappers/drafts"; +import { sessionAuth } from "@/middlewares/session"; +import type { ApiKeyRow } from "@/repositories/api-keys"; +import { + ApiKeyQuotaExceededError, + type ApiKeysService, +} from "@/services/api-keys"; + +const toResponse = (row: ApiKeyRow, lastUsedAt: string | null = null) => ({ + id: row.id, + label: row.label, + createdAt: row.createdAt.toISOString(), + revokedAt: row.revokedAt?.toISOString() ?? null, + lastUsedAt, +}); + +const unauthorizedResponses = { + 400: { + description: "Request Host is not a trusted domain", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + 401: { + description: "Missing or invalid session", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, +} as const; + +export function apiKeysController( + app: OpenAPIHono, + service: ApiKeysService, + resolver: AuthResolver, +) { + const auth = sessionAuth(resolver); + + app.openapi( + createRoute({ + method: "get", + operationId: "listApiKeys", + path: "/me/api-keys", + summary: "List the session user's active API keys", + middleware: [auth] as const, + responses: { + 200: { + description: "Active API keys, newest first", + content: { "application/json": { schema: ApiKeyListResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const { id: userId } = c.get("sessionUser"); + const keys = await service.list(userId); + return c.json( + ApiKeyListResponseSchema.parse({ + items: keys.map((k) => toResponse(k, k.lastUsedAt)), + }), + 200, + ); + }, + ); + + app.openapi( + createRoute({ + method: "post", + operationId: "createApiKey", + path: "/me/api-keys", + summary: "Create an API key", + description: + "Mints a key scoped to the session user via Authful. The plaintext is " + + "returned exactly once and never stored here.", + middleware: [auth] as const, + request: { + body: { + content: { "application/json": { schema: CreateApiKeyBodySchema } }, + }, + }, + responses: { + 201: { + description: "The created key, including its one-time plaintext", + content: { + "application/json": { schema: CreatedApiKeyResponseSchema }, + }, + }, + 403: { + description: "API key quota reached", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const { label } = c.req.valid("json"); + const { id: userId } = c.get("sessionUser"); + try { + const { key, plaintext } = await service.create(userId, label); + return c.json( + CreatedApiKeyResponseSchema.parse({ + ...toResponse(key), + token: plaintext, + }), + 201, + ); + } catch (err) { + if (err instanceof ApiKeyQuotaExceededError) { + return c.json({ error: "api_key_limit_reached" }, 403); + } + throw err; + } + }, + ); + + app.openapi( + createRoute({ + method: "delete", + operationId: "revokeApiKey", + path: "/me/api-keys/{id}", + summary: "Revoke an API key", + description: + "Only the owner can revoke. Ownership comes from the session.", + middleware: [auth] as const, + request: { params: ApiKeyParamsSchema }, + responses: { + 204: { description: "Key revoked" }, + 404: { + description: "Key not found", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, + ...unauthorizedResponses, + }, + }), + async (c) => { + const { id } = c.req.valid("param"); + const { id: userId } = c.get("sessionUser"); + const revoked = await service.revoke(id, userId); + if (!revoked) return c.json({ error: "api_key_not_found" }, 404); + return c.body(null, 204); + }, + ); +} diff --git a/apps/user-api/src/database/schema.ts b/apps/user-api/src/database/schema.ts index 6847ecff6..a14dbecf8 100644 --- a/apps/user-api/src/database/schema.ts +++ b/apps/user-api/src/database/schema.ts @@ -1,5 +1,13 @@ import { sql } from "drizzle-orm"; -import { bigint, index, jsonb, pgTable, text, uuid } from "drizzle-orm/pg-core"; +import { + bigint, + index, + jsonb, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; import { user } from "./auth-schema"; @@ -41,3 +49,23 @@ export const drafts = pgTable( index().on(table.authorAddress), ], ); + +// Ownership record for user-minted API keys. The plaintext and hash live only +// in Authful (tenant `user:`); here we keep just who owns which Authful +// token, plus the user's label. Never stores secrets. +export const userApiKeys = pgTable( + "user_api_keys", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + authfulTokenId: uuid("authful_token_id").notNull(), + label: text("label").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + }, + (table) => [index().on(table.userId)], +); diff --git a/apps/user-api/src/env.ts b/apps/user-api/src/env.ts index 9b02cfd6e..f1b53ab06 100644 --- a/apps/user-api/src/env.ts +++ b/apps/user-api/src/env.ts @@ -41,6 +41,12 @@ const envSchema = z // defaults to Resend's sandbox sender for local/dev. RESEND_API_KEY: z.string().optional(), RESEND_FROM_EMAIL: z.string().default("onboarding@resend.dev"), + + // User self-service API keys: enabled when both are set. The User API + // brokers keys into Authful over the private network using a provisioning + // key scoped to `user:*` tenants. + AUTHFUL_URL: z.string().url().optional(), + AUTHFUL_PROVISIONING_API_KEY: z.string().min(16).optional(), }) .superRefine((data, ctx) => { if (Boolean(data.GOOGLE_CLIENT_ID) !== Boolean(data.GOOGLE_CLIENT_SECRET)) { @@ -51,6 +57,16 @@ const envSchema = z "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set together", }); } + if ( + Boolean(data.AUTHFUL_URL) !== Boolean(data.AUTHFUL_PROVISIONING_API_KEY) + ) { + ctx.addIssue({ + code: "custom", + path: ["AUTHFUL_PROVISIONING_API_KEY"], + message: + "AUTHFUL_URL and AUTHFUL_PROVISIONING_API_KEY must be set together", + }); + } }); const _env = envSchema.safeParse(process.env); diff --git a/apps/user-api/src/index.ts b/apps/user-api/src/index.ts index fe00eb9de..d29fba4d5 100644 --- a/apps/user-api/src/index.ts +++ b/apps/user-api/src/index.ts @@ -2,16 +2,33 @@ import { serve } from "@hono/node-server"; import { createApp } from "@/app"; import { authResolver } from "@/auth-instance"; +import { AuthfulHttpClient } from "@/clients/authful"; import { db } from "@/database"; import { env } from "@/env"; import { logger } from "@/logger"; +import { ApiKeysRepository } from "@/repositories/api-keys"; import { DraftsRepository } from "@/repositories/drafts"; +import { ApiKeysService } from "@/services/api-keys"; import { DraftsService } from "@/services/drafts"; +// Self-service API keys are enabled only when Authful provisioning is wired +// (env validation guarantees the pair is set together). +const apiKeysService = + env.AUTHFUL_URL && env.AUTHFUL_PROVISIONING_API_KEY + ? new ApiKeysService( + new ApiKeysRepository(db), + new AuthfulHttpClient( + env.AUTHFUL_URL, + env.AUTHFUL_PROVISIONING_API_KEY, + ), + ) + : undefined; + const app = createApp({ db, authResolver, draftsService: new DraftsService(new DraftsRepository(db)), + apiKeysService, }); app.doc("/docs/json", { diff --git a/apps/user-api/src/mappers/api-keys/index.ts b/apps/user-api/src/mappers/api-keys/index.ts new file mode 100644 index 000000000..01fbc7285 --- /dev/null +++ b/apps/user-api/src/mappers/api-keys/index.ts @@ -0,0 +1,33 @@ +import { z } from "@hono/zod-openapi"; + +export const ApiKeySchema = z + .object({ + id: z.string(), + label: z.string(), + createdAt: z.string(), + revokedAt: z.string().nullable(), + // Sourced from Authful; null when never used or Authful was unreachable. + lastUsedAt: z.string().nullable(), + }) + .openapi("ApiKey"); + +export const ApiKeyListResponseSchema = z + .object({ items: z.array(ApiKeySchema) }) + .openapi("ApiKeyListResponse"); + +export const CreateApiKeyBodySchema = z + .object({ label: z.string().min(1).max(100) }) + .openapi("CreateApiKeyBody"); + +// The plaintext is included exactly once, on creation only. +export const CreatedApiKeyResponseSchema = ApiKeySchema.extend({ + token: z + .string() + .describe( + "Plaintext API key — shown exactly once, never retrievable again", + ), +}).openapi("CreatedApiKey"); + +export const ApiKeyParamsSchema = z + .object({ id: z.uuid() }) + .openapi("ApiKeyParams"); diff --git a/apps/user-api/src/repositories/api-keys/index.ts b/apps/user-api/src/repositories/api-keys/index.ts new file mode 100644 index 000000000..0b03df443 --- /dev/null +++ b/apps/user-api/src/repositories/api-keys/index.ts @@ -0,0 +1,58 @@ +import { and, desc, eq, isNull } from "drizzle-orm"; + +import type { UserApiDrizzle } from "@/database/types"; +import { userApiKeys } from "@/database/schema"; + +export type ApiKeyRow = typeof userApiKeys.$inferSelect; + +export class ApiKeysRepository { + constructor(private readonly db: UserApiDrizzle) {} + + async create(input: { + userId: string; + authfulTokenId: string; + label: string; + }): Promise { + const [row] = await this.db.insert(userApiKeys).values(input).returning(); + return row as ApiKeyRow; + } + + /** Active (non-revoked) keys for a user, newest first. */ + async listActiveByUser(userId: string): Promise { + return this.db + .select() + .from(userApiKeys) + .where(and(eq(userApiKeys.userId, userId), isNull(userApiKeys.revokedAt))) + .orderBy(desc(userApiKeys.createdAt)); + } + + async countActiveByUser(userId: string): Promise { + const rows = await this.listActiveByUser(userId); + return rows.length; + } + + /** Ownership is in the WHERE clause: a foreign id matches zero rows. */ + async findOwned(id: string, userId: string): Promise { + const [row] = await this.db + .select() + .from(userApiKeys) + .where(and(eq(userApiKeys.id, id), eq(userApiKeys.userId, userId))) + .limit(1); + return row; + } + + async markRevoked(id: string, userId: string): Promise { + const rows = await this.db + .update(userApiKeys) + .set({ revokedAt: new Date() }) + .where( + and( + eq(userApiKeys.id, id), + eq(userApiKeys.userId, userId), + isNull(userApiKeys.revokedAt), + ), + ) + .returning(); + return rows.length > 0; + } +} diff --git a/apps/user-api/src/services/api-keys/index.ts b/apps/user-api/src/services/api-keys/index.ts new file mode 100644 index 000000000..22de6eee3 --- /dev/null +++ b/apps/user-api/src/services/api-keys/index.ts @@ -0,0 +1,91 @@ +import type { AuthfulClient } from "@/clients/authful"; +import type { ApiKeyRow, ApiKeysRepository } from "@/repositories/api-keys"; + +export const DEFAULT_MAX_KEYS_PER_USER = 10; + +export const USER_TENANT_PREFIX = "user:"; + +export class ApiKeyQuotaExceededError extends Error { + constructor(readonly limit: number) { + super(`API key limit of ${limit} reached`); + this.name = "ApiKeyQuotaExceededError"; + } +} + +export type CreatedApiKey = { + key: ApiKeyRow; + /** Plaintext from Authful — returned to the caller exactly once. */ + plaintext: string; +}; + +export type ApiKeyWithUsage = ApiKeyRow & { lastUsedAt: string | null }; + +export class ApiKeysService { + constructor( + private readonly repo: ApiKeysRepository, + private readonly authful: AuthfulClient, + private readonly maxKeysPerUser = DEFAULT_MAX_KEYS_PER_USER, + ) {} + + async create(userId: string, label: string): Promise { + const current = await this.repo.countActiveByUser(userId); + if (current >= this.maxKeysPerUser) { + throw new ApiKeyQuotaExceededError(this.maxKeysPerUser); + } + + // Mint in Authful under this user's own tenant, then record ownership. + const minted = await this.authful.mint( + `${USER_TENANT_PREFIX}${userId}`, + label, + ); + + try { + const key = await this.repo.create({ + userId, + authfulTokenId: minted.id, + label, + }); + return { key, plaintext: minted.token }; + } catch (err) { + // Don't leak an orphan usable token if the ownership write fails. + await this.authful.revoke(minted.id).catch(() => undefined); + throw err; + } + } + + async list(userId: string): Promise { + const rows = await this.repo.listActiveByUser(userId); + + // lastUsedAt lives in Authful. Enrich best-effort: if Authful is + // unreachable, still return the keys (usage shown as unknown) rather than + // failing the whole list. + let usage = new Map(); + try { + const tokens = await this.authful.listByTenant( + `${USER_TENANT_PREFIX}${userId}`, + ); + usage = new Map(tokens.map((t) => [t.id, t.lastUsedAt])); + } catch { + // leave usage empty + } + + return rows.map((row) => ({ + ...row, + lastUsedAt: usage.get(row.authfulTokenId) ?? null, + })); + } + + /** + * Revokes a key the user owns. Revokes in Authful first (the security- + * relevant step) so a failure there aborts before we mark it revoked + * locally — never the reverse, which would leave a live token the user + * believes is gone. Returns false if the key isn't found or isn't theirs. + */ + async revoke(id: string, userId: string): Promise { + const key = await this.repo.findOwned(id, userId); + if (!key || key.revokedAt) return false; + + await this.authful.revoke(key.authfulTokenId); + return this.repo.markRevoked(id, userId); + } +}