mbkauthe 4.9.0 → 5.0.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.
@@ -14,6 +14,60 @@ const getEncryptionKey = () => {
14
14
  return crypto.createHash('sha256').update(COOKIE_ENCRYPTION_KEY).digest();
15
15
  };
16
16
 
17
+ const getSigningKey = () => {
18
+ return crypto.createHash('sha256').update(`${COOKIE_ENCRYPTION_KEY}:cookie-signing`).digest();
19
+ };
20
+
21
+ const encodePayload = (data) => {
22
+ return Buffer.from(JSON.stringify(data), 'utf8').toString('base64url');
23
+ };
24
+
25
+ const decodePayload = (encoded) => {
26
+ return JSON.parse(Buffer.from(encoded, 'base64url').toString('utf8'));
27
+ };
28
+
29
+ const signCookiePayload = (encodedPayload) => {
30
+ return crypto.createHmac('sha256', getSigningKey()).update(encodedPayload).digest('hex');
31
+ };
32
+
33
+ const verifyCookieSignature = (encodedPayload, signature) => {
34
+ if (!encodedPayload || !signature || typeof encodedPayload !== 'string' || typeof signature !== 'string') {
35
+ return false;
36
+ }
37
+
38
+ const expected = signCookiePayload(encodedPayload);
39
+ const expectedBuffer = Buffer.from(expected, 'hex');
40
+ const actualBuffer = Buffer.from(signature, 'hex');
41
+
42
+ return expectedBuffer.length === actualBuffer.length && crypto.timingSafeEqual(expectedBuffer, actualBuffer);
43
+ };
44
+
45
+ const createSignedCookiePayload = (data) => {
46
+ try {
47
+ const payload = encodePayload(data);
48
+ return {
49
+ payload,
50
+ signature: signCookiePayload(payload)
51
+ };
52
+ } catch (error) {
53
+ console.error(`[mbkauthe] Cookie signing error:`, error);
54
+ return null;
55
+ }
56
+ };
57
+
58
+ const parseSignedCookiePayload = (signedPayload) => {
59
+ try {
60
+ if (!signedPayload || !verifyCookieSignature(signedPayload.payload, signedPayload.signature)) {
61
+ return null;
62
+ }
63
+
64
+ return decodePayload(signedPayload.payload);
65
+ } catch (error) {
66
+ console.error(`[mbkauthe] Cookie signature verification error:`, error);
67
+ return null;
68
+ }
69
+ };
70
+
17
71
  // Encrypt and sign cookie payload
