mbkauthe 4.8.0 → 4.8.1

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/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
  - Multi-session support (configurable concurrent sessions per user)
24
24
  - Optional TOTP-based 2FA with trusted devices
25
25
  - Social login (GitHub App & Google OAuth)
26
- - Role-based access: SuperAdmin, NormalUser, Guest
26
+ - Role-based access: SuperAdmin, NormalUser, Guest, member
27
27
  - CSRF protection & rate limiting
28
28
  - Easy Express.js integration
29
29
  - Customizable Handlebars templates
package/docs/api.md CHANGED
@@ -1474,7 +1474,7 @@ These cookies allow front-end UI to display a friendly name without making extra
1474
1474
  Checks if the authenticated user has the required role.
1475
1475
 
1476
1476
  **Parameters:**
1477
- - `requiredRole` (string) - Required role: `"SuperAdmin"`, `"NormalUser"`, `"Guest"`, or `"Any"`/`"any"`
1477
+ - `requiredRole` (string) - Required role: `"SuperAdmin"`, `"NormalUser"`, `"Guest"`, `"member"`, or `"Any"`/`"any"`
1478
1478
  - `notAllowed` (string, optional) - Role that is explicitly not allowed
1479
1479
 
1480
1480
  **Usage:**
package/docs/db.md CHANGED
@@ -12,7 +12,7 @@ The project uses a Postgres `ENUM` type for user roles:
12
12
  DO $$
13
13
  BEGIN
14
14
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role') THEN
15
- CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest');
15
+ CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest', 'member');
16
16
  END IF;
17
17
  END
18
18
  $$;
@@ -319,7 +319,7 @@ To add new users to the `Users` table, use the following SQL queries:
319
319
  - Replace `support` and `test` with the desired usernames.
320
320
  - For raw passwords: Replace `12345678` with the actual plain text passwords.
321
321
  - For encrypted passwords: Use the hashPassword function to generate the hash before inserting.
322
- - Adjust the `Role` values as needed (`SuperAdmin`, `NormalUser`, or `Guest`).
322
+ - Adjust the `Role` values as needed (`SuperAdmin`, `NormalUser`, `Guest`, or `member`).
323
323
  - Modify the `Active` and `HaveMailAccount` values as required.
324
324
 
325
325
  **Generating Encrypted Passwords:**
package/docs/db.sql CHANGED
@@ -2,7 +2,7 @@
2
2
  DO $$
3
3
  BEGIN
4
4
  IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'role') THEN
5
- CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest');
5
+ CREATE TYPE role AS ENUM ('SuperAdmin', 'NormalUser', 'Guest', 'member');
6
6
  END IF;
7
7
  END
8
8
  $$;
