← 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;
}