18
72
  const encryptCookiePayload = (data) => {
19
73
  try {
@@ -160,33 +214,46 @@ export { getCookieOptions, getClearCookieOptions };
160
214
  const parseAccountList = (raw, req) => {
161
215
  if (!raw) return [];
162
216
  try {
163
- // First, decrypt the cookie payload
164
217
  const parsed = JSON.parse(raw);
165
- const decrypted = decryptCookiePayload(parsed);
218
+ let data = parseSignedCookiePayload(parsed);
219
+ let isLegacyEncryptedCookie = false;
220
+
221
+ // Backward compatibility for previously encrypted account-list cookies.
222
+ if (!data && parsed.iv && parsed.authTag && parsed.data) {
223
+ data = decryptCookiePayload(parsed);
224
+ isLegacyEncryptedCookie = true;
225
+ }
166
226
 
167
- if (!decrypted || !decrypted.accounts || !decrypted.fingerprint) {
227
+ if (!data || !data.accounts || !data.fingerprint) {
168
228
  return [];
169
229
  }
170
230
 
171
231
  // Verify fingerprint matches current request
172
232
  const currentFingerprint = generateFingerprint(req);
173
- if (decrypted.fingerprint !== currentFingerprint) {
233
+ if (data.fingerprint !== currentFingerprint) {
174
234
  console.warn(`[mbkauthe] Cookie fingerprint mismatch - possible cookie theft attempt`);
175
235
  return [];
176
236
  }
177
237
 
178
- const accounts = decrypted.accounts;
238
+ const accounts = data.accounts;
179
239
  if (!Array.isArray(accounts)) return [];
180
240
 
181
241
  // Accept only minimal safe fields
182
242
  return accounts
183
243
  .filter(item => item && typeof item === 'object')
184
- .map(item => ({
185
- sessionId: typeof item.sessionId === 'string' ? item.sessionId : null,
186
- username: typeof item.username === 'string' ? item.username : null,
187
- fullName: typeof item.fullName === 'string' ? item.fullName : null,
188
- image: typeof item.image === 'string' ? item.image : null
189
- }))
244
+ .map(item => {
245
+ const rawSessionId = typeof item.sessionId === 'string' ? item.sessionId : null;
246
+ const sessionId = isLegacyEncryptedCookie
247
+ ? rawSessionId
248
+ : decryptSessionId(rawSessionId);
249
+
250
+ return {
251
+ sessionId,
252
+ username: typeof item.username === 'string' ? item.username : null,
253
+ fullName: typeof item.fullName === 'string' ? item.fullName : null,
254
+ image: typeof item.image === 'string' ? item.image : null
255
+ };
256
+ })
190
257
  .filter(item => item.sessionId && item.username)
191
258
  .slice(0, MAX_REMEMBERED_ACCOUNTS);
192
259
  } catch (error) {
@@ -200,7 +267,7 @@ const writeAccountList = (res, list, req) => {
200
267
 
201
268
  // Clean and limit fields to safe values (limit image URL length)
202
269
  const cleaned = sanitized.map(item => ({
203
- sessionId: item && item.sessionId ? item.sessionId : null,
270
+ sessionId: item && item.sessionId ? encryptSessionId(item.sessionId) : null,
204
271
  username: item && item.username ? item.username : null,
205
272
  fullName: item && item.fullName ? item.fullName : null,
206
273
  image: (item && typeof item.image === 'string' && item.image.length <= 2048) ? item.image : null
@@ -212,14 +279,13 @@ const writeAccountList = (res, list, req) => {
212
279
  fingerprint: generateFingerprint(req)
213
280
  };
214
281
 
215
- // Encrypt the payload
216
- const encrypted = encryptCookiePayload(payload);
217
- if (!encrypted) {
218
- console.error(`[mbkauthe] Failed to encrypt account list cookie`);
282
+ const signed = createSignedCookiePayload(payload);
283
+ if (!signed) {
284
+ console.error(`[mbkauthe] Failed to sign account list cookie`);
219
285
  return;
220
286
  }
221
287
 
222
- res.cookie(ACCOUNT_LIST_COOKIE, JSON.stringify(encrypted), cachedCookieOptions);
288
+ res.cookie(ACCOUNT_LIST_COOKIE, JSON.stringify(signed), cachedCookieOptions);
223
289
  };
224
290
 
225
291
  export const readAccountListFromCookie = (req) => {
@@ -243,4 +309,4 @@ export const removeAccountFromCookie = (req, res, sessionId) => {
243
309
 
244
310
  export const clearAccountListCookie = (res) => {
245
311
  res.clearCookie(ACCOUNT_LIST_COOKIE, cachedClearCookieOptions);
246
- };
312
+ };
@@ -1,7 +1,9 @@
1
1
  import dotenv from "dotenv";
2
2
  import { createRequire } from "module";
3
+ import { createLogger } from "../utils/logger.js";
3
4
 
4
5
  dotenv.config();
6
+ const logConfig = createLogger("config");
5
7
 
6
8
  // Comprehensive validation function
7
9
  function validateConfiguration() {
@@ -215,7 +217,7 @@ function validateConfiguration() {
215
217
  configParts.push(`defaults: ${usedDefaults.length} keys`);
216
218
  }
217
219
  const configSummary = configParts.length > 0 ? ` (${configParts.join(', ')})` : '';
218
- console.log(`[mbkauthe] Configuration loaded${configSummary}`);
220
+ logConfig(`Configuration loaded${configSummary}`);
219
221
  return mbkautheVar;
220
222
  }
221
223
 
@@ -56,4 +56,4 @@ export function getAvailableScopes() {
56
56
  name: value.name,
57
57
  description: value.description
58
58
  }));
59
- }
59
+ }
@@ -2,33 +2,120 @@
2
2
  import { readFile } from "fs/promises";
3
3
  import path from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { performance } from "perf_hooks";
5
6
 
6
7
  const __filename = fileURLToPath(import.meta.url);
7
8
  const __dirname = path.dirname(__filename);
8
9
 
9
10
  async function main() {
11
+ const startTime = performance.now();
12
+
13
+ console.log("[mbkauthe] Starting schema creation...");
14
+
10
15
  const schemaPath = path.resolve(__dirname, "../docs/db.sql");
16
+
17
+ console.log(`[mbkauthe] Schema file: ${schemaPath}`);
18
+
11
19
  const schemaSql = await readFile(schemaPath, "utf8");
12
20
 
21
+ console.log(
22
+ `[mbkauthe] Schema loaded (${Buffer.byteLength(schemaSql, "utf8")} bytes)`
23
+ );
24
+
25
+ const statementCount = schemaSql
26
+ .split(";")
27
+ .map(s => s.trim())
28
+ .filter(Boolean).length;
29
+
30
+ console.log(
31
+ `[mbkauthe] Detected approximately ${statementCount} SQL statements`
32
+ );
33
+
13
34
  try {
35
+ console.log("[mbkauthe] Testing database connection...");
36
+
37
+ const ping = await dblogin.query("SELECT version()");
38
+
39
+ console.log(
40
+ `[mbkauthe] Connected to PostgreSQL`
41
+ );
42
+
43
+ console.log(
44
+ `[mbkauthe] PostgreSQL version: ${ping.rows[0].version}`
45
+ );
46
+
47
+ console.log("[mbkauthe] Applying schema...");
48
+
49
+ const queryStart = performance.now();
50
+
14
51
  const res = await dblogin.query(schemaSql);
15
- console.log(`[mbkauthe] Schema applied successfully.`);
52
+
53
+ const queryDuration = (
54
+ performance.now() - queryStart
55
+ ).toFixed(2);
56
+
57
+ console.log(
58
+ `[mbkauthe] Schema applied successfully in ${queryDuration} ms`
59
+ );
60
+
61
+ console.log(
62
+ `[mbkauthe] Command: ${res.command ?? "MULTI"}`
63
+ );
64
+
65
+ console.log(
66
+ `[mbkauthe] Row count: ${res.rowCount ?? 0}`
67
+ );
68
+
16
69
  if (res?.rows?.length) {
17
- console.log(`[mbkauthe] Rows returned by schema query:`, res.rows);
18
- } else {
19
- console.log(`[mbkauthe] No rows returned by schema query (expected). If you want to verify table creation, query the database separately.`);
70
+ console.log(
71
+ `[mbkauthe] Returned rows: ${res.rows.length}`
72
+ );
20
73
  }
74
+
21
75
  } catch (err) {
22
76
  const IGNORE_CODES = ["42710", "42P07"];
23
- if (err && typeof err.code === "string" && IGNORE_CODES.includes(err.code)) {
24
- console.warn(`[mbkauthe] Schema already exists (ignored):`, err.message);
77
+
78
+ if (
79
+ err &&
80
+ typeof err.code === "string" &&
81
+ IGNORE_CODES.includes(err.code)
82
+ ) {
83
+ console.warn(
84
+ `[mbkauthe] Schema object already exists (ignored)`
85
+ );
86
+
87
+ console.warn(`Code: ${err.code}`);
88
+ console.warn(`Message: ${err.message}`);
25
89
  } else {
26
- console.error(`[mbkauthe] Failed to apply schema:`, err);
90
+ console.error(
91
+ `[mbkauthe] Failed to apply schema`
92
+ );
93
+
94
+ console.error("Code:", err.code);
95
+ console.error("Severity:", err.severity);
96
+ console.error("Position:", err.position);
97
+ console.error("Table:", err.table);
98
+ console.error("Column:", err.column);
99
+ console.error("Constraint:", err.constraint);
100
+ console.error("Detail:", err.detail);
101
+ console.error("Hint:", err.hint);
102
+ console.error("Message:", err.message);
103
+
27
104
  process.exitCode = 1;
28
105
  }
29
106
  } finally {
107
+ console.log("[mbkauthe] Closing database connection...");
108
+
30
109
  await dblogin.end();
110
+
111
+ const totalDuration = (
112
+ performance.now() - startTime
113
+ ).toFixed(2);
114
+
115
+ console.log(
116
+ `[mbkauthe] Finished in ${totalDuration} ms`
117
+ );
31
118
  }
32
119
  }
33
120
 
34
- main();
121
+ main();
@@ -14,6 +14,28 @@ const OAUTH_PROVIDERS = {
14
14
  };
15
15
 
16
16
  export class AuthRepository extends BaseRepository {
17
+ buildSessionUserSelect({ includeProfile = false, includeTwoFA = false } = {}) {
18
+ const fields = [
19
+ `s.id as sid`,
20
+ `s.expires_at`,
21
+ `u.id as uid`,
22
+ `u."UserName"`,
23
+ `u."Active"`,
24
+ `u."Role"`,
25
+ `u."AllowedApps"`
26
+ ];
27
+
28
+ if (includeProfile) {
29
+ fields.push(`u."FullName"`, `u."Image"`);
30
+ }
31
+
32
+ if (includeTwoFA) {
33
+ fields.push(`tfa."TwoFAStatus"`);
34
+ }
35
+
36
+ return fields.join(", ");
37
+ }
38
+
17
39
  resolveOAuthProvider(provider) {
18
40
  const key = String(provider || "").toLowerCase();
19
41
  const config = OAUTH_PROVIDERS[key];
@@ -24,7 +46,7 @@ export class AuthRepository extends BaseRepository {
24
46
  }
25
47
 
26
48
  async fetchActiveSession(sessionId) {
27
- const query = `SELECT s.id as sid, s.expires_at, u.id as uid, u."UserName", u."Active", u."Role", u."AllowedApps"
49
+ const query = `SELECT ${this.buildSessionUserSelect({ includeProfile: true })}
28
50
  FROM "Sessions" s
29
51
  JOIN "Users" u ON s."UserName" = u."UserName"
30
52
  WHERE s.id = $1 LIMIT 1`;
@@ -42,6 +64,17 @@ export class AuthRepository extends BaseRepository {
42
64
  return this.executeRaw({ name: queryName, text: query, values: [sessionId] });
43
65
  }
44
66
 
67
+ async getSessionsWithUsersByIds(sessionIds, queryName = "multi-session-fetch-many") {
68
+ if (!Array.isArray(sessionIds) || sessionIds.length === 0) return [];
69
+
70
+ const query = `SELECT ${this.buildSessionUserSelect({ includeProfile: true })}
71
+ FROM "Sessions" s
72
+ JOIN "Users" u ON s."UserName" = u."UserName"
73
+ WHERE s.id = ANY($1)`;
74
+ const result = await this.executeRaw({ name: queryName, text: query, values: [sessionIds] });
75
+ return result.rows || [];
76
+ }
77
+
45
78
  async touchTrustedDevice(deviceTokenHash, username) {
46
79
  const query = `
47
80
  UPDATE "TrustedDevices" td
@@ -164,7 +197,7 @@ export class AuthRepository extends BaseRepository {
164
197
  async getUserWithTwoFA(username, queryName = "login-get-user") {
165
198
  const query = `
166
199
  SELECT u.id, u."UserName", u."PasswordEnc", u."Active", u."Role", u."AllowedApps",
167
- tfa."TwoFAStatus"
200
+ tfa."TwoFAStatus", u."FullName", u."Image"
168
201
  FROM "Users" u
169
202
  LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
170
203
  WHERE u."UserName" = $1
@@ -189,7 +222,11 @@ export class AuthRepository extends BaseRepository {
189
222
 
190
223
  async getOAuthUserByProviderId(provider, providerId) {
191
224
  const { table, idColumn, queryName } = this.resolveOAuthProvider(provider);
192
- const query = `SELECT ug.*, u."UserName", u."Role", u."Active", u."AllowedApps", u."id" FROM ${table} ug JOIN "Users" u ON ug.user_name = u."UserName" WHERE ug.${idColumn} = $1`;
225
+ const query = `SELECT ug.*, u."UserName", u."Role", u."Active", u."AllowedApps", u."id", tfa."TwoFAStatus"
226
+ FROM ${table} ug
227
+ JOIN "Users" u ON ug.user_name = u."UserName"
228
+ LEFT JOIN "TwoFA" tfa ON u."UserName" = tfa."UserName"
229
+ WHERE ug.${idColumn} = $1`;
193
230
  const result = await this.executeRaw({ name: queryName, text: query, values: [providerId] });
194
231
  return result.rows?.[0] || null;
195
232
  }
@@ -207,17 +244,26 @@ export class AuthRepository extends BaseRepository {
207
244
  return result.rows?.[0] || null;
208
245
  }
209
246
 
210
- async updateApiTokenLastUsed(tokenId, queryName = null) {
211
- const query = `UPDATE "ApiTokens" SET "LastUsed" = NOW() WHERE id = $1`;
247
+ async updateApiTokenLastUsed(tokenId, queryName = null, minIntervalMinutes = 15) {
248
+ const query = `
249
+ UPDATE "ApiTokens"
250
+ SET "LastUsed" = NOW()
251
+ WHERE id = $1
252
+ AND (
253
+ "LastUsed" IS NULL
254
+ OR "LastUsed" < NOW() - ($2::int * INTERVAL '1 minute')
255
+ )
256
+ `;
257
+ const values = [tokenId, minIntervalMinutes];
212
258
  if (queryName) {
213
- return this.executeRaw({ name: queryName, text: query, values: [tokenId] });
259
+ return this.executeRaw({ name: queryName, text: query, values });
214
260
  }
215
- return this.executeRaw({ text: query, values: [tokenId] });
261
+ return this.executeRaw({ text: query, values });
216
262
  }
217
263
 
218
264
  async getSessionAuthData(sessionId, queryName = "validate-app-session") {
219
265
  const query = `
220
- SELECT s.expires_at, u."Active", u."Role"
266
+ SELECT s.expires_at, u."Active", u."Role", u."AllowedApps", u."UserName"
221
267
  FROM "Sessions" s
222
268
  JOIN "Users" u ON s."UserName" = u."UserName"
223
269
  WHERE s.id = $1
@@ -229,7 +275,7 @@ export class AuthRepository extends BaseRepository {
229
275
  }
230
276
 
231
277
  async getSessionWithUserById(sessionId, queryName = "restore-user-session") {
232
- const query = `SELECT u.id, u."UserName", u."Active", u."Role", u."AllowedApps", s.expires_at
278
+ const query = `SELECT ${this.buildSessionUserSelect({ includeProfile: true })}
233
279
  FROM "Sessions" s
234
280
  JOIN "Users" u ON s."UserName" = u."UserName"
235
281
  WHERE s.id = $1 LIMIT 1`;
@@ -238,12 +284,7 @@ export class AuthRepository extends BaseRepository {
238
284
  }
239
285
 
240
286
  async getSessionWithUserForReload(sessionId, queryName = "reload-session-user") {
241
- const query = `SELECT s.id as sid, s.expires_at, u.id as uid, u."UserName", u."Active", u."Role", u."AllowedApps"
242
- FROM "Sessions" s
243
- JOIN "Users" u ON s."UserName" = u."UserName"
244
- WHERE s.id = $1 LIMIT 1`;
245
- const result = await this.executeRaw({ name: queryName, text: query, values: [sessionId] });
246
- return result.rows?.[0] || null;
287
+ return this.getSessionWithUserById(sessionId, queryName);
247
288
  }
248
289
 
249
290
  async getSessionValidationRow(sessionId, queryName = "check-session-validity-by-id") {
@@ -292,4 +333,4 @@ export class AuthRepository extends BaseRepository {
292
333
  const query = `DELETE FROM "session" WHERE expire > NOW()`;
293
334
  return this.executeRaw({ name: queryName, text: query, values: [] });
294
335
  }
295
- }
336
+ }
@@ -182,4 +182,12 @@ export class BaseRepository {
182
182
  values: []
183
183
  });
184
184
  }
185
- }
185
+
186
+ async advisoryTransactionLock(lockKey, queryName = "advisory-transaction-lock") {
187
+ return this.executeRaw({
188
+ name: queryName,
189
+ text: "SELECT pg_advisory_xact_lock(hashtext($1))",
190
+ values: [String(lockKey ?? "")]
191
+ });
192
+ }
193
+ }
@@ -15,4 +15,4 @@ export const postgresDialect = {
15
15
  return parts.length ? ` ${parts.join(" ")}` : "";
16
16
  },
17
17
  lockTable: (tableSql, mode = "ROW EXCLUSIVE") => `LOCK TABLE ${tableSql} IN ${mode} MODE`
18
- };
18
+ };
package/lib/main.js CHANGED
@@ -41,17 +41,17 @@ router.use(cookieParser());
41
41
  // CORS and security headers
42
42
  router.use(corsMiddleware);
43
43
 
44
+ // Attach request context as early as possible so session-store queries are tied to the request.
45
+ if (process.env.env === 'dev') {
46
+ router.use(requestContextMiddleware);
47
+ }
48
+
44
49
  // Session configuration
45
50
  router.use(session(sessionConfig));
46
51
 
47
52
  // Session restoration
48
53
  router.use(sessionRestorationMiddleware);
49
54
 
50
- // Attach request context for DB query logging (dev only)
51
- if (process.env.env === 'dev') {
52
- router.use(requestContextMiddleware);
53
- }
54
-
55
55
  // Initialize passport
56
56
  router.use(passport.initialize());
57
57
  router.use(passport.session());