mbkauthe 5.0.4 → 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.
@@ -35,7 +35,7 @@ This document describes the environment variables MBKAuth expects and keeps brie
35
35
  - Required: Yes
36
36
 
37
37
  - DOMAIN
38
- - Description: App domain (e.g., `localhost` or `yourdomain.com`). Required when deployed.
38
+ - Description: Root app domain used for cross-subdomain cookie sharing in production (e.g., `yourdomain.com`, not `auth.yourdomain.com`). When `IS_DEPLOYED=true`, session cookies are scoped to `.yourdomain.com` so they are sent on all subdomains.
39
39
  - Example: `"DOMAIN":"localhost"`
40
40
  - Required: Yes
41
41
 
@@ -95,11 +95,6 @@ This document describes the environment variables MBKAuth expects and keeps brie
95
95
  - If `GOOGLE_LOGIN_ENABLED=true`, `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` are required.
96
96
  - If `GITHUB_LOGIN_ENABLED=true`, GitHub App client credentials are required.
97
97
 
98
- - GITHUB_APP_SLUG
99
- - Description: GitHub App slug (optional for login flow in this package; useful for install/link flows handled elsewhere).
100
- - Required: No
101
- - Create GitHub App: https://github.com/settings/apps
102
-
103
98
  - GITHUB_APP_CLIENT_ID / GITHUB_APP_CLIENT_SECRET
104
99
  - Description: GitHub App OAuth credentials used for user sign-in.
105
100
  - Required when `GITHUB_LOGIN_ENABLED=true`.
@@ -16,7 +16,7 @@ Postgres enum `role`: `SuperAdmin`, `NormalUser`, `Guest`, `member`. The script
16
16
 
17
17
  Core accounts table (`"Users"`): username, activation flag, role, mail flag, `AllowedApps` and `Positions` as JSONB, timestamps, optional `last_login`, and password hash column `PasswordEnc` (no plaintext passwords).
18
18
 
19
- Profile-style columns include `FullName`, `email`, `Image`, `Bio`, `SocialAccounts`, and password-reset fields (`resetToken`, `resetTokenExpires`, `resetAttempts`, `lastResetAttempt`).
19
+ Profile-style columns include `FullName`, `UserId` (exactly 9 characters, unique business identifier assigned externally), `email`, `Image`, `Bio`, `SocialAccounts`, and password-reset fields (`resetToken`, `resetTokenExpires`, `resetAttempts`, `lastResetAttempt`).
20
20
 
21
21
  Indexes cover username, role, active, email, last login, and GIN indexes on JSONB for `AllowedApps` and `Positions`. The SQL file also adds optional covering indexes used on hot auth paths.
22
22
 
@@ -17,13 +17,16 @@ CREATE TABLE IF NOT EXISTS "Users" (
17
17
  last_login timestamp with time zone,
18
18
  "PasswordEnc" character varying(255),
19
19
  "FullName" character varying(100),
20
+ "UserId" character varying(9),
20
21
  email text DEFAULT 'support@mbktech.org'::text,
21
22
  "Image" text DEFAULT 'https://portal.mbktech.org/icon.svg'::text,
22
23
  "Bio" text DEFAULT 'I am ....'::text,
23
24
  "SocialAccounts" text DEFAULT '{}'::text,
24
25
  "Positions" jsonb DEFAULT '{"Not_Permanent": "Member Is Not Permanent"}'::jsonb,
25
26
  CONSTRAINT "Users_pkey" PRIMARY KEY (id),
26
- CONSTRAINT "Users_UserName_key" UNIQUE ("UserName")
27
+ CONSTRAINT "Users_UserName_key" UNIQUE ("UserName"),
28
+ CONSTRAINT "Users_UserId_key" UNIQUE ("UserId"),
29
+ CONSTRAINT chk_users_userid_length CHECK ("UserId" IS NULL OR length("UserId") = 9)
27
30
  );
28
31
  CREATE INDEX IF NOT EXISTS idx_users_active ON "Users" USING btree ("Active");
29
32
  CREATE INDEX IF NOT EXISTS idx_users_allowedapps_gin ON "Users" USING gin ("AllowedApps");
