Skip to content
Merged
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
82 changes: 82 additions & 0 deletions docs/token-revocation-list.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Per-Developer API Token Revocation List (#509)

## Overview
Implements an in-memory revocation list with TTL support for immediate API token invalidation without database queries. This addresses the need for immediate token revocation in the GrantFox campaign.

## Problem Statement
When an API key is revoked via DELETE `/api/keys/:id`, the key is marked as revoked in the repository. However, subsequent gateway requests with that key would still fail the prefix/hash lookup before checking the revoked flag. For immediate invalidation, we need an in-memory check that can be performed before authentication to ensure revoked tokens are rejected instantly.

## Solution
Created `TokenRevocationService` that:
- Stores SHA-256 hashes of revoked tokens (not raw tokens) for security
- Supports configurable TTL (default 1 hour) for automatic cleanup
- Runs a sweeper process to remove expired entries
- Integrates with the gateway to check revoked status before API key verification
- Provides singleton pattern for consistent service access across the application

## Files Changed

### New Files
- `src/services/tokenRevocation.ts` - Core service implementation (118 lines)
- `src/services/tokenRevocation.test.ts` - Unit tests (13 tests, 100% coverage)

### Modified Files
- `src/repositories/apiKeyRepository.ts`
- Added `sha256Hash` field to `ApiKeyRecord` interface
- Added `getSha256Hash(id)` method to retrieve hash for revocation list
- SHA-256 hash computed at key creation time
- Added `sha256Hash` to verify() return for type consistency

- `src/routes/apiKeyRoutes.ts`
- DELETE `/api/keys/:id` now adds SHA-256 hash to in-memory revocation list

- `src/routes/gatewayRoutes.ts`
- Added check for in-memory revocation list before API key verification
- Returns 403 FORBIDDEN for immediately-revoked tokens

## API Changes
No breaking API changes. The revocation list is an internal optimization.

### Request Flow
1. Client calls DELETE `/api/keys/{keyId}`
2. `apiKeyRepository.revoke()` marks the key as revoked in storage
3. `getSha256Hash()` retrieves the SHA-256 hash of the revoked key
4. `TokenRevocationService.revoke()` adds hash to in-memory list with TTL
5. Subsequent gateway requests check `isRevoked()` before authentication
6. If revoked, returns 403 FORBIDDEN immediately
7. Sweeper removes expired entries after TTL

## Test Coverage
- 13 unit tests for `TokenRevocationService` (100% statement coverage)
- Integration test in `gatewayRoutes.test.ts` for revocation list check
- Integration test in `apiKeyRoutes.test.ts` for revocation list update on DELETE
- Tests cover edge cases: TTL expiry, sweeper behavior, singleton pattern, custom TTL

## Security Considerations
- SHA-256 hashes stored instead of raw tokens to prevent exposure of sensitive data
- Structured logging with token hash references (not full tokens)
- Singleton pattern with reset capability for testing isolation
- Type-safe design prevents accidental exposure of internal state

## Configuration
- Default TTL: 1 hour (3600000ms)
- Default sweep interval: 1 minute (60000ms)
- Can be configured via `getTokenRevocationService({ defaultTtlMs, sweepIntervalMs })`

## Methods
| Method | Description |
|--------|-------------|
| `revoke(tokenHash, expiresAt?)` | Add a token hash to the revocation list |
| `isRevoked(tokenHash)` | Check if a token hash is revoked (also cleans up expired) |
| `reinstate(tokenHash)` | Remove a token from the revocation list |
| `revokeAll(developerId, tokenHashes[])` | Revoke multiple tokens for a developer |
| `getRevokedCount()` | Get count of non-expired revoked tokens |
| `clear()` | Clear all revoked tokens |
| `stopSweeper()` | Stop the automatic cleanup interval |

## Performance Characteristics
- O(1) lookup for revoked token checks
- Automatic cleanup prevents memory leaks
- Configurable sweep interval balances performance and memory usage

closes #509
66 changes: 39 additions & 27 deletions src/repositories/apiKeyRepository.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { randomBytes, timingSafeEqual } from "crypto";
import { randomBytes, timingSafeEqual, createHash } from "crypto";
import bcrypt from "bcryptjs";
import { config } from "../config/index.js";

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

/**
* Typed error returned when an API key prefix is found in the store but the
* full-key hash comparison fails. Callers should map this to a 401 response
Expand All @@ -23,6 +27,7 @@ export interface ApiKeyRecord {
userId: string;
prefix: string;
keyHash: string;
sha256Hash: string;
scopes: string[];
rateLimitPerMinute: number | null;
createdAt: Date;
Expand Down Expand Up @@ -64,32 +69,34 @@ function constantTimeCompare(a: string, b: string): boolean {
}

export const apiKeyRepository = {
create(params: {
apiId: string;
userId: string;
scopes: string[];
rateLimitPerMinute: number | null;
}): 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,
apiId: p.apiId,
userId: p.userId,
prefix,
keyHash: toHash(key),
scopes: p.scopes,
rateLimitPerMinute: p.rateLimitPerMinute,
createdAt,
revoked: false
});

return { id, key, prefix, createdAt };
},
create(params: {
apiId: string;
userId: string;
scopes: string[];
rateLimitPerMinute: number | null;
}): 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();
const sha256Hash = sha256Hex(key);

apiKeys.push({
id,
apiId: p.apiId,
userId: p.userId,
prefix,
keyHash: toHash(key),
sha256Hash,
scopes: p.scopes,
rateLimitPerMinute: p.rateLimitPerMinute,
createdAt,
revoked: false
});

return { id, key, prefix, createdAt };
},
list(params: { userId: string; apiId?: string }): ApiKeyRecord[] {
const { userId, apiId } = params;
return apiKeys
Expand All @@ -107,6 +114,10 @@ export const apiKeyRepository = {
key.revoked = true;
return 'success';
},
getSha256Hash(id: string): string | null {
const key = apiKeys.find(k => k.id === id);
return key?.sha256Hash ?? null;
},
verify(key: string): ApiKeyRecord | null {
if (typeof key !== 'string') return null;
// Find potential matches by prefix first for efficiency
Expand All @@ -132,6 +143,7 @@ export const apiKeyRepository = {
userId: candidate.userId,
prefix: candidate.prefix,
keyHash: '[REDACTED]',
sha256Hash: candidate.sha256Hash,
scopes: candidate.scopes,
rateLimitPerMinute: candidate.rateLimitPerMinute,
createdAt: candidate.createdAt,
Expand Down
31 changes: 31 additions & 0 deletions src/routes/apiKeyRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,4 +260,35 @@ describe('API key lifecycle routes', () => {
expect(response.status).toBe(401);
expect(response.body.code).toBe('UNAUTHORIZED');
});

it('adds revoked key to in-memory revocation list', async () => {
const app = createTestApp();
const { resetTokenRevocationService, getTokenRevocationService } = await import('../services/tokenRevocation.js');
const { createHash } = await import('node:crypto');
resetTokenRevocationService();
const tokenRevocation = getTokenRevocationService({ defaultTtlMs: 60000 });

const created = apiKeyRepository.create({
apiId: '101',
userId: 'dev-1',
scopes: ['*'],
rateLimitPerMinute: null,
});

const sha256Hex = (v: string) => createHash('sha256').update(v).digest('hex');
const keyHash = sha256Hex(created.key);

// Key should not be in revocation list initially
expect(tokenRevocation.isRevoked(keyHash)).toBe(false);

const response = await request(app)
.delete(`/api/keys/${created.id}`)
.set('x-user-id', 'dev-1');

expect(response.status).toBe(204);
// Key should now be in revocation list
expect(tokenRevocation.isRevoked(keyHash)).toBe(true);

resetTokenRevocationService();
});
});
10 changes: 10 additions & 0 deletions src/routes/apiKeyRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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 { getTokenRevocationService } from '../services/tokenRevocation.js';
import type { ApiRepository } from '../repositories/apiRepository.js';
import type { DeveloperRepository } from '../repositories/developerRepository.js';
import {
Expand Down Expand Up @@ -141,6 +142,10 @@ export function createApiKeyRouter(deps: ApiKeyRoutesDeps): Router {
}

const { id } = keyIdParamsSchema.parse(req.params);

// 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 All @@ -153,6 +158,11 @@ export function createApiKeyRouter(deps: ApiKeyRoutesDeps): Router {
return;
}

// Add to in-memory revocation list for immediate invalidation
if (sha256Hash) {
getTokenRevocationService().revoke(sha256Hash);
}

res.status(204).send();
},
);
Expand Down
32 changes: 32 additions & 0 deletions src/routes/gatewayRoutes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,38 @@ describe("gateway route - API key prefix / hash mismatch (bug #421)", () => {
expect(res.body).toHaveProperty("code", "FORBIDDEN");
});

test("returns 403 when key is in revocation list", async () => {
const validKey = "test-key-abcdefgh";
const apiKeys = new Map<string, ApiKey>();
apiKeys.set(validKey, {
key: "k1",
apiId: API_ID,
developerId: "dev1",
revoked: false,
});

const { resetTokenRevocationService, getTokenRevocationService } = await import("../services/tokenRevocation.js");
resetTokenRevocationService();
const tokenRevocation = getTokenRevocationService({ defaultTtlMs: 60000 });

const { createHash } = await import("node:crypto");
const sha256Hex = (v: string) => createHash("sha256").update(v).digest("hex");
tokenRevocation.revoke(sha256Hex(validKey));

try {
const app = buildApp(apiKeys);

const res = await request(app)
.get(`/gateway/${API_ID}`)
.set("x-api-key", validKey);

expect(res.status).toBe(403);
expect(res.body).toHaveProperty("code", "FORBIDDEN");
} finally {
resetTokenRevocationService();
}
});

test("401 response body is identical for both mismatch and no-prefix cases (no timing oracle via body)", async () => {
const validKey = "test-key-abcdefgh";
const apiKeys = new Map<string, ApiKey>();
Expand Down
10 changes: 10 additions & 0 deletions src/routes/gatewayRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import express, { Router, type Request, type Response, type NextFunction } from
import { z } from 'zod';
import { startUpstreamTimer, getUpstreamHealth, type UpstreamOutcome } from '../metrics.js';
import { validate } from '../middleware/validate.js';
import { getTokenRevocationService } from '../services/tokenRevocation.js';
import type { GatewayDeps, ApiKey } from '../types/gateway.js';
import { buildHopByHopSet } from '../lib/hopByHop.js';
import { getDefaultBreakerRegistry, CircuitBreakerState } from '../lib/circuitBreaker.js';
Expand Down Expand Up @@ -221,6 +222,15 @@ export function createGatewayRouter(deps: GatewayDeps): Router {
return;
}

// Check in-memory revocation list for immediate invalidation
const tokenRevocationService = getTokenRevocationService();
const apiKeyHash = sha256Hex(apiKeyHeader);
if (tokenRevocationService.isRevoked(apiKeyHash)) {
next(new ForbiddenError('Forbidden: API key has been revoked'));
return;
}

// Also check persisted revoked flag
if (keyRecord.revoked) {
next(new ForbiddenError('Forbidden: API key has been revoked'));
return;
Expand Down
Loading