diff --git a/drizzle/0012_api_key_rotation.sql b/drizzle/0012_api_key_rotation.sql new file mode 100644 index 0000000..61e3319 --- /dev/null +++ b/drizzle/0012_api_key_rotation.sql @@ -0,0 +1,3 @@ +-- #40 API key rotation: store revocation timestamp so old keys can be +-- invalidated without deleting the row. NULL means the key is still active. +ALTER TABLE "publishers" ADD COLUMN "api_key_revoked_at" timestamp; diff --git a/src/db/schema.ts b/src/db/schema.ts index 6c58ba4..8578a64 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -57,6 +57,9 @@ export const publishers = pgTable("publishers", { // Subscribed WebhookEvent values; null/empty means all events. webhookEvents: text("webhook_events").array(), webhookEnabled: boolean("webhook_enabled").notNull().default(false), + // Set when the publisher rotates their API key. Any key whose hash matches + // a row with a non-null api_key_revoked_at is rejected by apiKeyAuth (#40). + apiKeyRevokedAt: timestamp("api_key_revoked_at"), createdAt: timestamp("created_at").defaultNow().notNull(), }); diff --git a/src/middleware/apiKeyAuth.ts b/src/middleware/apiKeyAuth.ts index a100e74..43349c8 100644 --- a/src/middleware/apiKeyAuth.ts +++ b/src/middleware/apiKeyAuth.ts @@ -31,6 +31,11 @@ export async function apiKeyAuth(req: Request, res: Response, next: NextFunction throw new AppError("UNAUTHORIZED", "Invalid API key"); } + // Reject keys that have been rotated / revoked (#40). + if (publisher.apiKeyRevokedAt !== null) { + throw new AppError("UNAUTHORIZED", "API key has been revoked"); + } + req.publisher = publisher; next(); } \ No newline at end of file diff --git a/src/routes/publishers.api-key-rotation.test.ts b/src/routes/publishers.api-key-rotation.test.ts new file mode 100644 index 0000000..0f5e395 --- /dev/null +++ b/src/routes/publishers.api-key-rotation.test.ts @@ -0,0 +1,285 @@ +/** + * Tests for POST /publishers/me/api-keys/rotate (#40) + * + * Acceptance criteria: + * - The old key returns 401 after rotation. + * - The new key works (200 on subsequent authenticated endpoints). + * - The rotate endpoint itself returns the new plaintext key + rotatedAt. + */ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import express from "express"; +import request from "supertest"; +import { createHash } from "node:crypto"; + +// --------------------------------------------------------------------------- +// Schema + DB mocks (same pattern as publishers.test.ts) +// --------------------------------------------------------------------------- + +const { PUBLISHERS_MARKER, RESOURCES_MARKER, PAYMENTS_MARKER } = vi.hoisted(() => ({ + PUBLISHERS_MARKER: { __table: "publishers" }, + RESOURCES_MARKER: { __table: "resources" }, + PAYMENTS_MARKER: { __table: "payments" }, +})); + +vi.mock("../db/schema.js", () => ({ + publishers: PUBLISHERS_MARKER, + resources: RESOURCES_MARKER, + payments: PAYMENTS_MARKER, +})); + +// Pre-compute known key/hash pairs so we can assert auth outcomes precisely. +const OLD_API_KEY = "mv_old-key"; +const OLD_API_KEY_HASH = createHash("sha256").update(OLD_API_KEY).digest("hex"); + +const NEW_API_KEY = "mv_new-key"; +const NEW_API_KEY_HASH = createHash("sha256").update(NEW_API_KEY).digest("hex"); + +/** + * Rows returned by the DB mock for publisher lookups. + * Tests control this to simulate which key is currently valid. + * The mock always returns this exact array — set it to [] for "no match". + */ +let publishersLookupRows: any[]; + +vi.mock("../db/client.js", () => ({ + db: { + select: (_cols: unknown) => ({ + from: (table: unknown) => { + if (table === PUBLISHERS_MARKER) { + return { + // apiKeyAuth calls .where(eq(publishers.apiKeyHash, hash)).then(rows => rows[0]). + // The drizzle eq() expression stores the compared value at queryChunks[3], + // so we can filter the lookup rows by the queried hash. + where: (expr: any) => { + const queriedHash: string | undefined = expr?.queryChunks?.[3]; + const matched = queriedHash + ? publishersLookupRows.filter((r) => r.apiKeyHash === queriedHash) + : publishersLookupRows; + return { + then: (resolve: (rows: any[]) => unknown) => + Promise.resolve(matched).then(resolve), + }; + }, + }; + } + throw new Error(`unexpected table in select().from(): ${JSON.stringify(table)}`); + }, + }), + }, +})); + +vi.mock("../config.js", () => ({ + config: { + BASE_URL: "http://localhost:4021", + PUBLISHER_RATE_LIMIT_RPM_DEFAULT: 60, + PUBLISHER_RATE_LIMIT_MAX_RPM: 600, + PUBLISHER_RATE_LIMIT_WINDOW_MS: 60_000, + }, +})); + +// --------------------------------------------------------------------------- +// Service mock — rotateApiKey +// --------------------------------------------------------------------------- + +const { mockRotateApiKey, mockRegisterPublisher, mockGetPublisherResources } = vi.hoisted(() => ({ + mockRotateApiKey: vi.fn(), + mockRegisterPublisher: vi.fn(), + mockGetPublisherResources: vi.fn(), +})); + +vi.mock("../services/publisherService.js", () => ({ + registerPublisher: mockRegisterPublisher, + getPublisherResources: mockGetPublisherResources, + rotateApiKey: mockRotateApiKey, +})); + +import publisherRouter from "./publishers.js"; +import { errorHandler } from "../middleware/errorHandler.js"; + +function createTestApp() { + const app = express(); + app.use(express.json()); + app.use(publisherRouter); + app.use(errorHandler); + return app; +} + +// --------------------------------------------------------------------------- +// Fixture helpers +// --------------------------------------------------------------------------- + +function makePublisher(overrides: Record = {}) { + return { + id: "pub-1", + name: "Alice", + email: "alice@example.com", + walletAddress: "GALICE", + apiKeyHash: OLD_API_KEY_HASH, + apiKeyRevokedAt: null, + rateLimitRpm: null, + webhookUrl: null, + webhookSecret: null, + webhookEvents: null, + webhookEnabled: false, + createdAt: new Date("2026-01-01"), + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("POST /publishers/me/api-keys/rotate (#40)", () => { + beforeEach(() => { + mockRotateApiKey.mockReset(); + // Default: old key is valid and active. + publishersLookupRows = [makePublisher()]; + }); + + it("returns 401 when the x-api-key header is missing", async () => { + const res = await request(createTestApp()).post("/publishers/me/api-keys/rotate"); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Missing x-api-key header"); + expect(mockRotateApiKey).not.toHaveBeenCalled(); + }); + + it("returns 401 when an unrecognised key is supplied", async () => { + // No matching row for an unknown key. + publishersLookupRows = []; + + const res = await request(createTestApp()) + .post("/publishers/me/api-keys/rotate") + .set("x-api-key", "mv_unknown"); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("Invalid API key"); + expect(mockRotateApiKey).not.toHaveBeenCalled(); + }); + + it("rotates the key and returns the new plaintext key with a timestamp", async () => { + mockRotateApiKey.mockResolvedValue(NEW_API_KEY); + + const before = Date.now(); + const res = await request(createTestApp()) + .post("/publishers/me/api-keys/rotate") + .set("x-api-key", OLD_API_KEY); + const after = Date.now(); + + expect(res.status).toBe(200); + expect(res.body.apiKey).toBe(NEW_API_KEY); + expect(res.body.rotatedAt).toBeDefined(); + expect(new Date(res.body.rotatedAt).getTime()).toBeGreaterThanOrEqual(before); + expect(new Date(res.body.rotatedAt).getTime()).toBeLessThanOrEqual(after); + expect(mockRotateApiKey).toHaveBeenCalledWith("pub-1"); + }); + + it("calls rotateApiKey with the authenticated publisher's id", async () => { + mockRotateApiKey.mockResolvedValue(NEW_API_KEY); + + await request(createTestApp()) + .post("/publishers/me/api-keys/rotate") + .set("x-api-key", OLD_API_KEY); + + expect(mockRotateApiKey).toHaveBeenCalledTimes(1); + expect(mockRotateApiKey).toHaveBeenCalledWith("pub-1"); + }); + + it("does not expose the old key in the response", async () => { + mockRotateApiKey.mockResolvedValue(NEW_API_KEY); + + const res = await request(createTestApp()) + .post("/publishers/me/api-keys/rotate") + .set("x-api-key", OLD_API_KEY); + + expect(res.status).toBe(200); + expect(JSON.stringify(res.body)).not.toContain(OLD_API_KEY); + expect(JSON.stringify(res.body)).not.toContain(OLD_API_KEY_HASH); + }); +}); + +describe("Old key → 401 after rotation, new key → 200 (#40 acceptance criteria)", () => { + beforeEach(() => { + mockRotateApiKey.mockReset(); + }); + + it("old key returns 401 after rotation (hash replaced in the DB)", async () => { + // Initially: old key is valid. + publishersLookupRows = [makePublisher({ apiKeyHash: OLD_API_KEY_HASH })]; + + // The service rotation replaces the hash in the DB. Simulate by swapping + // publishersLookupRows when rotateApiKey is called. + mockRotateApiKey.mockImplementation(async () => { + publishersLookupRows = [makePublisher({ apiKeyHash: NEW_API_KEY_HASH })]; + return NEW_API_KEY; + }); + + // Perform the rotation. + const rotateRes = await request(createTestApp()) + .post("/publishers/me/api-keys/rotate") + .set("x-api-key", OLD_API_KEY); + + expect(rotateRes.status).toBe(200); + expect(rotateRes.body.apiKey).toBe(NEW_API_KEY); + + // Old key is now rejected — its hash is no longer in the DB. + const oldKeyRes = await request(createTestApp()) + .get("/publishers/me") + .set("x-api-key", OLD_API_KEY); + + expect(oldKeyRes.status).toBe(401); + expect(oldKeyRes.body.error).toBe("Invalid API key"); + }); + + it("new key returns 200 after rotation", async () => { + publishersLookupRows = [makePublisher({ apiKeyHash: OLD_API_KEY_HASH })]; + + mockRotateApiKey.mockImplementation(async () => { + publishersLookupRows = [makePublisher({ apiKeyHash: NEW_API_KEY_HASH })]; + return NEW_API_KEY; + }); + + // Rotate using the old key. + await request(createTestApp()) + .post("/publishers/me/api-keys/rotate") + .set("x-api-key", OLD_API_KEY); + + // New key is now accepted. + const newKeyRes = await request(createTestApp()) + .get("/publishers/me") + .set("x-api-key", NEW_API_KEY); + + expect(newKeyRes.status).toBe(200); + expect(newKeyRes.body.id).toBe("pub-1"); + }); + + it("revoked key (apiKeyRevokedAt set) returns 401 even if the hash matches", async () => { + // Simulate an admin revocation: same hash but apiKeyRevokedAt is non-null. + publishersLookupRows = [ + makePublisher({ apiKeyRevokedAt: new Date("2026-06-01T00:00:00Z") }), + ]; + + const res = await request(createTestApp()) + .get("/publishers/me") + .set("x-api-key", OLD_API_KEY); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("API key has been revoked"); + expect(res.body.code).toBe("UNAUTHORIZED"); + }); + + it("rotate endpoint itself is blocked when the caller's key is already revoked", async () => { + publishersLookupRows = [ + makePublisher({ apiKeyRevokedAt: new Date("2026-06-01T00:00:00Z") }), + ]; + + const res = await request(createTestApp()) + .post("/publishers/me/api-keys/rotate") + .set("x-api-key", OLD_API_KEY); + + expect(res.status).toBe(401); + expect(res.body.error).toBe("API key has been revoked"); + expect(mockRotateApiKey).not.toHaveBeenCalled(); + }); +}); diff --git a/src/routes/publishers.test.ts b/src/routes/publishers.test.ts index d198d33..4dde7a8 100644 --- a/src/routes/publishers.test.ts +++ b/src/routes/publishers.test.ts @@ -25,6 +25,7 @@ const authedPublisher = { email: "alice@example.com", walletAddress: "GALICE", apiKeyHash: VALID_API_KEY_HASH, + apiKeyRevokedAt: null, createdAt: new Date("2026-01-01"), }; diff --git a/src/routes/publishers.ts b/src/routes/publishers.ts index 7b93393..979dd07 100644 --- a/src/routes/publishers.ts +++ b/src/routes/publishers.ts @@ -9,7 +9,7 @@ import { updateRateLimitSchema, updateWebhookConfigSchema, } from "../schemas/requests.js"; -import { registerPublisher, getPublisherResources } from "../services/publisherService.js"; +import { registerPublisher, getPublisherResources, rotateApiKey } from "../services/publisherService.js"; import { deliver, webhookUrlProblem } from "../services/webhookService.js"; import { eq, inArray, sum, count, sql, desc } from "drizzle-orm"; import { db } from "../db/client.js"; @@ -80,6 +80,16 @@ router.get("/publishers/me/resources", apiKeyAuth, async (req, res) => { res.json(resources); }); +// POST /publishers/me/api-keys/rotate — issue a new key, invalidate the old one (#40) +// The new key is returned once in plaintext; the old key is immediately invalid. +router.post("/publishers/me/api-keys/rotate", apiKeyAuth, publisherRateLimit, async (req, res) => { + const newApiKey = await rotateApiKey(req.publisher!.id); + res.json({ + apiKey: newApiKey, // shown once — store it securely + rotatedAt: new Date().toISOString(), + }); +}); + // GET /publishers/me/analytics — earnings and stats (authenticated) router.get("/publishers/me/analytics", apiKeyAuth, async (req, res) => { const publisherId = req.publisher!.id; diff --git a/src/services/publisherService.ts b/src/services/publisherService.ts index 39efe37..a67a575 100644 --- a/src/services/publisherService.ts +++ b/src/services/publisherService.ts @@ -36,6 +36,31 @@ export async function getPublisherResources(publisherId: string) { return db.select().from(resources).where(eq(resources.publisherId, publisherId)); } +/** + * Rotate the API key for a publisher (#40). + * + * Atomically replaces the stored key hash with a new one and clears any + * previous revocation so the fresh key is immediately accepted by apiKeyAuth. + * The old key stops working the instant its hash is overwritten. + * + * Returns the new plaintext key (shown to the caller once, never stored). + */ +export async function rotateApiKey(publisherId: string): Promise { + const newApiKey = generateApiKey(); + const newApiKeyHash = hashApiKey(newApiKey); + + await db + .update(publishers) + .set({ + apiKeyHash: newApiKeyHash, + // Clear any prior revocation — the new key is active. + apiKeyRevokedAt: null, + }) + .where(eq(publishers.id, publisherId)); + + return newApiKey; +} + export async function getPublisherWebhookConfig(publisherId: string) { const publisher = await getPublisherById(publisherId); if (!publisher) {