diff --git a/supabase/functions/_shared/authHelpers.ts b/supabase/functions/_shared/authHelpers.ts new file mode 100644 index 00000000..6c455498 --- /dev/null +++ b/supabase/functions/_shared/authHelpers.ts @@ -0,0 +1,142 @@ +/** + * Authentication Helper Functions + * + * Utilities for extracting user information from requests, + * handling JWTs, and generating rate limit keys. + */ + +import { createClient } from 'jsr:@supabase/supabase-js@2'; + +/** + * Extract user ID from Authorization header JWT + * Returns null if not authenticated or invalid token + */ +export function extractUserIdFromAuth(req: Request): string | null { + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + + const token = authHeader.substring(7); + + // Decode JWT (just the payload, no verification needed for ID extraction) + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + + const payload = JSON.parse(atob(parts[1])); + return payload.sub || null; + } catch (error) { + console.error('Error extracting user ID from auth:', error); + return null; + } +} + +/** + * Get client IP address from request + * Handles various proxy headers + */ +export function getClientIP(req: Request): string { + // Check common proxy headers in order of preference + const forwardedFor = req.headers.get('x-forwarded-for'); + if (forwardedFor) { + // x-forwarded-for can contain multiple IPs, take the first one + return forwardedFor.split(',')[0].trim(); + } + + const realIP = req.headers.get('x-real-ip'); + if (realIP) { + return realIP.trim(); + } + + const cfConnectingIP = req.headers.get('cf-connecting-ip'); + if (cfConnectingIP) { + return cfConnectingIP.trim(); + } + + // Fallback to a default value + return 'unknown'; +} + +/** + * Generate a rate limit key for the request + * Prefers user ID, falls back to IP + */ +export function getRateLimitKey(req: Request, prefix: string = 'user'): string { + const userId = extractUserIdFromAuth(req); + if (userId) { + return `${prefix}:${userId}`; + } + + const clientIP = getClientIP(req); + return `${prefix}:ip:${clientIP}`; +} + +/** + * Verify JWT token and get user ID using Supabase client + * More robust than manual decoding, verifies signature + */ +export async function verifyAuthAndGetUserId( + req: Request, + supabaseUrl: string, + supabaseServiceKey: string +): Promise { + try { + const authHeader = req.headers.get('Authorization'); + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return null; + } + + const token = authHeader.substring(7); + + // Create a Supabase client for verification + const supabase = createClient(supabaseUrl, supabaseServiceKey); + + // Verify the JWT + const { data: { user }, error } = await supabase.auth.getUser(token); + + if (error || !user) { + return null; + } + + return user.id; + } catch (error) { + console.error('Error verifying auth token:', error); + return null; + } +} + +/** + * Check if request has valid authentication + */ +export function hasValidAuth(req: Request): boolean { + const authHeader = req.headers.get('Authorization'); + return authHeader !== null && authHeader.startsWith('Bearer '); +} + +/** + * Extract request metadata for logging + */ +export interface RequestMetadata { + userId: string | null; + clientIP: string; + userAgent: string | null; + referer: string | null; + method: string; + path: string; +} + +export function extractRequestMetadata(req: Request): RequestMetadata { + const url = new URL(req.url); + + return { + userId: extractUserIdFromAuth(req), + clientIP: getClientIP(req), + userAgent: req.headers.get('user-agent'), + referer: req.headers.get('referer'), + method: req.method, + path: url.pathname, + }; +} diff --git a/supabase/functions/_shared/rateLimitMetrics.ts b/supabase/functions/_shared/rateLimitMetrics.ts new file mode 100644 index 00000000..d22d4cd3 --- /dev/null +++ b/supabase/functions/_shared/rateLimitMetrics.ts @@ -0,0 +1,144 @@ +/** + * Rate Limit Metrics Tracking + * + * In-memory metrics collection for rate limiting operations. + * Tracks accepted/rejected requests, patterns, and provides analytics. + */ + +export interface RateLimitMetric { + timestamp: number; + functionName: string; + clientIP: string; + userId?: string; + allowed: boolean; + remaining: number; + retryAfter?: number; + tier: string; +} + +export interface MetricsStats { + totalRequests: number; + allowedRequests: number; + blockedRequests: number; + blockRate: number; + uniqueIPs: number; + uniqueUsers: number; + topBlockedIPs: Array<{ ip: string; count: number }>; + topBlockedUsers: Array<{ userId: string; count: number }>; + tierDistribution: Record; +} + +// In-memory storage for metrics +const metricsStore: RateLimitMetric[] = []; +const MAX_METRICS = 10000; // Keep last 10k metrics + +/** + * Record a rate limit check result + */ +export function recordRateLimitMetric(metric: RateLimitMetric): void { + metricsStore.push(metric); + + // Trim oldest metrics if we exceed max + if (metricsStore.length > MAX_METRICS) { + metricsStore.splice(0, metricsStore.length - MAX_METRICS); + } +} + +/** + * Get recent metrics + */ +export function getRecentMetrics(limit: number = 100): RateLimitMetric[] { + return metricsStore.slice(-limit); +} + +/** + * Get aggregated statistics for a time window + */ +export function getMetricsStats(timeWindowMs: number = 60000): MetricsStats { + const now = Date.now(); + const cutoff = now - timeWindowMs; + const recentMetrics = metricsStore.filter(m => m.timestamp >= cutoff); + + const allowedRequests = recentMetrics.filter(m => m.allowed).length; + const blockedRequests = recentMetrics.filter(m => !m.allowed).length; + const totalRequests = recentMetrics.length; + + // Track unique IPs and users + const uniqueIPs = new Set(recentMetrics.map(m => m.clientIP)).size; + const uniqueUsers = new Set( + recentMetrics.filter(m => m.userId).map(m => m.userId) + ).size; + + // Find top blocked IPs + const ipBlockCounts = new Map(); + const userBlockCounts = new Map(); + const tierCounts = new Map(); + + recentMetrics.forEach(metric => { + if (!metric.allowed) { + ipBlockCounts.set(metric.clientIP, (ipBlockCounts.get(metric.clientIP) || 0) + 1); + if (metric.userId) { + userBlockCounts.set(metric.userId, (userBlockCounts.get(metric.userId) || 0) + 1); + } + } + tierCounts.set(metric.tier, (tierCounts.get(metric.tier) || 0) + 1); + }); + + const topBlockedIPs = Array.from(ipBlockCounts.entries()) + .map(([ip, count]) => ({ ip, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const topBlockedUsers = Array.from(userBlockCounts.entries()) + .map(([userId, count]) => ({ userId, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 10); + + const tierDistribution = Object.fromEntries(tierCounts); + + return { + totalRequests, + allowedRequests, + blockedRequests, + blockRate: totalRequests > 0 ? blockedRequests / totalRequests : 0, + uniqueIPs, + uniqueUsers, + topBlockedIPs, + topBlockedUsers, + tierDistribution, + }; +} + +/** + * Clear all metrics (useful for testing) + */ +export function clearMetrics(): void { + metricsStore.length = 0; +} + +/** + * Get metrics for a specific function + */ +export function getFunctionMetrics(functionName: string, limit: number = 100): RateLimitMetric[] { + return metricsStore + .filter(m => m.functionName === functionName) + .slice(-limit); +} + +/** + * Get metrics for a specific user + */ +export function getUserMetrics(userId: string, limit: number = 100): RateLimitMetric[] { + return metricsStore + .filter(m => m.userId === userId) + .slice(-limit); +} + +/** + * Get metrics for a specific IP + */ +export function getIPMetrics(clientIP: string, limit: number = 100): RateLimitMetric[] { + return metricsStore + .filter(m => m.clientIP === clientIP) + .slice(-limit); +}