mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-22 04:11:13 -05:00
Create rateLimitMetrics and authHelpers
Implement Phase 1 by adding: - supabase/functions/_shared/rateLimitMetrics.ts: in-memory rate limit metrics utilities (record, query, stats, clear, and helpers) - supabase/functions/_shared/authHelpers.ts: auth helpers for extracting userId, client IP, and rate-limit keys (code scaffolding)
This commit is contained in:
142
supabase/functions/_shared/authHelpers.ts
Normal file
142
supabase/functions/_shared/authHelpers.ts
Normal file
@@ -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<string | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
144
supabase/functions/_shared/rateLimitMetrics.ts
Normal file
144
supabase/functions/_shared/rateLimitMetrics.ts
Normal file
@@ -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<string, number>;
|
||||
}
|
||||
|
||||
// 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<string, number>();
|
||||
const userBlockCounts = new Map<string, number>();
|
||||
const tierCounts = new Map<string, number>();
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user