/** * CannaiQ Authentication Middleware * * AUTH METHODS (in order of priority): * 1. IP-based: Localhost/trusted IPs get 'internal' role (full access, no token needed) * 2. Token-based: Bearer token (JWT or API token) * * NO username/password auth in API. Use tokens only. * * Trusted origins are managed via /admin and stored in the trusted_origins table. * Localhost bypass: curl from 127.0.0.1 gets automatic admin access. */ import { Request, Response, NextFunction } from 'express'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcrypt'; import { pool } from '../db/pool'; const JWT_SECRET = process.env.JWT_SECRET || 'change_this_in_production'; // Fallback trusted origins (used if DB unavailable) const FALLBACK_TRUSTED_ORIGINS = [ 'https://cannaiq.co', 'https://www.cannaiq.co', 'https://findadispo.com', 'https://www.findadispo.com', 'https://findagram.co', 'https://www.findagram.co', 'http://localhost:3010', 'http://localhost:8080', 'http://localhost:5173', ]; const FALLBACK_TRUSTED_PATTERNS = [ /^https:\/\/.*\.cannabrands\.app$/, /^https:\/\/.*\.cannaiq\.co$/, ]; const FALLBACK_TRUSTED_IPS = [ '127.0.0.1', '::1', '::ffff:127.0.0.1', ]; // Cache for DB-backed trusted origins let trustedOriginsCache: { ips: Set; domains: Set; patterns: RegExp[]; loadedAt: Date; } | null = null; /** * Load trusted origins from DB with caching (5 min TTL) */ async function loadTrustedOrigins(): Promise<{ ips: Set; domains: Set; patterns: RegExp[]; }> { // Return cached if fresh if (trustedOriginsCache) { const age = Date.now() - trustedOriginsCache.loadedAt.getTime(); if (age < 5 * 60 * 1000) { return trustedOriginsCache; } } try { const result = await pool.query(` SELECT origin_type, origin_value FROM trusted_origins WHERE active = true `); const ips = new Set(); const domains = new Set(); const patterns: RegExp[] = []; for (const row of result.rows) { switch (row.origin_type) { case 'ip': ips.add(row.origin_value); break; case 'domain': // Store as full origin for comparison if (!row.origin_value.startsWith('http')) { domains.add(`https://${row.origin_value}`); domains.add(`http://${row.origin_value}`); } else { domains.add(row.origin_value); } break; case 'pattern': try { patterns.push(new RegExp(row.origin_value)); } catch { console.warn(`[Auth] Invalid trusted origin pattern: ${row.origin_value}`); } break; } } trustedOriginsCache = { ips, domains, patterns, loadedAt: new Date() }; return trustedOriginsCache; } catch (error) { // DB not available or table doesn't exist - use fallbacks return { ips: new Set(FALLBACK_TRUSTED_IPS), domains: new Set(FALLBACK_TRUSTED_ORIGINS), patterns: FALLBACK_TRUSTED_PATTERNS, }; } } /** * Clear trusted origins cache (called when admin updates origins) */ export function clearTrustedOriginsCache() { trustedOriginsCache = null; } /** * Check if request is from a trusted origin/IP */ async function isTrustedRequest(req: Request): Promise { const { ips, domains, patterns } = await loadTrustedOrigins(); // Check origin header const origin = req.headers.origin; if (origin) { if (domains.has(origin)) { return true; } for (const pattern of patterns) { if (pattern.test(origin)) { return true; } } } // Check referer header (for same-origin requests without CORS) const referer = req.headers.referer; if (referer) { for (const trusted of domains) { if (referer.startsWith(trusted)) { return true; } } try { const refererUrl = new URL(referer); const refererOrigin = refererUrl.origin; for (const pattern of patterns) { if (pattern.test(refererOrigin)) { return true; } } } catch { // Invalid referer URL, skip } } // Check IP for internal requests (pod-to-pod, localhost) const clientIp = req.ip || req.socket.remoteAddress || ''; if (ips.has(clientIp)) { return true; } // Check for Kubernetes internal header (set by ingress/service mesh) const internalHeader = req.headers['x-internal-request']; if (internalHeader === process.env.INTERNAL_REQUEST_SECRET) { return true; } return false; } export interface AuthUser { id: number; email: string; role: string; } export interface AuthRequest extends Request { user?: AuthUser; apiToken?: { id: number; name: string; rate_limit: number; }; } export function generateToken(user: AuthUser): string { return jwt.sign( { id: user.id, email: user.email, role: user.role }, JWT_SECRET, { expiresIn: '7d' } ); } export function verifyToken(token: string): AuthUser | null { try { return jwt.verify(token, JWT_SECRET) as AuthUser; } catch (error) { return null; } } export async function authenticateUser(email: string, password: string): Promise { const result = await pool.query( 'SELECT id, email, password_hash, role FROM users WHERE email = $1', [email] ); if (result.rows.length === 0) { return null; } const user = result.rows[0]; const isValid = await bcrypt.compare(password, user.password_hash); if (!isValid) { return null; } return { id: user.id, email: user.email, role: user.role }; } export async function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) { const authHeader = req.headers.authorization; // If a Bearer token is provided, always try to use it first (logged-in user) if (authHeader && authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7); // Try JWT first const jwtUser = verifyToken(token); if (jwtUser) { req.user = jwtUser; return next(); } // If JWT fails, try API token try { const result = await pool.query(` SELECT id, name, rate_limit, active, expires_at, allowed_endpoints FROM api_tokens WHERE token = $1 `, [token]); if (result.rows.length > 0) { const apiToken = result.rows[0]; if (!apiToken.active) { return res.status(401).json({ error: 'API token is inactive' }); } if (apiToken.expires_at && new Date(apiToken.expires_at) < new Date()) { return res.status(401).json({ error: 'API token has expired' }); } req.user = { id: 0, email: `api:${apiToken.name}`, role: 'api_token' }; req.apiToken = apiToken; return next(); } } catch (err) { console.error('API token lookup error:', err); } // Token provided but invalid return res.status(401).json({ error: 'Invalid token' }); } // No token provided - check trusted origins for API access (WordPress, etc.) if (await isTrustedRequest(req)) { req.user = { id: 0, email: 'internal@system', role: 'internal' }; return next(); } return res.status(401).json({ error: 'No token provided' }); } /** * Require specific role(s) to access endpoint. * * NOTE: 'internal' role (localhost/trusted IPs) bypasses all role checks. * This allows local development and internal services full access. */ export function requireRole(...roles: string[]) { return (req: AuthRequest, res: Response, next: NextFunction) => { if (!req.user) { return res.status(401).json({ error: 'Not authenticated' }); } // Internal role (localhost) bypasses role checks if (req.user.role === 'internal') { return next(); } if (!roles.includes(req.user.role)) { return res.status(403).json({ error: 'Insufficient permissions' }); } next(); }; } /** * Optional auth middleware - attempts to authenticate but allows unauthenticated requests * * If a valid token is provided, sets req.user with the authenticated user. * If no token or invalid token, continues without setting req.user. * * Use this for endpoints that work for both authenticated and anonymous users * (e.g., product click tracking where we want user_id when available). */ export async function optionalAuthMiddleware(req: AuthRequest, res: Response, next: NextFunction) { const authHeader = req.headers.authorization; // No token provided - continue without auth if (!authHeader || !authHeader.startsWith('Bearer ')) { return next(); } const token = authHeader.substring(7); // Try JWT first const jwtUser = verifyToken(token); if (jwtUser) { req.user = jwtUser; return next(); } // If JWT fails, try API token try { const result = await pool.query(` SELECT id, name, rate_limit, active, expires_at FROM api_tokens WHERE token = $1 `, [token]); if (result.rows.length > 0) { const apiToken = result.rows[0]; // Check if token is active and not expired if (apiToken.active && (!apiToken.expires_at || new Date(apiToken.expires_at) >= new Date())) { req.apiToken = { id: apiToken.id, name: apiToken.name, rate_limit: apiToken.rate_limit }; req.user = { id: apiToken.id, email: `api-token-${apiToken.id}@system`, role: 'api' }; } } } catch (error) { // Silently ignore errors - optional auth should not fail the request console.warn('[OptionalAuth] Error checking API token:', error); } next(); }