From 6bcd73c58cb1cddb54ca857c4c7eab4d8a15edf1 Mon Sep 17 00:00:00 2001 From: telemarkdigital-publisher Date: Tue, 25 Aug 2026 02:14:27 +0200 Subject: [PATCH] Secure gateway API key management --- backend/src/gateway/APIKeyManager.ts | 121 ++++++++++++++++-- backend/src/gateway/PrivacyApiGateway.ts | 48 +++++-- .../gateway/__tests__/APIKeyManager.test.ts | 94 +++++++++++++- backend/src/routes/gateway.ts | 84 ++++++++---- 4 files changed, 296 insertions(+), 51 deletions(-) diff --git a/backend/src/gateway/APIKeyManager.ts b/backend/src/gateway/APIKeyManager.ts index 0159e39..f7ebc56 100644 --- a/backend/src/gateway/APIKeyManager.ts +++ b/backend/src/gateway/APIKeyManager.ts @@ -66,17 +66,28 @@ export interface APIKeyUsage { statusCode: number; } +type RateLimitWindow = { + windowStart: number; + count: number; +}; + +type APIKeyRateUsage = { + minute: RateLimitWindow; + hour: RateLimitWindow; + day: RateLimitWindow; +}; + export class APIKeyManager { private keys: Map; private usageLogs: APIKeyUsage[]; + private rateUsage: Map; private keyPrefixLength: number; - private keyLength: number; constructor() { this.keys = new Map(); this.usageLogs = []; - this.keyPrefixLength = 8; - this.keyLength = 64; + this.rateUsage = new Map(); + this.keyPrefixLength = 16; this.initializeDefaultKeys(); this.setupUsageLogCleanup(); @@ -98,10 +109,9 @@ export class APIKeyManager { keyPrefix, permissions: request.permissions, rateLimit: { - requestsPerMinute: 60, - requestsPerHour: 1000, - requestsPerDay: 10000, - ...request.rateLimit, + requestsPerMinute: request.rateLimit?.requestsPerMinute ?? 60, + requestsPerHour: request.rateLimit?.requestsPerHour ?? 1000, + requestsPerDay: request.rateLimit?.requestsPerDay ?? 10000, }, restrictions: { allowedIPs: request.restrictions?.allowedIPs || [], @@ -125,7 +135,7 @@ export class APIKeyManager { permissions: request.permissions, }); - return { key: apiKey, keyInfo }; + return { key: apiKey, keyInfo: this.cloneKeyForList(keyInfo) }; } catch (error) { logger.error("Failed to create API key:", error); throw new Error(`API key creation failed: ${(error as Error).message}`); @@ -176,10 +186,15 @@ export class APIKeyManager { return { valid: false, reason: restrictionCheck.reason }; } + const rateLimitCheck = this.checkRateLimit(keyInfo); + if (!rateLimitCheck.allowed) { + return { valid: false, reason: rateLimitCheck.reason }; + } + // Update last used timestamp keyInfo.metadata.lastUsedAt = new Date(); - return { valid: true, keyInfo }; + return { valid: true, keyInfo: this.cloneKeyForList(keyInfo) }; } catch (error) { logger.error("API key validation error:", error); return { valid: false, reason: "Validation error" }; @@ -216,6 +231,7 @@ export class APIKeyManager { } this.keys.delete(keyId); + this.rateUsage.delete(keyId); logger.info("API key deleted", { keyId, @@ -266,7 +282,23 @@ export class APIKeyManager { } async getKeyInfo(keyId: string): Promise { - return this.keys.get(keyId) || null; + const keyInfo = this.keys.get(keyId); + if (keyInfo) { + return this.cloneKeyForList(keyInfo); + } + + if (!keyId || typeof keyId !== "string") { + return null; + } + + const inputHash = this.hashKey(keyId); + const keyFromRawValue = Array.from(this.keys.values()).find( + (candidate) => + candidate.keyHash.length === inputHash.length && + timingSafeEqual(Buffer.from(inputHash), Buffer.from(candidate.keyHash)), + ); + + return keyFromRawValue ? this.cloneKeyForList(keyFromRawValue) : null; } async listKeys(filter?: { @@ -378,7 +410,7 @@ export class APIKeyManager { } private generateAPIKey(): string { - return randomBytes(this.keyLength).toString("hex"); + return `stellar_${randomBytes(32).toString("base64url")}`; } private hashKey(key: string): string { @@ -396,6 +428,73 @@ export class APIKeyManager { return clonedKey; } + private checkRateLimit(keyInfo: APIKey): { + allowed: boolean; + reason?: string; + } { + if (!keyInfo.rateLimit) { + return { allowed: true }; + } + + const now = Date.now(); + const usage = this.getRateUsage(keyInfo.id, now); + + const checks = [ + { + bucket: usage.minute, + windowMs: 60 * 1000, + limit: keyInfo.rateLimit.requestsPerMinute, + label: "per-minute", + }, + { + bucket: usage.hour, + windowMs: 60 * 60 * 1000, + limit: keyInfo.rateLimit.requestsPerHour, + label: "per-hour", + }, + { + bucket: usage.day, + windowMs: 24 * 60 * 60 * 1000, + limit: keyInfo.rateLimit.requestsPerDay, + label: "per-day", + }, + ]; + + for (const check of checks) { + if (now - check.bucket.windowStart >= check.windowMs) { + check.bucket.windowStart = now; + check.bucket.count = 0; + } + + if (check.limit > 0 && check.bucket.count >= check.limit) { + return { + allowed: false, + reason: `API key rate limit exceeded (${check.label})`, + }; + } + } + + checks.forEach((check) => { + check.bucket.count += 1; + }); + + return { allowed: true }; + } + + private getRateUsage(keyId: string, now: number): APIKeyRateUsage { + let usage = this.rateUsage.get(keyId); + if (!usage) { + usage = { + minute: { windowStart: now, count: 0 }, + hour: { windowStart: now, count: 0 }, + day: { windowStart: now, count: 0 }, + }; + this.rateUsage.set(keyId, usage); + } + + return usage; + } + private checkRestrictions( keyInfo: APIKey, context?: { diff --git a/backend/src/gateway/PrivacyApiGateway.ts b/backend/src/gateway/PrivacyApiGateway.ts index 6448747..13c4a6b 100644 --- a/backend/src/gateway/PrivacyApiGateway.ts +++ b/backend/src/gateway/PrivacyApiGateway.ts @@ -3,7 +3,7 @@ import { RateLimiterMemory } from "rate-limiter-flexible"; import { createProxyMiddleware } from "http-proxy-middleware"; import { PrivacyPolicyEngine } from "./PrivacyPolicyEngine"; import { ABACService } from "./ABACService"; -import { APIKeyManager } from "./APIKeyManager"; +import { APIKey, APIKeyCreateRequest, APIKeyManager } from "./APIKeyManager"; import { RequestTransformer } from "./RequestTransformer"; import { PrivacyMetrics } from "./PrivacyMetrics"; import { LoadBalancer } from "./LoadBalancer"; @@ -56,7 +56,13 @@ export interface PolicyConfig { export interface PolicyRule { attribute: string; - operator: "equals" | "contains" | "startsWith" | "endsWith" | "regex" | "not_equals"; + operator: + | "equals" + | "contains" + | "startsWith" + | "endsWith" + | "regex" + | "not_equals"; value: string; action: "allow" | "deny" | "transform" | "log"; transformation?: TransformationRule; @@ -127,7 +133,7 @@ export class PrivacyApiGateway { this.privacyMetrics = new PrivacyMetrics(config.metrics); this.loadBalancer = new LoadBalancer( config.services.map((service) => service.baseUrl), - { healthCheckInterval: config.loadBalancing.healthCheckInterval } + { healthCheckInterval: config.loadBalancing.healthCheckInterval }, ); this.setupMiddleware(); @@ -339,7 +345,11 @@ export class PrivacyApiGateway { return; } - const keyValidation = await this.apiKeyManager.validateKey(apiKey); + const keyValidation = await this.apiKeyManager.validateKey(apiKey, { + ipAddress: req.ip, + origin: req.headers.origin, + service: this.extractServiceFromPath(req.path), + }); if (!keyValidation.valid) { res.status(401).json({ @@ -420,7 +430,8 @@ export class PrivacyApiGateway { try { const token = authHeader.substring(7); // Verify with HS256 using the shared JWT secret - const jwtSecret = process.env.JWT_SECRET || "stellar-privacy-jwt-secret-dev-only"; + const jwtSecret = + process.env.JWT_SECRET || "stellar-privacy-jwt-secret-dev-only"; const decoded = jwt.verify(token, jwtSecret, { algorithms: ["HS256"], }) as { @@ -443,7 +454,8 @@ export class PrivacyApiGateway { if (keyInfo) { attributes.apiKeyId = keyInfo.id; attributes.apiKeyPermissions = keyInfo.permissions; - attributes.apiKeyOwner = keyInfo.metadata?.owner ?? (keyInfo as { owner?: string }).owner; + attributes.apiKeyOwner = + keyInfo.metadata?.owner ?? (keyInfo as { owner?: string }).owner; } } @@ -485,10 +497,7 @@ export class PrivacyApiGateway { ): void { const MAX_POLICY_BODY_SIZE = 100 * 1024; // 100KB - const contentLength = parseInt( - req.headers["content-length"] || "0", - 10, - ); + const contentLength = parseInt(req.headers["content-length"] || "0", 10); if (contentLength > MAX_POLICY_BODY_SIZE) { res.status(413).json({ @@ -572,6 +581,25 @@ export class PrivacyApiGateway { return this.app; } + public async createApiKey( + request: APIKeyCreateRequest, + ): Promise<{ key: string; keyInfo: APIKey }> { + return this.apiKeyManager.createKey(request); + } + + public async listApiKeys(filter?: { + owner?: string; + department?: string; + active?: boolean; + permissions?: string[]; + }): Promise { + return this.apiKeyManager.listKeys(filter); + } + + public async revokeApiKey(keyId: string): Promise { + return this.apiKeyManager.revokeKey(keyId); + } + public async start(port: number): Promise { await this.privacyMetrics.start(); await (this.loadBalancer as any).start(); diff --git a/backend/src/gateway/__tests__/APIKeyManager.test.ts b/backend/src/gateway/__tests__/APIKeyManager.test.ts index 04cb135..2ef6d58 100644 --- a/backend/src/gateway/__tests__/APIKeyManager.test.ts +++ b/backend/src/gateway/__tests__/APIKeyManager.test.ts @@ -152,12 +152,13 @@ describe("APIKeyManager", () => { "", ); - expect(apiKey).toHaveLength(128); - expect(apiKey.substring(0, 8)).toBe(keyPrefix); + expect(apiKey).toMatch(/^stellar_[A-Za-z0-9_-]{43}$/); + expect(apiKey.substring(0, 16)).toBe(keyPrefix); const validation = await manager.validateKey(apiKey); expect(validation.valid).toBe(true); expect(validation.keyInfo?.permissions).toContain("admin"); + expect(validation.keyInfo?.keyHash).toBe("[REDACTED]"); }); it("randomizes the development key on each start", () => { @@ -206,5 +207,94 @@ describe("APIKeyManager", () => { expect(secondValidation.valid).toBe(true); expect(secondValidation.keyInfo?.name).toBe("Key B"); }); + + it("returns API keys once and redacts stored hashes from callers", async () => { + process.env.NODE_ENV = "test"; + + const manager = new APIKeyManager(); + const { key: apiKey, keyInfo } = await manager.createKey({ + name: "Gateway Client", + permissions: ["analytics:read"], + metadata: { + owner: "gateway-team", + department: "privacy", + purpose: "regression-test", + }, + }); + + expect(apiKey).toMatch(/^stellar_[A-Za-z0-9_-]{43}$/); + expect(keyInfo.keyHash).toBe("[REDACTED]"); + + const listedKeys = await manager.listKeys({ owner: "gateway-team" }); + expect(listedKeys).toHaveLength(1); + expect(listedKeys[0].keyHash).toBe("[REDACTED]"); + expect(JSON.stringify(listedKeys)).not.toContain(apiKey); + + await expect(manager.validateKey(apiKey)).resolves.toMatchObject({ + valid: true, + }); + }); + + it("immediately rejects a revoked key", async () => { + process.env.NODE_ENV = "test"; + + const manager = new APIKeyManager(); + const { key: apiKey, keyInfo } = await manager.createKey({ + name: "Revocable Gateway Client", + permissions: ["analytics:read"], + metadata: { + owner: "gateway-team", + department: "privacy", + purpose: "regression-test", + }, + }); + + await expect(manager.validateKey(apiKey)).resolves.toMatchObject({ + valid: true, + }); + await expect(manager.revokeKey(keyInfo.id)).resolves.toBe(true); + await expect(manager.validateKey(apiKey)).resolves.toMatchObject({ + valid: false, + reason: "API key is deactivated", + }); + }); + + it("enforces per-key rate limits", async () => { + process.env.NODE_ENV = "test"; + jest.setSystemTime(new Date("2026-01-01T00:00:00Z")); + + const manager = new APIKeyManager(); + const { key: apiKey } = await manager.createKey({ + name: "Rate Limited Gateway Client", + permissions: ["analytics:read"], + rateLimit: { + requestsPerMinute: 2, + requestsPerHour: 10, + requestsPerDay: 20, + }, + metadata: { + owner: "gateway-team", + department: "privacy", + purpose: "regression-test", + }, + }); + + await expect(manager.validateKey(apiKey)).resolves.toMatchObject({ + valid: true, + }); + await expect(manager.validateKey(apiKey)).resolves.toMatchObject({ + valid: true, + }); + await expect(manager.validateKey(apiKey)).resolves.toMatchObject({ + valid: false, + reason: "API key rate limit exceeded (per-minute)", + }); + + jest.advanceTimersByTime(60 * 1000); + + await expect(manager.validateKey(apiKey)).resolves.toMatchObject({ + valid: true, + }); + }); }); }); diff --git a/backend/src/routes/gateway.ts b/backend/src/routes/gateway.ts index f6516f3..da21fb8 100644 --- a/backend/src/routes/gateway.ts +++ b/backend/src/routes/gateway.ts @@ -1,6 +1,7 @@ import { Router, Request, Response } from "express"; import { asyncHandler } from "../middleware/errorHandler"; import { getGateway } from "../gateway"; +import { APIKeyCreateRequest } from "../gateway/APIKeyManager"; import { logger } from "../utils/logger"; const router = Router(); @@ -161,7 +162,7 @@ router.get( router.post( "/api-keys", asyncHandler(async (req: Request, res: Response) => { - const { name, permissions, restrictions, metadata } = req.body; + const { name, permissions, rateLimit, restrictions, metadata } = req.body; if (!name || !permissions || !metadata) { return res.status(400).json({ @@ -169,21 +170,29 @@ router.post( }); } - // Mock API key creation - const apiKey = `stellar_${Math.random().toString(36).substring(2, 15)}_${Date.now()}`; - const keyInfo = { - id: `key_${Date.now()}`, + if (!metadata.owner || !metadata.department || !metadata.purpose) { + return res.status(400).json({ + error: "Missing required metadata fields: owner, department, purpose", + }); + } + + const gateway = getGateway(); + if (!gateway) { + return res.status(503).json({ + error: "Gateway not available", + }); + } + + const createRequest: APIKeyCreateRequest = { name, - keyPrefix: apiKey.substring(0, 12), permissions, - restrictions: restrictions || {}, - metadata: { - ...metadata, - createdAt: new Date(), - isActive: true, - }, + rateLimit, + restrictions, + metadata, }; + const { key: apiKey, keyInfo } = await gateway.createApiKey(createRequest); + logger.info("API key created via gateway management", { keyId: keyInfo.id, name, @@ -202,23 +211,28 @@ router.post( router.get( "/api-keys", asyncHandler(async (req: Request, res: Response) => { - // Mock API keys list + const gateway = getGateway(); + if (!gateway) { + return res.status(503).json({ + error: "Gateway not available", + }); + } + + const { owner, department, active, permissions } = req.query; + const permissionFilter = + typeof permissions === "string" + ? permissions.split(",").map((permission) => permission.trim()) + : undefined; + const activeFilter = active === undefined ? undefined : active === "true"; + const keys = await gateway.listApiKeys({ + owner: typeof owner === "string" ? owner : undefined, + department: typeof department === "string" ? department : undefined, + active: activeFilter, + permissions: permissionFilter, + }); + res.json({ - keys: [ - { - id: "key_001", - name: "Analytics Client Key", - keyPrefix: "stellar_an_", - permissions: ["analytics:read", "analytics:write"], - metadata: { - owner: "analytics-team", - department: "data-analytics", - createdAt: new Date("2024-01-15"), - lastUsedAt: new Date(), - isActive: true, - }, - }, - ], + keys, }); }), ); @@ -228,6 +242,20 @@ router.delete( "/api-keys/:keyId", asyncHandler(async (req: Request, res: Response) => { const { keyId } = req.params; + const gateway = getGateway(); + if (!gateway) { + return res.status(503).json({ + error: "Gateway not available", + }); + } + + const revoked = await gateway.revokeApiKey(keyId); + if (!revoked) { + return res.status(404).json({ + error: "API key not found", + keyId, + }); + } logger.info("API key revoked via gateway management", { keyId });