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.
@@ -5,140 +5,181 @@ import { createLogger } from "../utils/logger.js";
5
5
  dotenv.config();
6
6
  const logConfig = createLogger("config");
7
7
 
8
- // Comprehensive validation function
9
- function validateConfiguration() {
10
- const errors = [];
8
+ const CONFIG_KEYS = [
9
+ "APP_NAME", "DEVICE_TRUST_DURATION_DAYS", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY",
10
+ "IS_DEPLOYED", "LOGIN_DB", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
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
+ "GOOGLE_CLIENT_SECRET", "MAX_SESSIONS_PER_USER"
13
+ ];
14
+
15
+ const DEFAULT_CONFIG = {
16
+ DEVICE_TRUST_DURATION_DAYS: 7,
17
+ IS_DEPLOYED: 'false',
18
+ MBKAUTH_TWO_FA_ENABLE: 'false',
19
+ COOKIE_EXPIRE_TIME: 2,
20
+ loginRedirectURL: '/dashboard',
21
+ GITHUB_LOGIN_ENABLED: 'false',
22
+ GOOGLE_LOGIN_ENABLED: 'false',
23
+ MAX_SESSIONS_PER_USER: 5
24
+ };
25
+
26
+ const BOOLEAN_KEYS = ['GITHUB_LOGIN_ENABLED', 'GOOGLE_LOGIN_ENABLED', 'MBKAUTH_TWO_FA_ENABLE', 'IS_DEPLOYED'];
27
+ const STRING_KEYS = [
28
+ "APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "LOGIN_DB", "DOMAIN", "loginRedirectURL",
29
+ "GITHUB_APP_CLIENT_ID", "GITHUB_APP_CLIENT_SECRET", "GITHUB_CLIENT_ID", "GITHUB_CLIENT_SECRET",
30
+ "GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET"
31
+ ];
32
+ const REQUIRED_KEYS = ["APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "IS_DEPLOYED", "LOGIN_DB",
33
+ "MBKAUTH_TWO_FA_ENABLE", "DOMAIN"];
34
+
35
+ const isPlainObject = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
36
+ const isBlank = (value) => value === undefined || value === null || (typeof value === 'string' && value.trim() === '');
37
+ const hasValue = (value) => !isBlank(value);
38
+
39
+ function parseJsonEnv(name, { required = false } = {}) {
40
+ const raw = process.env[name];
41
+ if (isBlank(raw)) {
42
+ if (required) {
43
+ throw new Error(`[mbkauthe] Configuration Error:\n - process.env.${name} is not defined`);
44
+ }
45
+ return null;
46
+ }
11
47
 
12
- // Parse and validate mbkautheVar
13
- let mbkautheVar;
14
48
  try {
15
- if (!process.env.mbkautheVar) {
16
- errors.push("process.env.mbkautheVar is not defined");
17
- throw new Error("Configuration validation failed");
49
+ const parsed = JSON.parse(raw);
50
+ if (!isPlainObject(parsed)) {
51
+ throw new Error(`${name} must be a valid object`);
18
52
  }
19
- mbkautheVar = JSON.parse(process.env.mbkautheVar);
53
+ return parsed;
20
54
  } catch (error) {
21
- if (error.message === "Configuration validation failed") {
22
- throw new Error(`[mbkauthe] Configuration Error:\n - ${errors.join('\n - ')}`);
55
+ const message = error.message === `${name} must be a valid object`
56
+ ? error.message
57
+ : `Invalid JSON in process.env.${name}`;
58
+ if (required) {
59
+ throw new Error(`[mbkauthe] Configuration Error:\n - ${message}`);
23
60
  }
24
- errors.push("Invalid JSON in process.env.mbkautheVar");
25
- throw new Error(`[mbkauthe] Configuration Error:\n - ${errors.join('\n - ')}`);
61
+ console.warn(`[mbkauthe] ${message}, ignoring it`);
62
+ return null;
26
63
  }
64
+ }
27
65
 
28
- if (!mbkautheVar || typeof mbkautheVar !== 'object') {
29
- errors.push("mbkautheVar must be a valid object");
30
- throw new Error(`[mbkauthe] Configuration Error:\n - ${errors.join('\n - ')}`);
31
- }
66
+ function applySharedFallbacks(config, sharedConfig, usedFromShared) {
67
+ if (!sharedConfig) return;
32
68
 
33
- // Parse and validate mbkauthShared (optional fallback for shared settings)
34
- let mbkauthShared = null;
35
- try {
36
- if (process.env.mbkauthShared) {
37
- mbkauthShared = JSON.parse(process.env.mbkauthShared);
38
- if (mbkauthShared && typeof mbkauthShared !== 'object') {
39
- console.warn(`[mbkauthe] mbkauthShared is not a valid object, ignoring it`);
40
- mbkauthShared = null;
41
- }
42
- }
43
- } catch (error) {
44
- console.warn(`[mbkauthe] Invalid JSON in process.env.mbkauthShared, ignoring it`);
45
- mbkauthShared = null;
46
- }
47
-
48
- // Merge fallback settings: for any key missing or empty in mbkautheVar, check mbkauthShared
49
- const usedFromShared = [];
50
- const usedDefaults = [];
51
- const applyFallback = (source, sourceName) => {
52
- if (!source) return;
53
- Object.keys(source).forEach(key => {
54
- const val = source[key];
55
- if ((mbkautheVar[key] === undefined || (typeof mbkautheVar[key] === 'string' && mbkautheVar[key].trim() === '')) &&
56
- val !== undefined && !(typeof val === 'string' && val.trim() === '')) {
57
- mbkautheVar[key] = val;
58
- if (sourceName === 'mbkauthShared') usedFromShared.push(key);
59
- }
60
- });
61
- };
62
-
63
- applyFallback(mbkauthShared, 'mbkauthShared');
64
-
65
- // Ensure specific keys are checked in mbkautheVar first, then mbkauthShared, then apply config defaults
66
- const keysToCheck = [
67
- "APP_NAME", "DEVICE_TRUST_DURATION_DAYS", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY",
68
- "IS_DEPLOYED", "LOGIN_DB", "MBKAUTH_TWO_FA_ENABLE", "COOKIE_EXPIRE_TIME", "DOMAIN", "loginRedirectURL",
69
- "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",
70
- "GOOGLE_CLIENT_SECRET", "MAX_SESSIONS_PER_USER"
71
- ];
72
-
73
- const defaults = {
74
- DEVICE_TRUST_DURATION_DAYS: 7,
75
- IS_DEPLOYED: 'false',
76
- MBKAUTH_TWO_FA_ENABLE: 'false',
77
- COOKIE_EXPIRE_TIME: 2,
78
- loginRedirectURL: '/dashboard',
79
- GITHUB_LOGIN_ENABLED: 'false',
80
- GOOGLE_LOGIN_ENABLED: 'false',
81
- MAX_SESSIONS_PER_USER: 5
82
- };
83
-
84
- keysToCheck.forEach(key => {
85
- const current = mbkautheVar[key];
86
- const isEmpty = current === undefined || (typeof current === 'string' && current.trim() === '');
87
- if (isEmpty) {
88
- if (mbkauthShared && mbkauthShared[key] !== undefined && !(typeof mbkauthShared[key] === 'string' && mbkauthShared[key].trim() === '')) {
89
- mbkautheVar[key] = mbkauthShared[key];
90
- if (!usedFromShared.includes(key)) usedFromShared.push(key);
91
- } else if (defaults[key] !== undefined) {
92
- mbkautheVar[key] = defaults[key];
93
- usedDefaults.push(key);
94
- }
69
+ Object.entries(sharedConfig).forEach(([key, value]) => {
70
+ if (isBlank(config[key]) && hasValue(value)) {
71
+ config[key] = value;
72
+ usedFromShared.add(key);
95
73
  }
96
74
  });
75
+ }
97
76
 
98
- // Normalize boolean-like values to consistent lowercase 'true'/'false' strings
99
- ['GITHUB_LOGIN_ENABLED', 'GOOGLE_LOGIN_ENABLED', 'MBKAUTH_TWO_FA_ENABLE', 'IS_DEPLOYED'].forEach(k => {
100
- const val = mbkautheVar[k];
101
- if (typeof val === 'boolean') {
102
- mbkautheVar[k] = val ? 'true' : 'false';
103
- } else if (typeof val === 'string') {
104
- const norm = val.trim().toLowerCase();
105
- // Accept 'f' as shorthand for false but normalize it to 'false'
106
- mbkautheVar[k] = (norm === 'f') ? 'false' : norm;
77
+ function applyDefaults(config, usedDefaults) {
78
+ CONFIG_KEYS.forEach((key) => {
79
+ if (isBlank(config[key]) && DEFAULT_CONFIG[key] !== undefined) {
80
+ config[key] = DEFAULT_CONFIG[key];
81
+ usedDefaults.add(key);
107
82
  }
108
83
  });
84
+ }
109
85
 
110
- // Validate required keys
111
- // COOKIE_EXPIRE_TIME is not required but if provided must be valid, COOKIE_EXPIRE_TIME by default is 2 days
112
- // loginRedirectURL is not required but if provided must be valid, loginRedirectURL by default is /dashboard
113
- const requiredKeys = ["APP_NAME", "Main_SECRET_TOKEN", "SESSION_SECRET_KEY", "IS_DEPLOYED", "LOGIN_DB",
114
- "MBKAUTH_TWO_FA_ENABLE", "DOMAIN"];
86
+ function normalizeBooleanFlag(config, key, errors) {
87
+ const value = config[key];
88
+ if (typeof value === 'boolean') {
89
+ config[key] = value ? 'true' : 'false';
90
+ return;
91
+ }
115
92
 
116
- requiredKeys.forEach(key => {
117
- if (!mbkautheVar[key] || (typeof mbkautheVar[key] === 'string' && mbkautheVar[key].trim() === '')) {
118
- errors.push(`mbkautheVar.${key} is required and cannot be empty`);
119
- }
120
- });
93
+ const normalized = String(value ?? '').trim().toLowerCase();
94
+ if (normalized === 'f') {
95
+ config[key] = 'false';
96
+ return;
97
+ }
98
+
99
+ if (normalized === 'true' || normalized === 'false') {
100
+ config[key] = normalized;
101
+ return;
102
+ }
103
+
104
+ if (!isBlank(value)) {
105
+ errors.push(`mbkautheVar.${key} must be either 'true' or 'false' or 'f'`);
106
+ }
107
+ }
108
+
109
+ function normalizePositiveNumber(config, key, errors) {
110
+ const numericValue = Number(config[key]);
111
+ if (!Number.isFinite(numericValue) || numericValue <= 0) {
112
+ errors.push(`mbkautheVar.${key} must be a valid positive number`);
113
+ return;
114
+ }
115
+ config[key] = numericValue;
116
+ }
117
+
118
+ function normalizePositiveInteger(config, key, errors) {
119
+ const numericValue = Number(config[key]);
120
+ if (!Number.isInteger(numericValue) || numericValue <= 0) {
121
+ errors.push(`mbkautheVar.${key} must be a valid positive integer`);
122
+ return;
123
+ }
124
+ config[key] = numericValue;
125
+ }
121
126
 
122
- // Validate IS_DEPLOYED value
123
- if (mbkautheVar.IS_DEPLOYED && !['true', 'false', 'f'].includes((mbkautheVar.IS_DEPLOYED + '').toLowerCase())) {
124
- errors.push("mbkautheVar.IS_DEPLOYED must be either 'true' or 'false' or 'f'");
127
+ function normalizeString(config, key) {
128
+ if (hasValue(config[key])) {
129
+ config[key] = String(config[key]).trim();
125
130
  }
131
+ }
132
+
133
+ function normalizeAndValidateConfig(config, errors) {
134
+ STRING_KEYS.forEach((key) => normalizeString(config, key));
126
135
 
127
- // Validate MBKAUTH_TWO_FA_ENABLE value
128
- if (mbkautheVar.MBKAUTH_TWO_FA_ENABLE && !['true', 'false', 'f'].includes(mbkautheVar.MBKAUTH_TWO_FA_ENABLE.toLowerCase())) {
129
- errors.push("mbkautheVar.MBKAUTH_TWO_FA_ENABLE must be either 'true' or 'false' or 'f'");
136
+ if (hasValue(config.APP_NAME)) {
137
+ config.APP_NAME = config.APP_NAME.toLowerCase();
130
138
  }
131
139
 
132
- // Validate GITHUB_LOGIN_ENABLED value
133
- if (mbkautheVar.GITHUB_LOGIN_ENABLED && !['true', 'false', 'f'].includes(mbkautheVar.GITHUB_LOGIN_ENABLED.toLowerCase())) {
134
- errors.push("mbkautheVar.GITHUB_LOGIN_ENABLED must be either 'true' or 'false' or 'f'");
140
+ if (hasValue(config.DOMAIN)) {
141
+ const domain = config.DOMAIN.toLowerCase().replace(/^\.+/, '');
142
+ config.DOMAIN = domain;
143
+ if (domain.includes('://') || domain.includes('/') || domain.includes(':')) {
144
+ errors.push("mbkautheVar.DOMAIN must be a hostname only, without protocol, path, or port");
145
+ }
135
146
  }
136
147
 
137
- // Validate GOOGLE_LOGIN_ENABLED value
138
- if (mbkautheVar.GOOGLE_LOGIN_ENABLED && !['true', 'false', 'f'].includes(mbkautheVar.GOOGLE_LOGIN_ENABLED.toLowerCase())) {
139
- errors.push("mbkautheVar.GOOGLE_LOGIN_ENABLED must be either 'true' or 'false' or 'f'");
148
+ if (hasValue(config.loginRedirectURL)) {
149
+ const redirectUrl = String(config.loginRedirectURL).trim();
150
+ config.loginRedirectURL = redirectUrl;
151
+ if (!redirectUrl.startsWith('/') || redirectUrl.startsWith('//')) {
152
+ errors.push("mbkautheVar.loginRedirectURL must be a relative path starting with '/'");
153
+ }
140
154
  }
141
155
 
156
+ BOOLEAN_KEYS.forEach((key) => normalizeBooleanFlag(config, key, errors));
157
+ normalizePositiveNumber(config, "COOKIE_EXPIRE_TIME", errors);
158
+ normalizePositiveNumber(config, "DEVICE_TRUST_DURATION_DAYS", errors);
159
+ normalizePositiveInteger(config, "MAX_SESSIONS_PER_USER", errors);
160
+ }
161
+
162
+ // Comprehensive validation function
163
+ function validateConfiguration() {
164
+ const errors = [];
165
+ const usedFromShared = new Set();
166
+ const usedDefaults = new Set();
167
+ const mbkautheVar = parseJsonEnv("mbkautheVar", { required: true });
168
+ const mbkauthShared = parseJsonEnv("mbkauthShared");
169
+
170
+ applySharedFallbacks(mbkautheVar, mbkauthShared, usedFromShared);
171
+ applyDefaults(mbkautheVar, usedDefaults);
172
+ normalizeAndValidateConfig(mbkautheVar, errors);
173
+
174
+ // Validate required keys
175
+ // COOKIE_EXPIRE_TIME is not required but if provided must be valid, COOKIE_EXPIRE_TIME by default is 2 days
176
+ // loginRedirectURL is not required but if provided must be valid, loginRedirectURL by default is /dashboard
177
+ REQUIRED_KEYS.forEach(key => {
178
+ if (isBlank(mbkautheVar[key])) {
179
+ errors.push(`mbkautheVar.${key} is required and cannot be empty`);
180
+ }
181
+ });
182
+
142
183
  // Validate GitHub login configuration
143
184
  if (mbkautheVar.GITHUB_LOGIN_ENABLED === "true") {
144
185
  const hasGithubClientId = !!(mbkautheVar.GITHUB_APP_CLIENT_ID || mbkautheVar.GITHUB_CLIENT_ID);
@@ -154,50 +195,14 @@ function validateConfiguration() {
154
195
 
155
196
  // Validate Google login configuration
156
197
  if (mbkautheVar.GOOGLE_LOGIN_ENABLED === "true") {
157
- if (!mbkautheVar.GOOGLE_CLIENT_ID || mbkautheVar.GOOGLE_CLIENT_ID.trim() === '') {
198
+ if (isBlank(mbkautheVar.GOOGLE_CLIENT_ID)) {
158
199
  errors.push("mbkautheVar.GOOGLE_CLIENT_ID is required when GOOGLE_LOGIN_ENABLED is 'true'");
159
200
  }
160
- if (!mbkautheVar.GOOGLE_CLIENT_SECRET || mbkautheVar.GOOGLE_CLIENT_SECRET.trim() === '') {
201
+ if (isBlank(mbkautheVar.GOOGLE_CLIENT_SECRET)) {
161
202
  errors.push("mbkautheVar.GOOGLE_CLIENT_SECRET is required when GOOGLE_LOGIN_ENABLED is 'true'");
162
203
  }
163
204
  }
164
205
 
165
- // Validate COOKIE_EXPIRE_TIME if provided
166
- if (mbkautheVar.COOKIE_EXPIRE_TIME !== undefined) {
167
- const expireTime = parseFloat(mbkautheVar.COOKIE_EXPIRE_TIME);
168
- if (isNaN(expireTime) || expireTime <= 0) {
169
- errors.push("mbkautheVar.COOKIE_EXPIRE_TIME must be a valid positive number");
170
- }
171
- } else {
172
- // Set default value
173
- mbkautheVar.COOKIE_EXPIRE_TIME = 2;
174
- }
175
-
176
- // Validate DEVICE_TRUST_DURATION_DAYS if provided
177
- if (mbkautheVar.DEVICE_TRUST_DURATION_DAYS !== undefined) {
178
- const trustDuration = parseFloat(mbkautheVar.DEVICE_TRUST_DURATION_DAYS);
179
- if (isNaN(trustDuration) || trustDuration <= 0) {
180
- errors.push("mbkautheVar.DEVICE_TRUST_DURATION_DAYS must be a valid positive number");
181
- }
182
- } else {
183
- // Set default value
184
- mbkautheVar.DEVICE_TRUST_DURATION_DAYS = 7;
185
- }
186
-
187
- // Validate MAX_SESSIONS_PER_USER if provided (must be positive integer)
188
- if (mbkautheVar.MAX_SESSIONS_PER_USER !== undefined) {
189
- const maxSessions = parseInt(mbkautheVar.MAX_SESSIONS_PER_USER, 10);
190
- if (isNaN(maxSessions) || maxSessions <= 0) {
191
- errors.push("mbkautheVar.MAX_SESSIONS_PER_USER must be a valid positive integer");
192
- } else {
193
- // Normalize to integer
194
- mbkautheVar.MAX_SESSIONS_PER_USER = maxSessions;
195
- }
196
- } else {
197
- // Ensure default value is set
198
- mbkautheVar.MAX_SESSIONS_PER_USER = 5;
199
- }
200
-
201
206
  // Validate LOGIN_DB connection string format
202
207
  if (mbkautheVar.LOGIN_DB && !mbkautheVar.LOGIN_DB.startsWith('postgresql://') && !mbkautheVar.LOGIN_DB.startsWith('postgres://')) {
203
208
  errors.push("mbkautheVar.LOGIN_DB must be a valid PostgreSQL connection string");
@@ -211,14 +216,14 @@ function validateConfiguration() {
211
216
  // Print consolidated configuration summary
212
217
  const configParts = [];
213
218
  if (mbkauthShared) {
214
- configParts.push(`mbkauthShared: ${usedFromShared.length} keys`);
219
+ configParts.push(`mbkauthShared: ${usedFromShared.size} keys`);
215
220
  }
216
- if (usedDefaults.length > 0) {
217
- configParts.push(`defaults: ${usedDefaults.length} keys`);
221
+ if (usedDefaults.size > 0) {
222
+ configParts.push(`defaults: ${usedDefaults.size} keys`);
218
223
  }
219
224
  const configSummary = configParts.length > 0 ? ` (${configParts.join(', ')})` : '';
220
225
  logConfig(`Configuration loaded${configSummary}`);
221
- return mbkautheVar;
226
+ return Object.freeze(mbkautheVar);
222
227
  }
223
228
 
224
229
  // Parse and validate mbkautheVar once
@@ -248,6 +253,10 @@ try {
248
253
  }
249
254
  }
250
255
 
256
+ import { setPasswordPepper } from "./security.js";
257
+
258
+ setPasswordPepper(mbkautheVar.SESSION_SECRET_KEY);
259
+
251
260
  export { packageJson, appVersion, mbkautheVar };
252
- export { hashPassword, hashApiToken } from "./security.js";
261
+ export { hashPassword, hashApiToken, verifyPassword } from "./security.js";
253
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.
@@ -13,10 +13,37 @@ const IS_DEV = process.env.env === 'dev' || process.env.test === 'dev' || proces
13
13
  const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
14
14
  const isUuid = (val) => typeof val === 'string' && UUID_RE.test(val);
15
15
  const MAX_API_TOKEN_LENGTH = 4096;
16
+ const API_TOKEN_LAST_USED_INTERVAL_MS = 15 * 60 * 1000;
16
17
  const API_TOKEN_SESSION_RESTORE = Symbol('mbkauthe.apiTokenSessionRestore');
18
+ const apiTokenLastUsedCache = new Map();
17
19
  const authRepo = new AuthRepository({ db: dblogin });
18
20
  const logAuth = createLogger("auth");
19
21
 
22
+ function pruneApiTokenLastUsedCache(now) {
23
+ if (apiTokenLastUsedCache.size < 10000) return;
24
+ const staleBefore = now - (API_TOKEN_LAST_USED_INTERVAL_MS * 2);
25
+ for (const [tokenId, lastTouchedAt] of apiTokenLastUsedCache) {
26
+ if (lastTouchedAt < staleBefore) {
27
+ apiTokenLastUsedCache.delete(tokenId);
28
+ }
29
+ }
30
+ }
31
+
32
+ function updateApiTokenLastUsedThrottled(tokenId) {
33
+ if (!tokenId) return;
34
+
35
+ const now = Date.now();
36
+ const lastTouchedAt = apiTokenLastUsedCache.get(tokenId) || 0;
37
+ if (now - lastTouchedAt < API_TOKEN_LAST_USED_INTERVAL_MS) return;
38
+
39
+ pruneApiTokenLastUsedCache(now);
40
+ apiTokenLastUsedCache.set(tokenId, now);
41
+ authRepo.updateApiTokenLastUsed(tokenId).catch(e => {
42
+ apiTokenLastUsedCache.delete(tokenId);
43
+ console.error(`[mbkauthe] Failed to update token usage:`, e);
44
+ });
45
+ }
46
+
20
47
  /**
21
48
  * Decide if the incoming request should return JSON errors instead of HTML.
22
49
  * Non-browser clients (API calls / AJAX) should get JSON.
@@ -80,11 +107,10 @@ async function validateTokenAuthentication(req) {
80
107
  allowedApps = tokenAllowedApps;
81
108
  }
82
109
 
83
- // Update usage opportunistically, but not on every request.
84
- authRepo.updateApiTokenLastUsed(row.id).catch(e => console.error(`[mbkauthe] Failed to update token usage:`, e));
110
+ updateApiTokenLastUsedThrottled(row.id);
85
111
 
86
112
  return {
87
- id: row.uid,
113
+ userId: row.UserId || undefined,
88
114
  username: row.UserName,
89
115
  fullname: row.FullName,
90
116
  role: row.Role,
@@ -101,7 +127,7 @@ async function validateTokenAuthentication(req) {
101
127
 
102
128
  function attachApiTokenUser(req, res, tokenUser) {
103
129
  const user = {
104
- id: tokenUser.id,
130
+ userId: tokenUser.userId,
105
131
  username: tokenUser.username,
106
132
  fullname: tokenUser.fullname,
107
133
  role: tokenUser.role,
@@ -159,7 +185,7 @@ function hasAppAccess(role, allowedApps) {
159
185
  if (role === "SuperAdmin") return true;
160
186
  return Array.isArray(allowedApps)
161
187
  && allowedApps.length > 0
162
- && allowedApps.some((app) => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase());
188
+ && allowedApps.some((app) => app && app.toLowerCase() === mbkautheVar.APP_NAME);
163
189
  }
164
190
 
165
191
  function destroySessionCookies(req, res) {
@@ -308,11 +334,11 @@ async function validateSession(req, res, next, strictTokenValidation = false) {
308
334
  }
309
335
 
310
336
  const hasWildcard = allowedApps.includes('*');
311
- const hasSpecificApp = allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase());
337
+ const hasSpecificApp = allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME);
312
338
 
313
339
  if (hasWildcard) {
314
340
  const userHasApp = Array.isArray(userAllowedApps)
315
- && userAllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase());
341
+ && userAllowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME);
316
342
  if (!userHasApp) {
317
343
  return res.status(401).json(createErrorResponse(401, ErrorCodes.APP_NOT_AUTHORIZED));
318
344
  }
@@ -372,7 +398,7 @@ async function validateApiSession(req, res, next) {
372
398
  * Returns: true if session refreshed and valid, false if session invalidated
373
399
  */
374
400
  async function reloadSessionUser(req, res) {
375
- 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;
376
402
  try {
377
403
  const { sessionId: currentSessionId } = req.session.user;
378
404
 
@@ -410,7 +436,7 @@ async function reloadSessionUser(req, res) {
410
436
  if (row.Role !== 'SuperAdmin') {
411
437
  const allowedApps = row.AllowedApps;
412
438
  const hasAllowedApps = Array.isArray(allowedApps) && allowedApps.length > 0;
413
- if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME.toLowerCase())) {
439
+ if (!hasAllowedApps || !allowedApps.some(app => app && app.toLowerCase() === mbkautheVar.APP_NAME)) {
414
440
  req.session.destroy(() => { });
415
441
  clearSessionCookies(res);
416
442
  return false;
@@ -421,6 +447,7 @@ async function reloadSessionUser(req, res) {
421
447
  req.session.user.username = row.UserName;
422
448
  req.session.user.role = row.Role;
423
449
  req.session.user.allowedApps = row.AllowedApps;
450
+ req.session.user.userId = row.UserId || undefined;
424
451
 
425
452
  // Obtain fullname from client cookie cache when present else DB
426
453
  if (typeof row.FullName === 'string' && row.FullName.trim() !== '') {