mbkauthe 5.0.2 → 5.1.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.
@@ -3,8 +3,9 @@ import pgSession from "connect-pg-simple";
3
3
  const PgSession = pgSession(session);
4
4
  import { dblogin, runWithRequestContext } from "#pool.js";
5
5
  import { mbkautheVar } from "#config.js";
6
- import { cachedCookieOptions, decryptSessionId, encryptSessionId } from "#cookies.js";
6
+ import { cachedCookieOptions, decryptSessionId, encryptSessionId, cachedClearCookieOptions, getCookieDomain, getCookieSecure, isAllowedOriginHostname } from "#cookies.js";
7
7
  import { AuthRepository } from "../db/AuthRepository.js";
8
+ import { isUserAuthorizedForApp } from "../utils/appAccess.js";
8
9
 
9
10
  // Session configuration
10
11
  export const sessionConfig = {
@@ -24,10 +25,10 @@ export const sessionConfig = {
24
25
  cookie: {
25
26
  maxAge: mbkautheVar.COOKIE_EXPIRE_TIME * 24 * 60 * 60 * 1000,
26
27
  // Don't set domain in development/localhost to avoid cookie issues
27
- domain: (mbkautheVar.IS_DEPLOYED === 'true' && process.env.test !== 'dev') ? `.${mbkautheVar.DOMAIN}` : undefined,
28
+ domain: getCookieDomain(),
28
29
  httpOnly: true,
29
30
  // Only use secure cookies in production with HTTPS
30
- secure: mbkautheVar.IS_DEPLOYED === 'true' && process.env.test !== 'dev',
31
+ secure: getCookieSecure(),
31
32
  sameSite: 'lax',
32
33
  path: '/'
33
34
  },
@@ -37,16 +38,23 @@ export const sessionConfig = {
37
38
  const authRepo = new AuthRepository({ db: dblogin });
38
39
  const hasAuthorizationHeader = (req) => typeof req.headers?.authorization === 'string' && req.headers.authorization.trim().length > 0;
39
40
 
41
+ export function securityHeadersMiddleware(req, res, next) {
42
+ res.setHeader("X-Content-Type-Options", "nosniff");
43
+ res.setHeader("X-Frame-Options", "SAMEORIGIN");
44
+ res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");
45
+ if (getCookieSecure()) {
46
+ res.setHeader("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
47
+ }
48
+ next();
49
+ }
50
+
40
51
  // CORS middleware
41
52
  export function corsMiddleware(req, res, next) {
42
53
  const origin = req.headers.origin;
43
54
  if (origin) {
44
55
  try {
45
56
  const originUrl = new URL(origin);
46
- const allowedDomain = `.${mbkautheVar.DOMAIN}`;
47
- // Exact match or subdomain match
48
- if (originUrl.hostname === mbkautheVar.DOMAIN ||
49
- (originUrl.hostname.endsWith(allowedDomain) && originUrl.hostname.charAt(originUrl.hostname.length - allowedDomain.length - 1) !== '.')) {
57
+ if (isAllowedOriginHostname(originUrl.hostname)) {
50
58
  res.header('Access-Control-Allow-Origin', origin);
51
59
  res.header('Access-Control-Allow-Credentials', 'true');
52
60
  res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE');
@@ -73,13 +81,7 @@ export async function sessionRestorationMiddleware(req, res, next) {
73
81
  // Early validation to avoid unnecessary processing (expect DB UUID id)
74
82
  if (!sessionId || typeof sessionId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sessionId)) {
75
83
  // Clear invalid cookie to prevent repeated attempts
76
- res.clearCookie('sessionId', {
77
- domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
78
- path: '/',
79
- httpOnly: true,
80
- secure: mbkautheVar.IS_DEPLOYED === 'true',
81
- sameSite: 'lax'
82
- });
84
+ res.clearCookie('sessionId', cachedClearCookieOptions);
83
85
  return next();
84
86
  }
85
87
 
@@ -91,10 +93,12 @@ export async function sessionRestorationMiddleware(req, res, next) {
91
93
  // Reject expired sessions or inactive users
92
94
  if ((row.expires_at && new Date(row.expires_at) <= new Date()) || !row.Active) {
93
95
  // leave cookies cleared and don't restore session
96
+ } else if (!isUserAuthorizedForApp(row.Role, row.AllowedApps)) {
97
+ // Session exists but user is not authorized for this app
94
98
  } else {
95
99
  const normalizedSessionId = String(sessionId);
96
100
  req.session.user = {
97
- id: row.uid,
101
+ userId: row.UserId || undefined,
98
102
  username: row.UserName,
99
103
  role: row.Role,
100
104
  sessionId: normalizedSessionId,
@@ -122,11 +126,7 @@ export function sessionCookieSyncMiddleware(req, res, next) {
122
126
  }
123
127
 
124
128
  if (req.session && req.session.user) {
125
- // Decrypt existing cookie to compare with session
126
- const currentDecryptedId = decryptSessionId(req.cookies.sessionId);
127
-
128
- // Only set cookies if they're missing or different
129
- if (currentDecryptedId !== req.session.user.sessionId) {
129
+ if (!req.cookies.sessionId) {
130
130
  res.cookie("fullName", req.session.user.fullname || req.session.user.username, { ...cachedCookieOptions, httpOnly: false });
131
131
  const encryptedSessionId = encryptSessionId(req.session.user.sessionId);
132
132
  if (encryptedSessionId) {
@@ -1,5 +1,4 @@
1
1
  import express from "express";
2
- import crypto from "crypto";
3
2
  import csurf from "csurf";
4
3
  import speakeasy from "speakeasy";
5
4
  import rateLimit from 'express-rate-limit';
@@ -9,10 +8,10 @@ import {
9
8
  cachedCookieOptions, cachedClearCookieOptions, clearSessionCookies,
10
9
  generateDeviceToken, getDeviceTokenCookieOptions, DEVICE_TRUST_DURATION_MS, hashDeviceToken,
11
10
  upsertAccountListCookie, readAccountListFromCookie, removeAccountFromCookie, clearAccountListCookie,
12
- encryptSessionId
11
+ encryptSessionId, getCookieDomain
13
12
  } from "#cookies.js";
14
13
  import { packageJson } from "#config.js";
15
- import { hashPassword } from "#config.js";
14
+ import { hashPassword, verifyPassword } from "#config.js";
16
15
  import { ErrorCodes, createErrorResponse, logError } from "../utils/errors.js";
17
16
  import { AuthRepository } from "../db/AuthRepository.js";
18
17
  import { createLogger } from "../utils/logger.js";
@@ -78,7 +77,7 @@ async function fetchActiveSession(sessionId) {
78
77
  if (row.Role !== 'SuperAdmin') {
79
78
  const allowedApps = row.AllowedApps;
80
79
  const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
81
- if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
80
+ if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
82
81
  return null;
83
82
  }
84
83
  }
@@ -109,7 +108,6 @@ export async function checkTrustedDevice(req, username) {
109
108
  try {
110
109
  // Hash the provided device token before querying DB (we store token hashes in DB)
111
110
  const deviceTokenHash = hashDeviceToken(deviceToken);
112
- // Single round-trip: validate trusted device AND refresh LastUsed.
113
111
  const deviceUser = await authRepo.touchTrustedDevice(deviceTokenHash, username);
114
112
 
115
113
  if (deviceUser) {
@@ -121,7 +119,7 @@ export async function checkTrustedDevice(req, username) {
121
119
 
122
120
  if (deviceUser.Role !== "SuperAdmin") {
123
121
  const allowedApps = deviceUser.AllowedApps;
124
- if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
122
+ if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
125
123
  console.warn(`[mbkauthe] Trusted device check: User "${username}" is not authorized to use the application "${mbkautheVar.APP_NAME}"`);
126
124
  return null;
127
125
  }
@@ -129,7 +127,7 @@ export async function checkTrustedDevice(req, username) {
129
127
 
130
128
  logAuth(`Trusted device validated for user: ${username}`);
131
129
  return {
132
- id: deviceUser.id,
130
+ userId: deviceUser.UserId || undefined,
133
131
  username: username,
134
132
  role: deviceUser.Role,
135
133
  allowedApps: deviceUser.AllowedApps,
@@ -205,13 +203,13 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
205
203
  // Update last_login and fetch FullName/Image in a single query.
206
204
  let profileRow = null;
207
205
  try {
208
- profileRow = await authRepo.updateLastLoginReturnProfile(user.id);
206
+ profileRow = await authRepo.updateLastLoginReturnProfile(username);
209
207
  } catch (profileUpdateErr) {
210
208
  console.error(`[mbkauthe] Error updating last_login/returning profile:`, profileUpdateErr);
211
209
  }
212
210
 
213
211
  req.session.user = {
214
- id: user.id,
212
+ userId: user.userId || user.UserId || undefined,
215
213
  username: username,
216
214
  role: user.role || user.Role,
217
215
  sessionId: dbSessionId,
@@ -311,7 +309,6 @@ export async function completeLoginProcess(req, res, user, redirectUrl = null, t
311
309
  const responsePayload = {
312
310
  success: true,
313
311
  message: "Login successful",
314
- sessionId: dbSessionId,
315
312
  };
316
313
 
317
314
  if (redirectUrl) {
@@ -376,10 +373,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
376
373
  // Password verification (hash-only). We never read/compare plaintext passwords.
377
374
  let passwordMatches = false;
378
375
  if (user.PasswordEnc) {
379
- const hashedInputPassword = hashPassword(password, user.UserName);
380
- const stored = Buffer.from(String(user.PasswordEnc), 'utf8');
381
- const computed = Buffer.from(String(hashedInputPassword), 'utf8');
382
- passwordMatches = stored.length === computed.length && crypto.timingSafeEqual(stored, computed);
376
+ passwordMatches = await verifyPassword(password, user.UserName, user.PasswordEnc);
383
377
  }
384
378
 
385
379
  if (!passwordMatches) {
@@ -398,7 +392,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
398
392
 
399
393
  if (user.Role !== "SuperAdmin") {
400
394
  const allowedApps = user.AllowedApps;
401
- if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
395
+ if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
402
396
  logError('Login attempt', ErrorCodes.APP_NOT_AUTHORIZED, {
403
397
  username: user.UserName,
404
398
  app: mbkautheVar.APP_NAME
@@ -418,7 +412,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
418
412
  logAuth(`Trusted device login for user: ${trimmedUsername}, skipping 2FA only`);
419
413
 
420
414
  const userForSession = {
421
- id: user.id,
415
+ userId: user.UserId || undefined,
422
416
  username: user.UserName,
423
417
  role: user.Role,
424
418
  allowedApps: user.AllowedApps,
@@ -431,7 +425,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
431
425
  // 2FA is enabled, prompt for token on a separate page
432
426
  const requestedRedirect = typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : null;
433
427
  req.session.preAuthUser = {
434
- id: user.id,
428
+ userId: user.UserId || undefined,
435
429
  username: user.UserName,
436
430
  role: user.Role,
437
431
  allowedApps: user.AllowedApps,
@@ -443,7 +437,7 @@ router.post("/api/login", LoginLimit, async (req, res) => {
443
437
 
444
438
  // If 2FA is not enabled, proceed with login
445
439
  const userForSession = {
446
- id: user.id,
440
+ userId: user.UserId || undefined,
447
441
  username: user.UserName,
448
442
  role: user.Role,
449
443
  allowedApps: user.AllowedApps,
@@ -476,7 +470,7 @@ router.get("/2fa", csrfProtection, (req, res) => {
476
470
  layout: false,
477
471
  customURL: redirectToUse,
478
472
  csrfToken: req.csrfToken(),
479
- appName: mbkautheVar.APP_NAME.toLowerCase(),
473
+ appName: mbkautheVar.APP_NAME,
480
474
  version: packageJson.version,
481
475
  DEVICE_TRUST_DURATION_DAYS: mbkautheVar.DEVICE_TRUST_DURATION_DAYS
482
476
  });
@@ -493,7 +487,7 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
493
487
  }
494
488
 
495
489
  const { token, trustDevice } = req.body;
496
- const { username, id, role } = req.session.preAuthUser;
490
+ const { username, role, userId } = req.session.preAuthUser;
497
491
 
498
492
  // Validate 2FA token
499
493
  if (!token || typeof token !== 'string') {
@@ -541,7 +535,7 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
541
535
  }
542
536
 
543
537
  // 2FA successful, complete login with optional device trust
544
- const userForSession = { id, username, role, allowedApps };
538
+ const userForSession = { userId, username, role, allowedApps };
545
539
  // Prefer redirect stored in preAuthUser or in query/body, fallback to configured default
546
540
  let redirectFromSession = req.session.preAuthUser && req.session.preAuthUser.redirectUrl ? req.session.preAuthUser.redirectUrl : null;
547
541
  if (redirectFromSession && (!(typeof redirectFromSession === 'string') || !redirectFromSession.startsWith('/') || redirectFromSession.startsWith('//'))) {
@@ -564,7 +558,7 @@ router.post("/api/verify-2fa", TwoFALimit, csrfProtection, async (req, res) => {
564
558
  router.post("/api/logout", LogoutLimit, async (req, res) => {
565
559
  if (req.session.user) {
566
560
  try {
567
- const { id, username } = req.session.user;
561
+ const { username } = req.session.user;
568
562
 
569
563
  // Clear profile picture cache
570
564
  clearProfilePicCache(req, username);
@@ -636,7 +630,7 @@ router.get("/api/account-sessions", LoginLimit, async (req, res) => {
636
630
  const expired = row?.expires_at && new Date(row.expires_at) <= new Date();
637
631
  const authorized = row && row.Active && (
638
632
  row.Role === "SuperAdmin" ||
639
- (Array.isArray(row.AllowedApps) && row.AllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase()))
633
+ (Array.isArray(row.AllowedApps) && row.AllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME))
640
634
  );
641
635
 
642
636
  if (!row || expired || !authorized) {
@@ -692,7 +686,7 @@ router.post("/api/switch-session", LoginLimit, async (req, res) => {
692
686
  });
693
687
 
694
688
  req.session.user = {
695
- id: row.uid,
689
+ userId: row.UserId || undefined,
696
690
  username: row.UserName,
697
691
  role: row.Role,
698
692
  sessionId: row.sid,
@@ -771,10 +765,11 @@ router.get("/login", LoginLimit, csrfProtection, (req, res) => {
771
765
  githubLoginEnabled: mbkautheVar.GITHUB_LOGIN_ENABLED,
772
766
  googleLoginEnabled: mbkautheVar.GOOGLE_LOGIN_ENABLED,
773
767
  customURL: mbkautheVar.loginRedirectURL || '/dashboard',
768
+ cookieDomain: getCookieDomain() || '',
774
769
  userLoggedIn: !!req.session?.user,
775
770
  username: req.session?.user?.username || '',
776
771
  version: packageJson.version,
777
- appName: mbkautheVar.APP_NAME.toLowerCase(),
772
+ appName: mbkautheVar.APP_NAME,
778
773
  csrfToken: req.csrfToken(),
779
774
  // Last-login method flags for immediate server-side badge rendering
780
775
  lastLoginMethod: lastLogin,
@@ -797,7 +792,7 @@ router.get("/accounts", LoginLimit, csrfProtection, (req, res) => {
797
792
  layout: false,
798
793
  customURL: safeRedirect,
799
794
  version: packageJson.version,
800
- appName: mbkautheVar.APP_NAME.toLowerCase(),
795
+ appName: mbkautheVar.APP_NAME,
801
796
  csrfToken: req.csrfToken(),
802
797
  userLoggedIn: !!req.session?.user,
803
798
  username: req.session?.user?.username,
@@ -6,8 +6,9 @@ import { renderError, renderPage } from "#response.js";
6
6
  import { authenticate, sessVal, sessRole } from "../middleware/auth.js";
7
7
  import { ErrorCodes, ErrorMessages, createErrorResponse } from "../utils/errors.js";
8
8
  import { dblogin } from "#pool.js";
9
- import { clearSessionCookies, decryptSessionId, cachedCookieOptions } from "#cookies.js";
9
+ import { clearSessionCookies, decryptSessionId, cachedCookieOptions, getCookieDomain } from "#cookies.js";
10
10
  import { AuthRepository } from "../db/AuthRepository.js";
11
+ import { isSafeFetchUrl } from "../utils/urlSafety.js";
11
12
  import { fileURLToPath } from "url";
12
13
  import path from "path";
13
14
  import fs from "fs";
@@ -22,6 +23,23 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
23
  const router = express.Router();
23
24
  const authRepo = new AuthRepository({ db: dblogin });
24
25
  const logMisc = createLogger("misc");
26
+ const PROFILE_IMAGE_CACHE_SECONDS = 300;
27
+ const PROFILE_IMAGE_CACHE_CONTROL = `private, max-age=${PROFILE_IMAGE_CACHE_SECONDS}, stale-while-revalidate=${PROFILE_IMAGE_CACHE_SECONDS}`;
28
+ const LATEST_VERSION_CACHE_TTL_MS = 10 * 60 * 1000;
29
+ const LATEST_VERSION_FAILURE_CACHE_TTL_MS = 60 * 1000;
30
+ const latestVersionCache = {
31
+ value: null,
32
+ expiresAt: 0,
33
+ pending: null
34
+ };
35
+
36
+ function setProfileImageCacheHeaders(res, eTag = null) {
37
+ res.setHeader('Cache-Control', PROFILE_IMAGE_CACHE_CONTROL);
38
+ if (eTag) {
39
+ res.setHeader('ETag', eTag);
40
+ }
41
+ }
42
+
25
43
  // Rate limiter for info/test routes
26
44
  const LoginLimit = rateLimit({
27
45
  windowMs: 1 * 60 * 1000,
@@ -48,9 +66,26 @@ const AdminOperationLimit = rateLimit({
48
66
  });
49
67
 
50
68
  // Static file routes
69
+ const mainJsPath = path.join(__dirname, '..', '..', 'public', 'main.js');
70
+ let mainJsSource = null;
71
+
72
+ const getMainJsSource = () => {
73
+ if (mainJsSource === null) {
74
+ mainJsSource = fs.readFileSync(mainJsPath, 'utf8');
75
+ }
76
+ return mainJsSource;
77
+ };
78
+
51
79
  router.get('/main.js', (req, res) => {
80
+ const clientConfig = JSON.stringify({
81
+ cookieDomain: getCookieDomain() || null,
82
+ domain: mbkautheVar.DOMAIN,
83
+ isDeployed: mbkautheVar.IS_DEPLOYED === 'true'
84
+ });
85
+ const injectedSource = `window.mbkautheConfig=${clientConfig};\n${getMainJsSource()}`;
86
+
52
87
  res.setHeader('Cache-Control', 'public, max-age=31536000');
53
- res.sendFile(path.join(__dirname, '..', '..', 'public', 'main.js'));
88
+ res.type('application/javascript').send(injectedSource);
54
89
  });
55
90
 
56
91
  router.get('/main.css', (req, res) => {
@@ -76,9 +111,8 @@ router.get('/user/profilepic', async (req, res) => {
76
111
  const serveDefaultIcon = () => {
77
112
  const iconPath = path.join(__dirname, "..", "..", "public", "M.png");
78
113
  res.setHeader('Content-Type', 'image/png');
79
- // Ensure we don't override the Cache-Control we set earlier, or set a default if not set
80
114
  if (!res.getHeader('Cache-Control')) {
81
- res.setHeader('Cache-Control', 'private, no-cache');
115
+ setProfileImageCacheHeaders(res);
82
116
  }
83
117
  const stream = fs.createReadStream(iconPath);
84
118
  stream.on('error', (err) => {
@@ -118,9 +152,7 @@ router.get('/user/profilepic', async (req, res) => {
118
152
  // Generate ETag based on username and image URL
119
153
  const eTag = `"${Buffer.from(username + ':' + imageUrl).toString('base64')}"`;
120
154
 
121
- // Set caching headers
122
- res.setHeader('Cache-Control', 'private, no-cache');
123
- res.setHeader('ETag', eTag);
155
+ setProfileImageCacheHeaders(res, eTag);
124
156
 
125
157
  // Check for conditional request
126
158
  if (req.headers['if-none-match'] === eTag) {
@@ -131,6 +163,13 @@ router.get('/user/profilepic', async (req, res) => {
131
163
  return serveDefaultIcon();
132
164
  }
133
165
 
166
+ if (!isSafeFetchUrl(imageUrl)) {
167
+ console.warn(`[mbkauthe] Blocked unsafe profile image URL for user ${username}`);
168
+ res.cookie('profileImageUrl', 'default', { ...cachedCookieOptions, httpOnly: false });
169
+ res.cookie('profileImageUser', username, { ...cachedCookieOptions, httpOnly: false });
170
+ return serveDefaultIcon();
171
+ }
172
+
134
173
  // Fetch and stream the image
135
174
  try {
136
175
  const imageResponse = await fetch(imageUrl, {
@@ -173,7 +212,7 @@ if (process.env.env === 'dev') {
173
212
  success: true,
174
213
  message: 'SuperAdmin access granted',
175
214
  user: user ? {
176
- id: user.id,
215
+ userId: user.userId,
177
216
  username: user.username,
178
217
  role: user.role,
179
218
  sessionId: user.sessionId
@@ -188,7 +227,7 @@ if (process.env.env === 'dev') {
188
227
 
189
228
  // Test route
190
229
  router.get(['/test', '/'], sessVal, LoginLimit, async (req, res) => {
191
- const { username, fullname, role, id, sessionId, allowedApps } = req.session.user;
230
+ const { username, fullname, role, userId, sessionId, allowedApps } = req.session.user;
192
231
 
193
232
  const sessionExpiry = req.session.cookie?.expires
194
233
  ? new Date(req.session.cookie.expires).toISOString()
@@ -198,7 +237,7 @@ router.get(['/test', '/'], sessVal, LoginLimit, async (req, res) => {
198
237
  username,
199
238
  fullname: fullname || 'N/A',
200
239
  role,
201
- id,
240
+ userId: userId || 'N/A',
202
241
  sessionIdShort: sessionId.slice(0, 8),
203
242
  profilePicUrl: encodeURIComponent(username),
204
243
  displayName: fullname || username,
@@ -221,7 +260,7 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
221
260
  return res.status(200).json({ sessionValid: false, expiry: null });
222
261
  }
223
262
 
224
- const { id, sessionId } = req.session.user;
263
+ const { sessionId } = req.session.user;
225
264
  if (!sessionId) {
226
265
  req.session.destroy(() => { });
227
266
  clearSessionCookies(res);
@@ -345,7 +384,7 @@ router.post('/api/verifySession', LoginLimit, async (req, res) => {
345
384
  }
346
385
 
347
386
  const expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null;
348
- return res.status(200).json({ valid: true, expiry, username: row.UserName, role: row.Role });
387
+ return res.status(200).json({ valid: true, expiry });
349
388
  } catch (err) {
350
389
  console.error(`[mbkauthe] verifySession error:`, err);
351
390
  return res.status(200).json({ valid: false, expiry: null });
@@ -450,20 +489,44 @@ router.get("/ErrorCode", (req, res) => {
450
489
  }
451
490
  });
452
491
 
453
- // Fetch latest version from GitHub\
454
- export async function getLatestVersion() {
455
- try {
456
- const response = await fetch('https://raw.githubusercontent.com/MIbnEKhalid/mbkauthe/main/package.json');
457
- if (!response.ok) {
458
- console.error(`[mbkauthe] GitHub API responded with status ${response.status}`);
492
+ // Fetch latest version from GitHub with a short in-memory cache.
493
+ export async function getLatestVersion({ forceRefresh = false } = {}) {
494
+ const now = Date.now();
495
+
496
+ if (!forceRefresh && latestVersionCache.expiresAt > now) {
497
+ return latestVersionCache.value;
498
+ }
499
+
500
+ if (!forceRefresh && latestVersionCache.pending) {
501
+ return latestVersionCache.pending;
502
+ }
503
+
504
+ latestVersionCache.pending = (async () => {
505
+ try {
506
+ const response = await fetch('https://raw.githubusercontent.com/MIbnEKhalid/mbkauthe/main/package.json');
507
+ if (!response.ok) {
508
+ console.error(`[mbkauthe] GitHub API responded with status ${response.status}`);
509
+ latestVersionCache.value = null;
510
+ latestVersionCache.expiresAt = Date.now() + LATEST_VERSION_FAILURE_CACHE_TTL_MS;
511
+ return null;
512
+ }
513
+
514
+ const latestPackageJson = await response.json();
515
+ const latestVersion = typeof latestPackageJson.version === 'string' ? latestPackageJson.version : null;
516
+ latestVersionCache.value = latestVersion;
517
+ latestVersionCache.expiresAt = Date.now() + LATEST_VERSION_CACHE_TTL_MS;
518
+ return latestVersion;
519
+ } catch (error) {
520
+ console.error(`[mbkauthe] Error fetching latest version from GitHub`, error);
521
+ latestVersionCache.value = null;
522
+ latestVersionCache.expiresAt = Date.now() + LATEST_VERSION_FAILURE_CACHE_TTL_MS;
459
523
  return null;
524
+ } finally {
525
+ latestVersionCache.pending = null;
460
526
  }
461
- const latestPackageJson = await response.json();
462
- return typeof latestPackageJson.version === 'string' ? latestPackageJson.version : null;
463
- } catch (error) {
464
- console.error(`[mbkauthe] Error fetching latest version from GitHub`, error);
465
- return null;
466
- }
527
+ })();
528
+
529
+ return latestVersionCache.pending;
467
530
  }
468
531
 
469
532
  // Version check with error handling
@@ -66,7 +66,7 @@ const createOAuthStrategy = async (provider, profile, done) => {
66
66
  // Check if user is authorized for this app
67
67
  if (user.Role !== "SuperAdmin") {
68
68
  const allowedApps = user.AllowedApps;
69
- if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
69
+ if (!allowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
70
70
  const error = new Error(`Not authorized to use ${mbkautheVar.APP_NAME}`);
71
71
  error.code = 'NOT_AUTHORIZED';
72
72
  return done(error);
@@ -75,7 +75,7 @@ const createOAuthStrategy = async (provider, profile, done) => {
75
75
 
76
76
  // Return user data for login
77
77
  const userData = {
78
- id: user.id,
78
+ userId: user.UserId || undefined,
79
79
  username: user.UserName,
80
80
  role: user.Role,
81
81
  allowedApps: user.AllowedApps,
@@ -309,7 +309,7 @@ const finishProviderLogin = async (req, res, provider, user, detailValue = '') =
309
309
  const oauthRedirect = req.session.oauthRedirect;
310
310
  if (oauthRedirect) delete req.session.oauthRedirect;
311
311
  req.session.preAuthUser = {
312
- id: user.id,
312
+ userId: user.UserId || user.userId || undefined,
313
313
  username: user.UserName,
314
314
  role: user.Role,
315
315
  allowedApps: user.AllowedApps,
@@ -364,7 +364,7 @@ const createOAuthCallback = (provider, strategy) => {
364
364
  res,
365
365
  provider,
366
366
  {
367
- id: oauthUser.id,
367
+ UserId: oauthUser.userId,
368
368
  UserName: oauthUser.username,
369
369
  Role: oauthUser.role,
370
370
  AllowedApps: oauthUser.allowedApps,
@@ -389,7 +389,7 @@ const createOAuthCallback = (provider, strategy) => {
389
389
  // Helper function to handle OAuth redirect flow
390
390
  const handleOAuthRedirect = async (req, res, user, type, method = null) => {
391
391
  const userForSession = {
392
- id: user.id,
392
+ userId: user.UserId || user.userId || undefined,
393
393
  username: user.UserName,
394
394
  role: user.Role,
395
395
  allowedApps: user.AllowedApps,
@@ -0,0 +1,8 @@
1
+ import { mbkautheVar } from "#config.js";
2
+
3
+ export function isUserAuthorizedForApp(role, allowedApps) {
4
+ if (role === "SuperAdmin") return true;
5
+ return Array.isArray(allowedApps)
6
+ && allowedApps.length > 0
7
+ && allowedApps.some((app) => app && app.toLowerCase() === mbkautheVar.APP_NAME);
8
+ }
@@ -195,7 +195,7 @@ const buildRequestContext = () => {
195
195
  method: req.method,
196
196
  url: req.originalUrl || req.url,
197
197
  ip: req.ip,
198
- userId: user?.id || null,
198
+ userId: user?.userId || null,
199
199
  username: user?.username || null,
200
200
  };
201
201
  };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Validates that a redirect target is a safe same-origin relative path.
3
+ */
4
+ export function isSafeRelativeRedirect(value) {
5
+ if (typeof value !== 'string') return false;
6
+ const trimmed = value.trim();
7
+ if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return false;
8
+ if (trimmed.includes('://') || trimmed.includes('\\')) return false;
9
+ return true;
10
+ }
11
+
12
+ export function sanitizeRelativeRedirect(value) {
13
+ return isSafeRelativeRedirect(value) ? value.trim() : null;
14
+ }
@@ -0,0 +1,67 @@
1
+ import { mbkautheVar } from "#config.js";
2
+
3
+ const BLOCKED_HOSTNAMES = new Set([
4
+ 'localhost',
5
+ '0.0.0.0',
6
+ 'metadata.google.internal',
7
+ 'metadata',
8
+ ]);
9
+
10
+ function isPrivateIpv4(hostname) {
11
+ const parts = hostname.split('.').map((part) => Number(part));
12
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
13
+ return false;
14
+ }
15
+
16
+ const [a, b] = parts;
17
+ if (a === 10) return true;
18
+ if (a === 127) return true;
19
+ if (a === 0) return true;
20
+ if (a === 169 && b === 254) return true;
21
+ if (a === 172 && b >= 16 && b <= 31) return true;
22
+ if (a === 192 && b === 168) return true;
23
+ if (a === 100 && b >= 64 && b <= 127) return true;
24
+ return false;
25
+ }
26
+
27
+ function isPrivateIpv6(hostname) {
28
+ const normalized = hostname.toLowerCase();
29
+ if (normalized === '::1' || normalized === '::') return true;
30
+ if (normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
31
+ if (normalized.startsWith('fe80:')) return true;
32
+ return false;
33
+ }
34
+
35
+ function isBlockedHostname(hostname) {
36
+ const normalized = hostname.toLowerCase().replace(/\.$/, '');
37
+ if (BLOCKED_HOSTNAMES.has(normalized)) return true;
38
+ if (normalized.endsWith('.localhost') || normalized.endsWith('.local')) return true;
39
+ if (normalized.includes(':') && isPrivateIpv6(normalized)) return true;
40
+ if (isPrivateIpv4(normalized)) return true;
41
+ return false;
42
+ }
43
+
44
+ /**
45
+ * Returns true when a URL is safe for server-side fetch (profile images, etc.).
46
+ */
47
+ export function isSafeFetchUrl(urlString) {
48
+ if (!urlString || typeof urlString !== 'string' || urlString === 'default') {
49
+ return false;
50
+ }
51
+
52
+ let parsed;
53
+ try {
54
+ parsed = new URL(urlString);
55
+ } catch {
56
+ return false;
57
+ }
58
+
59
+ const allowHttpInDev = mbkautheVar.IS_DEPLOYED !== 'true';
60
+ const allowedProtocols = allowHttpInDev ? ['https:', 'http:'] : ['https:'];
61
+ if (!allowedProtocols.includes(parsed.protocol)) return false;
62
+ if (parsed.username || parsed.password) return false;
63
+ if (!parsed.hostname) return false;
64
+ if (isBlockedHostname(parsed.hostname)) return false;
65
+
66
+ return true;
67
+ }