← back to Wine Finder Next

lib/jwtAuth.ts

197 lines

// JWT Authentication
import crypto from 'crypto';

interface JWTPayload {
  sub: string; // subject (user ID)
  email: string;
  role: string;
  iat: number; // issued at
  exp: number; // expiration
  jti?: string; // JWT ID (for revocation)
}

interface JWTHeader {
  alg: string;
  typ: string;
}

const JWT_CONFIG = {
  algorithm: 'HS256',
  expiresIn: 24 * 60 * 60, // 24 hours in seconds
  issuer: 'wine-dao-platform',
  audience: 'wine-dao-users'
};

// Get JWT secret
function getJWTSecret(): string {
  return process.env.JWT_SECRET || 'change-me-in-production-use-256-bit-key';
}

// Base64 URL encode
function base64urlEncode(str: string): string {
  return Buffer.from(str)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// Base64 URL decode
function base64urlDecode(str: string): string {
  const padding = '='.repeat((4 - (str.length % 4)) % 4);
  const base64 = str.replace(/-/g, '+').replace(/_/g, '/') + padding;
  return Buffer.from(base64, 'base64').toString('utf8');
}

// Create JWT token
export function createJWT(userId: string, email: string, role: string): string {
  const now = Math.floor(Date.now() / 1000);

  const header: JWTHeader = {
    alg: JWT_CONFIG.algorithm,
    typ: 'JWT'
  };

  const payload: JWTPayload = {
    sub: userId,
    email,
    role,
    iat: now,
    exp: now + JWT_CONFIG.expiresIn,
    jti: crypto.randomBytes(16).toString('hex')
  };

  const encodedHeader = base64urlEncode(JSON.stringify(header));
  const encodedPayload = base64urlEncode(JSON.stringify(payload));

  const signature = crypto
    .createHmac('sha256', getJWTSecret())
    .update(`${encodedHeader}.${encodedPayload}`)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  return `${encodedHeader}.${encodedPayload}.${signature}`;
}

// Verify JWT token
export function verifyJWT(token: string): { valid: boolean; payload?: JWTPayload; error?: string } {
  try {
    const parts = token.split('.');

    if (parts.length !== 3) {
      return { valid: false, error: 'Invalid token format' };
    }

    const [encodedHeader, encodedPayload, signature] = parts;

    // Verify signature
    const expectedSignature = crypto
      .createHmac('sha256', getJWTSecret())
      .update(`${encodedHeader}.${encodedPayload}`)
      .digest('base64')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, '');

    if (signature !== expectedSignature) {
      return { valid: false, error: 'Invalid signature' };
    }

    // Decode payload
    const payload: JWTPayload = JSON.parse(base64urlDecode(encodedPayload));

    // Check expiration
    const now = Math.floor(Date.now() / 1000);
    if (payload.exp && payload.exp < now) {
      return { valid: false, error: 'Token expired' };
    }

    return { valid: true, payload };
  } catch (error) {
    return { valid: false, error: 'Token verification failed' };
  }
}

// Refresh token (create new one with extended expiration)
export function refreshJWT(oldToken: string): { token?: string; error?: string } {
  const verification = verifyJWT(oldToken);

  if (!verification.valid || !verification.payload) {
    return { error: verification.error || 'Invalid token' };
  }

  // Only allow refresh if token is not too old (within 7 days of expiration)
  const now = Math.floor(Date.now() / 1000);
  const daysUntilExpiry = (verification.payload.exp - now) / (24 * 60 * 60);

  if (daysUntilExpiry > 7) {
    return { error: 'Token not eligible for refresh yet' };
  }

  const newToken = createJWT(
    verification.payload.sub,
    verification.payload.email,
    verification.payload.role
  );

  return { token: newToken };
}

// Extract token from Authorization header
export function extractTokenFromHeader(authHeader: string | null): string | null {
  if (!authHeader) return null;

  const parts = authHeader.split(' ');
  if (parts.length !== 2 || parts[0] !== 'Bearer') {
    return null;
  }

  return parts[1];
}

// Revoked tokens store (in production, use Redis or database)
const revokedTokens = new Set<string>();

// Revoke token
export function revokeJWT(token: string): void {
  const verification = verifyJWT(token);
  if (verification.valid && verification.payload?.jti) {
    revokedTokens.add(verification.payload.jti);
  }
}

// Check if token is revoked
export function isTokenRevoked(token: string): boolean {
  const verification = verifyJWT(token);
  if (!verification.valid || !verification.payload?.jti) {
    return true;
  }
  return revokedTokens.has(verification.payload.jti);
}

// Clean up expired revoked tokens periodically
export function cleanupRevokedTokens(): void {
  // This would query the database in production
  // For now, we just clear the set periodically
  if (revokedTokens.size > 10000) {
    revokedTokens.clear();
  }
}

// Decode JWT without verification (for debugging only)
export function decodeJWT(token: string): { header: any; payload: any } | null {
  try {
    const parts = token.split('.');
    if (parts.length !== 3) return null;

    const header = JSON.parse(base64urlDecode(parts[0]));
    const payload = JSON.parse(base64urlDecode(parts[1]));

    return { header, payload };
  } catch {
    return null;
  }
}