EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
jwt-auth.js
Go to the documentation of this file.
1/**
2 * JWT Authentication Module for MeshCentral
3 * Integrates with PostgreSQL user database for tenant-based authentication
4 *
5 * This module validates JWT tokens from the RMM+PSA backend and maps users
6 * to MeshCentral's internal user format, enabling true SSO.
7 */
8
9'use strict';
10
11module.exports.CreateJWTAuth = function (parent) {
12 const obj = {};
13 const jwt = require('jsonwebtoken');
14 const { Pool } = require('pg');
15
16 // Log what environment variables we actually receive
17 console.log('[JWT Auth] Environment variables check:');
18 console.log(' JWT_SECRET:', process.env.JWT_SECRET ? `${process.env.JWT_SECRET.substring(0, 20)}...` : 'NOT SET');
19 console.log(' AGENT_SIGN_KEY:', process.env.AGENT_SIGN_KEY ? `${process.env.AGENT_SIGN_KEY.substring(0, 20)}...` : 'NOT SET');
20 console.log(' POSTGRES_HOST:', process.env.POSTGRES_HOST ? 'SET' : 'NOT SET');
21 console.log(' POSTGRES_PASSWORD:', process.env.POSTGRES_PASSWORD ? 'SET' : 'NOT SET');
22
23 // JWT configuration
24 obj.jwtSecret = process.env.JWT_SECRET;
25 obj.agentSignKey = process.env.AGENT_SIGN_KEY;
26
27 // PostgreSQL connection pool
28 // NOTE: jwt-auth connects to user database (defaultdb), NOT MeshCentral's device database
29 // Use DB_NAME first (for users), fallback to POSTGRES_DB only if DB_NAME not set
30 const dbHost = process.env.POSTGRES_HOST || process.env.DB_HOST;
31 const dbPort = parseInt(process.env.POSTGRES_PORT || process.env.DB_PORT || '25060');
32 const dbName = process.env.DB_NAME || process.env.POSTGRES_DB || 'defaultdb';
33 const dbUser = process.env.POSTGRES_USER || process.env.DB_USER || 'doadmin';
34 const dbPassword = process.env.POSTGRES_PASSWORD || process.env.DB_PASSWORD;
35
36 console.log('[JWT Auth] Database config:', {
37 host: dbHost ? dbHost.substring(0, 20) + '...' : 'NOT SET',
38 port: dbPort,
39 database: dbName,
40 user: dbUser,
41 passwordSet: !!dbPassword
42 });
43
44 if (!dbHost || !dbPassword) {
45 console.error('❌ JWT Auth: Missing required database environment variables');
46 console.error('Required: POSTGRES_HOST/DB_HOST and POSTGRES_PASSWORD/DB_PASSWORD');
47 throw new Error('Missing database configuration');
48 }
49
50 obj.pool = new Pool({
51 host: dbHost,
52 port: dbPort,
53 database: dbName,
54 user: dbUser,
55 password: dbPassword,
56 ssl: { rejectUnauthorized: false },
57 connectionTimeoutMillis: 5000,
58 idleTimeoutMillis: 30000,
59 max: 10
60 });
61
62 // User cache to reduce database queries (5 minute TTL)
63 obj.userCache = new Map();
64 obj.cacheTimeout = 5 * 60 * 1000; // 5 minutes
65
66 // Initialize and log configuration
67 obj.init = function () {
68 parent.debug('jwt-auth', 'JWT Authentication Module Initialized');
69 parent.debug('jwt-auth', `PostgreSQL: ${obj.pool.options.host}:${obj.pool.options.port}/${obj.pool.options.database}`);
70
71 // Test database connection
72 obj.pool.query('SELECT NOW()', (err, result) => {
73 if (err) {
74 parent.debug('jwt-auth', 'PostgreSQL connection failed:', err.message);
75 console.error('❌ JWT Auth: PostgreSQL connection failed:', err.message);
76 } else {
77 parent.debug('jwt-auth', 'PostgreSQL connection successful');
78 console.log('✅ JWT Auth: PostgreSQL connected');
79 }
80 });
81 };
82
83 /**
84 * Validate JWT token and return decoded payload
85 * Tries JWT_SECRET first, then AGENT_SIGN_KEY (for agent tokens)
86 */
87 obj.verifyToken = function (token, callback) {
88 if (!token) return callback(null, null);
89
90 // Try JWT_SECRET first (dashboard tokens)
91 jwt.verify(token, obj.jwtSecret, (err, decoded) => {
92 if (!err) {
93 parent.debug('jwt-auth', `Token verified with JWT_SECRET for user: ${decoded.email || decoded.user_id}`);
94 return callback(null, decoded);
95 }
96
97 // Try AGENT_SIGN_KEY (agent tokens)
98 if (obj.agentSignKey) {
99 jwt.verify(token, obj.agentSignKey, (err2, decoded2) => {
100 if (!err2) {
101 parent.debug('jwt-auth', `Token verified with AGENT_SIGN_KEY for agent: ${decoded2.agent_uuid || decoded2.agentId}`);
102 return callback(null, decoded2);
103 }
104
105 parent.debug('jwt-auth', 'Token verification failed:', err.message, err2.message);
106 return callback(new Error('Invalid token'), null);
107 });
108 } else {
109 parent.debug('jwt-auth', 'Token verification failed:', err.message);
110 return callback(err, null);
111 }
112 });
113 };
114
115 /**
116 * Validate JWT token and fetch corresponding user from PostgreSQL
117 * Returns MeshCentral-formatted user object
118 */
119 obj.validateToken = function (token, callback) {
120 console.log('[JWT Auth] validateToken called with token:', token ? token.substring(0, 30) + '...' : 'null');
121
122 obj.verifyToken(token, (err, decoded) => {
123 if (err || !decoded) {
124 console.log('[JWT Auth] Token verification failed:', err ? err.message : 'no decoded payload');
125 return callback(null);
126 }
127
128 console.log('[JWT Auth] Token decoded successfully:', JSON.stringify(decoded, null, 2));
129
130 // Extract user identifier - could be email, user_id, or userId
131 const email = decoded.email;
132 const userId = decoded.user_id || decoded.userId;
133 const tenantId = decoded.tenant_id || decoded.tenantId;
134
135 if ((!email && !userId) || !tenantId) {
136 console.log('[JWT Auth] Invalid token payload - missing user identifier or tenant_id', { email, userId, tenantId });
137 return callback(null);
138 }
139
140 // Check cache first
141 const cacheKey = `${email || userId}_${tenantId}`;
142 const cached = obj.userCache.get(cacheKey);
143 if (cached && (Date.now() - cached.timestamp < obj.cacheTimeout)) {
144 parent.debug('jwt-auth', `Cache hit for user: ${cacheKey}`);
145 console.log('[JWT Auth] Cache hit for user:', cacheKey);
146 return callback(cached.user);
147 }
148
149 // Fetch from database - prefer email lookup, fall back to user_id
150 console.log('[JWT Auth] Looking up user in database:', { email, userId, tenantId });
151
152 const lookupFunction = email ? obj.getUserByEmail : obj.getUserById;
153 const lookupValue = email || userId;
154
155 lookupFunction.call(obj, lookupValue, tenantId, (meshUser) => {
156 if (meshUser) {
157 console.log('[JWT Auth] User found and mapped:', meshUser._id);
158 // Cache the result
159 obj.userCache.set(cacheKey, {
160 user: meshUser,
161 timestamp: Date.now()
162 });
163 } else {
164 console.log('[JWT Auth] User not found in database');
165 }
166 callback(meshUser);
167 });
168 });
169 };
170
171 /**
172 * Fetch user from PostgreSQL and map to MeshCentral user format
173 */
174 obj.getUserByEmail = async function (email, tenantId, callback) {
175 try {
176 const result = await obj.pool.query(
177 `SELECT
178 user_id,
179 email,
180 name,
181 role,
182 tenant_id,
183 created_at,
184 mfa_enabled
185 FROM users
186 WHERE email = $1 AND tenant_id = $2`,
187 [email, tenantId]
188 );
189
190 if (result.rows.length === 0) {
191 parent.debug('jwt-auth', `User not found: ${email} in tenant ${tenantId}`);
192 return callback(null);
193 }
194
195 const pgUser = result.rows[0];
196
197 // Determine site admin privileges
198 // Root tenant (tenant_id = 1 or specific UUID) gets full admin
199 // Regular admins get limited admin rights
200 let siteadmin = 0;
201 if (pgUser.role === 'admin') {
202 // Check if root tenant
203 if (tenantId === '1' || tenantId === '00000000-0000-0000-0000-000000000001') {
204 siteadmin = 0xFFFFFFFF; // Full admin
205 } else {
206 siteadmin = 0x00000006; // Manage users + server update
207 }
208 }
209
210 // Map PostgreSQL user to MeshCentral user format
211 // Use empty domain string "" for default domain
212 const meshUser = {
213 _id: `user//${email}`, // MeshCentral format: user/{domain}/{username}
214 email: pgUser.email,
215 name: pgUser.name || pgUser.email.split('@')[0],
216 domain: "", // Default domain (empty string)
217 siteadmin: siteadmin,
218 emailVerified: true, // Assume emails are verified in your system
219 creation: Math.floor(new Date(pgUser.created_at).getTime() / 1000),
220 links: {},
221 // Custom fields for RMM+PSA integration
222 _external: true, // Mark as externally authenticated
223 _postgres_user_id: pgUser.user_id,
224 _tenant_id: tenantId
225 };
226
227 parent.debug('jwt-auth', `Mapped user: ${meshUser._id} (${meshUser.email}) siteadmin=${siteadmin}`);
228 console.log(`[JWT Auth] User mapped: ${meshUser._id}, siteadmin=${siteadmin}`);
229
230 // Fetch device links for this user's tenant
231 obj.getUserDeviceLinks(pgUser.user_id, tenantId, (links) => {
232 meshUser.links = links;
233 callback(meshUser);
234 });
235
236 } catch (err) {
237 parent.debug('jwt-auth', 'PostgreSQL query error:', err.message);
238 console.error('❌ JWT Auth: Database query failed:', err.message);
239 callback(null);
240 }
241 };
242
243 /**
244 * Fetch user by user_id from PostgreSQL and map to MeshCentral user format
245 */
246 obj.getUserById = async function (userId, tenantId, callback) {
247 try {
248 console.log('[JWT Auth] getUserById:', { userId, tenantId });
249
250 const result = await obj.pool.query(
251 `SELECT
252 user_id,
253 email,
254 name,
255 role,
256 tenant_id,
257 created_at,
258 mfa_enabled
259 FROM users
260 WHERE user_id = $1 AND tenant_id = $2`,
261 [userId, tenantId]
262 );
263
264 if (result.rows.length === 0) {
265 parent.debug('jwt-auth', `User not found: ID ${userId} in tenant ${tenantId}`);
266 console.log(`[JWT Auth] User not found: ID ${userId} in tenant ${tenantId}`);
267 return callback(null);
268 }
269
270 const pgUser = result.rows[0];
271 console.log('[JWT Auth] Found user in database:', { userId: pgUser.user_id, email: pgUser.email, name: pgUser.name, role: pgUser.role });
272
273 // Determine site admin privileges
274 let siteadmin = 0;
275 if (pgUser.role === 'admin') {
276 // Check if root tenant
277 if (tenantId === '1' || tenantId === '00000000-0000-0000-0000-000000000001') {
278 siteadmin = 0xFFFFFFFF; // Full admin
279 } else {
280 siteadmin = 0x00000006; // Manage users + server update
281 }
282 }
283
284 // Map PostgreSQL user to MeshCentral user format
285 // Use empty domain string "" for default domain
286 const email = pgUser.email || `user${pgUser.user_id}@local`;
287 const meshUser = {
288 _id: `user//${email}`, // MeshCentral format: user/{domain}/{username}
289 email: email,
290 name: pgUser.name || email.split('@')[0],
291 domain: "", // Default domain (empty string)
292 siteadmin: siteadmin,
293 emailVerified: true,
294 creation: Math.floor(new Date(pgUser.created_at).getTime() / 1000),
295 links: {},
296 // Custom fields for RMM+PSA integration
297 _external: true,
298 _postgres_user_id: pgUser.user_id,
299 _tenant_id: tenantId
300 };
301
302 parent.debug('jwt-auth', `Mapped user by ID: ${meshUser._id} (${meshUser.email}) siteadmin=${siteadmin}`);
303 console.log(`[JWT Auth] User mapped by ID: ${meshUser._id}, siteadmin=${siteadmin}`);
304
305 // Fetch device links for this user's tenant
306 obj.getUserDeviceLinks(pgUser.user_id, tenantId, (links) => {
307 meshUser.links = links;
308 callback(meshUser);
309 });
310
311 } catch (err) {
312 parent.debug('jwt-auth', 'PostgreSQL query error (getUserById):', err.message);
313 console.error('❌ JWT Auth: Database query failed (getUserById):', err.message);
314 callback(null);
315 }
316 };
317
318 /**
319 * Fetch device links for user's tenant from agents table
320 * Returns MeshCentral-compatible device links
321 */
322 obj.getUserDeviceLinks = async function (userId, tenantId, callback) {
323 try {
324 const result = await obj.pool.query(
325 `SELECT
326 agent_id,
327 agent_uuid,
328 hostname,
329 meshcentral_nodeid,
330 platform,
331 ip_address,
332 last_seen
333 FROM agents
334 WHERE tenant_id = $1 AND meshcentral_nodeid IS NOT NULL`,
335 [tenantId]
336 );
337
338 const links = {};
339 const meshId = `mesh/${tenantId}/default`; // Default mesh for tenant
340
341 result.rows.forEach(agent => {
342 if (agent.meshcentral_nodeid) {
343 // Link user to device
344 links[agent.meshcentral_nodeid] = { rights: 0xFFFFFFFF }; // Full rights
345
346 // Link to mesh
347 if (!links[meshId]) {
348 links[meshId] = { rights: 0xFFFFFFFF }; // Full rights to mesh
349 }
350 }
351 });
352
353 parent.debug('jwt-auth', `Found ${result.rows.length} devices for tenant ${tenantId}`);
354 callback(links);
355
356 } catch (err) {
357 parent.debug('jwt-auth', 'Failed to fetch device links:', err.message);
358 callback({});
359 }
360 };
361
362 /**
363 * Ensure tenant mesh exists, create if not
364 * Each tenant gets their own device group
365 */
366 obj.ensureTenantMesh = function (tenantId, callback) {
367 const meshId = `mesh/${tenantId}/default`;
368
369 parent.db.Get(meshId, function (err, meshes) {
370 if (meshes && meshes.length > 0) {
371 parent.debug('jwt-auth', `Mesh exists: ${meshId}`);
372 return callback(meshes[0]);
373 }
374
375 // Create new mesh for tenant
376 const newMesh = {
377 _id: meshId,
378 name: `Tenant ${tenantId} Devices`,
379 mtype: 2, // Managed mesh
380 desc: `Auto-created device group for tenant ${tenantId}`,
381 domain: tenantId,
382 flags: 0,
383 links: {}
384 };
385
386 parent.db.Set(newMesh);
387 parent.debug('jwt-auth', `Created mesh: ${meshId}`);
388 console.log(`✅ JWT Auth: Created mesh for tenant ${tenantId}`);
389
390 callback(newMesh);
391 });
392 };
393
394 /**
395 * Clear user cache (useful for debugging or after user updates)
396 */
397 obj.clearCache = function (email, tenantId) {
398 if (email && tenantId) {
399 const cacheKey = `${email}_${tenantId}`;
400 obj.userCache.delete(cacheKey);
401 parent.debug('jwt-auth', `Cleared cache for ${cacheKey}`);
402 } else {
403 obj.userCache.clear();
404 parent.debug('jwt-auth', 'Cleared entire user cache');
405 }
406 };
407
408 /**
409 * Extract JWT token from various sources
410 * Checks: Authorization header, query parameter, cookie
411 */
412 obj.extractToken = function (req) {
413 // Check Authorization header (Bearer token)
414 if (req.headers && req.headers.authorization) {
415 const parts = req.headers.authorization.split(' ');
416 if (parts.length === 2 && parts[0] === 'Bearer') {
417 return parts[1];
418 }
419 }
420
421 // Check query parameter
422 if (req.query && req.query.token) {
423 return req.query.token;
424 }
425
426 // Check cookie (from WebSocket upgrade request)
427 if (req.headers && req.headers.cookie) {
428 const cookies = req.headers.cookie.split(';');
429 for (let i = 0; i < cookies.length; i++) {
430 const cookie = cookies[i].trim();
431 if (cookie.startsWith('jwt=')) {
432 return cookie.substring(4);
433 }
434 }
435 }
436
437 return null;
438 };
439
440 /**
441 * Authenticate user with username/password against PostgreSQL
442 * Returns MeshCentral user object if successful, null otherwise
443 */
444 obj.authenticatePassword = async function (username, password, callback) {
445 try {
446 console.log('[JWT Auth] Password authentication attempt for:', username);
447
448 // Query user by email or username
449 const result = await obj.pool.query(
450 `SELECT
451 user_id,
452 email,
453 name,
454 role,
455 tenant_id,
456 created_at,
457 mfa_enabled,
458 password_hash
459 FROM users
460 WHERE (email = $1 OR name = $1)`,
461 [username.toLowerCase()]
462 );
463
464 if (result.rows.length === 0) {
465 console.log('[JWT Auth] User not found:', username);
466 return callback(null);
467 }
468
469 const pgUser = result.rows[0];
470
471 if (!pgUser.password_hash) {
472 console.log('[JWT Auth] User has no password hash (OAuth-only user?)');
473 return callback(null);
474 }
475
476 // Verify password using bcrypt
477 const bcrypt = require('bcrypt');
478 const passwordMatch = await bcrypt.compare(password, pgUser.password_hash);
479
480 if (!passwordMatch) {
481 console.log('[JWT Auth] Password verification failed for:', username);
482 return callback(null);
483 }
484
485 console.log('[JWT Auth] Password verified successfully for:', username);
486
487 // Determine site admin privileges
488 let siteadmin = 0;
489 if (pgUser.role === 'admin') {
490 // Check if root tenant
491 if (pgUser.tenant_id === '1' || pgUser.tenant_id === '00000000-0000-0000-0000-000000000001') {
492 siteadmin = 0xFFFFFFFF; // Full admin
493 } else {
494 siteadmin = 0x00000006; // Manage users + server update
495 }
496 }
497
498 // Map PostgreSQL user to MeshCentral user format
499 const meshUser = {
500 _id: `user//${pgUser.email}`,
501 email: pgUser.email,
502 name: pgUser.name || pgUser.email.split('@')[0],
503 domain: "",
504 siteadmin: siteadmin,
505 emailVerified: true,
506 creation: Math.floor(new Date(pgUser.created_at).getTime() / 1000),
507 links: {},
508 _external: true,
509 _postgres_user_id: pgUser.user_id,
510 _tenant_id: pgUser.tenant_id
511 };
512
513 console.log(`[JWT Auth] User authenticated: ${meshUser._id}, siteadmin=${siteadmin}`);
514
515 // Fetch device links for this user's tenant
516 obj.getUserDeviceLinks(pgUser.user_id, pgUser.tenant_id, (links) => {
517 meshUser.links = links;
518 callback(meshUser);
519 });
520
521 } catch (err) {
522 console.error('❌ JWT Auth: Password authentication error:', err.message);
523 callback(null);
524 }
525 };
526
527 /**
528 * Health check
529 */
530 obj.healthCheck = async function (callback) {
531 try {
532 const result = await obj.pool.query('SELECT NOW() as time, COUNT(*) as user_count FROM users');
533 callback({
534 status: 'healthy',
535 database: 'connected',
536 users: result.rows[0].user_count,
537 cache_size: obj.userCache.size,
538 timestamp: result.rows[0].time
539 });
540 } catch (err) {
541 callback({
542 status: 'unhealthy',
543 database: 'disconnected',
544 error: err.message
545 });
546 }
547 };
548
549 // Initialize on creation
550 obj.init();
551
552 return obj;
553};