mbkauthe 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/docs/api.md CHANGED
@@ -194,6 +194,39 @@ fetch('/mbkauthe/api/login', {
194
194
 
195
195
  ---
196
196
 
197
+ #### `GET /mbkauthe/api/checkSession`
198
+
199
+ Checks whether the current session (cookie-based) is valid. Returns a JSON response suitable for AJAX/SPA checks.
200
+
201
+ **Authentication:** Requires a valid session cookie set by `/mbkauthe/api/login`.
202
+
203
+ **Success Response (200 OK):**
204
+ ```json
205
+ {
206
+ "sessionValid": true,
207
+ "expiry": "2025-12-27T12:34:56.000Z"
208
+ }
209
+ ```
210
+
211
+ **Error Responses (examples):**
212
+ - 200 Session invalid ( { "sessionValid": false, "expiry": null } )
213
+ - 500 Internal Server Error (rare)
214
+
215
+ **Example Request:**
216
+ ```javascript
217
+ fetch('/mbkauthe/api/checkSession')
218
+ .then(res => res.json())
219
+ .then(data => {
220
+ if (data.sessionValid) {
221
+ // session active, expiry available in data.expiry
222
+ } else {
223
+ // not authenticated
224
+ }
225
+ });
226
+ ```
227
+
228
+ ---
229
+
197
230
  #### `GET /mbkauthe/2fa`
198
231
 
199
232
  Renders the Two-Factor Authentication verification page.
@@ -759,16 +792,51 @@ app.get('/protected', validateSession, (req, res) => {
759
792
  - Verifies user is authorized for the current application
760
793
  - Redirects to login page if validation fails
761
794
 
795
+ ### reloadSessionUser(req, res)
796
+
797
+ Use this helper when you need to refresh the values stored in `req.session.user` from the authoritative database record (for example, after a profile update that changes FullName, or when session expiration policies are updated).
798
+
799
+ - Behavior:
800
+ - Validates the session against the database (sessionId, active)
801
+ - Updates `req.session.user` fields: `username`, `role`, `allowedApps`, `fullname`
802
+ - Uses cached `fullName` cookie if available; falls back to querying `profiledata`
803
+ - Syncs `username`, `fullName`, and `sessionId` cookies for client display
804
+ - If the session is invalid (sessionId mismatch, inactive account, or unauthorized), it destroys the session and clears cookies
805
+
806
+ - Returns: `Promise<boolean>` — `true` if session was refreshed and still valid, `false` if session was invalidated or reload failed.
807
+
808
+ - Example:
809
+ ```javascript
810
+ import { reloadSessionUser } from 'mbkauthe';
811
+
812
+ // After updating profile data
813
+ app.post('/mbkauthe/api/update-profile', validateSession, async (req, res) => {
814
+ // ... update profiledata.FullName in DB ...
815
+ const refreshed = await reloadSessionUser(req, res);
816
+ if (!refreshed) {
817
+ return res.status(401).json({ success: false, message: 'Session invalidated' });
818
+ }
819
+ res.json({ success: true, fullname: req.session.user.fullname });
820
+ });
821
+ ```
822
+
762
823
  **Session Object:**
763
824
  ```javascript
764
825
  req.session.user = {
765
826
  id: 1, // User ID
766
- username: "john.doe", // Username
827
+ username: "john.doe", // Username (login name)
828
+ fullname: "John Doe", // Optional display name fetched from profiledata
767
829
  role: "NormalUser", // User role
768
830
  sessionId: "abc123...", // 64-char hex session ID
769
831
  }
770
832
  ```
771
833
 
834
+ **Session Cookie Sync:**
835
+ - The middleware sets non-httpOnly cookies for client display:
836
+ - `username` — the login username (exposed for UI)
837
+ - `fullName` — the display name (falls back to username if not available)
838
+
839
+ These cookies allow front-end UI to display a friendly name without making extra requests to the server.
772
840
  ---
773
841
 
774
842
  ### `checkRolePermission(requiredRole, notAllowed)`
package/index.d.ts CHANGED
@@ -12,6 +12,7 @@ declare global {
12
12
  user?: {
13
13
  username: string;
14
14
  role: 'SuperAdmin' | 'NormalUser' | 'Guest';
15
+ fullname?: string;
15
16
  };
16
17
  userRole?: 'SuperAdmin' | 'NormalUser' | 'Guest';
17
18
  }
@@ -20,6 +21,7 @@ declare global {
20
21
  user?: {
21
22
  id: number;
22
23
  username: string;
24
+ fullname?: string;
23
25
  role: 'SuperAdmin' | 'NormalUser' | 'Guest';
24
26
  sessionId: string;
25
27
  allowedApps?: string[];
@@ -75,6 +77,7 @@ declare module 'mbkauthe' {
75
77
  export interface SessionUser {
76
78
  id: number;
77
79
  username: string;
80
+ fullname?: string;
78
81
  role: UserRole;
79
82
  sessionId: string;
80
83
  allowedApps?: string[];
@@ -209,6 +212,10 @@ declare module 'mbkauthe' {
209
212
 
210
213
  export function authenticate(token: string): AuthMiddleware;
211
214
 
215
+ // Reload session user values from DB and refresh cookies.
216
+ // Returns true when session is refreshed and valid, false if session invalidated.
217
+ export function reloadSessionUser(req: Request, res: Response): Promise<boolean>;
218
+
212
219
  // Utility Functions
213
220
  export function renderError(
214
221
  res: Response,
package/index.js CHANGED
@@ -89,7 +89,10 @@ if (process.env.test !== "dev") {
89
89
  await checkVersion();
90
90
  }
91
91
 
92
- export { validateSession, checkRolePermission, validateSessionAndRole, authenticate } from "./lib/middleware/auth.js";
92
+ export {
93
+ validateSession, validateApiSession, checkRolePermission,
94
+ validateSessionAndRole, authenticate, reloadSessionUser
95
+ } from "./lib/middleware/auth.js";
93
96
  export { renderError } from "./lib/utils/response.js";
94
97
  export { dblogin } from "./lib/database/pool.js";
95
98
  export { ErrorCodes, ErrorMessages, getErrorByCode, createErrorResponse, logError } from "./lib/utils/errors.js";
@@ -46,6 +46,7 @@ export const clearSessionCookies = (res) => {
46
46
  res.clearCookie("mbkauthe.sid", cachedClearCookieOptions);
47
47
  res.clearCookie("sessionId", cachedClearCookieOptions);
48
48
  res.clearCookie("username", cachedClearCookieOptions);
49
+ res.clearCookie("fullName", cachedClearCookieOptions);
49
50
  res.clearCookie("device_token", cachedClearCookieOptions);
50
51
  };
51
52
 
@@ -1,7 +1,7 @@
1
1
  import { dblogin } from "../database/pool.js";
2
2
  import { mbkautheVar } from "../config/index.js";
3
3
  import { renderError } from "../utils/response.js";
4
- import { clearSessionCookies } from "../config/cookies.js";
4
+ import { clearSessionCookies, cachedCookieOptions } from "../config/cookies.js";
5
5
 
6
6
  async function validateSession(req, res, next) {
7
7
  if (!req.session.user) {
@@ -97,6 +97,160 @@ async function validateSession(req, res, next) {
97
97
  }
98
98
  }
99
99
 
100
+ /**
101
+ * API-friendly session validation middleware
102
+ * Returns JSON error responses instead of rendering pages
103
+ */
104
+ async function validateApiSession(req, res, next) {
105
+ if (!req.session.user) {
106
+ return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND));
107
+ }
108
+
109
+ try {
110
+ const { id, sessionId, role, allowedApps } = req.session.user;
111
+
112
+ // Defensive checks for sessionId and allowedApps
113
+ if (!sessionId) {
114
+ console.warn(`[mbkauthe] Missing sessionId for user "${req.session.user.username}"`);
115
+ req.session.destroy();
116
+ clearSessionCookies(res);
117
+ return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
118
+ }
119
+
120
+ // Normalize sessionId to lowercase for consistent comparison
121
+ const normalizedSessionId = sessionId.toLowerCase();
122
+
123
+ // Single optimized query to validate session and get role
124
+ const query = `SELECT "SessionId", "Active", "Role" FROM "Users" WHERE "id" = $1`;
125
+ const result = await dblogin.query({ name: 'validate-user-session-for-api', text: query, values: [id] });
126
+
127
+ const dbSessionId = result.rows.length > 0 && result.rows[0].SessionId ? String(result.rows[0].SessionId).toLowerCase() : null;
128
+ if (!dbSessionId || dbSessionId !== normalizedSessionId) {
129
+ if (result.rows.length > 0 && !result.rows[0].SessionId) {
130
+ console.warn(`[mbkauthe] DB sessionId is null for user "${req.session.user.username}"`);
131
+ }
132
+ console.log(`[mbkauthe] Session invalidated for user "${req.session.user.username}"`);
133
+ req.session.destroy();
134
+ clearSessionCookies(res);
135
+ return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_INVALID));
136
+ }
137
+
138
+ if (!result.rows[0].Active) {
139
+ console.log(`[mbkauthe] Account is inactive for user "${req.session.user.username}"`);
140
+ req.session.destroy();
141
+ clearSessionCookies(res);
142
+ return res.status(401).json(createErrorResponse(401, ErrorCodes.ACCOUNT_INACTIVE));
143
+ }
144
+
145
+ if (role !== "SuperAdmin") {
146
+ // If allowedApps is not provided or not an array, treat as no access
147
+ const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
148
+ if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
149
+ console.warn(`[mbkauthe] User \"${req.session.user.username}\" is not authorized to use the application \"${mbkautheVar.APP_NAME}\"`);
150
+ req.session.destroy();
151
+ clearSessionCookies(res);
152
+ return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
153
+ }
154
+ }
155
+
156
+ // Store user role in request for checkRolePermission to use
157
+ req.userRole = result.rows[0].Role;
158
+
159
+ next();
160
+ } catch (err) {
161
+ console.error("[mbkauthe] API session validation error:", err);
162
+ return res.status(500).json(createErrorResponse(500, ErrorCodes.INTERNAL_SERVER_ERROR));
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Reload session user values from the database and refresh cookies.
168
+ * - Validates sessionId and active status
169
+ * - Updates `req.session.user` fields (username, role, allowedApps, fullname)
170
+ * - Uses cached `fullName` cookie when available, otherwise queries `profiledata`
171
+ * - Syncs `username`, `fullName` and `sessionId` cookies
172
+ * Returns: true if session refreshed and valid, false if session invalidated
173
+ */
174
+ export async function reloadSessionUser(req, res) {
175
+ if (!req.session || !req.session.user || !req.session.user.id) return false;
176
+ try {
177
+ const { id, sessionId: currentSessionId } = req.session.user;
178
+
179
+ // Fetch fresh user record
180
+ const query = `SELECT id, "UserName", "Active", "Role", "AllowedApps", "SessionId" FROM "Users" WHERE id = $1`;
181
+ const result = await dblogin.query({ name: 'reload-session-user', text: query, values: [id] });
182
+
183
+ if (result.rows.length === 0) {
184
+ // User not found — invalidate session
185
+ req.session.destroy(() => {});
186
+ clearSessionCookies(res);
187
+ return false;
188
+ }
189
+
190
+ const row = result.rows[0];
191
+ const dbSessionId = row.SessionId ? String(row.SessionId).toLowerCase() : null;
192
+ if (!dbSessionId || dbSessionId !== String(currentSessionId).toLowerCase()) {
193
+ // Session invalidated in DB
194
+ req.session.destroy(() => {});
195
+ clearSessionCookies(res);
196
+ return false;
197
+ }
198
+
199
+ if (!row.Active) {
200
+ // Account is inactive
201
+ req.session.destroy(() => {});
202
+ clearSessionCookies(res);
203
+ return false;
204
+ }
205
+
206
+ // Authorization: ensure allowed for current app unless SuperAdmin
207
+ if (row.Role !== 'SuperAdmin') {
208
+ const allowedApps = row.AllowedApps;
209
+ const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
210
+ if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
211
+ req.session.destroy(() => {});
212
+ clearSessionCookies(res);
213
+ return false;
214
+ }
215
+ }
216
+
217
+ // Update session fields
218
+ req.session.user.username = row.UserName;
219
+ req.session.user.role = row.Role;
220
+ req.session.user.allowedApps = row.AllowedApps;
221
+
222
+ // Obtain fullname from client cookie cache when present else DB
223
+ if (req.cookies && req.cookies.fullName && typeof req.cookies.fullName === 'string') {
224
+ req.session.user.fullname = req.cookies.fullName;
225
+ } else {
226
+ try {
227
+ const prof = await dblogin.query({ name: 'reload-get-fullname', text: 'SELECT "FullName" FROM "profiledata" WHERE "UserName" = $1 LIMIT 1', values: [row.UserName] });
228
+ if (prof.rows.length > 0 && prof.rows[0].FullName) req.session.user.fullname = prof.rows[0].FullName;
229
+ } catch (profileErr) {
230
+ console.error('[mbkauthe] Error fetching fullname during reload:', profileErr);
231
+ }
232
+ }
233
+
234
+ // Persist session changes
235
+ await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
236
+
237
+ // Sync cookies for client UI (non-httpOnly)
238
+ try {
239
+ res.cookie('username', req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
240
+ res.cookie('fullName', req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
241
+ res.cookie('sessionId', req.session.user.sessionId, cachedCookieOptions);
242
+ } catch (cookieErr) {
243
+ // Ignore cookie setting errors, session is still refreshed
244
+ console.error('[mbkauthe] Error syncing cookies during reload:', cookieErr);
245
+ }
246
+
247
+ return true;
248
+ } catch (err) {
249
+ console.error('[mbkauthe] reloadSessionUser error:', err);
250
+ return false;
251
+ }
252
+ }
253
+
100
254
  const checkRolePermission = (requiredRoles, notAllowed) => {
101
255
  return async (req, res, next) => {
102
256
  try {
@@ -173,5 +327,4 @@ const authenticate = (authentication) => {
173
327
  };
174
328
  };
175
329
 
176
-
177
- export { validateSession, checkRolePermission, validateSessionAndRole, authenticate };
330
+ export { validateSession, validateApiSession, checkRolePermission, validateSessionAndRole, authenticate };
@@ -85,6 +85,25 @@ export async function sessionRestorationMiddleware(req, res, next) {
85
85
  sessionId: normalizedSessionId,
86
86
  allowedApps: user.AllowedApps,
87
87
  };
88
+
89
+ // Use cached FullName from client cookie when available to avoid extra DB queries
90
+ if (req.cookies.fullName && typeof req.cookies.fullName === 'string') {
91
+ req.session.user.fullname = req.cookies.fullName;
92
+ } else {
93
+ // Fallback: attempt to fetch FullName from profiledata to populate session
94
+ try {
95
+ const profileRes = await dblogin.query({
96
+ name: 'restore-get-fullname',
97
+ text: 'SELECT "FullName" FROM "profiledata" WHERE "UserName" = $1 LIMIT 1',
98
+ values: [user.UserName]
99
+ });
100
+ if (profileRes.rows.length > 0 && profileRes.rows[0].FullName) {
101
+ req.session.user.fullname = profileRes.rows[0].FullName;
102
+ }
103
+ } catch (profileErr) {
104
+ console.error("[mbkauthe] Error fetching FullName during session restore:", profileErr);
105
+ }
106
+ }
88
107
  }
89
108
  } catch (err) {
90
109
  console.error("[mbkauthe] Session restoration error:", err);
@@ -99,8 +118,10 @@ export function sessionCookieSyncMiddleware(req, res, next) {
99
118
  // Only set cookies if they're missing or different
100
119
  if (req.cookies.sessionId !== req.session.user.sessionId) {
101
120
  res.cookie("username", req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
121
+ // Also expose FullName (fallback to username) for display in client-side UI
122
+ res.cookie("fullName", req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
102
123
  res.cookie("sessionId", req.session.user.sessionId, cachedCookieOptions);
103
124
  }
104
125
  }
105
126
  next();
106
- }
127
+ }
@@ -170,6 +170,20 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
170
170
  allowedApps: user.allowedApps || user.AllowedApps,
171
171
  };
172
172
 
173
+ // Attempt to fetch FullName from profiledata and store it in session for display purposes
174
+ try {
175
+ const profileResult = await dblogin.query({
176
+ name: 'login-get-fullname',
177
+ text: 'SELECT "FullName" FROM "profiledata" WHERE "UserName" = $1 LIMIT 1',
178
+ values: [username]
179
+ });
180
+ if (profileResult.rows.length > 0 && profileResult.rows[0].FullName) {
181
+ req.session.user.fullname = profileResult.rows[0].FullName;
182
+ }
183
+ } catch (profileErr) {
184
+ console.error("[mbkauthe] Error fetching FullName for user:", profileErr);
185
+ }
186
+
173
187
  if (req.session.preAuthUser) {
174
188
  delete req.session.preAuthUser;
175
189
  }
@@ -180,7 +194,9 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
180
194
  return res.status(500).json({ success: false, message: "Internal Server Error" });
181
195
  }
182
196
 
197
+ // Expose sessionId and display name to client for UI (fullName falls back to username)
183
198
  res.cookie("sessionId", sessionId, cachedCookieOptions);
199
+ res.cookie("fullName", req.session.user.fullname || username, { ...cachedCookieOptions, httpOnly: false });
184
200
 
185
201
  // Handle trusted device if requested
186
202
  if (trustDevice) {
@@ -3,7 +3,7 @@ import fetch from 'node-fetch';
3
3
  import rateLimit from 'express-rate-limit';
4
4
  import { mbkautheVar, packageJson, appVersion } from "../config/index.js";
5
5
  import { renderError } from "../utils/response.js";
6
- import { authenticate, validateSession } from "../middleware/auth.js";
6
+ import { authenticate, validateSession, validateApiSession } from "../middleware/auth.js";
7
7
  import { ErrorCodes, ErrorMessages } from "../utils/errors.js";
8
8
  import { dblogin } from "../database/pool.js";
9
9
  import { clearSessionCookies } from "../config/cookies.js";
@@ -90,6 +90,41 @@ router.get('/test', validateSession, LoginLimit, async (req, res) => {
90
90
  }
91
91
  });
92
92
 
93
+ // API: check current session validity (JSON) — minimal response
94
+ router.get('/api/checkSession', LoginLimit, async (req, res) => {
95
+ try {
96
+ if (!req.session?.user) {
97
+ return res.status(200).json({ sessionValid: false, expiry: null });
98
+ }
99
+
100
+ const { id, sessionId } = req.session.user;
101
+ if (!sessionId) {
102
+ req.session.destroy(() => { });
103
+ clearSessionCookies(res);
104
+ return res.status(200).json({ sessionValid: false, expiry: null });
105
+ }
106
+
107
+ const normalizedSessionId = String(sessionId).toLowerCase();
108
+ const result = await dblogin.query({ name: 'check-session-validity', text: 'SELECT "SessionId", "Active" FROM "Users" WHERE id = $1', values: [id] });
109
+
110
+ const dbSessionId = result.rows.length > 0 && result.rows[0].SessionId ? String(result.rows[0].SessionId).toLowerCase() : null;
111
+ if (!dbSessionId || dbSessionId !== normalizedSessionId || !result.rows[0].Active) {
112
+ req.session.destroy(() => { });
113
+ clearSessionCookies(res);
114
+ return res.status(200).json({ sessionValid: false, expiry: null });
115
+ }
116
+
117
+ // Fetch expiry timestamp from session table (connect-pg-simple)
118
+ const sessResult = await dblogin.query({ name: 'get-session-expiry', text: 'SELECT expire FROM "session" WHERE sid = $1', values: [req.sessionID] });
119
+ const expiry = sessResult.rows.length > 0 && sessResult.rows[0].expire ? new Date(sessResult.rows[0].expire).toISOString() : null;
120
+
121
+ return res.status(200).json({ sessionValid: true, expiry });
122
+ } catch (err) {
123
+ console.error('[mbkauthe] checkSession error:', err);
124
+ return res.status(200).json({ sessionValid: false, expiry: null });
125
+ }
126
+ });
127
+
93
128
  // Error codes page
94
129
  router.get("/ErrorCode", (req, res) => {
95
130
  try {
@@ -251,12 +286,12 @@ router.post("/api/terminateAllSessions", AdminOperationLimit, authenticate(mbkau
251
286
  try {
252
287
  // Run both operations in parallel for better performance
253
288
  await Promise.all([
254
- dblogin.query({
255
- name: 'terminate-all-user-sessions',
289
+ dblogin.query({
290
+ name: 'terminate-all-user-sessions',
256
291
  text: 'UPDATE "Users" SET "SessionId" = NULL WHERE "SessionId" IS NOT NULL'
257
292
  }),
258
- dblogin.query({
259
- name: 'terminate-all-db-sessions',
293
+ dblogin.query({
294
+ name: 'terminate-all-db-sessions',
260
295
  text: 'DELETE FROM "session" WHERE expire > NOW()'
261
296
  })
262
297
  ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkauthe",
3
- "version": "3.4.0",
3
+ "version": "3.5.0",
4
4
  "description": "MBKTech's reusable authentication system for Node.js applications.",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/test.spec.js CHANGED
@@ -192,5 +192,20 @@ describe('mbkauthe Routes', () => {
192
192
  expect(response.headers['content-type']).toContain('application/json');
193
193
  }
194
194
  });
195
+
196
+ test('GET /mbkauthe/api/checkSession handles session check', async () => {
197
+ const response = await request(BASE_URL).get('/mbkauthe/api/checkSession');
198
+ expect(response.status).toBe(200);
199
+ expect(response.headers['content-type']).toContain('application/json');
200
+ expect(response.body).toHaveProperty('sessionValid');
201
+ });
202
+
203
+ test('POST /mbkauthe/api/logout handles logout', async () => {
204
+ const response = await request(BASE_URL).post('/mbkauthe/api/logout').send();
205
+ expect([200, 400, 401, 403, 429]).toContain(response.status);
206
+ if (response.status === 200) {
207
+ expect(response.headers['content-type']).toContain('application/json');
208
+ }
209
+ });
195
210
  });
196
211
  });