← back to Wine Finder Next
lib/apiKeyAuth.ts
181 lines
// API Key Authentication System
import { generateAPIKey, hashAPIKey } from './encryption';
import { generateSecureToken } from './encryption';
export interface APIKey {
id: string;
key: string; // Only shown once at creation
hashedKey: string;
name: string;
userId: string;
permissions: APIPermission[];
rateLimit: number; // requests per minute
ipWhitelist?: string[];
expiresAt?: Date;
createdAt: Date;
lastUsedAt?: Date;
isActive: boolean;
}
export type APIPermission =
| 'read:bottles'
| 'read:votes'
| 'write:votes'
| 'read:marketplace'
| 'write:marketplace'
| 'read:members'
| 'admin:all';
// In-memory store (use database in production)
const apiKeys: Map<string, Omit<APIKey, 'key'>> = new Map();
// Create new API key
export function createAPIKey(
userId: string,
name: string,
permissions: APIPermission[],
options?: {
rateLimit?: number;
ipWhitelist?: string[];
expiresInDays?: number;
}
): APIKey {
const key = generateAPIKey();
const hashedKey = hashAPIKey(key);
const apiKey: APIKey = {
id: generateSecureToken(16),
key, // Only returned here, never stored
hashedKey,
name,
userId,
permissions,
rateLimit: options?.rateLimit || 100,
ipWhitelist: options?.ipWhitelist,
expiresAt: options?.expiresInDays
? new Date(Date.now() + options.expiresInDays * 24 * 60 * 60 * 1000)
: undefined,
createdAt: new Date(),
isActive: true
};
// Store without the plain key
const { key: _, ...storedKey } = apiKey;
apiKeys.set(hashedKey, storedKey);
return apiKey; // Return with plain key for one-time display
}
// Verify API key
export function verifyAPIKey(
key: string,
requiredPermission?: APIPermission,
ipAddress?: string
): { valid: boolean; apiKey?: Omit<APIKey, 'key'>; error?: string } {
const hashedKey = hashAPIKey(key);
const apiKey = apiKeys.get(hashedKey);
if (!apiKey) {
return { valid: false, error: 'Invalid API key' };
}
if (!apiKey.isActive) {
return { valid: false, error: 'API key is inactive' };
}
if (apiKey.expiresAt && new Date() > apiKey.expiresAt) {
return { valid: false, error: 'API key expired' };
}
// Check IP whitelist
if (apiKey.ipWhitelist && ipAddress && !apiKey.ipWhitelist.includes(ipAddress)) {
return { valid: false, error: 'IP address not whitelisted' };
}
// Check permission
if (requiredPermission &&
!apiKey.permissions.includes(requiredPermission) &&
!apiKey.permissions.includes('admin:all')) {
return { valid: false, error: 'Insufficient permissions' };
}
// Update last used
apiKey.lastUsedAt = new Date();
return { valid: true, apiKey };
}
// Revoke API key
export function revokeAPIKey(keyId: string): boolean {
for (const [hash, key] of apiKeys.entries()) {
if (key.id === keyId) {
key.isActive = false;
return true;
}
}
return false;
}
// List API keys for user
export function listAPIKeys(userId: string): Omit<APIKey, 'key' | 'hashedKey'>[] {
return Array.from(apiKeys.values())
.filter(k => k.userId === userId)
.map(({ hashedKey, ...key }) => key);
}
// Get API key by ID
export function getAPIKeyById(keyId: string): Omit<APIKey, 'key'> | null {
for (const key of apiKeys.values()) {
if (key.id === keyId) {
return key;
}
}
return null;
}
// Check rate limit for API key
const apiKeyRateLimits = new Map<string, { count: number; resetAt: number }>();
export function checkAPIKeyRateLimit(apiKeyId: string, limit: number): {
allowed: boolean;
remaining: number;
reset: number;
} {
const now = Date.now();
const record = apiKeyRateLimits.get(apiKeyId);
if (!record || now > record.resetAt) {
apiKeyRateLimits.set(apiKeyId, {
count: 1,
resetAt: now + 60000 // 1 minute
});
return { allowed: true, remaining: limit - 1, reset: now + 60000 };
}
if (record.count >= limit) {
return { allowed: false, remaining: 0, reset: record.resetAt };
}
record.count++;
return { allowed: true, remaining: limit - record.count, reset: record.resetAt };
}
// Extract API key from request header
export function extractAPIKey(authHeader: string | null): string | null {
if (!authHeader) return null;
// Support both formats:
// Authorization: Bearer wdao_xxx
// X-API-Key: wdao_xxx
if (authHeader.startsWith('Bearer ')) {
return authHeader.substring(7);
}
if (authHeader.startsWith('wdao_')) {
return authHeader;
}
return null;
}