diff --git a/src/middleware/gatewayApiKeyAuth.ts b/src/middleware/gatewayApiKeyAuth.ts index 674d8db2..9a3fc7f0 100644 --- a/src/middleware/gatewayApiKeyAuth.ts +++ b/src/middleware/gatewayApiKeyAuth.ts @@ -93,7 +93,7 @@ const SHA256_HEX_LENGTH = 64; const VERIFICATION_CACHE_MAX_ENTRIES = 1000; -function sha256Hex(value: string): string { +export function sha256Hex(value: string): string { return createHash('sha256').update(value).digest('hex'); } @@ -276,7 +276,7 @@ export function createGatewayApiKeyAuthMiddleware< // The key exists but was explicitly revoked by the developer evictCachedVerification(extracted.apiKey); recordApiKeyLookup('revoked'); - handleForbidden(next, 'Unauthorized: API key has been revoked'); + handleUnauthorized(next, 'Unauthorized: API key has been revoked'); return; } diff --git a/src/routes/apiKeyRoutes.ts b/src/routes/apiKeyRoutes.ts index 3cf75ca3..bd41d979 100644 --- a/src/routes/apiKeyRoutes.ts +++ b/src/routes/apiKeyRoutes.ts @@ -2,12 +2,9 @@ import { Router, type RequestHandler } from 'express'; import { z } from 'zod'; import { requireAuth, type AuthenticatedLocals } from '../middleware/requireAuth.js'; import { validate } from '../middleware/validate.js'; -import { idempotencyMiddleware } from '../middleware/idempotency.js'; -import { apiKeyRepository } from '../repositories/apiKeyRepository.js'; -import { getTokenRevocationService } from '../services/tokenRevocation.js'; -import type { ApiRepository } from '../repositories/apiRepository.js'; -import type { DeveloperRepository } from '../repositories/developerRepository.js'; -import { +import { idempotencyMiddleware } from '../middleware/idempotency.js';import { apiKeyRepository } from '../repositories/apiKeyRepository.js'; +import { getTokenRevocationService } from '../services/tokenRevocation.js';import type { ApiRepository } from '../repositories/apiRepository.js'; +import type { DeveloperRepository } from '../repositories/developerRepository.js';import { ForbiddenError, NotFoundError, UnauthorizedError, @@ -32,7 +29,7 @@ const createApiKeyBodySchema = z.object({ }); function maskKey(prefix: string): string { - return `${prefix}****************`; + return `${prefix}*****************`; } async function assertDeveloperOwnsApi( @@ -72,7 +69,7 @@ export function createApiKeyRouter(deps: ApiKeyRoutesDeps): Router { requireAuth, validate({ params: apiIdParamsSchema, body: createApiKeyBodySchema }), keyIdempotency, - async (req, res: import('express').Response, next) => { + async (req, res: import('express').Response, next) => { try { const user = res.locals.authenticatedUser; if (!user) { @@ -153,10 +150,10 @@ export function createApiKeyRouter(deps: ApiKeyRoutesDeps): Router { } const { id } = keyIdParamsSchema.parse(req.params); - - // Get the SHA-256 hash BEFORE revoking (while key still exists) + + // Get the SHA-256 hash BEFORE(revoking (while key still exists) const sha256Hash = apiKeyRepository.getSha256Hash(id); - + const result = apiKeyRepository.revoke(id, user.id); if (result === 'not_found') { 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/src/services/tokenRevocation.ts b/src/services/tokenRevocation.ts index 2c4b604a..09991d72 100644 --- a/src/services/tokenRevocation.ts +++ b/src/services/tokenRevocation.ts @@ -16,22 +16,14 @@ export class TokenRevocationService { this.startSweeper(); } - revoke(tokenHash: string, expiresAt?: number): void { + revoke(tokenZero: string, expiresAt?: number): void { const now = Date.now(); const effectiveExpiresAt = expiresAt && expiresAt > 0 ? expiresAt : now + this.defaultTtlMs; - this.revokedTokens.set(tokenHash, { - revokedAt: now, - expiresAt: effectiveExpiresAt, - }); - - logger.info('[TokenRevocation] Token revoked', { - tokenHash, - expiresAt: effectiveExpiresAt, - }); + this.revokedTokens.set(tokenHash, expiresAt); } - isRevoked(tokenHash: string): boolean { + isRevoked(tokenZero: string): boolean { const entry = this.revokedTokens.get(tokenHash); if (!entry) { return false; @@ -45,12 +37,12 @@ export class TokenRevocationService { return true; } - reinstate(tokenHash: string): void { - this.revokedTokens.delete(tokenHash); + reinstate(tokenZero: string): void { + this.revokedTokens.delete(tokenZero); logger.info('[TokenRevocation] Token reinstated', { tokenHash }); } - revokeAll(developerId: string, tokenHashes: string[]): number { + revokeAll(developerId: string, tokenZeros: string[]): number { let revokedCount = 0; for (const tokenHash of tokenHashes) { this.revoke(tokenHash); @@ -83,7 +75,7 @@ export class TokenRevocationService { const now = Date.now(); for (const [tokenHash, entry] of this.revokedTokens) { if (entry.expiresAt < now) { - this.revokedTokens.delete(tokenHash); + this.revokedTokens.delete(tokenZero); } } } @@ -115,4 +107,4 @@ export function resetTokenRevocationService(): void { revocationService.stopSweeper(); } revocationService = null; -} \ No newline at end of file +} diff --git a/tests/integration/keys.test.ts b/tests/integration/keys.test.ts index 01cc806d..3c5aff0c 100644 --- a/tests/integration/keys.test.ts +++ b/tests/integration/keys.test.ts @@ -30,6 +30,8 @@ import jwt from 'jsonwebtoken'; import { createApp } from '../../src/app.js'; import { defaultApiRepository } from '../../src/repositories/apiRepository.js'; import { defaultDeveloperRepository } from '../../src/repositories/developerRepository.js'; +import { getTokenRevocationService } from '../../src/services/tokenRevocation.js'; +import crypto from 'crypto'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -68,6 +70,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 +168,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 +243,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 +269,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 +813,108 @@ describe('API Keys Integration Tests (End-to-End with Real PostgreSQL)', () => { expect(requestId1).not.toBe(requestId2); }); }); + + // ======================================================================== + // Test: Immediate Revocation Enforced at Gateway + // ======================================================================== + + describe('Gateway: Immediate revocation of API keys', () => { + it('should reject a revoked key at the gateway with 401 and never call upstream', async () => { + const token = signTestToken(testUser.userId, testUser.walletAddress); + + // 1. Create a key via the API key router + const create = await request(app) + .post(`/apis/${testApiId}/keys`) + .set('Authorization', `Bearer ${token}`) + .send({ scopes: ['read'] }); + + expect(create.status).toBe(201); + const keyId = create.body.id; + const rawKey = create.body.key as string; + expect(rawKey).toMatch(/^ck_live_/); + + // 2. Call the gateway successfully before revocation + upstreamRequests = []; + const beforeRevocation = await request(app) + .get('/gateway/echo') + .set('Authorization', `Bearer ${rawKey}`) + .set('X-Upstream-Recorder', 'test'); + + // Gateway should have reached the upstream stub (2xx) before revocation + expect(beforeRevocation.status).toBeGreaterThanOrEqual(200); + expect(beforeRevocation.status).toBeLessThan(300); + expect(upstreamRequests.length).toBeGreaterThan(0); + const requestsBeforeRevocation = upstreamRequests.length; + + // 3. Revoke the key via DELETE /keys/:id + const revoke = await request(app) + .delete(`/keys/${keyId}`) + .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(app) + .get('/gateway/echo') + .set('Authorization', `Bearer ${rawKey}`) + .set('X-Upstream-Recorder', 'test'); + + // Must be rejected with 401 + expect(afterRevocation.status).toBe(401); + 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); + + // Create two keys + const createA = await request(app) + .post(`/apis/${testApiId}/keys`) + .set('Authorization', `Bearer ${token}`) + .send({ scopes: ['read'] }); + const createB = await request(app) + .post(`/apis/${testApiId}/keys`) + .set('Authorization', `Bearer ${token}`) + .send({ scopes: ['read'] }); + + expect(createA.status).toBe(201); + expect(createB.status).toBe(201); + + const keyA = createA.body.key as string; + const keyB = createB.body.key as string; + const keyAId = createA.body.id; + + // Revoke only key A + const revoke = await request(app) + .delete(`/keys/${keyAId}`) + .set('Authorization', `Bearer ${token}`); + expect(revoke.status).toBe(204); + + // Key A must be rejected + const callA = await request(app) + .get('/gateway/echo') + .set('Authorization', `Bearer ${keyA}`); + expect(callA.status).toBe(401); + + // Key B must still succeed + const callB = await request(app) + .get('/gateway/echo') + .set('Authorization', `Bearer ${keyB}`); + expect(callB.status).toBeGreaterThanOrEqual(200); + expect(callB.status).toBeLessThan(300); + }); + }); });