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
121 changes: 110 additions & 11 deletions backend/src/gateway/APIKeyManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, APIKey>;
private usageLogs: APIKeyUsage[];
private rateUsage: Map<string, APIKeyRateUsage>;
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();
Expand All @@ -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 || [],
Expand All @@ -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}`);
Expand Down Expand Up @@ -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" };
Expand Down Expand Up @@ -216,6 +231,7 @@ export class APIKeyManager {
}

this.keys.delete(keyId);
this.rateUsage.delete(keyId);

logger.info("API key deleted", {
keyId,
Expand Down Expand Up @@ -266,7 +282,23 @@ export class APIKeyManager {
}

async getKeyInfo(keyId: string): Promise<APIKey | null> {
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?: {
Expand Down Expand Up @@ -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 {
Expand All @@ -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?: {
Expand Down
48 changes: 38 additions & 10 deletions backend/src/gateway/PrivacyApiGateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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 {
Expand All @@ -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;
}
}

Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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<APIKey[]> {
return this.apiKeyManager.listKeys(filter);
}

public async revokeApiKey(keyId: string): Promise<boolean> {
return this.apiKeyManager.revokeKey(keyId);
}

public async start(port: number): Promise<void> {
await this.privacyMetrics.start();
await (this.loadBalancer as any).start();
Expand Down
Loading
Loading