Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/middleware/gatewayApiKeyAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ export interface DatabaseGatewayApiKeyRow {

const SHA256_HEX_LENGTH = 64;

function sha256Hex(value: string): string {
export function sha256Hex(value: string): string {
return createHash('sha256').update(value).digest('hex');
}

Expand Down Expand Up @@ -218,7 +218,7 @@ export function createGatewayApiKeyAuthMiddleware<
if (matchedCandidate.apiKeyRecord.revoked) {
// The key exists but was explicitly revoked by the developer
recordApiKeyLookup('revoked');
handleForbidden(next, 'Unauthorized: API key has been revoked');
handleUnauthorized(next, 'Unauthorized: API key has been revoked');
return;
}

Expand Down
19 changes: 8 additions & 11 deletions src/routes/apiKeyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -32,7 +29,7 @@ const createApiKeyBodySchema = z.object({
});

function maskKey(prefix: string): string {
return `${prefix}****************`;
return `${prefix}*****************`;
}

async function assertDeveloperOwnsApi(
Expand Down Expand Up @@ -72,7 +69,7 @@ export function createApiKeyRouter(deps: ApiKeyRoutesDeps): Router {
requireAuth,
validate({ params: apiIdParamsSchema, body: createApiKeyBodySchema }),
keyIdempotency,
async (req, res: import('express').Response<unknown, AuthenticatedLocals>, next) => {
async (req, res: import('express').Response<unknown, AuthenticatedLocals>, next) => {
try {
const user = res.locals.authenticatedUser;
if (!user) {
Expand Down Expand Up @@ -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') {
Expand Down
1 change: 1 addition & 0 deletions src/routes/gatewayRoutes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
24 changes: 8 additions & 16 deletions src/services/tokenRevocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
}
}
Expand Down Expand Up @@ -115,4 +107,4 @@ export function resetTokenRevocationService(): void {
revocationService.stopSweeper();
}
revocationService = null;
}
}
120 changes: 120 additions & 0 deletions tests/integration/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

/**
Expand All @@ -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?.();
}
});

Expand Down Expand Up @@ -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);
});
});
});