EverydayTech Platform - Developer Reference
Complete Source Code Documentation - All Applications
Loading...
Searching...
No Matches
auth.js
Go to the documentation of this file.
1/**
2 * @file routes/auth.js
3 * @brief Authentication and tenant impersonation API endpoints
4 * @description Provides authentication endpoints for tenant impersonation in multi-tenant
5 * MSP environment. Allows root/MSP users to impersonate other tenants for support and
6 * administration purposes, with full audit logging. Includes endpoints for starting and
7 * stopping impersonation sessions.
8 *
9 * **Key Features:**
10 * - Tenant impersonation (MSP → Customer tenant)
11 * - Exit impersonation (return to root tenant)
12 * - Full audit trail of impersonation sessions
13 * - IP address and user agent tracking
14 * - JWT token generation with impersonation context
15 *
16 * **Security:**
17 * - Only root/MSP/admin roles can impersonate
18 * - All impersonation events are logged
19 * - JWT tokens contain impersonation flag
20 * - 2-hour token expiration
21 * @author Independent Business Group
22 * @date 2026-03-11
23 * @module routes/auth
24 * @requires express
25 * @requires jsonwebtoken
26 * @requires ../services/db
27 * @requires ../middleware/auth
28 * @requires ../middleware/tenant
29 */
30
31const express = require('express');
32const router = express.Router();
33const jwt = require('jsonwebtoken');
34const pool = require('../services/db');
35const authenticateToken = require('../middleware/auth');
36const { setTenantContext } = require('../middleware/tenant');
37
38/**
39 * @api {post} /auth/exit-impersonation Exit tenant impersonation
40 * @apiName ExitImpersonation
41 * @apiGroup Authentication
42 * @apiDescription Exits current tenant impersonation session and returns user to
43 * their root tenant context. Issues new JWT token without impersonation flag. Records
44 * the impersonation stop event in audit log with IP address and user agent for
45 * security tracking.
46 *
47 * **Use Case:** MSP admin who was impersonating a customer tenant needs to return
48 * to their root tenant to manage other customers or access MSP-level features.
49 * @apiHeader {string} Authorization Bearer JWT token with impersonation flag
50 * @apiSuccess {string} token New JWT token for root tenant
51 * @apiSuccess {string} message Success message
52 * @apiSuccess {object} user User object with root tenant context
53 * @apiError {string} error Error message
54 * @apiError {number} 403 User is not MSP/root/admin role
55 * @apiError {number} 500 Token generation or audit logging failed
56 * @apiExample {curl} Example Request:
57 * curl -X POST \
58 * -H "Authorization: Bearer eyJhbGc..." \
59 * "https://api.everydaytech.au/auth/exit-impersonation"
60 * @apiExample {json} Success Response:
61 * HTTP/1.1 200 OK
62 * {
63 * "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
64 * "message": "Exited impersonation mode",
65 * "user": {
66 * "user_id": "uuid-here",
67 * "tenant_id": "00000000-0000-0000-0000-000000000001",
68 * "role": "msp",
69 * "impersonated": false
70 * }
71 * }
72 * @since 2.0.0
73 * @see {@link module:routes/auth.impersonate} for starting impersonation
74 */
75router.post('/exit-impersonation', authenticateToken, setTenantContext, async (req, res) => {
76 try {
77 if (!req.user || !(req.user.role === 'msp' || req.user.role === 'root' || req.user.role === 'admin')) {
78 return res.status(403).json({ error: 'Only MSP/root/admin can exit impersonation' });
79 }
80 // Issue new JWT for root context (no impersonation)
81 const rootTenantId = '00000000-0000-0000-0000-000000000001';
82 const payload = {
83 user_id: req.user.impersonator_id || req.user.user_id,
84 tenant_id: rootTenantId,
85 tenantId: rootTenantId,
86 role: req.user.role,
87 name: req.user.impersonator_name || req.user.name,
88 email: req.user.email,
89 is_msp: req.user.role === 'msp' || req.user.role === 'root',
90 impersonated: false
91 };
92 // Remove impersonation-specific fields
93 delete payload.impersonator_id;
94 delete payload.impersonator_name;
95 const token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '2h' });
96 // Audit log
97 try {
98 const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
99 let impersonatorId = req.user.user_id;
100 if (!uuidRegex.test(String(impersonatorId))) {
101 impersonatorId = req.user.tenantId && uuidRegex.test(String(req.user.tenantId))
102 ? req.user.tenantId
103 : '00000000-0000-0000-0000-000000000000';
104 }
105 await pool.query(
106 `INSERT INTO impersonation_audit (
107 impersonator_id, impersonator_name, impersonated_tenant_id, impersonated_tenant_name, action, ip_address, user_agent
108 ) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
109 [
110 impersonatorId,
111 req.user.name,
112 rootTenantId,
113 'Root Tenant',
114 'stop',
115 req.ip,
116 req.headers['user-agent'] || ''
117 ]
118 );
119 } catch (auditErr) {
120 console.error('[auth/exit-impersonation] Audit log insert error:', auditErr);
121 }
122
123 // Set token as HTTP-only cookie
124 // Use SameSite=None for production cross-origin (App Platform separate domains)
125 res.cookie('auth_token', token, {
126 httpOnly: true,
127 secure: true, // Always require HTTPS in production
128 sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
129 maxAge: 2 * 60 * 60 * 1000, // 2 hours
130 domain: process.env.NODE_ENV === 'production' ? '.ondigitalocean.app' : undefined
131 });
132
133 res.json({ token });
134 } catch (err) {
135 console.error('[auth/exit-impersonation] Error:', err);
136 res.status(500).json({ error: 'Failed to exit impersonation', details: err.message });
137 }
138});
139
140/**
141 * @api {post} /auth/impersonate Start tenant impersonation
142 * @apiName ImpersonateTenant
143 * @apiGroup Authentication
144 * @apiDescription Starts tenant impersonation session, allowing MSP/admin users to
145 * access another tenant's context as if they were that tenant. Issues new JWT token
146 * with impersonated tenant context. Records impersonation start event in audit log
147 * with full details for compliance and security tracking.
148 *
149 * **Use Case:** MSP support staff needs to troubleshoot issues in a customer's
150 * account by viewing their dashboard, tickets, and data as they would see it.
151 *
152 * **Security Considerations:**
153 * - Only root, MSP, and admin roles can impersonate
154 * - All impersonation sessions are audit logged
155 * - Impersonated flag is set in JWT for transparency
156 * - IP address and user agent tracked
157 * - 2-hour session expiration
158 * @apiHeader {string} Authorization Bearer JWT token (root/MSP/admin)
159 * @apiHeader {string} Content-Type application/json
160 * @apiParam {string} tenant_id UUID of tenant to impersonate
161 * @apiSuccess {string} token New JWT token with impersonated tenant context
162 * @apiSuccess {string} message Success message with tenant name
163 * @apiSuccess {object} user User object with impersonated tenant context
164 * @apiSuccess {string} user.tenant_id Impersonated tenant ID
165 * @apiSuccess {boolean} user.impersonated Always true
166 * @apiSuccess {string} user.role User's original role
167 * @apiError {string} error Error message
168 * @apiError {number} 400 tenant_id is required
169 * @apiError {number} 403 User is not MSP/root/admin role
170 * @apiError {number} 404 Tenant not found
171 * @apiError {number} 500 Token generation or audit logging failed
172 * @apiExample {curl} Example Request:
173 * curl -X POST \
174 * -H "Authorization: Bearer eyJhbGc..." \
175 * -H "Content-Type: application/json" \
176 * -d '{"tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"}' \
177 * "https://api.everydaytech.au/auth/impersonate"
178 * @apiExample {json} Success Response:
179 * HTTP/1.1 200 OK
180 * {
181 * "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
182 * "message": "Successfully impersonating Acme Corporation",
183 * "user": {
184 * "user_id": "original-user-id",
185 * "tenant_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
186 * "role": "msp",
187 * "impersonated": true,
188 * "original_tenant_id": "00000000-0000-0000-0000-000000000001"
189 * }
190 * }
191 *
192 * @apiExample {json} Error Response (Not Authorized):
193 * HTTP/1.1 403 Forbidden
194 * {
195 * "error": "Only MSP/root/admin can impersonate tenants"
196 * }
197 *
198 * @since 2.0.0
199 * @see {@link module:routes/auth.exitImpersonation} for stopping impersonation
200 * @see {@link module:middleware/tenant.setTenantContext} for tenant filtering
201 */
202router.post('/impersonate', authenticateToken, setTenantContext, async (req, res) => {
203 try {
204 const { tenant_id } = req.body;
205 if (!tenant_id) return res.status(400).json({ error: 'Missing tenant_id' });
206
207 // Log for debugging
208 console.log('[auth/impersonate] Request from user:', {
209 user_id: req.user?.user_id,
210 role: req.user?.role,
211 name: req.user?.name,
212 tenant_id: req.user?.tenant_id
213 });
214
215 if (!req.user || !(req.user.role === 'msp' || req.user.role === 'root' || req.user.role === 'admin')) {
216 console.log('[auth/impersonate] 403 - User role not authorized:', req.user?.role);
217 return res.status(403).json({ error: 'Only MSP, root, or admin can impersonate tenants', user_role: req.user?.role });
218 }
219
220 // Lookup impersonated tenant
221 const tenantRes = await pool.query('SELECT name FROM tenants WHERE tenant_id = $1', [tenant_id]);
222 if (tenantRes.rows.length === 0) {
223 return res.status(404).json({ error: 'Tenant not found' });
224 }
225 const impersonatedTenantName = tenantRes.rows[0].name;
226
227 // Issue new JWT for impersonated tenant
228 // IMPORTANT: Only include specific fields to avoid carrying over is_msp=true
229 const payload = {
230 user_id: req.user.user_id,
231 tenant_id,
232 tenantId: tenant_id,
233 role: req.user.role,
234 name: req.user.name,
235 email: req.user.email,
236 is_msp: false, // Act as regular tenant user, not MSP
237 impersonated: true,
238 impersonator_id: req.user.user_id,
239 impersonator_name: req.user.name,
240 impersonated_tenant_name: impersonatedTenantName
241 };
242 let token;
243 try {
244 token = jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '2h' });
245 } catch (jwtErr) {
246 console.error('[auth/impersonate] JWT sign error:', jwtErr, 'Payload:', payload);
247 return res.status(500).json({ error: 'JWT sign error', details: jwtErr.message });
248 }
249
250 // Audit log
251 try {
252 // Use a valid UUID for impersonator_id
253 const uuidRegex = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
254 let impersonatorId = req.user.user_id;
255 if (!uuidRegex.test(String(impersonatorId))) {
256 impersonatorId = req.user.tenantId && uuidRegex.test(String(req.user.tenantId))
257 ? req.user.tenantId
258 : '00000000-0000-0000-0000-000000000000'; // fallback null UUID
259 }
260 await pool.query(
261 `INSERT INTO impersonation_audit (
262 impersonator_id, impersonator_name, impersonated_tenant_id, impersonated_tenant_name, action, ip_address, user_agent
263 ) VALUES ($1, $2, $3, $4, $5, $6, $7)`,
264 [
265 impersonatorId,
266 req.user.name,
267 tenant_id,
268 impersonatedTenantName,
269 'start',
270 req.ip,
271 req.headers['user-agent'] || ''
272 ]
273 );
274 } catch (auditErr) {
275 console.error('[auth/impersonate] Audit log insert error:', auditErr, {
276 impersonator_id: req.user.user_id,
277 impersonator_name: req.user.name,
278 impersonated_tenant_id: tenant_id,
279 impersonated_tenant_name: impersonatedTenantName,
280 ip_address: req.ip,
281 user_agent: req.headers['user-agent'] || ''
282 });
283 return res.status(500).json({ error: 'Audit log insert error', details: auditErr.message });
284 }
285
286 // Set token as HTTP-only cookie
287 // Use SameSite=None for production cross-origin (App Platform separate domains)
288 res.cookie('auth_token', token, {
289 httpOnly: true,
290 secure: true, // Always require HTTPS in production
291 sameSite: process.env.NODE_ENV === 'production' ? 'none' : 'lax',
292 maxAge: 2 * 60 * 60 * 1000, // 2 hours
293 domain: process.env.NODE_ENV === 'production' ? '.ondigitalocean.app' : undefined
294 });
295
296 res.json({ token });
297 } catch (err) {
298 console.error('[auth/impersonate] Error:', err, {
299 user: req.user,
300 body: req.body,
301 ip: req.ip,
302 headers: req.headers
303 });
304 res.status(500).json({ error: 'Failed to impersonate tenant', details: err.message });
305 }
306});
307
308module.exports = router;