diff --git a/src/routes/gatewayRoutes.ts b/src/routes/gatewayRoutes.ts index c9533060..9c332a84 100644 --- a/src/routes/gatewayRoutes.ts +++ b/src/routes/gatewayRoutes.ts @@ -1,6 +1,7 @@ import { randomUUID, timingSafeEqual, createHash } from 'node:crypto'; import express, { Router, type Request, type Response, type NextFunction } from 'express'; import { z } from 'zod'; +import { getTokenRevocationService } from '../services/tokenRevocation.js'; import { startUpstreamTimer, getUpstreamHealth, type UpstreamOutcome } from '../metrics.js'; import { validate } from '../middleware/validate.js'; import { createConfiguredGatewayRateLimitMiddleware } from '../middleware/gatewayRateLimit.js'; diff --git a/tests/integration/keys.test.ts b/tests/integration/keys.test.ts index 01cc806d..009e504b 100644 --- a/tests/integration/keys.test.ts +++ b/tests/integration/keys.test.ts @@ -25,11 +25,24 @@ import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs'; import jwt from 'jsonwebtoken'; +import express from 'express'; +import type { Server } from 'node:http'; // Import the app factory import { createApp } from '../../src/app.js'; import { defaultApiRepository } from '../../src/repositories/apiRepository.js'; import { defaultDeveloperRepository } from '../../src/repositories/developerRepository.js'; +import { apiKeyRepository } from '../../src/repositories/apiKeyRepository.js'; +import { getTokenRevocationService } from '../../src/services/tokenRevocation.js'; +import { createGatewayRouter } from '../../src/routes/gatewayRoutes.js'; +import { createApiKeyRouter } from '../../src/routes/apiKeyRoutes.js'; +import { MockSorobanBilling } from '../../src/services/billingService.js'; +import { InMemoryRateLimiter } from '../../src/services/rateLimiter.js'; +import { InMemoryUsageStore } from '../../src/services/usageStore.js'; +import { requestIdMiddleware } from '../../src/middleware/requestId.js'; +import { errorHandler } from '../../src/middleware/errorHandler.js'; +import type { ApiKey } from '../../src/types/gateway.js'; +import crypto from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -68,6 +81,13 @@ function generateTestApiKey(): string { return `ck_live_${randomPart}`; } +/** + * Helper: Compute sha256 hash of an API key (matches gateway/revocation keying) + */ +function sha256Hex(value: string): string { + return crypto.createHash('sha256').update(value).digest('hex'); +} + /** * Helper: Sign a JWT token with test secret */ @@ -159,6 +179,7 @@ describe('API Keys Integration Tests (End-to-End with Real PostgreSQL)', () => { let testUser: TestUser; let otherUser: TestUser; let testApiId: number; + let upstreamRequests: Array<{ url: string; method: string; at: number }>; /** * Setup: Start PostgreSQL container, run migrations, seed test data @@ -233,6 +254,9 @@ describe('API Keys Integration Tests (End-to-End with Real PostgreSQL)', () => { // Create test APIs testApiId = await createTestApi(testContext.pool, testUser.developerId!, 'My API'); await createTestApi(testContext.pool, otherUser.developerId!, "Other's API"); + + // Reset upstream request recorder + upstreamRequests = []; }, 60000); // Allow 60s for container startup /** @@ -256,6 +280,9 @@ describe('API Keys Integration Tests (End-to-End with Real PostgreSQL)', () => { )`, [testUser.developerId] ); + // Clear revocation entries so tests remain independent + const revocationService = getTokenRevocationService(); + await revocationService.clear?.(); } }); @@ -797,4 +824,210 @@ describe('API Keys Integration Tests (End-to-End with Real PostgreSQL)', () => { expect(requestId1).not.toBe(requestId2); }); }); + + // ======================================================================== + // Test: Immediate Revocation Enforced at Gateway + // + // The gateway proxy handler lives in `createGatewayRouter` and is mounted at + // `/api/gateway` (see `src/index.ts`: `app.use("/api/gateway", ...)`), and it + // authenticates callers with the `x-api-key` header. `createApp()` only mounts + // the public health router at that prefix, so these cases mount the real + // router on a dedicated app — the same convention used by + // `tests/integration/proxy.test.ts` — and point it at an in-process upstream + // stub. `DELETE /api/keys/:id` is served by the real `createApiKeyRouter`, so + // the revocation entry really is written by the production route before the + // second gateway call is made. + // ======================================================================== + + describe('Gateway: Immediate revocation of API keys', () => { + let gatewayApp: express.Express; + let upstreamServer: Server | undefined; + let upstreamUrl: string; + let gatewayApiKeys: Map; + let billing: MockSorobanBilling; + let rateLimiter: InMemoryRateLimiter; + let usageStore: InMemoryUsageStore; + + // Isolate the gateway from the configured per-user token bucket; rate + // limiting is covered by its own suite and is not what these cases assert. + const rateLimitPassthrough = (_req: any, _res: any, next: any) => next(); + + beforeAll(async () => { + // In-process upstream stub that records every proxied request. + await new Promise((resolve) => { + const upstream = express(); + upstream.use(express.json()); + upstream.all('*', (req, res) => { + upstreamRequests.push({ url: req.originalUrl, method: req.method, at: Date.now() }); + res.status(200).json({ ok: true, path: req.originalUrl }); + }); + upstreamServer = upstream.listen(0, '127.0.0.1', () => { + const addr = upstreamServer?.address(); + if (addr && typeof addr === 'object') { + upstreamUrl = `http://127.0.0.1:${addr.port}`; + } + resolve(); + }); + }); + + gatewayApiKeys = new Map(); + billing = new MockSorobanBilling({ [testUser.userId]: 1000 }); + rateLimiter = new InMemoryRateLimiter(100, 60_000); + usageStore = new InMemoryUsageStore(); + + gatewayApp = express(); + gatewayApp.use(express.json()); + gatewayApp.use(requestIdMiddleware); + + // Real API-key management router at its production prefix (`/api`), so + // `DELETE /api/keys/:id` writes the sha256 hash into the shared + // token-revocation singleton that the gateway handler reads. + gatewayApp.use( + '/api', + createApiKeyRouter({ + apiRepository: defaultApiRepository, + developerRepository: defaultDeveloperRepository, + }), + ); + + // Real gateway proxy handler at its production mount prefix. + gatewayApp.use( + '/api/gateway', + createGatewayRouter({ + billing, + rateLimiter, + usageStore, + upstreamUrl, + apiKeys: gatewayApiKeys, + gatewayRateLimitMiddleware: rateLimitPassthrough, + }), + ); + + gatewayApp.use(errorHandler); + }, 30000); + + afterAll(async () => { + if (upstreamServer) { + await new Promise((resolve) => upstreamServer!.close(() => resolve())); + } + }); + + beforeEach(() => { + gatewayApiKeys.clear(); + upstreamRequests = []; + billing.setBalance(testUser.userId, 1000); + rateLimiter.reset(); + usageStore.clear(); + }); + + afterEach(() => { + apiKeyRepository.clear(); + }); + + it('should reject a revoked key at the gateway with 403 and never call upstream', async () => { + const token = signTestToken(testUser.userId, testUser.walletAddress); + + // 1. Register a key the gateway handler recognises (in-memory repository, + // the same store createApiKeyRouter writes to). + const created = await apiKeyRepository.create({ + apiId: 'echo', + userId: testUser.userId, + scopes: ['read'], + rateLimitPerMinute: null, + }); + const rawKey = created.key; + gatewayApiKeys.set(rawKey, { + key: rawKey, + developerId: testUser.userId, + apiId: 'echo', + }); + expect(rawKey).toMatch(/^ck_live_/); + + // 2. Call the gateway successfully before revocation (real proxy handler). + const beforeRevocation = await request(gatewayApp) + .get('/api/gateway/echo') + .set('x-api-key', rawKey); + + // Gateway should have reached the upstream stub (2xx) before revocation + expect(beforeRevocation.status).toBe(200); + expect(upstreamRequests.length).toBeGreaterThan(0); + const requestsBeforeRevocation = upstreamRequests.length; + + // 3. Revoke the key via the real DELETE /api/keys/:id route + const revoke = await request(gatewayApp) + .delete(`/api/keys/${created.id}`) + .set('Authorization', `Bearer ${token}`); + + expect(revoke.status).toBe(204); + + // 4. Confirm the revocation service entry is keyed by sha256 hash, not plaintext + const revocationService = getTokenRevocationService(); + const expectedHash = sha256Hex(rawKey); + const isRevokedByHash = await revocationService.isRevoked(expectedHash); + expect(isRevokedByHash).toBe(true); + + // The plaintext key must NOT be the revocation key + const isRevokedByPlaintext = await revocationService.isRevoked(rawKey); + expect(isRevokedByPlaintext).toBe(false); + + // 5. Call the gateway again with the revoked key + const afterRevocation = await request(gatewayApp) + .get('/api/gateway/echo') + .set('x-api-key', rawKey); + + // The gateway handler fails closed with 403 Forbidden for a revoked key. + expect(afterRevocation.status).toBe(403); + expect(afterRevocation.body).toHaveProperty('error'); + + // 6. Upstream stub must NOT have recorded any new request after revocation + expect(upstreamRequests.length).toBe(requestsBeforeRevocation); + }); + + it('should not revoke other keys when one key is deleted', async () => { + const token = signTestToken(testUser.userId, testUser.walletAddress); + + // Register two keys the gateway handler recognises. + const createdA = await apiKeyRepository.create({ + apiId: 'echo', + userId: testUser.userId, + scopes: ['read'], + rateLimitPerMinute: null, + }); + const createdB = await apiKeyRepository.create({ + apiId: 'echo', + userId: testUser.userId, + scopes: ['read'], + rateLimitPerMinute: null, + }); + gatewayApiKeys.set(createdA.key, { + key: createdA.key, + developerId: testUser.userId, + apiId: 'echo', + }); + gatewayApiKeys.set(createdB.key, { + key: createdB.key, + developerId: testUser.userId, + apiId: 'echo', + }); + + // Revoke only key A through the real route. + const revoke = await request(gatewayApp) + .delete(`/api/keys/${createdA.id}`) + .set('Authorization', `Bearer ${token}`); + expect(revoke.status).toBe(204); + + // Key A must be rejected by the gateway handler. + const callA = await request(gatewayApp) + .get('/api/gateway/echo') + .set('x-api-key', createdA.key); + expect(callA.status).toBe(403); + + // Key B must still succeed. + const callB = await request(gatewayApp) + .get('/api/gateway/echo') + .set('x-api-key', createdB.key); + expect(callB.status).toBeGreaterThanOrEqual(200); + expect(callB.status).toBeLessThan(300); + }); + }); });