File size: 4,868 Bytes
57a889c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { AuthRequest, OptionalAuthRequest, User } from '../types';
import { applyIdempotency } from './idempotency';
import { isDemoEmail } from '../services/demo';

export function extractToken(req: Request): string | null {
  // Prefer httpOnly cookie; fall back to Authorization: Bearer (MCP, API clients)
  const cookieToken = (req as any).cookies?.trek_session;
  if (cookieToken) return cookieToken;
  const authHeader = req.headers['authorization'];
  return (authHeader && authHeader.split(' ')[1]) || null;
}

/**
 * Verify a JWT and load its user, enforcing the password_version gate.
 *
 * Exported so every auth surface in the codebase (MCP bearer tokens,
 * file download query tokens, the photo-serving route) goes through the
 * same check. A password reset bumps `users.password_version`, which
 * invalidates every JWT that embedded the prior value — but only if
 * every verify path actually compares the claim. Previously several
 * paths called `jwt.verify` directly and skipped the DB lookup, so a
 * stolen token kept working after the victim reset.
 */
export function verifyJwtAndLoadUser(token: string): User | null {
  try {
    const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number; pv?: number; purpose?: string };
    // Purpose-scoped tokens (e.g. the short-lived mfa_login token) share this
    // secret but are not full session tokens — only their dedicated endpoint
    // may accept them, so reject any token carrying a purpose claim here.
    if (decoded.purpose) return null;
    const row = db.prepare(
      'SELECT id, username, email, role, password_version FROM users WHERE id = ?'
    ).get(decoded.id) as (User & { password_version?: number }) | undefined;
    if (!row) return null;
    // Session invalidation: any token whose embedded password_version
    // predates the user's current one is rejected. Tokens issued before
    // the `pv` claim existed (decoded.pv === undefined) are treated as
    // version 0 so legacy sessions keep working until the user resets.
    const tokenPv = typeof decoded.pv === 'number' ? decoded.pv : 0;
    const currentPv = typeof row.password_version === 'number' ? row.password_version : 0;
    if (tokenPv !== currentPv) return null;
    // Don't leak password_version beyond the middleware.
    const { password_version: _pv, ...user } = row;
    return user as User;
  } catch {
    return null;
  }
}

const authenticate = (req: Request, res: Response, next: NextFunction): void => {
  const token = extractToken(req);

  if (!token) {
    res.status(401).json({ error: 'Access token required', code: 'AUTH_REQUIRED' });
    return;
  }

  const user = verifyJwtAndLoadUser(token);
  if (!user) {
    res.status(401).json({ error: 'Invalid or expired token', code: 'AUTH_REQUIRED' });
    return;
  }
  (req as AuthRequest).user = user;
  applyIdempotency(req, res, next, user.id);
};

/** Like `authenticate` but rejects requests that don't carry an httpOnly session cookie.
 *  Used on state-mutating OAuth endpoints (consent POST, client CRUD, session revoke)
 *  to prevent Bearer JWT tokens obtained by other means from managing OAuth clients. */
const requireCookieAuth = (req: Request, res: Response, next: NextFunction): void => {
  const cookieToken = (req as any).cookies?.trek_session;
  if (!cookieToken) {
    res.status(401).json({ error: 'Cookie session required for this endpoint', code: 'COOKIE_AUTH_REQUIRED' });
    return;
  }
  const user = verifyJwtAndLoadUser(cookieToken);
  if (!user) {
    res.status(401).json({ error: 'Invalid or expired session', code: 'AUTH_REQUIRED' });
    return;
  }
  (req as AuthRequest).user = user;
  next();
};

const optionalAuth = (req: Request, res: Response, next: NextFunction): void => {
  const token = extractToken(req);

  if (!token) {
    (req as OptionalAuthRequest).user = null;
    return next();
  }

  (req as OptionalAuthRequest).user = verifyJwtAndLoadUser(token) || null;
  next();
};

const adminOnly = (req: Request, res: Response, next: NextFunction): void => {
  const authReq = req as AuthRequest;
  if (!authReq.user || authReq.user.role !== 'admin') {
    res.status(403).json({ error: 'Admin access required' });
    return;
  }
  next();
};

const demoUploadBlock = (req: Request, res: Response, next: NextFunction): void => {
  const authReq = req as AuthRequest;
  if (process.env.DEMO_MODE?.toLowerCase() === 'true' && isDemoEmail(authReq.user?.email)) {
    res.status(403).json({ error: 'Uploads are disabled in demo mode. Self-host TREK for full functionality.' });
    return;
  }
  next();
};

export { authenticate, requireCookieAuth, optionalAuth, adminOnly, demoUploadBlock };