@@ -94,6 +94,24 @@ CREATE TABLE IF NOT EXISTS "Sessions" (
94
94
  CREATE INDEX IF NOT EXISTS idx_sessions_username ON "Sessions" ("UserName");
95
95
  CREATE INDEX IF NOT EXISTS idx_sessions_user_created ON "Sessions" ("UserName", created_at);
96
96
 
97
+ -- Support expiry-based cleanup and validity checks
98
+ CREATE INDEX IF NOT EXISTS idx_sessions_username_expires
99
+ ON "Sessions" ("UserName", expires_at);
100
+
101
+ CREATE INDEX IF NOT EXISTS idx_sessions_expires
102
+ ON "Sessions" (expires_at)
103
+ WHERE expires_at IS NOT NULL;
104
+
105
+ -- Optional (Postgres 11+): covering indexes for hot-path lookups (validateSession)
106
+ -- These can enable index-only scans for the exact columns used in auth middleware.
107
+ CREATE INDEX IF NOT EXISTS idx_sessions_id_cover
108
+ ON "Sessions" (id)
109
+ INCLUDE ("UserName", expires_at);
110
+
111
+ CREATE INDEX IF NOT EXISTS idx_users_username_cover
112
+ ON "Users" ("UserName")
113
+ INCLUDE ("Active", "Role");
114
+
97
115
 
98
116
  CREATE TABLE IF NOT EXISTS "session" (
99
117
  sid VARCHAR(33) PRIMARY KEY NOT NULL,
package/index.d.ts CHANGED
@@ -242,6 +242,9 @@ declare module 'mbkauthe' {
242
242
  notAllowed?: UserRole
243
243
  ): AuthMiddleware;
244
244
 
245
+ export const sessVal: AuthMiddleware;
246
+ export const sessRole: AuthMiddleware;
247
+
245
248
  export const strictValidateSession: AuthMiddleware;
246
249
 
247
250
  export function strictValidateSessionAndRole(
package/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  import express from "express";
2
- import router from "./lib/main.js";
3
- import { checkVersion } from "./lib/main.js";
2
+ import router, { checkVersion } from "./lib/main.js";
4
3
  import { engine } from "express-handlebars";
5
4
  import path from "path";
6
5
  import { fileURLToPath } from "url";
@@ -9,129 +8,95 @@ import { packageJson } from "#config.js";
9
8
 
10
9
  const __filename = fileURLToPath(import.meta.url);
11
10
  const __dirname = path.dirname(__filename);
11
+ const isDevMode = process.env.test === "dev";
12
+ const DEV_PORT = 5555;
13
+ const viewsPath = path.join(__dirname, "views");
14
+ const packageVersion = packageJson.version;
12
15
 
13
16
  const app = express();
14
17
 
15
18
  app.set("views", [
16
- path.join(__dirname, "views"),
19
+ viewsPath,
17
20
  path.join(__dirname, "node_modules/mbkauthe/views")
18
21
  ]);
19
22
 
23
+ const handlebarsHelpers = {
24
+ eq: (a, b) => a === b,
25
+ encodeURIComponent: (str) => encodeURIComponent(str),
26
+ formatTimestamp: (timestamp) => new Date(timestamp).toLocaleString(),
27
+ jsonStringify: (context) => JSON.stringify(context),
28
+ json: (obj) => JSON.stringify(obj, null, 2),
29
+ objectEntries: (obj) => {
30
+ if (!obj || typeof obj !== 'object') return [];
31
+ return Object.entries(obj).map(([key, value]) => ({ key, value }));
32
+ },
33
+ cacheBuster: () => `?v=${packageVersion}`
34
+ };
35
+
20
36
  app.engine("handlebars", engine({
21
37
  defaultLayout: false,
22
38
  cache: true,
23
39
  partialsDir: [
24
- path.join(__dirname, "views"),
40
+ viewsPath,
25
41
  path.join(__dirname, "node_modules/mbkauthe/views"),
26
42
  path.join(__dirname, "node_modules/mbkauthe/views/Error"),
27
43
  ],
28
- helpers: {
29
- eq: function (a, b) {
30
- return a === b;
31
- },
32
- encodeURIComponent: function (str) {
33
- return encodeURIComponent(str);
34
- },
35
- formatTimestamp: function (timestamp) {
36
- return new Date(timestamp).toLocaleString();
37
- },
38
- jsonStringify: function (context) {
39
- return JSON.stringify(context);
40
- },
41
- json: (obj) => JSON.stringify(obj, null, 2),
42
- objectEntries: function (obj) {
43
- if (!obj || typeof obj !== 'object') {
44
- return []; // Return an empty array if obj is undefined, null, or not an object
45
- }
46
- return Object.entries(obj).map(([key, value]) => ({ key, value }));
47
- },
48
- cacheBuster: function () {
49
- return "?v=" + packageJson.version;
50
- }
51
- }
52
-
44
+ helpers: handlebarsHelpers
53
45
  }));
54
46
 
55
47
  app.set("view engine", "handlebars");
56
-
57
48
  app.use(router);
58
49
 
59
- if (process.env.test === "dev") {
50
+ const renderDevError = (res, req, code, error, message, page, details) => renderError(res, req, {
51
+ layout: false,
52
+ code,
53
+ error,
54
+ message,
55
+ details,
56
+ pagename: "Home",
57
+ page,
58
+ });
59
+
60
+ if (isDevMode) {
60
61
  console.log("[mbkauthe] Dev mode is enabled. Starting server in dev mode.");
61
- const port = 5555;
62
- app.get(["/dashboard", "/home", "/"], (req, res) => {
63
- return res.redirect("/mbkauthe/");
64
- });
65
- app.get("/dev/2fa", (req, res) => {
66
- return renderPage(req, res, "pages/2fa.handlebars", {
67
- layout: false,
68
- pagename: "Two-Factor Authentication",
69
- page: "/home"
70
- });
71
- });
72
- app.get("/showmessage", (req, res) => {
73
- //uncomment line 26 on showmessage.handlebars for testing, after testing comment it back
74
- return renderPage(req, res, "showmessage", false);
75
- });
76
- app.get("/500", (req, res) => {
77
- return renderError(res, req, {
78
- layout: false,
79
- code: 500,
80
- error: "Internal Server Error",
81
- message: "Simulated 500 Error",
82
- details: "This is a simulated 500 error page for testing purposes.",
83
- pagename: "Home",
84
- page: "/mbkauthe/login",
85
- });
86
- });
62
+
63
+ app.get(["/dashboard", "/home", "/"], (req, res) => res.redirect("/mbkauthe/"));
64
+
65
+ app.get("/dev/2fa", (req, res) => renderPage(req, res, "pages/2fa.handlebars", false, {
66
+ pagename: "Two-Factor Authentication",
67
+ page: "/home"
68
+ }));
69
+
70
+ app.get("/showmessage", (req, res) => renderPage(req, res, "showmessage", false));
71
+
72
+ app.get("/500", (req, res) => renderDevError(res, req, 500,
73
+ "Internal Server Error", "Simulated 500 Error",
74
+ "/mbkauthe/login", "This is a simulated 500 error page for testing purposes."
75
+ ));
76
+
87
77
  app.use((req, res) => {
88
78
  console.log(`[mbkauthe] Path not found: ${req.method} ${req.url}`);
89
- return renderError(res, req, {
90
- layout: false,
91
- code: 404,
92
- error: "Not Found",
93
- message: "The requested page was not found.",
94
- pagename: "Home",
95
- page: "/mbkauthe/login",
96
- });
79
+ renderDevError(res, req, 404, "Not Found", "The requested page was not found.", "/mbkauthe/login");
97
80
  });
98
- app.listen(port, () => {
99
- console.log(`[mbkauthe] Server running on http://localhost:${port}`);
81
+
82
+ app.listen(DEV_PORT, () => {
83
+ console.log(`[mbkauthe] Server running on http://localhost:${DEV_PORT}`);
100
84
  });
101
85
  }
102
86
 
103
- if (process.env.test !== "dev") {
87
+ if (!isDevMode) {
104
88
  await checkVersion();
105
89
  }
106
90
 
107
- export {
108
- validateSession, validateApiSession, checkRolePermission,
109
- validateSessionAndRole, authenticate, reloadSessionUser,
110
- strictValidateSession, strictValidateSessionAndRole
111
- } from "./lib/middleware/auth.js";
112
- export {
113
- sessionConfig,
114
- corsMiddleware,
115
- sessionRestorationMiddleware,
116
- sessionCookieSyncMiddleware,
117
- requestContextMiddleware
118
- } from "./lib/middleware/index.js";
91
+ export * from "./lib/middleware/auth.js";
92
+ export * from "./lib/middleware/index.js";
119
93
  export { validateTokenScope } from "./lib/middleware/scopeValidator.js";
120
- export { renderError, getUserContext, renderPage, proxycall } from "#response.js";
94
+ export * from "#response.js";
121
95
  export { dblogin } from "#pool.js";
122
96
  export { getLatestVersion } from "./lib/routes/misc.js";
123
- export { checkTrustedDevice, completeLoginProcess } from "./lib/routes/auth.js";
124
- export {
125
- ErrorCodes, ErrorMessages, getErrorByCode,
126
- createErrorResponse, logError
127
- } from "./lib/utils/errors.js";
128
- export {
129
- encryptSessionId, decryptSessionId, cachedCookieOptions, cachedClearCookieOptions,
130
- DEVICE_TRUST_DURATION_DAYS, DEVICE_TRUST_DURATION_MS,
131
- generateDeviceToken, hashDeviceToken, getDeviceTokenCookieOptions,
132
- getCookieOptions, getClearCookieOptions, clearSessionCookies,
133
- readAccountListFromCookie, upsertAccountListCookie, removeAccountFromCookie, clearAccountListCookie
134
- } from "./lib/config/cookies.js";
135
- export { hashPassword, hashApiToken } from "./lib/config/security.js";
97
+ export * from "./lib/routes/auth.js";
98
+ export * from "./lib/utils/errors.js";
99
+ export * from "./lib/config/cookies.js";
100
+ export * from "./lib/config/security.js";
136
101
  export { mbkautheVar } from "#config.js";
137
102
  export default app;
@@ -6,6 +6,18 @@ import { ErrorCodes, createErrorResponse } from "../utils/errors.js";
6
6
  import { hashApiToken } from "#config.js";
7
7
  import { canAccessMethod } from "#config.js";
8
8
 
9
+ const IS_DEV = process.env.env === 'dev' || process.env.test === 'dev' || process.env.NODE_ENV === 'development';
10
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
11
+ const isUuid = (val) => typeof val === 'string' && UUID_RE.test(val);
12
+
13
+ const SQL_VALIDATE_APP_SESSION = `
14
+ SELECT s.expires_at, u."Active", u."Role"
15
+ FROM "Sessions" s
16
+ JOIN "Users" u ON s."UserName" = u."UserName"
17
+ WHERE s.id = $1
18
+ LIMIT 1
19
+ `;
20
+
9
21
  /**
10
22
  * Decide if the incoming request should return JSON errors instead of HTML.
11
23
  * Non-browser clients (API calls / AJAX) should get JSON.
@@ -184,8 +196,10 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
184
196
 
185
197
  // --- Fallback to Cookie Session ---
186
198
  if (!req.session.user) {
187
- console.log("[mbkauthe] User not authenticated");
188
- console.log("[mbkauthe]: ", req.session.user);
199
+ if (IS_DEV) {
200
+ console.log("[mbkauthe] User not authenticated");
201
+ console.log("[mbkauthe]: ", req.session.user);
202
+ }
189
203
  if (isJsonRequest(req)) {
190
204
  return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_NOT_FOUND));
191
205
  }
@@ -199,10 +213,10 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
199
213
  }
200
214
 
201
215
  try {
202
- const { id, sessionId, role, allowedApps } = req.session.user;
216
+ const { sessionId, role, allowedApps } = req.session.user;
203
217
 
204
218
  // Defensive checks for sessionId and allowedApps
205
- if (!sessionId) {
219
+ if (!sessionId || !isUuid(sessionId)) {
206
220
  console.warn(`[mbkauthe] Missing sessionId for user "${req.session.user.username}"`);
207
221
  req.session.destroy();
208
222
  clearSessionCookies(res);
@@ -218,15 +232,8 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
218
232
  });
219
233
  }
220
234
 
221
- // Normalize sessionId (DB id) for consistent comparison
222
- const normalizedSessionId = sessionId;
223
-
224
235
  // Validate session by DB primary key id and join to user
225
- const query = `SELECT s.id as sid, s.expires_at, u."Active", u."Role"
226
- FROM "Sessions" s
227
- JOIN "Users" u ON s."UserName" = u."UserName"
228
- WHERE s.id = $1 LIMIT 1`;
229
- const result = await dblogin.query({ name: 'validate-app-session', text: query, values: [normalizedSessionId] });
236
+ const result = await dblogin.query({ name: 'validate-app-session', text: SQL_VALIDATE_APP_SESSION, values: [sessionId] });
230
237
 
231
238
  if (result.rows.length === 0) {
232
239
  console.log(`[mbkauthe] Session not found for user "${req.session.user.username}"`);
@@ -247,7 +254,11 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
247
254
  const sessionRow = result.rows[0];
248
255
 
249
256
  // Check expired
250
- if (sessionRow.expires_at && new Date(sessionRow.expires_at) <= new Date()) {
257
+ if (sessionRow.expires_at) {
258
+ const expiresMs = sessionRow.expires_at instanceof Date
259
+ ? sessionRow.expires_at.getTime()
260
+ : Date.parse(sessionRow.expires_at);
261
+ if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) {
251
262
  console.log(`[mbkauthe] Session invalidated (expired) for user "${req.session.user.username}"`);
252
263
  // destroy and clear cookies
253
264
  req.session.destroy();
@@ -263,6 +274,7 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
263
274
  page: `/mbkauthe/login?redirect=${encodeURIComponent(req.originalUrl)}`,
264
275
  });
265
276
  }
277
+ }
266
278
 
267
279
 
268
280
  if (!sessionRow.Active) {
@@ -302,7 +314,7 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
302
314
  }
303
315
 
304
316
  // Store user role in request for checkRolePermission to use
305
- req.userRole = result.rows[0].Role;
317
+ req.userRole = sessionRow.Role;
306
318
 
307
319
  next();
308
320
  } catch (err) {
@@ -321,25 +333,18 @@ async function validateApiSession(req, res, next) {
321
333
  }
322
334
 
323
335
  try {
324
- const { id, sessionId, role, allowedApps } = req.session.user;
336
+ const { sessionId, role, allowedApps } = req.session.user;
325
337
 
326
338
  // Defensive checks for sessionId and allowedApps
327
- if (!sessionId) {
339
+ if (!sessionId || !isUuid(sessionId)) {
328
340
  console.warn(`[mbkauthe] Missing sessionId for user "${req.session.user.username}"`);
329
341
  req.session.destroy();
330
342
  clearSessionCookies(res);
331
343
  return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
332
344
  }
333
345
 
334
- // Normalize sessionId (DB id) for consistent comparison
335
- const normalizedSessionId = sessionId;
336
-
337
346
  // Validate session by DB primary key id and join to user
338
- const query = `SELECT s.id as sid, s.expires_at, u."Active", u."Role"
339
- FROM "Sessions" s
340
- JOIN "Users" u ON s."UserName" = u."UserName"
341
- WHERE s.id = $1 LIMIT 1`;
342
- const result = await dblogin.query({ name: 'validate-app-session-for-api', text: query, values: [normalizedSessionId] });
347
+ const result = await dblogin.query({ name: 'validate-app-session-for-api', text: SQL_VALIDATE_APP_SESSION, values: [sessionId] });
343
348
 
344
349
  if (result.rows.length === 0) {
345
350
  req.session.destroy();
@@ -350,11 +355,16 @@ async function validateApiSession(req, res, next) {
350
355
  const sessionRow = result.rows[0];
351
356
 
352
357
  // Check expired
353
- if (sessionRow.expires_at && new Date(sessionRow.expires_at) <= new Date()) {
358
+ if (sessionRow.expires_at) {
359
+ const expiresMs = sessionRow.expires_at instanceof Date
360
+ ? sessionRow.expires_at.getTime()
361
+ : Date.parse(sessionRow.expires_at);
362
+ if (!Number.isNaN(expiresMs) && expiresMs <= Date.now()) {
354
363
  req.session.destroy();
355
364
  clearSessionCookies(res);
356
365
  return res.status(401).json(createErrorResponse(401, ErrorCodes.SESSION_EXPIRED));
357
366
  }
367
+ }
358
368
 
359
369
 
360
370
  if (!result.rows[0].Active) {
@@ -376,7 +386,7 @@ async function validateApiSession(req, res, next) {
376
386
  }
377
387
 
378
388
  // Store user role in request for checkRolePermission to use
379
- req.userRole = result.rows[0].Role;
389
+ req.userRole = sessionRow.Role;
380
390
 
381
391
  next();
382
392
  } catch (err) {
@@ -500,6 +510,11 @@ const checkRolePermission = (requiredRoles, notAllowed) => {
500
510
  });
501
511
  }
502
512
 
513
+ // SuperAdmin bypasses all role checks
514
+ if(req.session.role === "SuperAdmin") {
515
+ return next();
516
+ }
517
+
503
518
  // Use role from validateSession to avoid additional DB query
504
519
  const userRole = req.userRole;
505
520
 
@@ -521,7 +536,7 @@ const checkRolePermission = (requiredRoles, notAllowed) => {
521
536
  const rolesArray = Array.isArray(requiredRoles) ? requiredRoles : [requiredRoles];
522
537
 
523
538
  // Check for "Any" or "any" role
524
- if (rolesArray.includes("Any") || rolesArray.includes("any")) {
539
+ if (rolesArray.includes("Any") || rolesArray.includes("any") || rolesArray.includes("*")) {
525
540
  return next();
526
541
  }
527
542
 
@@ -555,7 +570,6 @@ const validateSessionAndRole = (requiredRole, notAllowed, strictTokenValidation
555
570
  };
556
571
  };
557
572
 
558
-
559
573
  const authenticate = (authentication) => {
560
574
  return (req, res, next) => {
561
575
  const token = req.headers["authorization"];
@@ -573,8 +587,13 @@ const authenticate = (authentication) => {
573
587
  const strictValidateSession = (req, res, next) => validateSession(req, res, next, true);
574
588
  const strictValidateSessionAndRole = (requiredRole, notAllowed) => validateSessionAndRole(requiredRole, notAllowed, true);
575
589
 
590
+ // Short aliases for convenience
591
+ const sessVal = validateSession;
592
+ const sessRole = validateSessionAndRole;
593
+
576
594
  export {
577
595
  validateSession, validateApiSession, checkRolePermission,
578
596
  validateSessionAndRole, authenticate, reloadSessionUser,
579
- strictValidateSession, strictValidateSessionAndRole
597
+ strictValidateSession, strictValidateSessionAndRole,
598
+ sessVal, sessRole
580
599
  }
package/lib/pool.js CHANGED
@@ -34,6 +34,7 @@ attachDevQueryLogger(dblogin);
34
34
  try {
35
35
  const client = await dblogin.connect();
36
36
  client.release();
37
+ console.log("[mbkauthe] Database connection pool established successfully.");
37
38
  } catch (err) {
38
39
  console.error("[mbkauthe] Database connection error (pool):", err);
39
40
  }
@@ -110,11 +110,17 @@ export async function checkTrustedDevice(req, username) {
110
110
  try {
111
111
  // Hash the provided device token before querying DB (we store token hashes in DB)
112
112
  const deviceTokenHash = hashDeviceToken(deviceToken);
113
+ // Single round-trip: validate trusted device AND refresh LastUsed.
113
114
  const deviceQuery = `
114
- SELECT td."UserName", td."LastUsed", td."ExpiresAt", u."id", u."Active", u."Role", u."AllowedApps"
115
- FROM "TrustedDevices" td
116
- JOIN "Users" u ON td."UserName" = u."UserName"
117
- WHERE td."DeviceToken" = $1 AND td."UserName" = $2 AND td."ExpiresAt" > NOW()
115
+ UPDATE "TrustedDevices" td
116
+ SET "LastUsed" = NOW()
117
+ FROM "Users" u
118
+ WHERE td."DeviceToken" = $1
119
+ AND td."UserName" = $2
120
+ AND td."ExpiresAt" > NOW()
121
+ AND u."UserName" = td."UserName"
122
+ AND u."Active" = TRUE
123
+ RETURNING td."UserName", td."ExpiresAt", u."id" as id, u."Active", u."Role", u."AllowedApps"
118
124
  `;
119
125
  const deviceResult = await dblogin.query({
120
126
  name: 'check-trusted-device',
@@ -138,13 +144,6 @@ export async function checkTrustedDevice(req, username) {
138
144
  }
139
145
  }
140
146
 
141
- // Update last used timestamp
142
- await dblogin.query({
143
- name: 'update-device-last-used',
144
- text: 'UPDATE "TrustedDevices" SET "LastUsed" = NOW() WHERE "DeviceToken" = $1',
145
- values: [deviceTokenHash]
146
- });
147
-
148
147
  console.log(`[mbkauthe] Trusted device validated for user: ${username}`);
149
148
  return {
150
149
  id: deviceUser.id,
@@ -193,21 +192,24 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
193
192
  const configuredMax = parseInt(mbkautheVar.MAX_SESSIONS_PER_USER, 10);
194
193
  const MAX_SESSIONS = Number.isInteger(configuredMax) && configuredMax > 0 ? configuredMax : 5;
195
194
 
196
- // Clean up expired sessions first to prevent accumulation
197
- await dblogin.query({
198
- name: 'cleanup-expired-sessions',
199
- text: `DELETE FROM "Sessions" WHERE "UserName" = $1 AND expires_at IS NOT NULL AND expires_at <= NOW()`,
200
- values: [username]
201
- });
202
-
203
- // Count active sessions for this user (by username)
195
+ // Clean up expired sessions + count active sessions in a single round-trip.
204
196
  const countRes = await dblogin.query({
205
- name: 'count-user-sessions',
206
- text: `SELECT id FROM "Sessions" WHERE "UserName" = $1 AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at ASC`,
197
+ name: 'cleanup-and-count-user-sessions',
198
+ text: `
199
+ WITH deleted AS (
200
+ DELETE FROM "Sessions"
201
+ WHERE "UserName" = $1
202
+ AND expires_at IS NOT NULL
203
+ AND expires_at <= NOW()
204
+ )
205
+ SELECT COUNT(*)::int AS count
206
+ FROM "Sessions"
207
+ WHERE "UserName" = $1
208
+ `,
207
209
  values: [username]
208
210
  });
209
211
 
210
- const currentSessions = countRes.rows.length;
212
+ const currentSessions = Number(countRes.rows?.[0]?.count ?? 0);
211
213
  // If we have MAX_SESSIONS or more, delete oldest to make room for exactly 1 new session
212
214
  if (currentSessions >= MAX_SESSIONS) {
213
215
  const sessionsToDelete = currentSessions - MAX_SESSIONS + 1; // +1 to make room for new session
@@ -215,15 +217,15 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
215
217
 
216
218
  await dblogin.query({
217
219
  name: 'prune-oldest-user-session',
218
- text: `DELETE FROM "Sessions" WHERE id IN (SELECT id FROM "Sessions" WHERE "UserName" = $1 AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at ASC LIMIT $2)`,
220
+ // Expired sessions were already removed above; remaining rows are active.
221
+ text: `DELETE FROM "Sessions" WHERE id IN (SELECT id FROM "Sessions" WHERE "UserName" = $1 ORDER BY created_at ASC LIMIT $2)`,
219
222
  values: [username, sessionsToDelete]
220
223
  });
221
224
  }
222
225
 
223
226
  const expiresAt = new Date(Date.now() + (cachedCookieOptions.maxAge || 0));
224
227
 
225
- // Insert new session record for the user (generate id explicitly to avoid relying on DB default)
226
- const newSessionId = crypto.randomUUID();
228
+ // Insert new session record for the user.
227
229
  let dbSessionId;
228
230
  try {
229
231
  const insertRes = await dblogin.query({
@@ -237,12 +239,18 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
237
239
  throw insertErr;
238
240
  }
239
241
 
240
- // Update last_login timestamp for the user
241
- await dblogin.query({
242
- name: 'login-update-last-login',
243
- text: `UPDATE "Users" SET "last_login" = NOW() WHERE "id" = $1`,
244
- values: [user.id]
245
- });
242
+ // Update last_login and fetch FullName/Image in a single query.
243
+ let profileRow = null;
244
+ try {
245
+ const profUpdateRes = await dblogin.query({
246
+ name: 'login-update-last-login-return-profile',
247
+ text: `UPDATE "Users" SET "last_login" = NOW() WHERE "id" = $1 RETURNING "FullName", "Image"`,
248
+ values: [user.id]
249
+ });
250
+ profileRow = profUpdateRes.rows?.[0] || null;
251
+ } catch (profileUpdateErr) {
252
+ console.error('[mbkauthe] Error updating last_login/returning profile:', profileUpdateErr);
253
+ }
246
254
 
247
255
  req.session.user = {
248
256
  id: user.id,
@@ -255,20 +263,26 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
255
263
  // Clear profile picture cache to fetch fresh data
256
264
  clearProfilePicCache(req, username);
257
265
 
258
- // Attempt to fetch FullName and Image from Users and store it in session for display purposes
266
+ // Store FullName/Image in session and cache cookie values.
259
267
  let loginProfileImage = null;
260
- try {
261
- const profileResult = await dblogin.query({
262
- name: 'login-get-fullname-and-image',
263
- text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
264
- values: [username]
265
- });
266
- if (profileResult.rows.length > 0) {
267
- if (profileResult.rows[0].FullName) req.session.user.fullname = profileResult.rows[0].FullName;
268
- if (profileResult.rows[0].Image && profileResult.rows[0].Image.trim() !== '') loginProfileImage = profileResult.rows[0].Image;
268
+ if (profileRow) {
269
+ if (profileRow.FullName) req.session.user.fullname = profileRow.FullName;
270
+ if (typeof profileRow.Image === 'string' && profileRow.Image.trim() !== '') loginProfileImage = profileRow.Image;
271
+ } else {
272
+ // Fallback: try a read query if UPDATE...RETURNING failed unexpectedly.
273
+ try {
274
+ const profileResult = await dblogin.query({
275
+ name: 'login-get-fullname-and-image',
276
+ text: 'SELECT "FullName", "Image" FROM "Users" WHERE "UserName" = $1 LIMIT 1',
277
+ values: [username]
278
+ });
279
+ if (profileResult.rows.length > 0) {
280
+ if (profileResult.rows[0].FullName) req.session.user.fullname = profileResult.rows[0].FullName;
281
+ if (profileResult.rows[0].Image && profileResult.rows[0].Image.trim() !== '') loginProfileImage = profileResult.rows[0].Image;
282
+ }
283
+ } catch (profileErr) {
284
+ console.error("[mbkauthe] Error fetching FullName/Image for user:", profileErr);
269
285
  }
270
- } catch (profileErr) {
271
- console.error("[mbkauthe] Error fetching FullName/Image for user:", profileErr);
272
286
  }
273
287
 
274
288
  if (req.session.preAuthUser) {
@@ -228,7 +228,24 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
228
228
  return res.status(200).json({ sessionValid: false, expiry: null });
229
229
  }
230
230
 
231
- const result = await dblogin.query({ name: 'check-session-validity', text: `SELECT s.expires_at, u."Active" FROM "Sessions" s JOIN "Users" u ON s."UserName" = u."UserName" WHERE s.id = $1 LIMIT 1`, values: [sessionId] });
231
+ // Single round-trip: fetch app-session expiry and (if needed) connect-pg-simple expiry.
232
+ const result = await dblogin.query({
233
+ name: 'check-session-validity',
234
+ text: `
235
+ SELECT
236
+ s.expires_at,
237
+ u."Active",
238
+ CASE
239
+ WHEN s.expires_at IS NULL THEN (SELECT expire FROM "session" WHERE sid = $2)
240
+ ELSE NULL
241
+ END AS connect_expire
242
+ FROM "Sessions" s
243
+ JOIN "Users" u ON s."UserName" = u."UserName"
244
+ WHERE s.id = $1
245
+ LIMIT 1
246
+ `,
247
+ values: [sessionId, req.sessionID]
248
+ });
232
249
 
233
250
  if (result.rows.length === 0) {
234
251
  req.session.destroy(() => { });
@@ -243,12 +260,9 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
243
260
  return res.status(200).json({ sessionValid: false, expiry: null });
244
261
  }
245
262
 
246
- // Determine expiry: prefer application session expiry if present else fallback to connect-pg-simple expire
247
- let expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null;
248
- if (!expiry) {
249
- const sessResult = await dblogin.query({ name: 'get-session-expiry', text: 'SELECT expire FROM "session" WHERE sid = $1', values: [req.sessionID] });
250
- expiry = sessResult.rows.length > 0 && sessResult.rows[0].expire ? new Date(sessResult.rows[0].expire).toISOString() : null;
251
- }
263
+ // Determine expiry: prefer application session expiry if present else fallback to connect-pg-simple expiry.
264
+ const expirySource = row.expires_at || row.connect_expire || null;
265
+ const expiry = expirySource ? new Date(expirySource).toISOString() : null;
252
266
 
253
267
  return res.status(200).json({ sessionValid: true, expiry });
254
268
  } catch (err) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkauthe",
3
- "version": "4.8.0",
3
+ "version": "4.8.1",
4
4
  "description": "MBKTech's reusable authentication system for Node.js applications.",
5
5
  "main": "index.js",
6
6
  "type": "module",