@@ -32,6 +35,7 @@ CREATE INDEX IF NOT EXISTS idx_users_last_login ON "Users" USING btree (last_log
32
35
  CREATE INDEX IF NOT EXISTS idx_users_positions_gin ON "Users" USING gin ("Positions");
33
36
  CREATE INDEX IF NOT EXISTS idx_users_role ON "Users" USING btree ("Role");
34
37
  CREATE INDEX IF NOT EXISTS idx_users_username_cover ON "Users" USING btree ("UserName") INCLUDE ("Active", "Role");
38
+ CREATE INDEX IF NOT EXISTS idx_users_userid ON "Users" USING btree ("UserId");
35
39
  COMMENT ON TABLE "Users" IS '[core]';
36
40
 
37
41
  -- Table: ApiTokens
@@ -326,3 +330,20 @@ COMMENT ON TABLE "website_access_logs" IS '[core]';
326
330
  INSERT INTO "Users" ("UserName", "PasswordEnc", "Role", "Active", "HaveMailAccount", "FullName")
327
331
  VALUES ('support', 'b8b10c1c9006d8c30ab81c412463c65ff6dae3293d9bfbaf5fd8e275081d0947f000a828004e2fbd3a8f6ef5a35ae3eddd4c57b00ecab376b12e607a16a57459', 'SuperAdmin', true, false, 'Support User')
328
332
  ON CONFLICT ("UserName") DO NOTHING;
333
+
334
+ -- Migration: add UserId to existing Users tables (idempotent)
335
+ ALTER TABLE "Users" ADD COLUMN IF NOT EXISTS "UserId" character varying(9);
336
+
337
+ DO $$
338
+ BEGIN
339
+ IF NOT EXISTS (
340
+ SELECT 1 FROM pg_constraint WHERE conname = 'Users_UserId_key'
341
+ ) THEN
342
+ ALTER TABLE "Users" ADD CONSTRAINT "Users_UserId_key" UNIQUE ("UserId");
343
+ END IF;
344
+ END $$;
345
+
346
+ ALTER TABLE "Users" DROP CONSTRAINT IF EXISTS chk_users_userid_length;
347
+ ALTER TABLE "Users" ADD CONSTRAINT chk_users_userid_length CHECK ("UserId" IS NULL OR length("UserId") = 9);
348
+
349
+ CREATE INDEX IF NOT EXISTS idx_users_userid ON "Users" USING btree ("UserId");
package/index.d.ts CHANGED
@@ -19,7 +19,7 @@ declare global {
19
19
 
20
20
  interface Session {
21
21
  user?: {
22
- id: number;
22
+ userId?: string;
23
23
  username: string;
24
24
  fullname?: string;
25
25
  role: 'SuperAdmin' | 'NormalUser' | 'Guest';
@@ -28,7 +28,7 @@ declare global {
28
28
  tokenScope?: 'read-only' | 'write' | null;
29
29
  };
30
30
  preAuthUser?: {
31
- id: number;
31
+ userId?: string;
32
32
  username: string;
33
33
  role: 'SuperAdmin' | 'NormalUser' | 'Guest';
34
34
  loginMethod?: 'password' | 'github' | 'google';
@@ -55,7 +55,6 @@ declare module 'mbkauthe' {
55
55
  COOKIE_EXPIRE_TIME?: number;
56
56
  DEVICE_TRUST_DURATION_DAYS?: number;
57
57
  GITHUB_LOGIN_ENABLED?: 'true' | 'false' | 'f';
58
- GITHUB_APP_SLUG?: string;
59
58
  GITHUB_APP_CLIENT_ID?: string;
60
59
  GITHUB_APP_CLIENT_SECRET?: string;
61
60
  GOOGLE_LOGIN_ENABLED?: 'true' | 'false' | 'f';
@@ -67,7 +66,6 @@ declare module 'mbkauthe' {
67
66
 
68
67
  export interface OAuthConfig {
69
68
  GITHUB_LOGIN_ENABLED?: 'true' | 'false' | 'f';
70
- GITHUB_APP_SLUG?: string;
71
69
  GITHUB_APP_CLIENT_ID?: string;
72
70
  GITHUB_APP_CLIENT_SECRET?: string;
73
71
  GOOGLE_LOGIN_ENABLED?: 'true' | 'false' | 'f';
@@ -79,7 +77,7 @@ declare module 'mbkauthe' {
79
77
  export type UserRole = 'SuperAdmin' | 'NormalUser' | 'Guest';
80
78
 
81
79
  export interface SessionUser {
82
- id: number;
80
+ userId?: string;
83
81
  username: string;
84
82
  fullname?: string;
85
83
  role: UserRole;
@@ -88,7 +86,7 @@ declare module 'mbkauthe' {
88
86
  }
89
87
 
90
88
  export interface PreAuthUser {
91
- id: number;
89
+ userId?: string;
92
90
  username: string;
93
91
  role: UserRole;
94
92
  allowedApps?: string[];
@@ -100,6 +98,7 @@ declare module 'mbkauthe' {
100
98
  export interface DBUser {
101
99
  id: number;
102
100
  UserName: string;
101
+ UserId?: string;
103
102
  PasswordEnc?: string;
104
103
  Role: UserRole;
105
104
  Active: boolean;
@@ -300,6 +299,18 @@ declare module 'mbkauthe' {
300
299
  httpOnly: boolean;
301
300
  };
302
301
 
302
+ export function resolveCookieDomain(
303
+ isDeployed: 'true' | 'false' | 'f' | string,
304
+ domain?: string,
305
+ isTestDev?: boolean
306
+ ): string | undefined;
307
+
308
+ export function getCookieDomain(): string | undefined;
309
+
310
+ export function getCookieSecure(): boolean;
311
+
312
+ export function isAllowedOriginHostname(hostname: string, domain?: string): boolean;
313
+
303
314
  export function getClearCookieOptions(): {
304
315
  domain?: string;
305
316
  secure: boolean;
@@ -321,6 +332,8 @@ declare module 'mbkauthe' {
321
332
 
322
333
  export function hashPassword(password: string, username: string): string;
323
334
 
335
+ export function verifyPassword(password: string, username: string, storedHash: string): Promise<boolean>;
336
+
324
337
  export function clearSessionCookies(res: Response): void;
325
338
 
326
339
  export function getLatestVersion(): Promise<string>;
@@ -6,7 +6,7 @@ const MAX_REMEMBERED_ACCOUNTS = 5;
6
6
  const ACCOUNT_LIST_COOKIE = 'mbkauthe_accounts';
7
7
 
8
8
  // Cookie security: encryption and signing
9
- const COOKIE_ENCRYPTION_KEY = mbkautheVar.SESSION_SECRET_KEY || 'fallback-secret-key-change-this';
9
+ const COOKIE_ENCRYPTION_KEY = mbkautheVar.SESSION_SECRET_KEY;
10
10
  const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
11
11
 
12
12
  // Derive encryption key from session secret
@@ -121,8 +121,7 @@ const decryptCookiePayload = (payload) => {
121
121
  // Generate fingerprint from user-agent only (salted)
122
122
  const generateFingerprint = (req) => {
123
123
  const userAgent = req.headers['user-agent'] || '';
124
- // Use SESSION_SECRET_KEY as salt if available, otherwise fallback to encryption key
125
- const salt = mbkautheVar.SESSION_SECRET_KEY || COOKIE_ENCRYPTION_KEY;
124
+ const salt = mbkautheVar.SESSION_SECRET_KEY;
126
125
 
127
126
  // Hash user-agent with salt to prevent rainbow table attacks on UAs
128
127
  return crypto
@@ -152,19 +151,41 @@ export const decryptSessionId = (encryptedSessionId) => {
152
151
  }
153
152
  };
154
153
 
154
+ const isTestDevEnvironment = () => process.env.test === 'dev';
155
+
156
+ export const resolveCookieDomain = (isDeployed, domain, isTestDev = isTestDevEnvironment()) => {
157
+ if (isDeployed !== 'true' || isTestDev || !domain) {
158
+ return undefined;
159
+ }
160
+
161
+ return `.${String(domain).replace(/^\.+/, '')}`;
162
+ };
163
+
164
+ export const getCookieDomain = () => resolveCookieDomain(mbkautheVar.IS_DEPLOYED, mbkautheVar.DOMAIN);
165
+
166
+ export const getCookieSecure = () => mbkautheVar.IS_DEPLOYED === 'true' && !isTestDevEnvironment();
167
+
168
+ export const isAllowedOriginHostname = (hostname, domain = mbkautheVar.DOMAIN) => {
169
+ if (!hostname || !domain) {
170
+ return false;
171
+ }
172
+
173
+ return hostname === domain || hostname.endsWith(`.${domain}`);
174
+ };
175
+
155
176
  // Shared cookie options functions
156
177
  const getCookieOptions = () => ({
157
178
  maxAge: mbkautheVar.COOKIE_EXPIRE_TIME * 24 * 60 * 60 * 1000,
158
- domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
159
- secure: mbkautheVar.IS_DEPLOYED === 'true',
179
+ domain: getCookieDomain(),
180
+ secure: getCookieSecure(),
160
181
  sameSite: 'lax',
161
182
  path: '/',
162
183
  httpOnly: true
163
184
  });
164
185
 
165
186
  const getClearCookieOptions = () => ({
166
- domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
167
- secure: mbkautheVar.IS_DEPLOYED === 'true',
187
+ domain: getCookieDomain(),
188
+ secure: getCookieSecure(),
168
189
  sameSite: 'lax',
169
190
  path: '/',
170
191
  httpOnly: true
@@ -183,16 +204,20 @@ export const generateDeviceToken = () => {
183
204
  return crypto.randomBytes(32).toString('hex');
184
205
  };
185
206
 
207
+ const getDeviceTokenKey = () => {
208
+ return crypto.createHash('sha256').update(`${mbkautheVar.SESSION_SECRET_KEY}:device-token`).digest();
209
+ };
210
+
186
211
  // Hash a device token for safe storage in the database
187
212
  export const hashDeviceToken = (token) => {
188
213
  if (!token || typeof token !== 'string') return null;
189
- return crypto.createHmac('sha256').update(token).digest('hex');
214
+ return crypto.createHmac('sha256', getDeviceTokenKey()).update(token).digest('hex');
190
215
  };
191
216
 
192
217
  export const getDeviceTokenCookieOptions = () => ({
193
218
  maxAge: DEVICE_TRUST_DURATION_MS,
194
- domain: mbkautheVar.IS_DEPLOYED === 'true' ? `.${mbkautheVar.DOMAIN}` : undefined,
195
- secure: mbkautheVar.IS_DEPLOYED === 'true',
219
+ domain: getCookieDomain(),
220
+ secure: getCookieSecure(),
196
221
  sameSite: 'lax',
197
222
  path: '/',
198
223
  httpOnly: true
@@ -8,7 +8,7 @@ const logConfig = createLogger("config");
8
8
  const CONFIG_KEYS = [
9
9
  "APP_NAME", "DEVICE_TRUST_DURATION_DAYS", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY",
10
10
  "IS_DEPLOYED", "LOGIN_DB", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
11
- "GITHUB_LOGIN_ENABLED", "GITHUB_APP_SLUG", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GOOGLE_LOGIN_ENABLED", "GOOGLE_CLIENT_ID",
11
+ "GITHUB_LOGIN_ENABLED", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET", "GOOGLE_LOGIN_ENABLED", "GOOGLE_CLIENT_ID",
12
12
  "GOOGLE_CLIENT_SECRET", "MAX_SESSIONS_PER_USER"
13
13
  ];
14
14
 
@@ -26,7 +26,7 @@ const DEFAULT_CONFIG = {
26
26
  const BOOLEAN_KEYS = ['GITHUB_LOGIN_ENABLED', 'GOOGLE_LOGIN_ENABLED', 'MBKAUTH_TWO_FA_ENABLE', 'IS_DEPLOYED'];
27
27
  const STRING_KEYS = [
28
28
  "APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "LOGIN_DB", "DOMAIN", "loginRedirectURL",
29
- "GITHUB_APP_SLUG", "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
29
+ "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
30
30
  "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"
31
31
  ];
32
32
  const REQUIRED_KEYS = ["APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "IS_DEPLOYED", "LOGIN_DB",
@@ -253,6 +253,10 @@ try {
253
253
  }
254
254
  }
255
255
 
256
+ import { setPasswordPepper } from "./security.js";
257
+
258
+ setPasswordPepper(mbkautheVar.SESSION_SECRET_KEY);
259
+
256
260
  export { packageJson, appVersion, mbkautheVar };
257
- export { hashPassword, hashApiToken } from "./security.js";
261
+ export { hashPassword, hashApiToken, verifyPassword } from "./security.js";
258
262
  export { TOKEN_SCOPES, DEFAULT_SCOPE, canAccessMethod, isValidScope, getAvailableScopes } from "./tokenScopes.js";
@@ -1,15 +1,55 @@
1
1
  import crypto from "crypto";
2
+ import { promisify } from "util";
2
3
 
3
- // Password hashing using PBKDF2
4
+ const pbkdf2Async = promisify(crypto.pbkdf2);
5
+ const PBKDF2_ITERATIONS = 100000;
6
+ const PBKDF2_KEYLEN = 64;
7
+ const PBKDF2_DIGEST = "sha512";
8
+
9
+ let passwordPepper = null;
10
+
11
+ export function setPasswordPepper(pepper) {
12
+ passwordPepper = pepper;
13
+ }
14
+
15
+ const getPasswordPepper = () => {
16
+ if (!passwordPepper) {
17
+ throw new Error("[mbkauthe] Password pepper not initialized");
18
+ }
19
+ return passwordPepper;
20
+ };
21
+
22
+ const getPasswordSalt = (username) => `${username}:${getPasswordPepper()}`;
23
+
24
+ async function derivePasswordHash(password, username) {
25
+ const derived = await pbkdf2Async(password, getPasswordSalt(username), PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST);
26
+ return derived.toString("hex");
27
+ }
28
+
29
+ function derivePasswordHashSync(password, username) {
30
+ return crypto.pbkdf2Sync(password, getPasswordSalt(username), PBKDF2_ITERATIONS, PBKDF2_KEYLEN, PBKDF2_DIGEST).toString("hex");
31
+ }
32
+
33
+ function timingSafeHashEqual(stored, computed) {
34
+ const storedBuffer = Buffer.from(String(stored), "utf8");
35
+ const computedBuffer = Buffer.from(String(computed), "utf8");
36
+ return storedBuffer.length === computedBuffer.length && crypto.timingSafeEqual(storedBuffer, computedBuffer);
37
+ }
38
+
39
+ // Password hashing using PBKDF2 with username + application pepper as salt
4
40
  export const hashPassword = (password, username) => {
5
- const salt = username;
6
- // 128 characters returned
7
- return crypto.pbkdf2Sync(password, salt, 100000, 64, 'sha512').toString('hex');
41
+ return derivePasswordHashSync(password, username);
42
+ };
43
+
44
+ export const verifyPassword = async (password, username, storedHash) => {
45
+ if (!password || !username || !storedHash) return false;
46
+
47
+ const computed = await derivePasswordHash(password, username);
48
+ return timingSafeHashEqual(storedHash, computed);
8
49
  };
9
50
 
10
51
  // Hash an API token for storage
11
- // Uses SHA-256 for fast, secure non-reversible hashing
12
52
  export const hashApiToken = (token) => {
13
- if (!token) return null;
14
- return crypto.createHash('sha256').update(token).digest('hex');
15
- };
53
+ if (!token) return null;
54
+ return crypto.createHash("sha256").update(token).digest("hex");
55
+ };
@@ -18,8 +18,8 @@ export class AuthRepository extends BaseRepository {
18
18
  const fields = [
19
19
  `s.id as sid`,
20
20
  `s.expires_at`,
21
- `u.id as uid`,
22
21
  `u."UserName"`,
22
+ `u."UserId"`,
23
23
  `u."Active"`,
24
24
  `u."Role"`,
25
25
  `u."AllowedApps"`
@@ -85,7 +85,7 @@ export class AuthRepository extends BaseRepository {
85
85
  AND td."ExpiresAt" > NOW()
86
86
  AND u."UserName" = td."UserName"
87
87
  AND u."Active" = TRUE
88
- RETURNING td."UserName", td."ExpiresAt", u."id" as id, u."Active", u."Role", u."AllowedApps"
88
+ RETURNING td."UserName", td."ExpiresAt", u."UserId", u."Active", u."Role", u."AllowedApps"
89
89
  `;
90
90
 
91
91
  const result = await this.executeRaw({
@@ -168,12 +168,12 @@ export class AuthRepository extends BaseRepository {
168
168
  return result.rows?.[0] || null;
169
169
  }
170
170
 
171
- async updateLastLoginReturnProfile(userId) {
172
- const query = `UPDATE "Users" SET "last_login" = NOW() WHERE "id" = $1 RETURNING "FullName", "Image"`;
171
+ async updateLastLoginReturnProfile(username) {
172
+ const query = `UPDATE "Users" SET "last_login" = NOW() WHERE "UserName" = $1 RETURNING "FullName", "Image"`;
173
173
  const result = await this.executeRaw({
174
174
  name: "login-update-last-login-return-profile",
175
175
  text: query,
176
- values: [userId]
176
+ values: [username]
177
177
  });
178
178
  return result.rows?.[0] || null;
179
179
  }
@@ -196,7 +196,7 @@ export class AuthRepository extends BaseRepository {
196
196
 
197
197
  async getUserWithTwoFA(username, queryName = "login-get-user") {
198
198
  const query = `
199
- SELECT u.id, u."UserName", u."PasswordEnc", u."Active", u."Role", u."AllowedApps",
199
+ SELECT u."UserName", u."UserId", u."PasswordEnc", u."Active", u."Role", u."AllowedApps",
200
200
  tfa."TwoFAStatus", u."FullName", u."Image"
201
201
  FROM "Users" u
202
202
  LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
@@ -222,7 +222,7 @@ export class AuthRepository extends BaseRepository {
222
222
 
223
223
  async getOAuthUserByProviderId(provider, providerId) {
224
224
  const { table, idColumn, queryName } = this.resolveOAuthProvider(provider);
225
- const query = `SELECT ug.*, u."UserName", u."Role", u."Active", u."AllowedApps", u."id", tfa."TwoFAStatus"
225
+ const query = `SELECT ug.*, u."UserName", u."UserId", u."Role", u."Active", u."AllowedApps", tfa."TwoFAStatus"
226
226
  FROM ${table} ug
227
227
  JOIN "Users" u ON ug.user_name = u."UserName"
228
228
  LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
@@ -234,7 +234,7 @@ export class AuthRepository extends BaseRepository {
234
234
  async getApiTokenByHash(tokenHash, queryName = "validate-api-token") {
235
235
  const query = `
236
236
  SELECT t.id, t."UserName", t."ExpiresAt", t."Permissions",
237
- u.id as uid, u."Active", u."Role", u."AllowedApps" as user_allowed_apps, u."FullName"
237
+ u."UserId", u."Active", u."Role", u."AllowedApps" as user_allowed_apps, u."FullName"
238
238
  FROM "ApiTokens" t
239
239
  JOIN "Users" u ON t."UserName" = u."UserName"
240
240
  WHERE t."TokenHash" = $1 LIMIT 1
package/lib/main.js CHANGED
@@ -5,6 +5,7 @@ import passport from 'passport';
5
5
  import {
6
6
  sessionConfig,
7
7
  corsMiddleware,
8
+ securityHeadersMiddleware,
8
9
  sessionRestorationMiddleware,
9
10
  sessionCookieSyncMiddleware,
10
11
  requestContextMiddleware
@@ -39,6 +40,7 @@ router.use(express.urlencoded({ extended: true }));
39
40
  router.use(cookieParser());
40
41
 
41
42
  // CORS and security headers
43
+ router.use(securityHeadersMiddleware);
42
44
  router.use(corsMiddleware);
43
45
 
44
46
  // Attach request context as early as possible so session-store queries are tied to the request.
@@ -110,7 +110,7 @@ async function validateTokenAuthentication(req) {
110
110
  updateApiTokenLastUsedThrottled(row.id);
111
111
 
112
112
  return {
113
- id: row.uid,
113
+ userId: row.UserId || undefined,
114
114
  username: row.UserName,
115
115
  fullname: row.FullName,
116
116
  role: row.Role,
@@ -127,7 +127,7 @@ async function validateTokenAuthentication(req) {
127
127
 
128
128
  function attachApiTokenUser(req, res, tokenUser) {
129
129
  const user = {
130
- id: tokenUser.id,
130
+ userId: tokenUser.userId,
131
131
  username: tokenUser.username,
132
132
  fullname: tokenUser.fullname,
133
133
  role: tokenUser.role,
@@ -398,7 +398,7 @@ async function validateApiSession(req, res, next) {
398
398
  * Returns: true if session refreshed and valid, false if session invalidated
399
399
  */
400
400
  async function reloadSessionUser(req, res) {
401
- if (!req.session || !req.session.user || !req.session.user.id) return false;
401
+ if (!req.session || !req.session.user || !req.session.user.username) return false;
402
402
  try {
403
403
  const { sessionId: currentSessionId } = req.session.user;
404
404
 
@@ -447,6 +447,7 @@ async function reloadSessionUser(req, res) {
447
447
  req.session.user.username = row.UserName;
448
448
  req.session.user.role = row.Role;
449
449
  req.session.user.allowedApps = row.AllowedApps;
450
+ req.session.user.userId = row.UserId || undefined;
450
451
 
451
452
  // Obtain fullname from client cookie cache when present else DB
452
453
  if (typeof row.FullName === 'string' && row.FullName.trim() !== '') {
@@ -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,
@@ -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";
@@ -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) {
@@ -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) {
@@ -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,
@@ -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);
@@ -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,6 +765,7 @@ 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,
@@ -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";
@@ -65,9 +66,26 @@ const AdminOperationLimit = rateLimit({
65
66
  });
66
67
 
67
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
+
68
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
+
69
87
  res.setHeader('Cache-Control', 'public, max-age=31536000');
70
- res.sendFile(path.join(__dirname, '..', '..', 'public', 'main.js'));
88
+ res.type('application/javascript').send(injectedSource);
71
89
  });
72
90
 
73
91
  router.get('/main.css', (req, res) => {
@@ -145,6 +163,13 @@ router.get('/user/profilepic', async (req, res) => {
145
163
  return serveDefaultIcon();
146
164
  }
147
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
+
148
173
  // Fetch and stream the image
149
174
  try {
150
175
  const imageResponse = await fetch(imageUrl, {
@@ -187,7 +212,7 @@ if (process.env.env === 'dev') {
187
212
  success: true,
188
213
  message: 'SuperAdmin access granted',
189
214
  user: user ? {
190
- id: user.id,
215
+ userId: user.userId,
191
216
  username: user.username,
192
217
  role: user.role,
193
218
  sessionId: user.sessionId
@@ -202,7 +227,7 @@ if (process.env.env === 'dev') {
202
227
 
203
228
  // Test route
204
229
  router.get(['/test', '/'], sessVal, LoginLimit, async (req, res) => {
205
- const { username, fullname, role, id, sessionId, allowedApps } = req.session.user;
230
+ const { username, fullname, role, userId, sessionId, allowedApps } = req.session.user;
206
231
 
207
232
  const sessionExpiry = req.session.cookie?.expires
208
233
  ? new Date(req.session.cookie.expires).toISOString()
@@ -212,7 +237,7 @@ router.get(['/test', '/'], sessVal, LoginLimit, async (req, res) => {
212
237
  username,
213
238
  fullname: fullname || 'N/A',
214
239
  role,
215
- id,
240
+ userId: userId || 'N/A',
216
241
  sessionIdShort: sessionId.slice(0, 8),
217
242
  profilePicUrl: encodeURIComponent(username),
218
243
  displayName: fullname || username,
@@ -235,7 +260,7 @@ router.get('/api/checkSession', LoginLimit, async (req, res) => {
235
260
  return res.status(200).json({ sessionValid: false, expiry: null });
236
261
  }
237
262
 
238
- const { id, sessionId } = req.session.user;
263
+ const { sessionId } = req.session.user;
239
264
  if (!sessionId) {
240
265
  req.session.destroy(() => { });
241
266
  clearSessionCookies(res);
@@ -359,7 +384,7 @@ router.post('/api/verifySession', LoginLimit, async (req, res) => {
359
384
  }
360
385
 
361
386
  const expiry = row.expires_at ? new Date(row.expires_at).toISOString() : null;
362
- return res.status(200).json({ valid: true, expiry, username: row.UserName, role: row.Role });
387
+ return res.status(200).json({ valid: true, expiry });
363
388
  } catch (err) {
364
389
  console.error(`[mbkauthe] verifySession error:`, err);
365
390
  return res.status(200).json({ valid: false, expiry: null });
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mbkauthe",
3
- "version": "5.0.4",
3
+ "version": "5.1.0",
4
4
  "description": "MBKTech's reusable authentication system for Node.js applications.",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/public/main.js CHANGED
@@ -28,7 +28,19 @@
28
28
  return [];
29
29
  }
30
30
 
31
- return [...new Set([hostname, hostname.includes('.') ? `.${hostname}` : null].filter(Boolean))];
31
+ const domains = new Set();
32
+ const configuredDomain = window.mbkautheConfig?.cookieDomain;
33
+
34
+ if (configuredDomain) {
35
+ domains.add(configuredDomain);
36
+ }
37
+
38
+ domains.add(hostname);
39
+ if (hostname.includes('.')) {
40
+ domains.add(`.${hostname}`);
41
+ }
42
+
43
+ return [...domains];
32
44
  };
33
45
 
34
46
  const clearCookie = (name) => {
package/test.spec.js CHANGED
@@ -14,7 +14,13 @@ process.env.dbLogs = 'true';
14
14
  process.env.dbLogsCallsite = 'false';
15
15
 
16
16
  const { default: router } = await import('./lib/main.js');
17
- const { packageJson } = await import('./lib/config/index.js');
17
+ const { packageJson, mbkautheVar } = await import('./lib/config/index.js');
18
+ const {
19
+ resolveCookieDomain,
20
+ isAllowedOriginHostname,
21
+ getCookieDomain,
22
+ cachedCookieOptions
23
+ } = await import('./lib/config/cookies.js');
18
24
  const { dblogin } = await import('./lib/pool.js');
19
25
  const {
20
26
  attachDevQueryLogger,
@@ -23,6 +29,11 @@ const {
23
29
  runWithRequestContext
24
30
  } = await import('./lib/utils/dbQueryLogger.js');
25
31
 
32
+ const { isSafeRelativeRedirect, sanitizeRelativeRedirect } = await import('./lib/utils/redirect.js');
33
+ const { isSafeFetchUrl } = await import('./lib/utils/urlSafety.js');
34
+ const { hashPassword, verifyPassword } = await import('./lib/config/index.js');
35
+ const { hashDeviceToken } = await import('./lib/config/cookies.js');
36
+
26
37
  const viewsPath = path.join(__dirname, 'views');
27
38
 
28
39
  const handlebarsHelpers = {
@@ -252,6 +263,7 @@ describe('mbkauthe Routes', () => {
252
263
 
253
264
  expect(response.status).toBe(200);
254
265
  expect(response.headers['content-type']).toContain('javascript');
266
+ expect(response.text).toMatch(/^window\.mbkautheConfig=/);
255
267
  });
256
268
 
257
269
  test('GET /icon.svg returns SVG content', async () => {
@@ -680,4 +692,64 @@ describe('mbkauthe Routes', () => {
680
692
  }
681
693
  });
682
694
  });
695
+
696
+ describe('Security hardening', () => {
697
+ test('sanitizeRelativeRedirect accepts safe paths and rejects open redirects', () => {
698
+ expect(sanitizeRelativeRedirect('/dashboard')).toBe('/dashboard');
699
+ expect(sanitizeRelativeRedirect('//evil.com')).toBeNull();
700
+ expect(sanitizeRelativeRedirect('https://evil.com')).toBeNull();
701
+ expect(isSafeRelativeRedirect('/app/settings')).toBe(true);
702
+ });
703
+
704
+ test('isSafeFetchUrl blocks localhost and allows public https URLs', () => {
705
+ expect(isSafeFetchUrl('https://avatars.githubusercontent.com/u/1')).toBe(true);
706
+ expect(isSafeFetchUrl('http://127.0.0.1/secret')).toBe(false);
707
+ expect(isSafeFetchUrl('https://localhost/admin')).toBe(false);
708
+ expect(isSafeFetchUrl('ftp://example.com/icon.png')).toBe(false);
709
+ });
710
+
711
+ test('verifyPassword validates peppered password hashes', async () => {
712
+ const username = 'test.user';
713
+ const password = 'test-password-123';
714
+ const stored = hashPassword(password, username);
715
+
716
+ expect(stored).toMatch(/^[0-9a-f]{128}$/);
717
+ expect(await verifyPassword(password, username, stored)).toBe(true);
718
+ expect(await verifyPassword('wrong-password', username, stored)).toBe(false);
719
+ });
720
+
721
+ test('hashDeviceToken returns a keyed hex digest', () => {
722
+ const token = 'a'.repeat(64);
723
+ expect(hashDeviceToken(token)).toMatch(/^[0-9a-f]{64}$/);
724
+ });
725
+
726
+ test('security headers are set on API responses', async () => {
727
+ const response = await request(app).get('/mbkauthe/main.js');
728
+ expect(response.headers['x-content-type-options']).toBe('nosniff');
729
+ expect(response.headers['x-frame-options']).toBe('SAMEORIGIN');
730
+ });
731
+ });
732
+
733
+ describe('Cross-subdomain cookie sharing', () => {
734
+ test('resolveCookieDomain returns parent domain only when deployed outside test dev', () => {
735
+ expect(resolveCookieDomain('true', 'mbktech.org', true)).toBeUndefined();
736
+ expect(resolveCookieDomain('false', 'mbktech.org', false)).toBeUndefined();
737
+ expect(resolveCookieDomain('true', 'mbktech.org', false)).toBe('.mbktech.org');
738
+ expect(resolveCookieDomain('true', '.mbktech.org', false)).toBe('.mbktech.org');
739
+ });
740
+
741
+ test('isAllowedOriginHostname accepts root and nested subdomains', () => {
742
+ expect(isAllowedOriginHostname('mbktech.org', 'mbktech.org')).toBe(true);
743
+ expect(isAllowedOriginHostname('auth.mbktech.org', 'mbktech.org')).toBe(true);
744
+ expect(isAllowedOriginHostname('app.auth.mbktech.org', 'mbktech.org')).toBe(true);
745
+ expect(isAllowedOriginHostname('notmbktech.org', 'mbktech.org')).toBe(false);
746
+ expect(isAllowedOriginHostname('mbktech.org.evil.com', 'mbktech.org')).toBe(false);
747
+ });
748
+
749
+ test('cached cookie options omit domain in test dev environment', () => {
750
+ expect(getCookieDomain()).toBeUndefined();
751
+ expect(cachedCookieOptions.domain).toBeUndefined();
752
+ expect(cachedCookieOptions.secure).toBe(false);
753
+ });
754
+ });
683
755
  });
@@ -340,7 +340,15 @@
340
340
  </style>
341
341
 
342
342
  <script>
343
- const redirectTarget = new URLSearchParams(window.location.search).get('redirect') || '{{customURL}}';
343
+ function sanitizeRelativeRedirect(value) {
344
+ if (typeof value !== 'string') return null;
345
+ const trimmed = value.trim();
346
+ if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null;
347
+ if (trimmed.includes('://') || trimmed.includes('\\')) return null;
348
+ return trimmed;
349
+ }
350
+
351
+ const redirectTarget = sanitizeRelativeRedirect(new URLSearchParams(window.location.search).get('redirect')) || '{{customURL}}';
344
352
 
345
353
  // Ensure login anchors include ?redirect using the redirect query param (not the current page URL)
346
354
  (function setLoginHrefFromQuery() {
@@ -485,16 +493,8 @@
485
493
  if (res.ok && data.success) {
486
494
  // Prioritize explicit ?redirect if present and valid, else fall back to server-provided redirect or template
487
495
  const params = new URLSearchParams(window.location.search);
488
- let queryRedirect = null;
489
- if (params.has('redirect')) {
490
- try {
491
- const raw = params.get('redirect');
492
- if (raw) queryRedirect = decodeURIComponent(raw);
493
- } catch (e) {
494
- queryRedirect = null;
495
- }
496
- }
497
- const finalRedirect = queryRedirect || data.redirect || redirectTarget;
496
+ const queryRedirect = sanitizeRelativeRedirect(params.get('redirect'));
497
+ const finalRedirect = data.redirect || queryRedirect || redirectTarget;
498
498
  if (finalRedirect) window.location.href = finalRedirect;
499
499
  } else {
500
500
  showMessage(data.message || 'Could not switch account', 'Switch Account');
@@ -249,6 +249,14 @@
249
249
  {{> versionInfo}}
250
250
 
251
251
  <script>
252
+ function sanitizeRelativeRedirect(value) {
253
+ if (typeof value !== 'string') return null;
254
+ const trimmed = value.trim();
255
+ if (!trimmed.startsWith('/') || trimmed.startsWith('//')) return null;
256
+ if (trimmed.includes('://') || trimmed.includes('\\')) return null;
257
+ return trimmed;
258
+ }
259
+
252
260
  // Toggle password visibility
253
261
  const togglePassword = document.getElementById('togglePassword');
254
262
  const passwordInput = document.getElementById('loginPassword');
@@ -301,7 +309,7 @@
301
309
 
302
310
  // Pass redirect query param through to server so it can be used by 2FA flow
303
311
  const urlParams = new URLSearchParams(window.location.search);
304
- const pageRedirect = urlParams.get('redirect');
312
+ const pageRedirect = sanitizeRelativeRedirect(urlParams.get('redirect'));
305
313
  fetch('/mbkauthe/api/login', {
306
314
  method: 'POST',
307
315
  credentials: 'include',
@@ -330,9 +338,8 @@
330
338
  deleteCookie('rememberedUsername');
331
339
  }
332
340
 
333
- // Redirect to the appropriate page
334
- const redirectUrl = new URLSearchParams(window.location.search).get('redirect');
335
- window.location.href = redirectUrl ? decodeURIComponent(redirectUrl) : '{{customURL}}';
341
+ // Redirect to the validated target from the server or safe query param
342
+ window.location.href = data.redirectUrl || pageRedirect || '{{customURL}}';
336
343
  }
337
344
  } else {
338
345
  // Handle errors
@@ -349,7 +356,9 @@
349
356
  });
350
357
  });
351
358
 
352
- // Cookie helper functions for cross-domain functionality
359
+ // Cookie helper functions for cross-subdomain sharing
360
+ const cookieDomain = '{{cookieDomain}}';
361
+
353
362
  function setCookie(name, value, days) {
354
363
  let expires = "";
355
364
  if (days) {
@@ -357,11 +366,11 @@
357
366
  date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000));
358
367
  expires = "; expires=" + date.toUTCString();
359
368
  }
360
- // Set cookie for the entire domain (works across all subdomains)
361
- const domain = window.location.hostname.includes('.') ?
362
- '.' + window.location.hostname.split('.').slice(-2).join('.') :
363
- window.location.hostname;
364
- document.cookie = name + "=" + (value || "") + expires + "; path=/; domain=" + domain + "; SameSite=Lax";
369
+ let cookieValue = name + "=" + (value || "") + expires + "; path=/; SameSite=Lax";
370
+ if (cookieDomain) {
371
+ cookieValue += "; domain=" + cookieDomain;
372
+ }
373
+ document.cookie = cookieValue;
365
374
  }
366
375
 
367
376
  function getCookie(name) {
@@ -376,19 +385,26 @@
376
385
  }
377
386
 
378
387
  function deleteCookie(name) {
379
- const domain = window.location.hostname.includes('.') ?
380
- '.' + window.location.hostname.split('.').slice(-2).join('.') :
381
- window.location.hostname;
382
- document.cookie = name + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/; domain=" + domain;
388
+ let cookieValue = name + "=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/";
389
+ if (cookieDomain) {
390
+ cookieValue += "; domain=" + cookieDomain;
391
+ }
392
+ document.cookie = cookieValue;
383
393
  }
384
394
 
385
395
  // Check for URL parameters
386
396
  document.addEventListener('DOMContentLoaded', function () {
387
397
  const urlParams = new URLSearchParams(window.location.search);
388
- const usernameFromUrl = urlParams.get('username');
389
- const passwordFromUrl = urlParams.get('password');
390
398
  const usernameInput = document.getElementById('loginUsername');
391
399
 
400
+ if (urlParams.has('password')) {
401
+ urlParams.delete('password');
402
+ const sanitizedQuery = urlParams.toString();
403
+ const nextUrl = `${window.location.pathname}${sanitizedQuery ? `?${sanitizedQuery}` : ''}${window.location.hash}`;
404
+ window.history.replaceState({}, document.title, nextUrl);
405
+ showMessage('Passwords must not be passed in the URL. Enter your password in the form instead.', 'Security Notice');
406
+ }
407
+
392
408
  // Check for remembered username in cookies
393
409
  const rememberedUsername = getCookie('rememberedUsername');
394
410
  if (rememberedUsername) {
@@ -396,14 +412,6 @@
396
412
  document.getElementById('rememberMe').checked = true;
397
413
  }
398
414
 
399
- if (usernameFromUrl) {
400
- usernameInput.value = usernameFromUrl;
401
- }
402
-
403
- if (passwordFromUrl) {
404
- document.getElementById('loginPassword').value = passwordFromUrl;
405
- }
406
-
407
415
  // Automatically focus the first empty field
408
416
  if (!usernameInput.value) {
409
417
  usernameInput.focus();
@@ -194,7 +194,7 @@
194
194
  <div>
195
195
  <div class="session-status">✅ Authentication successful</div>
196
196
  <h3 class="session-title">{{username}} <small class="session-role">· {{role}}</small></h3>
197
- <p class="session-sub">ID: {{id}} · Session: {{sessionIdShort}}…</p>
197
+ <p class="session-sub">UserId: {{userId}} · Session: {{sessionIdShort}}…</p>
198
198
  </div>
199
199
 
200
200
  <div class="session-details" aria-live="polite">