All files / lib/auth rate-limit.ts

92.92% Statements 92/99
81.25% Branches 13/16
100% Functions 5/5
92.92% Lines 92/99

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 1631x                                           1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x           5x 5x 5x 5x 5x   5x   5x                   5x 5x   5x 5x 5x 5x 5x 5x   5x 1x   1x 1x   4x 4x             2x 2x 2x 2x     2x 2x   2x   2x 2x 2x 2x   2x 1x 1x 2x         2x 2x 2x 2x   2x 2x 8x 8x     2x   2x 2x 2x 2x   2x 1x 1x 2x         1x 13x 13x 13x 13x 13x 13x         1x 6x 6x 6x   6x 3x 3x 1x 3x 2x 2x 2x 6x  
/**
 * Authentication Rate Limiting
 *
 * Redis-first with DB fallback.
 * If Upstash Redis is configured, uses sliding window via @upstash/ratelimit.
 * Otherwise, falls back to Supabase RPC-based rate limiting.
 */
 
import { getAdminClient } from '@/lib/supabase/admin';
import { checkRedisRateLimit, resetRedisRateLimit } from '@/lib/redis/rate-limit';
 
export type RateLimitType = 'phone' | 'email' | 'ip';
 
export interface RateLimitResult {
  allowed: boolean;
  blocked_until?: string;
  attempts: number;
}
 
/**
 * Rate limit configuration by type
 */
export const RATE_LIMIT_CONFIG = {
  otp_send: {
    maxAttempts: 3,
    windowMinutes: 10,
  },
  otp_verify: {
    maxAttempts: 5,
    windowMinutes: 15,
  },
  magic_link: {
    maxAttempts: 5,
    windowMinutes: 60,
  },
  password_login: {
    maxAttempts: 5,
    windowMinutes: 15,
  },
};
 
/**
 * Check if an identifier is rate limited.
 * Tries Redis first, falls back to DB.
 */
export async function checkRateLimit(
  identifier: string,
  identifierType: RateLimitType,
  configKey: keyof typeof RATE_LIMIT_CONFIG = 'otp_send'
): Promise<RateLimitResult> {
  // Try Redis first
  const redisResult = await checkRedisRateLimit(`${identifierType}:${identifier}`, configKey);
 
  if (redisResult !== null) {
    const config = RATE_LIMIT_CONFIG[configKey];
    return {
      allowed: redisResult.allowed,
      blocked_until: redisResult.allowed ? undefined : redisResult.resetAt.toISOString(),
      attempts: config.maxAttempts - redisResult.remaining,
    };
  }
 
  // Fallback to DB
  const adminClient = getAdminClient();
  const config = RATE_LIMIT_CONFIG[configKey];
 
  const { data, error } = await adminClient.rpc('check_otp_rate_limit', {
    p_identifier: identifier,
    p_identifier_type: identifierType,
    p_max_attempts: config.maxAttempts,
    p_window_minutes: config.windowMinutes,
  });
 
  if (error) {
    console.error('Rate limit check error:', error);
    // Fail open but log the error
    return { allowed: true, attempts: 0 };
  }
 
  return data as RateLimitResult;
}
 
/**
 * Increment rate limit counter.
 * With Redis, this is handled automatically by checkRateLimit (sliding window).
 * Only needed for DB fallback.
 */
export async function incrementRateLimit(
  identifier: string,
  identifierType: RateLimitType
): Promise<void> {
  // Redis handles this automatically via the sliding window in checkRateLimit.
  // Only increment in DB as fallback.
  const { isRedisAvailable } = await import('@/lib/redis/client');
  if (isRedisAvailable()) return;
 
  const adminClient = getAdminClient();
 
  const { error } = await adminClient.rpc('increment_otp_attempt', {
    p_identifier: identifier,
    p_identifier_type: identifierType,
  });
 
  if (error) {
    console.error('Rate limit increment error:', error);
  }
}
 
/**
 * Clear rate limit after successful authentication
 */
export async function clearRateLimit(
  identifier: string,
  identifierType: RateLimitType
): Promise<void> {
  // Try Redis first
  const configKeys = Object.keys(RATE_LIMIT_CONFIG) as (keyof typeof RATE_LIMIT_CONFIG)[];
  for (const configKey of configKeys) {
    await resetRedisRateLimit(`${identifierType}:${identifier}`, configKey);
  }
 
  // Also clear DB (in case of mixed usage during transition)
  const adminClient = getAdminClient();
 
  const { error } = await adminClient.rpc('clear_otp_rate_limit', {
    p_identifier: identifier,
    p_identifier_type: identifierType,
  });
 
  if (error) {
    console.error('Rate limit clear error:', error);
  }
}
 
/**
 * Get remaining attempts before rate limit
 */
export function getRemainingAttempts(
  attempts: number,
  configKey: keyof typeof RATE_LIMIT_CONFIG = 'otp_send'
): number {
  const config = RATE_LIMIT_CONFIG[configKey];
  return Math.max(0, config.maxAttempts - attempts);
}
 
/**
 * Format blocked until timestamp for display
 */
export function formatBlockedUntil(blockedUntil: string): string {
  const date = new Date(blockedUntil);
  const now = new Date();
  const diffMinutes = Math.ceil((date.getTime() - now.getTime()) / (1000 * 60));
 
  if (diffMinutes <= 1) {
    return 'less than a minute';
  } else if (diffMinutes < 60) {
    return `${diffMinutes} minutes`;
  } else {
    const hours = Math.ceil(diffMinutes / 60);
    return `${hours} hour${hours > 1 ? 's' : ''}`;
  }
}