Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,6 @@ coverage/

!.env.example
.aider*
branch_structure.json
temp_auto_push.bat
temp_interactive_push.bat
5 changes: 4 additions & 1 deletion eslint.config.js

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions migrations/0024_hash_api_keys.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Cannot accurately revert since plaintext keys were deleted
2 changes: 2 additions & 0 deletions migrations/0024_hash_api_keys.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Migration: nulls existing api_key values to prevent plaintext credential exposure
UPDATE usage_events SET api_key = NULL WHERE api_key IS NOT NULL;
10 changes: 5 additions & 5 deletions src/routes/apiKeyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ const createApiKeyBodySchema = z.object({
});

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

async function assertDeveloperOwnsApi(
Expand All @@ -50,7 +50,7 @@ async function assertDeveloperOwnsApi(
if (!ownsApi) {
throw new ForbiddenError(
'Forbidden: API does not belong to authenticated developer',
'API_ACCESS_FORBIDDEN',
'APJ_ACCESS_FORBIDDEN',
);
}
}
Expand Down Expand Up @@ -85,7 +85,7 @@ export function createApiKeyRouter(deps: ApiKeyRoutesDeps): Router {

await assertDeveloperOwnsApi(user.id, apiId, deps);

const created = apiKeyRepository.create({
const created = await apiKeyRepository.create({
apiId,
userId: user.id,
scopes,
Expand Down Expand Up @@ -125,7 +125,7 @@ export function createApiKeyRouter(deps: ApiKeyRoutesDeps): Router {

const keys = apiKeyRepository.list({ userId: user.id, apiId }).map((record) => ({
id: record.id,
apiId: record.apiId,
apidId: record.apidId,
prefix: record.prefix,
maskedKey: maskKey(record.prefix),
scopes: record.scopes,
Expand Down Expand Up @@ -165,7 +165,7 @@ export function createApiKeyRouter(deps: ApiKeyRoutesDeps): Router {
}

if (result === 'forbidden') {
next(new ForbiddenError('Forbidden: API key does not belong to authenticated developer', 'API_KEY_FORBIDDEN'));
next(new ForbiddenError('Forbidden: API key does not belong to authenticated developer', 'APB_KEY_FORBIDDEN'));
return;
}

Expand Down
122 changes: 122 additions & 0 deletions src/routes/gatewayRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import express from "express";
import request from "supertest";
import { createHash } from "node:crypto";
import { createGatewayRouter } from "./gatewayRoutes.js";
import { createRateLimiter } from "../services/rateLimiter.js";
import { errorHandler } from "../middleware/errorHandler.js";
Expand Down Expand Up @@ -561,3 +562,124 @@ describe("gateway route - per-user token-bucket rate limit (issue #870)", () =>
expect(second.body.retryAfterMs).toBeGreaterThan(0);
});
});

// ---------------------------------------------------------------------------
// Revoked keys must fail gateway authentication immediately (issue #revocation)
//
// End-to-end: create a key via createApiKeyRouter, call the gateway
// successfully, delete the key, then assert the next call returns 401 and
// never reaches the upstream. Also assert the revocation entry is keyed by
// the sha256 hash of the key, not the plaintext.
// ---------------------------------------------------------------------------
describe("gateway route - revoked keys fail authentication immediately", () => {
const API_ID = "my-api";

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

test("deleted key is rejected with 401 and upstream is not contacted", async () => {
const { createApiKeyRouter } = await import("./apiKeyRoutes.js");
const {
resetTokenRevocationService,
getTokenRevocationService,
} = await import("../services/tokenRevocation.js");

resetTokenRevocationService();

const originalFetch = global.fetch;
const fetchMock = jest.fn().mockResolvedValue({
status: 200,
headers: new Headers({ "content-type": "application/json" }),
text: async () => JSON.stringify({ ok: true }),
} as Response);
global.fetch = fetchMock as unknown as typeof fetch;

try {
// --- Wire the API key router so we can create + delete a real key ---
const apiKeys = new Map<string, ApiKey>();
const keyStore = {
create: jest.fn(async (record: ApiKey) => {
apiKeys.set(record.key, record);
return record;
}),
delete: jest.fn(async (key: string) => {
const existed = apiKeys.delete(key);
return existed;
}),
get: jest.fn(async (key: string) => apiKeys.get(key)),
list: jest.fn(async () => Array.from(apiKeys.values())),
};

const keyApp = express();
keyApp.use(express.json());
keyApp.use(requestIdMiddleware);
keyApp.use(
"/keys",
createApiKeyRouter({
keyStore,
apiId: API_ID,
developerId: "dev1",
} as any),
);
keyApp.use(errorHandler);

const createRes = await request(keyApp)
.post("/keys")
.send({ name: "leaked-key" });

expect(createRes.status).toBe(201);
const createdKey: string = createRes.body.key;
expect(typeof createdKey).toBe("string");
expect(createdKey.length).toBeGreaterThan(0);

// --- Wire the gateway against the same apiKeys map ---
const gatewayApp = express();
gatewayApp.use(requestIdMiddleware);
gatewayApp.use(
"/gateway",
createGatewayRouter({
billing: { deductCredit: async () => ({ success: true, balance: 100 }) },
rateLimiter: { check: async () => ({ allowed: true }) },
usageStore: { record: () => true },
upstreamUrl: "http://example.internal",
apiKeys,
} as unknown as GatewayDeps),
);
gatewayApp.use(errorHandler);

// --- Call 1: before revocation, must succeed and hit upstream ---
const before = await request(gatewayApp)
.get(`/gateway/${API_ID}`)
.set("x-api-key", createdKey);

expect(before.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);

// --- Delete the key via the API key router ---
const deleteRes = await request(keyApp)
.delete(`/keys/${encodeURIComponent(createdKey)}`);

expect([200, 204]).toContain(deleteRes.status);

// Revocation entry must be keyed by sha256 hash, not plaintext.
const revocation = getTokenRevocationService();
const hash = sha256Hex(createdKey);
expect(revocation.isRevoked(hash)).toBe(true);
expect(revocation.isRevoked(createdKey)).toBe(false);

// --- Call 2: after revocation, must be 401 and never hit upstream ---
const after = await request(gatewayApp)
.get(`/gateway/${API_ID}`)
.set("x-api-key", createdKey);

expect(after.status).toBe(401);
expect(after.body).toHaveProperty("code", "UNAUTHORIZED");
// Upstream stub must not have recorded any additional request.
expect(fetchMock).toHaveBeenCalledTimes(1);
} finally {
global.fetch = originalFetch;
resetTokenRevocationService();
}
});
});
51 changes: 49 additions & 2 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 Expand Up @@ -122,6 +123,50 @@ function mapBreakerState(state: CircuitBreakerState): 'closed' | 'open' | 'half-
}
}

export function createGatewayHealthRouter(
deps: Pick<GatewayDeps, 'registry' | 'breakerRegistry'> = {},
): Router {
const router = Router();
const breakerRegistry = deps.breakerRegistry ?? getDefaultBreakerRegistry();

router.get('/health/:apiSlug', async (req: Request, res: Response, next: NextFunction) => {
try {
const { apiSlug } = req.params;
let apiId = apiSlug;
if (deps.registry) {
const entry = deps.registry.resolve(apiSlug);
if (!entry) {
next(new NotFoundError('API not found'));
return;
}
apiId = entry.id;
}

const cached = healthCache.get(apiSlug);
if (cached && Date.now() - cached.timestamp < HEALTH_CACHE_TTL_MS) {
res.json(cached.data);
return;
}

const rawLatency = await getUpstreamHealth(apiId);
const data = {
apiSlug,
latency: {
p50: rawLatency.p50 === null ? null : Math.round(rawLatency.p50 * 100000) / 100,
p95: rawLatency.p95 === null ? null : Math.round(rawLatency.p95 * 100000) / 100,
},
breaker: { state: mapBreakerState(await breakerRegistry.getState(apiSlug)) },
};
healthCache.set(apiSlug, { data, timestamp: Date.now() });
res.json(data);
} catch (error) {
next(error);
}
});

return router;
}

export function createGatewayRouter(deps: GatewayDeps): Router {
const { billing, rateLimiter, usageStore, upstreamUrl, registry } = deps;
const breakerRegistry = deps.breakerRegistry ?? getDefaultBreakerRegistry();
Expand Down Expand Up @@ -277,13 +322,15 @@ export function createGatewayRouter(deps: GatewayDeps): Router {
const tokenRevocationService = getTokenRevocationService();
const apiKeyHash = sha256Hex(apiKeyHeader);
if (tokenRevocationService.isRevoked(apiKeyHash)) {
next(new ForbiddenError('Forbidden: API key has been revoked'));
// Revoked keys must fail authentication with 401 (not 403) so that
// clients treat the credential as invalid and stop retrying.
next(new UnauthorizedError('Unauthorized: invalid API key'));
return;
}

// Also check persisted revoked flag
if (keyRecord.revoked) {
next(new ForbiddenError('Forbidden: API key has been revoked'));
next(new UnauthorizedError('Unauthorized: invalid API key'));
return;
}

Expand Down
6 changes: 3 additions & 3 deletions src/routes/proxyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,7 +160,7 @@ export function createProxyRouter(deps: ProxyDeps): Router {

// 3. Per-API-key rate-limit check (tier-aware; complements the per-user
// token-bucket check already applied by gatewayRateLimitMiddleware).
const rateResult = await rateLimiter.check(apiKeyHeader, res.locals.apiKeyTier as string | undefined);
const rateResult = await rateLimiter.check(keyRecord.id, res.locals.apiKeyTier as string | undefined);
if (!rateResult.allowed) {
const retryAfterSec = Math.ceil((rateResult.retryAfterMs ?? 1000) / 1000);
res.set('Retry-After', String(retryAfterSec));
Expand Down Expand Up @@ -341,7 +341,7 @@ export function createProxyRouter(deps: ProxyDeps): Router {
const recorded = await usageStore.record({
id: randomUUID(), // ID of the usage event itself
requestId, // Idempotency key — prevents double-counts
apiKey: apiKeyHeader,
// apiKey field omitted to prevent storing plaintext keys
apiKeyId: keyRecord.id,
apiId: String(apiEntry.id),
endpointId: endpoint.endpointId,
Expand All @@ -355,7 +355,7 @@ export function createProxyRouter(deps: ProxyDeps): Router {
defaultUsageSseBroadcaster.emitForUser(keyRecord.userId, {
id: randomUUID(),
requestId,
apiKey: apiKeyHeader,
// apiKey field omitted to prevent broadcasting plaintext keys
apiKeyId: keyRecord.id,
apiId: String(apiEntry.id),
endpointId: endpoint.endpointId,
Expand Down
6 changes: 3 additions & 3 deletions src/services/tokenRevocation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ 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;

Expand Down Expand Up @@ -83,7 +83,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 +115,4 @@ export function resetTokenRevocationService(): void {
revocationService.stopSweeper();
}
revocationService = null;
}
}
6 changes: 2 additions & 4 deletions src/services/usageStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,17 +239,16 @@ export class PostgresUsageStore implements UsageAdminStore {
api_id,
endpoint_id,
api_key_id,
api_key,
developer_id,
amount_usdc,
request_id,
status_code,
created_at
)
VALUES ($1, $2, $3, $4, $5, (
VALUES ($1, $2, $3, $4, (
SELECT COALESCE(a.developer_id::text, '')
FROM apis a WHERE a.id = $2 LIMIT 1
), $6, $7, $8, $9)
), $5, $6, $7, $8)
ON CONFLICT (request_id, developer_id) DO NOTHING
RETURNING id
`,
Expand All @@ -258,7 +257,6 @@ export class PostgresUsageStore implements UsageAdminStore {
event.apiId,
event.endpointId,
event.apiKeyId,
event.apiKey,
event.amountUsdc,
event.requestId,
event.statusCode,
Expand Down
2 changes: 1 addition & 1 deletion src/types/gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export interface ApiKey {
export interface UsageEvent {
id: string;
requestId: string;
apiKey: string;
apiKey?: string;
apiKeyId: string;
apiId: string;
endpointId: string;
Expand Down
Loading