Files
cannaiq/backend/src/auth/middleware.ts
Kelly 1fa9ea496c fix(auth): Prioritize JWT token over trusted origin bypass
When a user logs in and has a Bearer token, use their actual identity
instead of falling back to internal@system. This ensures logged-in
users see their real email in the admin UI.

Order of auth:
1. If Bearer token provided → use JWT/API token (real user identity)
2. If no token → check trusted origins (for API access like WordPress)
3. Otherwise → 401 unauthorized

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-10 18:21:50 -07:00

301 lines
7.7 KiB
TypeScript
Executable File

/**
* 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.
*
* 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';
// Trusted origins that bypass auth for internal/same-origin requests
const 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',
];
// Pattern-based trusted origins (wildcards)
const TRUSTED_ORIGIN_PATTERNS = [
/^https:\/\/.*\.cannabrands\.app$/, // *.cannabrands.app
/^https:\/\/.*\.cannaiq\.co$/, // *.cannaiq.co
];
// Trusted IPs for internal pod-to-pod communication
const TRUSTED_IPS = [
'127.0.0.1',
'::1',
'::ffff:127.0.0.1',
];
/**
* Check if request is from a trusted origin/IP
*/
function isTrustedRequest(req: Request): boolean {
// Check origin header
const origin = req.headers.origin;
if (origin) {
if (TRUSTED_ORIGINS.includes(origin)) {
return true;
}
// Check pattern-based origins (wildcards like *.cannabrands.app)
for (const pattern of TRUSTED_ORIGIN_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 TRUSTED_ORIGINS) {
if (referer.startsWith(trusted)) {
return true;
}
}
// Check pattern-based referers
try {
const refererUrl = new URL(referer);
const refererOrigin = refererUrl.origin;
for (const pattern of TRUSTED_ORIGIN_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 (TRUSTED_IPS.includes(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<AuthUser | null> {
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 (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();
}