From e62db618ff054a4bc5d7b25985b29e666df3e6b7 Mon Sep 17 00:00:00 2001 From: David-patrick-chuks Date: Wed, 27 May 2026 00:07:32 +0100 Subject: [PATCH] feat: add API key management endpoints --- README.md | 10 + src/app.ts | 26 +- src/repositories/apiKeyRepository.test.ts | 31 ++ src/repositories/apiKeyRepository.ts | 30 +- src/routes/apiKeyRoutes.test.ts | 333 +++++++++++++--------- src/routes/apiKeyRoutes.ts | 161 +++++++++++ src/routes/index.ts | 25 +- 7 files changed, 446 insertions(+), 170 deletions(-) create mode 100644 src/routes/apiKeyRoutes.ts diff --git a/README.md b/README.md index 036b2e0..edfb616 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,16 @@ The gateway auth middleware performs prefix-based lookup, timing-safe full-key h See [docs/gateway-api-key-auth.md](./docs/gateway-api-key-auth.md) for the full flow, attached request fields, and failure responses. +## API key management + +Authenticated developers can manage API keys for their own APIs through: + +- `POST /api/apis/:apiId/keys` to create a key +- `GET /api/apis/:apiId/keys` to list keys with masked values and revoked status +- `DELETE /api/keys/:id` to revoke a key + +The plaintext API key is returned only at creation time. Subsequent list responses expose only the stored prefix and a masked display value. + ## Vault repository behavior - Enforces one vault per user per network. diff --git a/src/app.ts b/src/app.ts index 82c01b9..59d6c4d 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,7 @@ import express from 'express'; import cors from 'cors'; import helmet from 'helmet'; import adminRouter from './routes/admin.js'; -import routes from './routes/index.js'; +import { createRoutes } from './routes/index.js'; import { pool } from './db.js'; import { InMemoryUsageEventsRepository, @@ -42,7 +42,6 @@ import { NotFoundError, UnauthorizedError, } from './errors/index.js'; -import { apiKeyRepository } from './repositories/apiKeyRepository.js'; interface AppDependencies { usageEventsRepository?: UsageEventsRepository; @@ -73,6 +72,10 @@ const parseDate = (value: unknown): Date | null => { export const createApp = (dependencies?: Partial) => { const app = express(); + const routes = createRoutes({ + apiRepository: dependencies?.apiRepository ?? defaultApiRepository, + developerRepository: dependencies?.developerRepository ?? defaultDeveloperRepository, + }); // Set database pool in locals for billing routes app.locals.dbPool = pool; @@ -562,25 +565,6 @@ export const createApp = (dependencies?: Partial) => { vaultController.getBalance(req, res); }); - // Revoke API key endpoint - app.delete('/api/keys/:id', requireAuth, (req, res: express.Response, next) => { - const user = res.locals.authenticatedUser; - if (!user) { - next(new UnauthorizedError()); - return; - } - - const { id } = req.params; - const result = apiKeyRepository.revoke(id, user.id); - - if (result === 'forbidden') { - next(new ForbiddenError()); - return; - } - - res.status(204).send(); - }); - /** * POST /api/developers/apis * diff --git a/src/repositories/apiKeyRepository.test.ts b/src/repositories/apiKeyRepository.test.ts index 0a81df6..2d20cab 100644 --- a/src/repositories/apiKeyRepository.test.ts +++ b/src/repositories/apiKeyRepository.test.ts @@ -25,6 +25,8 @@ describe("ApiKeyRepository Security Tests", () => { expect(storedKey.keyHash).not.toBe(result.key); expect(storedKey.keyHash).not.toContain(result.key); expect(storedKey.keyHash.length).toBeGreaterThan(50); // bcrypt hashes are long + expect(result.id).toBeTruthy(); + expect(result.createdAt).toBeInstanceOf(Date); }); it("should use different salts for different keys", () => { @@ -267,6 +269,35 @@ describe("ApiKeyRepository Security Tests", () => { }); describe("Error Handling and Edge Cases", () => { + it("lists keys for a specific user and API", () => { + apiKeyRepository.create({ + apiId: "api-1", + userId: "user-1", + scopes: ["*"], + rateLimitPerMinute: null, + }); + apiKeyRepository.create({ + apiId: "api-2", + userId: "user-1", + scopes: ["read"], + rateLimitPerMinute: 60, + }); + apiKeyRepository.create({ + apiId: "api-1", + userId: "user-2", + scopes: ["write"], + rateLimitPerMinute: null, + }); + + const userKeys = apiKeyRepository.list({ userId: "user-1" }); + const apiKeys = apiKeyRepository.list({ userId: "user-1", apiId: "api-1" }); + + expect(userKeys).toHaveLength(2); + expect(apiKeys).toHaveLength(1); + expect(apiKeys[0].apiId).toBe("api-1"); + expect(apiKeys[0].userId).toBe("user-1"); + }); + it("should handle concurrent operations safely", () => { const userId = "user-1"; const promises = Array.from({ length: 10 }, (_, i) => diff --git a/src/repositories/apiKeyRepository.ts b/src/repositories/apiKeyRepository.ts index 8cbc858..84b4c7b 100644 --- a/src/repositories/apiKeyRepository.ts +++ b/src/repositories/apiKeyRepository.ts @@ -1,4 +1,4 @@ -import { createHash, randomBytes, timingSafeEqual } from "crypto"; +import { randomBytes, timingSafeEqual } from "crypto"; import bcrypt from "bcryptjs"; import { config } from "../config/index.js"; @@ -16,6 +16,13 @@ export interface ApiKeyRecord { const apiKeys: ApiKeyRecord[] = []; +export interface ApiKeyCreateResult { + id: string; + key: string; + prefix: string; + createdAt: Date; +} + function generatePlainKey(): string { return `ck_live_${randomBytes(24).toString("hex")}`; } @@ -47,24 +54,35 @@ export const apiKeyRepository = { userId: string; scopes: string[]; rateLimitPerMinute: number | null; - }): { key: string; prefix: string } { - const p = params || {} as any; + }): ApiKeyCreateResult { + const p = params as any; const key = generatePlainKey(); const prefix = key.slice(0, 16); + const id = randomBytes(8).toString('hex'); + const createdAt = new Date(); apiKeys.push({ - id: randomBytes(8).toString('hex'), + id, apiId: p.apiId, userId: p.userId, prefix, keyHash: toHash(key), scopes: p.scopes, rateLimitPerMinute: p.rateLimitPerMinute, - createdAt: new Date(), + createdAt, revoked: false }); - return { key, prefix }; + return { id, key, prefix, createdAt }; + }, + list(params: { userId: string; apiId?: string }): ApiKeyRecord[] { + const { userId, apiId } = params; + return apiKeys + .filter((record) => + record.userId === userId && + (apiId === undefined || record.apiId === apiId) + ) + .map((record) => ({ ...record })); }, revoke(id: string, userId: string): 'success' | 'not_found' | 'forbidden' { const key = apiKeys.find(k => k.id === id); diff --git a/src/routes/apiKeyRoutes.test.ts b/src/routes/apiKeyRoutes.test.ts index b5bbe9a..443146b 100644 --- a/src/routes/apiKeyRoutes.test.ts +++ b/src/routes/apiKeyRoutes.test.ts @@ -1,186 +1,245 @@ import request from 'supertest'; import express from 'express'; +import { createApiKeyRouter } from './apiKeyRoutes.js'; import { apiKeyRepository } from '../repositories/apiKeyRepository.js'; import { errorHandler } from '../middleware/errorHandler.js'; +import { requestIdMiddleware } from '../middleware/requestId.js'; +import type { ApiRepository } from '../repositories/apiRepository.js'; +import type { DeveloperRepository } from '../repositories/developerRepository.js'; +import type { Api, Developer } from '../db/schema.js'; + +const developerProfile: Developer = { + id: 11, + user_id: 'dev-1', + name: 'Test Developer', + website: null, + description: null, + category: null, + created_at: new Date(1000), + updated_at: new Date(1000), +}; + +const ownedApi: Api = { + id: 101, + developer_id: 11, + name: 'Owned API', + description: null, + base_url: 'https://owned.example.com', + logo_url: null, + category: 'search', + status: 'active', + created_at: new Date(1000), + updated_at: new Date(1000), +}; + +const otherApi: Api = { + id: 202, + developer_id: 22, + name: 'Other API', + description: null, + base_url: 'https://other.example.com', + logo_url: null, + category: 'payments', + status: 'active', + created_at: new Date(1000), + updated_at: new Date(1000), +}; + +const createDeveloperRepository = (): DeveloperRepository => ({ + async findByUserId(userId: string) { + return userId === developerProfile.user_id ? developerProfile : undefined; + }, +}); + +const createApiRepository = (apis: Api[]): ApiRepository => ({ + async create() { + throw new Error('not implemented'); + }, + async update() { + return null; + }, + async listByDeveloper(developerId: number) { + return apis.filter((api) => api.developer_id === developerId); + }, + async listPublic() { + return apis.filter((api) => api.status === 'active'); + }, + async findById() { + return null; + }, + async getEndpoints() { + return []; + }, +}); -function createTestApp() { +function createTestApp(apis: Api[] = [ownedApi]) { const app = express(); app.use(express.json()); - - // Mock requireAuth to accept essentially any user - app.use((req, res, next) => { - const userId = req.headers['x-user-id'] as string; - if (userId) { - res.locals.authenticatedUser = { - id: userId, - email: `${userId}@example.com`, - }; - next(); - } else { - res.status(401).json({ error: 'Authentication required' }); - } - }); - - app.delete('/api/keys/:id', (req, res: express.Response) => { - const user = res.locals.authenticatedUser; - if (!user) { - res.status(401).json({ error: 'Unauthorized' }); - return; - } - - const { id } = req.params; - const result = apiKeyRepository.revoke(id, user.id); - - if (result === 'forbidden') { - res.status(403).json({ error: 'Forbidden' }); - return; - } - - res.status(204).send(); - }); - + app.use(requestIdMiddleware); + app.use( + '/api', + createApiKeyRouter({ + apiRepository: createApiRepository(apis), + developerRepository: createDeveloperRepository(), + }), + ); app.use(errorHandler); return app; } -describe('API Key Revocation Route', () => { +describe('API key lifecycle routes', () => { beforeEach(() => { - // Clear the keys before each test apiKeyRepository.clear(); }); - it('revokes an API key successfully', async () => { + it('creates an API key and returns the plaintext value exactly once', async () => { const app = createTestApp(); - // Create a key in the repository - const userId = 'user-1'; - apiKeyRepository.create({ - apiId: 'api-1', - userId: userId, - scopes: ['*'], - rateLimitPerMinute: null - }); + const response = await request(app) + .post('/api/apis/101/keys') + .set('x-user-id', 'dev-1') + .send({ + scopes: ['read'], + rateLimitPerMinute: 120, + }); + + expect(response.status).toBe(201); + expect(response.body).toEqual(expect.objectContaining({ + id: expect.any(String), + apiId: '101', + key: expect.stringMatching(/^ck_live_/), + prefix: expect.any(String), + revoked: false, + scopes: ['read'], + rateLimitPerMinute: 120, + createdAt: expect.any(String), + })); + + const stored = apiKeyRepository.list({ userId: 'dev-1', apiId: '101' }); + expect(stored).toHaveLength(1); + expect(stored[0].keyHash).not.toBe(response.body.key); + + const listResponse = await request(app) + .get('/api/apis/101/keys') + .set('x-user-id', 'dev-1'); + + expect(listResponse.status).toBe(200); + expect(listResponse.body.keys).toHaveLength(1); + expect(listResponse.body.keys[0]).toEqual(expect.objectContaining({ + id: response.body.id, + apiId: '101', + prefix: response.body.prefix, + maskedKey: `${response.body.prefix}****************`, + revoked: false, + })); + expect(listResponse.body.keys[0]).not.toHaveProperty('key'); + expect(listResponse.body.keys[0]).not.toHaveProperty('keyHash'); + }); - const keys = apiKeyRepository.listForTesting(); - const keyToRevoke = keys.find(k => k.userId === userId)!; - expect(keyToRevoke).toBeDefined(); + it('lists keys with masked values and revoked status', async () => { + const app = createTestApp(); + const created = apiKeyRepository.create({ + apiId: '101', + userId: 'dev-1', + scopes: ['read', 'write'], + rateLimitPerMinute: null, + }); - const response = await request(app) - .delete(`/api/keys/${keyToRevoke.id}`) - .set('x-user-id', userId); + const [record] = apiKeyRepository.list({ userId: 'dev-1', apiId: '101' }); + expect(record.id).toBe(created.id); - expect(response.status).toBe(204); + apiKeyRepository.revoke(record.id, 'dev-1'); - // Verify it is gone - const updatedKeys = apiKeyRepository.listForTesting(); - expect(updatedKeys.find(k => k.id === keyToRevoke.id)).toBeUndefined(); + const response = await request(app) + .get('/api/apis/101/keys') + .set('x-user-id', 'dev-1'); + + expect(response.status).toBe(200); + expect(response.body.keys).toEqual([ + expect.objectContaining({ + id: record.id, + apiId: '101', + maskedKey: `${record.prefix}****************`, + revoked: true, + scopes: ['read', 'write'], + }), + ]); }); - it('returns 204 successfully when revoking an already revoked/non-existent key', async () => { + it('revokes an API key and revoked keys are no longer verifiable', async () => { const app = createTestApp(); - const userId = 'user-1'; + const created = apiKeyRepository.create({ + apiId: '101', + userId: 'dev-1', + scopes: ['*'], + rateLimitPerMinute: null, + }); const response = await request(app) - .delete(`/api/keys/non-existent-id`) - .set('x-user-id', userId); + .delete(`/api/keys/${created.id}`) + .set('x-user-id', 'dev-1'); expect(response.status).toBe(204); + + const [record] = apiKeyRepository.list({ userId: 'dev-1', apiId: '101' }); + expect(record.revoked).toBe(true); + expect(apiKeyRepository.verify(created.key)).toBeNull(); }); - it('should verify API keys correctly', async () => { - const userId = 'user-1'; - const createResult = apiKeyRepository.create({ - apiId: 'api-1', - userId, - scopes: ['read', 'write'], - rateLimitPerMinute: 100 - }); + it('returns 403 when attempting to create or list keys for an API the developer does not own', async () => { + const app = createTestApp([ownedApi, otherApi]); - // Valid key should verify - const verifiedKey = apiKeyRepository.verify(createResult.key); - expect(verifiedKey).toBeTruthy(); - expect(verifiedKey!.userId).toBe(userId); - expect(verifiedKey!.scopes).toEqual(['read', 'write']); - expect(verifiedKey!.keyHash).toBe('[REDACTED]'); + const createResponse = await request(app) + .post('/api/apis/202/keys') + .set('x-user-id', 'dev-1') + .send({}); - // Invalid key should not verify - expect(apiKeyRepository.verify('invalid_key')).toBeNull(); - }); + expect(createResponse.status).toBe(403); + expect(createResponse.body.code).toBe('API_ACCESS_FORBIDDEN'); - it('should rotate API keys securely', async () => { - const userId = 'user-1'; - const createResult = apiKeyRepository.create({ - apiId: 'api-1', - userId, - scopes: ['read'], - rateLimitPerMinute: 50 - }); + const listResponse = await request(app) + .get('/api/apis/202/keys') + .set('x-user-id', 'dev-1'); - const keys = apiKeyRepository.listForTesting(); - const keyId = keys.find(k => k.userId === userId)!.id; - - const rotateResult = apiKeyRepository.rotate(keyId, userId); - expect(rotateResult.success).toBe(true); - - if (rotateResult.success) { - // Old key should no longer work - expect(apiKeyRepository.verify(createResult.key)).toBeNull(); - - // New key should work - expect(apiKeyRepository.verify(rotateResult.newKey)).toBeTruthy(); - expect(rotateResult.newKey).not.toBe(createResult.key); - } + expect(listResponse.status).toBe(403); + expect(listResponse.body.code).toBe('API_ACCESS_FORBIDDEN'); }); - it('should reject rotation for unauthorized users', async () => { - const userId = 'user-1'; - const otherUserId = 'user-2'; - - apiKeyRepository.create({ - apiId: 'api-1', - userId, - scopes: ['*'], - rateLimitPerMinute: null - }); + it('returns 404 when revoking a missing key', async () => { + const app = createTestApp(); - const keys = apiKeyRepository.listForTesting(); - const keyId = keys.find(k => k.userId === userId)!.id; + const response = await request(app) + .delete('/api/keys/missing-key') + .set('x-user-id', 'dev-1'); - const rotateResult = apiKeyRepository.rotate(keyId, otherUserId); - expect(rotateResult.success).toBe(false); - if (!rotateResult.success) { - expect(rotateResult.error).toBe('forbidden'); - } + expect(response.status).toBe(404); + expect(response.body.code).toBe('API_KEY_NOT_FOUND'); }); - it('returns 403 when trying to revoke a key owned by another user', async () => { + it('returns 400 validation details for invalid create payloads', async () => { const app = createTestApp(); - // Create a key for user-2 - apiKeyRepository.create({ - apiId: 'api-1', - userId: 'user-2', - scopes: ['*'], - rateLimitPerMinute: null - }); - - const keys = apiKeyRepository.listForTesting(); - const keyToRevoke = keys.find(k => k.userId === 'user-2')!; - const response = await request(app) - .delete(`/api/keys/${keyToRevoke.id}`) - .set('x-user-id', 'user-1'); // acting as user-1 - - expect(response.status).toBe(403); - - // Check it's still there - const updatedKeys = apiKeyRepository.listForTesting(); - expect(updatedKeys.find(k => k.id === keyToRevoke.id)).toBeDefined(); + .post('/api/apis/101/keys') + .set('x-user-id', 'dev-1') + .send({ + scopes: [''], + rateLimitPerMinute: 0, + }); + + expect(response.status).toBe(400); + expect(response.body.code).toBe('VALIDATION_ERROR'); + expect(response.body.details).toEqual(expect.arrayContaining([ + expect.objectContaining({ field: 'body.scopes[0]' }), + expect.objectContaining({ field: 'body.rateLimitPerMinute' }), + ])); }); - it('returns 401 if unauthenticated', async () => { + it('returns 401 when unauthenticated', async () => { const app = createTestApp(); - const response = await request(app).delete('/api/keys/some-id'); + + const response = await request(app).get('/api/apis/101/keys'); expect(response.status).toBe(401); + expect(response.body.code).toBe('UNAUTHORIZED'); }); }); diff --git a/src/routes/apiKeyRoutes.ts b/src/routes/apiKeyRoutes.ts new file mode 100644 index 0000000..52bea51 --- /dev/null +++ b/src/routes/apiKeyRoutes.ts @@ -0,0 +1,161 @@ +import { Router } from 'express'; +import { z } from 'zod'; +import { requireAuth, type AuthenticatedLocals } from '../middleware/requireAuth.js'; +import { validate } from '../middleware/validate.js'; +import { apiKeyRepository } from '../repositories/apiKeyRepository.js'; +import type { ApiRepository } from '../repositories/apiRepository.js'; +import type { DeveloperRepository } from '../repositories/developerRepository.js'; +import { + ForbiddenError, + NotFoundError, + UnauthorizedError, +} from '../errors/index.js'; + +export interface ApiKeyRoutesDeps { + apiRepository: ApiRepository; + developerRepository: DeveloperRepository; +} + +const apiIdParamsSchema = z.object({ + apiId: z.string().min(1), +}); + +const keyIdParamsSchema = z.object({ + id: z.string().min(1), +}); + +const createApiKeyBodySchema = z.object({ + scopes: z.array(z.string().min(1)).max(20).optional().default(['*']), + rateLimitPerMinute: z.number().int().positive().nullable().optional().default(null), +}); + +function maskKey(prefix: string): string { + return `${prefix}****************`; +} + +async function assertDeveloperOwnsApi( + userId: string, + apiId: string, + deps: ApiKeyRoutesDeps, +): Promise { + const developer = await deps.developerRepository.findByUserId(userId); + if (!developer) { + throw new NotFoundError('Developer profile not found', 'DEVELOPER_NOT_FOUND'); + } + + const apis = await deps.apiRepository.listByDeveloper(developer.id); + const ownsApi = apis.some((api) => String(api.id) === apiId); + if (!ownsApi) { + throw new ForbiddenError( + 'Forbidden: API does not belong to authenticated developer', + 'API_ACCESS_FORBIDDEN', + ); + } +} + +export function createApiKeyRouter(deps: ApiKeyRoutesDeps): Router { + const router = Router(); + + router.post( + '/apis/:apiId/keys', + requireAuth, + validate({ params: apiIdParamsSchema, body: createApiKeyBodySchema }), + async (req, res: import('express').Response, next) => { + try { + const user = res.locals.authenticatedUser; + if (!user) { + next(new UnauthorizedError()); + return; + } + + const { apiId } = apiIdParamsSchema.parse(req.params); + const { scopes, rateLimitPerMinute } = createApiKeyBodySchema.parse(req.body); + + await assertDeveloperOwnsApi(user.id, apiId, deps); + + const created = apiKeyRepository.create({ + apiId, + userId: user.id, + scopes, + rateLimitPerMinute, + }); + + res.status(201).json({ + id: created.id, + apiId, + key: created.key, + prefix: created.prefix, + createdAt: created.createdAt.toISOString(), + revoked: false, + scopes, + rateLimitPerMinute, + }); + } catch (error) { + next(error); + } + }, + ); + + router.get( + '/apis/:apiId/keys', + requireAuth, + validate({ params: apiIdParamsSchema }), + async (req, res: import('express').Response, next) => { + try { + const user = res.locals.authenticatedUser; + if (!user) { + next(new UnauthorizedError()); + return; + } + + const { apiId } = apiIdParamsSchema.parse(req.params); + await assertDeveloperOwnsApi(user.id, apiId, deps); + + const keys = apiKeyRepository.list({ userId: user.id, apiId }).map((record) => ({ + id: record.id, + apiId: record.apiId, + prefix: record.prefix, + maskedKey: maskKey(record.prefix), + scopes: record.scopes, + rateLimitPerMinute: record.rateLimitPerMinute, + createdAt: record.createdAt.toISOString(), + revoked: record.revoked, + })); + + res.json({ keys }); + } catch (error) { + next(error); + } + }, + ); + + router.delete( + '/keys/:id', + requireAuth, + validate({ params: keyIdParamsSchema }), + (req, res: import('express').Response, next) => { + const user = res.locals.authenticatedUser; + if (!user) { + next(new UnauthorizedError()); + return; + } + + const { id } = keyIdParamsSchema.parse(req.params); + const result = apiKeyRepository.revoke(id, user.id); + + if (result === 'not_found') { + next(new NotFoundError('API key not found', 'API_KEY_NOT_FOUND')); + return; + } + + if (result === 'forbidden') { + next(new ForbiddenError('Forbidden: API key does not belong to authenticated developer', 'API_KEY_FORBIDDEN')); + return; + } + + res.status(204).send(); + }, + ); + + return router; +} diff --git a/src/routes/index.ts b/src/routes/index.ts index 93dec62..03441f2 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -3,12 +3,25 @@ import healthRouter from './health.js'; import apisRouter from './apis.js'; import usageRouter from './usage.js'; import billingRouter from './billing.js'; +import { createApiKeyRouter, type ApiKeyRoutesDeps } from './apiKeyRoutes.js'; +import { defaultApiRepository } from '../repositories/apiRepository.js'; +import { defaultDeveloperRepository } from '../repositories/developerRepository.js'; -const router = Router(); +export function createRoutes(deps: Partial = {}): Router { + const router = Router(); -router.use('/health', healthRouter); -router.use('/apis', apisRouter); -router.use('/usage', usageRouter); -router.use('/billing', billingRouter); + router.use('/health', healthRouter); + router.use( + createApiKeyRouter({ + apiRepository: deps.apiRepository ?? defaultApiRepository, + developerRepository: deps.developerRepository ?? defaultDeveloperRepository, + }), + ); + router.use('/apis', apisRouter); + router.use('/usage', usageRouter); + router.use('/billing', billingRouter); -export default router; + return router; +} + +export default createRoutes();