najm-auth 1.1.39 → 1.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -67,9 +67,9 @@ var permissionsTable = pgTable("permissions", {
67
67
  });
68
68
  var tokensTable = pgTable("tokens", {
69
69
  ...baseFields(10),
70
- userId: text("user_id").references(() => usersTable.id, { onDelete: "cascade" }).unique().notNull(),
70
+ userId: text("user_id").references(() => usersTable.id, { onDelete: "cascade" }).notNull(),
71
71
  token: text("token").notNull(),
72
- tokenFamily: text("token_family"),
72
+ tokenFamily: text("token_family").notNull().unique(),
73
73
  previousHash: text("previous_hash"),
74
74
  previousValidUntil: timestamp("previous_valid_until", { mode: "string" }),
75
75
  previousUsedAt: timestamp("previous_used_at", { mode: "string" }),
@@ -77,6 +77,7 @@ var tokensTable = pgTable("tokens", {
77
77
  status: tokenStatusEnum("status").default("active"),
78
78
  expiresAt: timestamp("expires_at", { mode: "string" }).notNull()
79
79
  }, (table) => ({
80
+ userIdIdx: index("tokens_user_id_idx").on(table.userId),
80
81
  expiresAtIdx: index("tokens_expires_at_idx").on(table.expiresAt)
81
82
  }));
82
83
  var rolePermissionsTable = pgTable("role_permissions", {
@@ -134,9 +135,9 @@ var permissionsTable2 = sqliteTable("permissions", {
134
135
  });
135
136
  var tokensTable2 = sqliteTable("tokens", {
136
137
  ...baseFields2(10),
137
- userId: text2("user_id").references(() => usersTable2.id, { onDelete: "cascade" }).unique().notNull(),
138
+ userId: text2("user_id").references(() => usersTable2.id, { onDelete: "cascade" }).notNull(),
138
139
  token: text2("token").notNull(),
139
- tokenFamily: text2("token_family"),
140
+ tokenFamily: text2("token_family").notNull().unique(),
140
141
  previousHash: text2("previous_hash"),
141
142
  previousValidUntil: text2("previous_valid_until"),
142
143
  previousUsedAt: text2("previous_used_at"),
@@ -144,6 +145,7 @@ var tokensTable2 = sqliteTable("tokens", {
144
145
  status: text2("status").$type().default("active"),
145
146
  expiresAt: text2("expires_at").notNull()
146
147
  }, (table) => ({
148
+ userIdIdx: index2("tokens_user_id_idx").on(table.userId),
147
149
  expiresAtIdx: index2("tokens_expires_at_idx").on(table.expiresAt)
148
150
  }));
149
151
  var rolePermissionsTable2 = sqliteTable("role_permissions", {
@@ -161,87 +163,6 @@ var authSchema2 = {
161
163
  rolePermissions: rolePermissionsTable2
162
164
  };
163
165
 
164
- // src/schema/mysql.ts
165
- import { mysqlTable, varchar, boolean as boolean2, timestamp as timestamp2, mysqlEnum, primaryKey as primaryKey2, int, index as index3 } from "drizzle-orm/mysql-core";
166
- import { sql as sql3 } from "drizzle-orm";
167
- import { nanoid as nanoid3 } from "nanoid";
168
- var baseFields3 = /* @__PURE__ */ __name((idLength = 5) => ({
169
- id: varchar("id", { length: 21 }).primaryKey().$defaultFn(() => nanoid3(idLength)),
170
- createdAt: timestamp2("created_at", { mode: "string" }).defaultNow(),
171
- updatedAt: timestamp2("updated_at", { mode: "string" }).defaultNow().$onUpdate(() => sql3`CURRENT_TIMESTAMP`)
172
- }), "baseFields");
173
- var rolesTable3 = mysqlTable("roles", {
174
- ...baseFields3(5),
175
- name: varchar("name", { length: 255 }).notNull(),
176
- description: varchar("description", { length: 1e3 })
177
- });
178
- var usersTable3 = mysqlTable("users", {
179
- ...baseFields3(8),
180
- name: varchar("name", { length: 255 }),
181
- email: varchar("email", { length: 255 }).notNull().unique(),
182
- emailVerified: boolean2("email_verified").default(false),
183
- phone: varchar("phone", { length: 255 }).unique(),
184
- phoneVerified: boolean2("phone_verified").default(false),
185
- password: varchar("password", { length: 255 }).notNull(),
186
- image: varchar("image", { length: 255 }).default("noavatar.png"),
187
- status: mysqlEnum("status", [...USER_STATUS]).default("pending"),
188
- roleId: varchar("role_id", { length: 21 }).references(() => rolesTable3.id),
189
- lastLogin: timestamp2("last_login", { mode: "string" }),
190
- failedLoginAttempts: int("failed_login_attempts").default(0),
191
- lockoutUntil: timestamp2("lockout_until", { mode: "string" })
192
- }, (table) => ({
193
- roleIdx: index3("users_role_id_idx").on(table.roleId)
194
- }));
195
- var permissionsTable3 = mysqlTable("permissions", {
196
- ...baseFields3(5),
197
- name: varchar("name", { length: 255 }).notNull().unique(),
198
- description: varchar("description", { length: 1e3 }),
199
- resource: varchar("resource", { length: 255 }).notNull(),
200
- action: varchar("action", { length: 255 }).notNull()
201
- });
202
- var tokensTable3 = mysqlTable("tokens", {
203
- ...baseFields3(10),
204
- userId: varchar("user_id", { length: 21 }).references(() => usersTable3.id, { onDelete: "cascade" }).unique().notNull(),
205
- token: varchar("token", { length: 500 }).notNull(),
206
- tokenFamily: varchar("token_family", { length: 16 }),
207
- previousHash: varchar("previous_hash", { length: 500 }),
208
- previousValidUntil: timestamp2("previous_valid_until", { mode: "string" }),
209
- previousUsedAt: timestamp2("previous_used_at", { mode: "string" }),
210
- type: mysqlEnum("type", [...TOKEN_TYPE]).default("refresh"),
211
- status: mysqlEnum("status", [...TOKEN_STATUS]).default("active"),
212
- expiresAt: timestamp2("expires_at", { mode: "string" }).notNull()
213
- }, (table) => ({
214
- expiresAtIdx: index3("tokens_expires_at_idx").on(table.expiresAt)
215
- }));
216
- var rolePermissionsTable3 = mysqlTable("role_permissions", {
217
- roleId: varchar("role_id", { length: 21 }).notNull().references(() => rolesTable3.id, { onDelete: "cascade" }),
218
- permissionId: varchar("permission_id", { length: 21 }).notNull().references(() => permissionsTable3.id, { onDelete: "cascade" }),
219
- createdAt: timestamp2("created_at", { mode: "string" }).defaultNow()
220
- }, (table) => ({
221
- pk: primaryKey2({ columns: [table.roleId, table.permissionId] })
222
- }));
223
- var authSchema3 = {
224
- users: usersTable3,
225
- tokens: tokensTable3,
226
- roles: rolesTable3,
227
- permissions: permissionsTable3,
228
- rolePermissions: rolePermissionsTable3
229
- };
230
-
231
- // src/auth/index.ts
232
- var auth_exports = {};
233
- __export(auth_exports, {
234
- AUTH_MODULE: () => AUTH_MODULE,
235
- AuthController: () => AuthController,
236
- AuthGuard: () => AuthGuard,
237
- AuthResolver: () => AuthResolver,
238
- AuthService: () => AuthService,
239
- CookieManager: () => CookieManager,
240
- EncryptionService: () => EncryptionService,
241
- isAuth: () => isAuth,
242
- runAsUser: () => runAsUser
243
- });
244
-
245
166
  // src/auth/EncryptionService.ts
246
167
  import { Inject, Injectable } from "najm-core";
247
168
  import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
@@ -469,7 +390,7 @@ import { I18nService } from "najm-i18n";
469
390
  import { I18n as I18n3 } from "najm-i18n";
470
391
 
471
392
  // src/users/UserRepository.ts
472
- import { eq as eq2, ne, sql as sql4 } from "drizzle-orm";
393
+ import { eq as eq2, ne, sql as sql3 } from "drizzle-orm";
473
394
  import { Repository, Inject as Inject3 } from "najm-core";
474
395
  import { DB } from "najm-database";
475
396
 
@@ -639,7 +560,7 @@ var UserRepository = class UserRepository2 {
639
560
  return updatedUser;
640
561
  }
641
562
  async incrementFailedAttempts(id) {
642
- const [updatedUser] = await this.db.update(this.users).set({ failedLoginAttempts: sql4`coalesce(${this.users.failedLoginAttempts}, 0) + 1` }).where(eq2(this.users.id, id)).returning();
563
+ const [updatedUser] = await this.db.update(this.users).set({ failedLoginAttempts: sql3`coalesce(${this.users.failedLoginAttempts}, 0) + 1` }).where(eq2(this.users.id, id)).returning();
643
564
  return updatedUser;
644
565
  }
645
566
  async resetFailedAttempts(id) {
@@ -1044,7 +965,7 @@ RoleService = __decorate7([
1044
965
  ], RoleService);
1045
966
 
1046
967
  // src/users/UserService.ts
1047
- import { nanoid as nanoid4 } from "nanoid";
968
+ import { nanoid as nanoid3 } from "nanoid";
1048
969
 
1049
970
  // src/shared/index.ts
1050
971
  import * as fs from "fs/promises";
@@ -1254,7 +1175,7 @@ var UserService = class UserService2 {
1254
1175
  Err4("Password is required");
1255
1176
  }
1256
1177
  this.userValidator.validatePasswordStrength(password);
1257
- let userId = id || nanoid4(10);
1178
+ let userId = id || nanoid3(10);
1258
1179
  await this.userValidator.checkEmailUnique(data.email);
1259
1180
  await this.userValidator.checkUserIdIsUnique(userId);
1260
1181
  const hashedPassword = await this.encryptionService.hashPassword(password);
@@ -1336,7 +1257,7 @@ var UserService = class UserService2 {
1336
1257
  await this.delete(existingUser.id);
1337
1258
  }
1338
1259
  const newAdminUser = await this.create({
1339
- id: nanoid4(10),
1260
+ id: nanoid3(10),
1340
1261
  // Random ID instead of predictable 'USR00'
1341
1262
  name: config.name || "System Administrator",
1342
1263
  email: config.email,
@@ -1378,10 +1299,10 @@ import { I18n as I18n4 } from "najm-i18n";
1378
1299
  import { CacheService } from "najm-cache";
1379
1300
  import { createHash } from "crypto";
1380
1301
  import jwt from "jsonwebtoken";
1381
- import { nanoid as nanoid5 } from "nanoid";
1302
+ import { nanoid as nanoid4 } from "nanoid";
1382
1303
 
1383
1304
  // src/tokens/TokenRepository.ts
1384
- import { and, eq as eq4, isNull } from "drizzle-orm";
1305
+ import { and, eq as eq4, isNull, lt } from "drizzle-orm";
1385
1306
  import { Repository as Repository3, Inject as Inject6 } from "najm-core";
1386
1307
  import { DB as DB3 } from "najm-database";
1387
1308
  var __decorate9 = function(decorators, target, key, desc) {
@@ -1410,12 +1331,17 @@ var TokenRepository = class TokenRepository2 {
1410
1331
  get q() {
1411
1332
  return this.queryHelper ??= new AuthQueries(this.db, this.schema);
1412
1333
  }
1334
+ /**
1335
+ * Upsert the refresh-token row for a session, keyed on `tokenFamily` (the
1336
+ * per-login session identifier, unique). A brand-new login inserts a fresh
1337
+ * family row; a refresh rotation updates only that family's row, leaving the
1338
+ * user's other sessions untouched.
1339
+ */
1413
1340
  async storeRefreshToken(tokenData) {
1414
1341
  return await this.db.insert(this.tokens).values(tokenData).onConflictDoUpdate({
1415
- target: this.tokens.userId,
1342
+ target: this.tokens.tokenFamily,
1416
1343
  set: {
1417
1344
  token: tokenData.token,
1418
- tokenFamily: tokenData.tokenFamily,
1419
1345
  expiresAt: tokenData.expiresAt,
1420
1346
  previousHash: tokenData.previousHash ?? null,
1421
1347
  previousValidUntil: tokenData.previousValidUntil ?? null,
@@ -1424,27 +1350,37 @@ var TokenRepository = class TokenRepository2 {
1424
1350
  }).returning();
1425
1351
  }
1426
1352
  /**
1427
- * Claim the previous-token grace slot. Conditional on BOTH the stored
1428
- * previousHash still matching the presented token AND previousUsedAt being
1429
- * NULL. Gating on the hash (not just the flag) closes the rotation race: the
1430
- * winner's rotation rewrites previousHash via storeRefreshToken, so a loser
1431
- * whose UPDATE lands after that rotation no longer matches and gets zero
1432
- * rows — exactly one caller ever claims the slot.
1353
+ * Claim the previous-token grace slot for a single family. Conditional on
1354
+ * BOTH the stored previousHash still matching the presented token AND
1355
+ * previousUsedAt being NULL. Gating on the hash (not just the flag) closes
1356
+ * the rotation race: the winner's rotation rewrites previousHash via
1357
+ * storeRefreshToken (and resets previousUsedAt to NULL), so a loser whose
1358
+ * UPDATE lands after that rotation no longer matches and gets zero rows —
1359
+ * exactly one caller ever claims the slot.
1433
1360
  */
1434
- async markPreviousUsed(userId, previousHash) {
1435
- return await this.db.update(this.tokens).set({ previousUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(and(eq4(this.tokens.userId, userId), eq4(this.tokens.previousHash, previousHash), isNull(this.tokens.previousUsedAt))).returning();
1361
+ async markPreviousUsed(tokenFamily, previousHash) {
1362
+ return await this.db.update(this.tokens).set({ previousUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(and(eq4(this.tokens.tokenFamily, tokenFamily), eq4(this.tokens.previousHash, previousHash), isNull(this.tokens.previousUsedAt))).returning();
1436
1363
  }
1437
- async getRefreshTokenWithFamily(userId) {
1438
- const [token] = await this.db.select().from(this.tokens).where(eq4(this.tokens.userId, userId));
1364
+ /** Look up a single session's token row by its family identifier. */
1365
+ async getByFamily(tokenFamily) {
1366
+ const [token] = await this.db.select().from(this.tokens).where(eq4(this.tokens.tokenFamily, tokenFamily));
1439
1367
  return token ?? null;
1440
1368
  }
1441
- async revokeToken(userId) {
1442
- const [deletedToken] = await this.db.delete(this.tokens).where(eq4(this.tokens.userId, userId)).returning();
1443
- return deletedToken;
1444
- }
1445
- async revokeByFamily(tokenFamily) {
1369
+ /** Revoke a single session (one family). */
1370
+ async revokeFamily(tokenFamily) {
1446
1371
  return this.db.delete(this.tokens).where(eq4(this.tokens.tokenFamily, tokenFamily)).returning();
1447
1372
  }
1373
+ /** Revoke every session for a user (password change/reset, logout-all). */
1374
+ async revokeAllForUser(userId) {
1375
+ return this.db.delete(this.tokens).where(eq4(this.tokens.userId, userId)).returning();
1376
+ }
1377
+ /**
1378
+ * Opportunistic cleanup: with one row per family (no unique userId), expired
1379
+ * and abandoned sessions accumulate. Delete every expired row.
1380
+ */
1381
+ async deleteExpired() {
1382
+ return this.db.delete(this.tokens).where(lt(this.tokens.expiresAt, (/* @__PURE__ */ new Date()).toISOString())).returning();
1383
+ }
1448
1384
  async isUserExists(userId) {
1449
1385
  const [user] = await this.db.select({ id: this.users.id }).from(this.users).where(eq4(this.users.id, userId)).limit(1);
1450
1386
  return !!user;
@@ -1559,19 +1495,22 @@ var TokenService = class TokenService2 {
1559
1495
  Err5(this.t("errors.tokenVerificationFailed"));
1560
1496
  }
1561
1497
  const sessionKey = this.sessionVersionKey(payload.userId);
1562
- let activeSessionVersion;
1563
- if (payload.jti) {
1564
- const [blacklisted, sessionVersion] = await this.getCacheValues([
1565
- `${this.blacklistPrefix}${payload.jti}`,
1566
- sessionKey
1567
- ]);
1568
- if (blacklisted !== null) {
1569
- Err5(this.t("errors.tokenRevoked"));
1570
- }
1571
- activeSessionVersion = this.parseSessionVersion(sessionVersion);
1572
- } else {
1573
- activeSessionVersion = this.parseSessionVersion(await this.cache.get(sessionKey));
1498
+ const blacklistKey = payload.jti ? `${this.blacklistPrefix}${payload.jti}` : null;
1499
+ const familyKey = payload.tokenFamily ? this.revokedFamilyKey(payload.tokenFamily) : null;
1500
+ const keys = [
1501
+ ...blacklistKey ? [blacklistKey] : [],
1502
+ sessionKey,
1503
+ ...familyKey ? [familyKey] : []
1504
+ ];
1505
+ const values = await this.getCacheValues(keys);
1506
+ const valueByKey = new Map(keys.map((key, i) => [key, values[i]]));
1507
+ if (blacklistKey && valueByKey.get(blacklistKey) != null) {
1508
+ Err5(this.t("errors.tokenRevoked"));
1509
+ }
1510
+ if (familyKey && valueByKey.get(familyKey) != null) {
1511
+ Err5(this.t("errors.tokenRevoked"));
1574
1512
  }
1513
+ const activeSessionVersion = this.parseSessionVersion(valueByKey.get(sessionKey) ?? null);
1575
1514
  const tokenSessionVersion = payload.sessionVersion ?? 0;
1576
1515
  if (tokenSessionVersion !== activeSessionVersion) {
1577
1516
  Err5(this.t("errors.tokenRevoked"));
@@ -1579,15 +1518,19 @@ var TokenService = class TokenService2 {
1579
1518
  return payload;
1580
1519
  }
1581
1520
  verifyRefreshToken(token) {
1521
+ let decoded;
1582
1522
  try {
1583
- const decoded = jwt.verify(token, this.config.jwt.refreshSecret);
1584
- if (decoded.type && decoded.type !== "refresh") {
1585
- Err5(this.t("errors.tokenVerificationFailed"));
1586
- }
1587
- return decoded.userId;
1523
+ decoded = jwt.verify(token, this.config.jwt.refreshSecret);
1588
1524
  } catch {
1589
1525
  Err5(this.t("errors.tokenVerificationFailed"));
1590
1526
  }
1527
+ if (decoded.type && decoded.type !== "refresh") {
1528
+ Err5(this.t("errors.tokenVerificationFailed"));
1529
+ }
1530
+ if (!decoded.tokenFamily) {
1531
+ Err5(this.t("errors.tokenVerificationFailed"));
1532
+ }
1533
+ return { userId: decoded.userId, tokenFamily: decoded.tokenFamily };
1591
1534
  }
1592
1535
  static PREVIOUS_GRACE_SECONDS = 120;
1593
1536
  /**
@@ -1606,9 +1549,9 @@ var TokenService = class TokenService2 {
1606
1549
  if (!refreshToken) {
1607
1550
  Err5(this.t("errors.refreshTokenMissing"));
1608
1551
  }
1609
- const userId = this.verifyRefreshToken(refreshToken);
1610
- const stored = await this.tokenRepository.getRefreshTokenWithFamily(userId);
1611
- if (!stored) {
1552
+ const { userId, tokenFamily } = this.verifyRefreshToken(refreshToken);
1553
+ const stored = await this.tokenRepository.getByFamily(tokenFamily);
1554
+ if (!stored || stored.userId !== userId) {
1612
1555
  Err5(this.t("errors.refreshTokenInvalid"));
1613
1556
  }
1614
1557
  const presentedHash = this.hashToken(refreshToken);
@@ -1653,7 +1596,7 @@ var TokenService = class TokenService2 {
1653
1596
  return jwt.decode(token);
1654
1597
  }
1655
1598
  async signAccessToken(data) {
1656
- const jti = nanoid5(16);
1599
+ const jti = nanoid4(16);
1657
1600
  const sessionVersion = await this.getUserSessionVersion(data.userId);
1658
1601
  const expiresAt = this.expiresAt(this.config.jwt.accessExpiresIn);
1659
1602
  if (sessionVersion > 0) {
@@ -1670,30 +1613,33 @@ var TokenService = class TokenService2 {
1670
1613
  return (await this.signAccessToken(data)).token;
1671
1614
  }
1672
1615
  /**
1673
- * Generate refresh token with unique jti
1616
+ * Generate refresh token with unique jti. The token carries its session's
1617
+ * family so rotation/revocation can target a single session.
1674
1618
  */
1675
1619
  signRefreshToken(data) {
1676
- const jti = nanoid5(16);
1620
+ const jti = nanoid4(16);
1677
1621
  const expiresAt = this.expiresAt(this.config.jwt.refreshExpiresIn);
1678
1622
  const token = jwt.sign({ ...data, jti, type: "refresh", exp: expiresAt }, this.config.jwt.refreshSecret);
1679
1623
  return { token, expiresAt };
1680
1624
  }
1681
1625
  generateRefreshToken(data) {
1682
- return this.signRefreshToken(data).token;
1626
+ return this.signRefreshToken({ userId: data.userId, tokenFamily: data.tokenFamily ?? nanoid4(16) }).token;
1683
1627
  }
1684
1628
  async generateTokens(userId, tokenFamily) {
1685
- const family = tokenFamily ?? nanoid5(16);
1629
+ const family = tokenFamily ?? nanoid4(16);
1686
1630
  const { roleName, permissions } = await this.tokenRepository.getRoleAndPermissions(userId);
1687
1631
  const accessTokenData = {
1688
1632
  userId,
1689
1633
  roles: roleName ? [roleName] : [],
1690
- permissions: permissions ?? []
1634
+ permissions: permissions ?? [],
1635
+ tokenFamily: family
1691
1636
  };
1692
1637
  const access = await this.signAccessToken(accessTokenData);
1693
- const refresh = this.signRefreshToken({ userId });
1638
+ const refresh = this.signRefreshToken({ userId, tokenFamily: family });
1694
1639
  await this.storeRefreshToken(userId, refresh.token, family);
1695
1640
  return {
1696
1641
  userId,
1642
+ tokenFamily: family,
1697
1643
  roles: accessTokenData.roles,
1698
1644
  permissions: accessTokenData.permissions,
1699
1645
  accessToken: access.token,
@@ -1743,7 +1689,7 @@ var TokenService = class TokenService2 {
1743
1689
  async storeRefreshToken(userId, refreshToken, tokenFamily) {
1744
1690
  const expireInSecond = timestring2(this.config.jwt.refreshExpiresIn, "s");
1745
1691
  const hashedToken = this.hashToken(refreshToken);
1746
- const existing = await this.tokenRepository.getRefreshTokenWithFamily(userId);
1692
+ const existing = await this.tokenRepository.getByFamily(tokenFamily);
1747
1693
  const previousHash = existing?.token ?? null;
1748
1694
  const previousValidUntil = previousHash ? new Date(Date.now() + TokenService_1.PREVIOUS_GRACE_SECONDS * 1e3).toISOString() : null;
1749
1695
  await this.tokenRepository.storeRefreshToken({
@@ -1765,28 +1711,45 @@ var TokenService = class TokenService2 {
1765
1711
  if (!refreshToken) {
1766
1712
  Err5(this.t("errors.refreshTokenMissing"));
1767
1713
  }
1768
- const userId = this.verifyRefreshToken(refreshToken);
1769
- const stored = await this.tokenRepository.getRefreshTokenWithFamily(userId);
1770
- if (!stored) {
1714
+ const { userId, tokenFamily } = this.verifyRefreshToken(refreshToken);
1715
+ const stored = await this.tokenRepository.getByFamily(tokenFamily);
1716
+ if (!stored || stored.userId !== userId) {
1771
1717
  Err5(this.t("errors.refreshTokenInvalid"));
1772
1718
  }
1773
1719
  const presentedHash = this.hashToken(refreshToken);
1774
1720
  if (presentedHash === stored.token) {
1775
- return this.generateTokens(userId, stored.tokenFamily ?? void 0);
1721
+ return this.generateTokens(userId, tokenFamily);
1776
1722
  }
1777
1723
  const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && new Date(stored.previousValidUntil).getTime() > Date.now() && !stored.previousUsedAt;
1778
1724
  if (canRecover) {
1779
- const claimed = await this.tokenRepository.markPreviousUsed(userId, presentedHash);
1725
+ const claimed = await this.tokenRepository.markPreviousUsed(tokenFamily, presentedHash);
1780
1726
  if (!claimed?.length) {
1781
1727
  Err5(this.t("errors.refreshTokenInvalid"));
1782
1728
  }
1783
- return this.generateTokens(userId, stored.tokenFamily ?? void 0);
1729
+ return this.generateTokens(userId, tokenFamily);
1784
1730
  }
1785
- await this.revokeSuspectRefreshFamily(userId, stored.tokenFamily ?? null);
1731
+ await this.revokeSuspectRefreshFamily(userId, tokenFamily);
1786
1732
  Err5(this.t("errors.refreshTokenInvalid"));
1787
1733
  }
1788
- async revokeToken(userId) {
1789
- return this.tokenRepository.revokeToken(userId);
1734
+ /** Revoke every refresh session for a user (password change/reset, logout-all). */
1735
+ async revokeAllForUser(userId) {
1736
+ return this.tokenRepository.revokeAllForUser(userId);
1737
+ }
1738
+ /** Revoke a single refresh session (one family). */
1739
+ async revokeFamily(tokenFamily) {
1740
+ await this.markFamilyRevoked(tokenFamily);
1741
+ return this.tokenRepository.revokeFamily(tokenFamily);
1742
+ }
1743
+ /**
1744
+ * Opportunistic cleanup of expired/abandoned sessions. With one row per
1745
+ * family (no unique userId), abandoned logins would otherwise accumulate.
1746
+ * Best-effort — never let cleanup failure break the calling flow.
1747
+ */
1748
+ async deleteExpiredSessions() {
1749
+ try {
1750
+ await this.tokenRepository.deleteExpired();
1751
+ } catch {
1752
+ }
1790
1753
  }
1791
1754
  async invalidateUserAccessTokens(userId) {
1792
1755
  const nextVersion = await this.getUserSessionVersion(userId) + 1;
@@ -1802,27 +1765,99 @@ var TokenService = class TokenService2 {
1802
1765
  }
1803
1766
  return user;
1804
1767
  }
1768
+ get revokedFamilyPrefix() {
1769
+ return "auth:revoked-family:";
1770
+ }
1771
+ revokedFamilyKey(tokenFamily) {
1772
+ return `${this.revokedFamilyPrefix}${tokenFamily}`;
1773
+ }
1774
+ /**
1775
+ * Mark a family as revoked in cache for the access-token TTL, so every
1776
+ * access token minted for that family (not just the presented one) is
1777
+ * rejected by verifyAccessToken until it would have expired anyway.
1778
+ */
1779
+ async markFamilyRevoked(tokenFamily) {
1780
+ await this.cache.set(this.revokedFamilyKey(tokenFamily), "1", this.accessTokenTtlMs());
1781
+ }
1782
+ /**
1783
+ * Revoke only the suspect family — NOT the whole user. Bumping the global
1784
+ * per-user session version here would kill every device's access tokens on a
1785
+ * single family's reuse detection. Instead drop the family's refresh row and
1786
+ * mark the family revoked so its access tokens stop verifying.
1787
+ */
1805
1788
  async revokeSuspectRefreshFamily(userId, tokenFamily) {
1806
- await this.invalidateUserAccessTokens(userId);
1807
1789
  if (tokenFamily) {
1808
- await this.tokenRepository.revokeByFamily(tokenFamily);
1790
+ await this.revokeFamily(tokenFamily);
1809
1791
  return;
1810
1792
  }
1811
- await this.revokeToken(userId);
1793
+ await this.invalidateUserAccessTokens(userId);
1794
+ await this.revokeAllForUser(userId);
1812
1795
  }
1813
1796
  /**
1814
- * Logout user - blacklist access token and revoke refresh token
1797
+ * Logout the CURRENT session only — blacklist the presented access token,
1798
+ * mark its family revoked, and delete that family's refresh row. Other
1799
+ * devices/sessions for the same user keep working. Use a password change or
1800
+ * reset (revoke-all) to terminate every session.
1801
+ *
1802
+ * The family is resolved from, in order: a verified Bearer access token's
1803
+ * `tokenFamily` claim, then a verified refresh cookie whose hash still
1804
+ * matches the current family row. If neither is available, fall back to
1805
+ * revoke-all.
1815
1806
  */
1816
1807
  async logout(userId, authorization) {
1808
+ let tokenFamily = null;
1817
1809
  if (authorization) {
1810
+ let accessToken = null;
1818
1811
  try {
1819
- const accessToken = this.extractAccessToken(authorization);
1820
- await this.blacklistCurrentToken(accessToken);
1812
+ accessToken = this.extractAccessToken(authorization);
1821
1813
  } catch {
1822
1814
  }
1815
+ if (accessToken) {
1816
+ try {
1817
+ const decoded = await this.verifyAccessToken(accessToken);
1818
+ if (decoded.userId === userId && decoded.tokenFamily) {
1819
+ tokenFamily = decoded.tokenFamily;
1820
+ }
1821
+ } catch {
1822
+ }
1823
+ await this.blacklistCurrentToken(accessToken);
1824
+ }
1825
+ }
1826
+ if (!tokenFamily) {
1827
+ tokenFamily = await this.resolveRefreshCookieFamily(userId);
1828
+ }
1829
+ if (tokenFamily) {
1830
+ await this.revokeFamily(tokenFamily);
1831
+ return;
1823
1832
  }
1824
1833
  await this.invalidateUserAccessTokens(userId);
1825
- await this.revokeToken(userId);
1834
+ await this.revokeAllForUser(userId);
1835
+ }
1836
+ /**
1837
+ * Resolve a logout target from the refresh cookie only if the cookie maps to
1838
+ * the user's current/valid family row. This mirrors resolveUserFromCookie()
1839
+ * without throwing, because logout can still fall back to revoke-all.
1840
+ */
1841
+ async resolveRefreshCookieFamily(userId) {
1842
+ const refreshToken = this.cookieManager.getRefreshToken();
1843
+ if (!refreshToken)
1844
+ return null;
1845
+ try {
1846
+ const decoded = this.verifyRefreshToken(refreshToken);
1847
+ if (decoded.userId !== userId)
1848
+ return null;
1849
+ const stored = await this.tokenRepository.getByFamily(decoded.tokenFamily);
1850
+ if (!stored || stored.userId !== userId)
1851
+ return null;
1852
+ const presentedHash = this.hashToken(refreshToken);
1853
+ if (presentedHash === stored.token) {
1854
+ return decoded.tokenFamily;
1855
+ }
1856
+ const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && new Date(stored.previousValidUntil).getTime() > Date.now() && !stored.previousUsedAt;
1857
+ return canRecover ? decoded.tokenFamily : null;
1858
+ } catch {
1859
+ return null;
1860
+ }
1826
1861
  }
1827
1862
  // ============ PASSWORD RESET TOKENS ============
1828
1863
  /**
@@ -1830,7 +1865,7 @@ var TokenService = class TokenService2 {
1830
1865
  * Returns both the plain token (to send via email) and userId for identification
1831
1866
  */
1832
1867
  async generateResetToken(userId) {
1833
- const jti = nanoid5(16);
1868
+ const jti = nanoid4(16);
1834
1869
  const resetData = {
1835
1870
  userId,
1836
1871
  type: "reset",
@@ -1972,6 +2007,7 @@ var AuthService = class AuthService2 {
1972
2007
  if ((user.failedLoginAttempts ?? 0) > 0 || user.lockoutUntil) {
1973
2008
  await this.userService.resetFailedAttempts(user.id);
1974
2009
  }
2010
+ await this.tokenService.deleteExpiredSessions();
1975
2011
  const generated = await this.tokenService.generateTokens(user.id);
1976
2012
  this.cookieManager.setRefreshToken(generated.refreshToken);
1977
2013
  await this.userService.updateLastLogin(user.id);
@@ -1982,7 +2018,7 @@ var AuthService = class AuthService2 {
1982
2018
  roles,
1983
2019
  permissions
1984
2020
  });
1985
- const { userId: _userId, roles: _roles, permissions: _permissions, ...tokens } = generated;
2021
+ const { userId: _userId, tokenFamily: _tokenFamily, roles: _roles, permissions: _permissions, ...tokens } = generated;
1986
2022
  return { ...tokens, user: sanitized };
1987
2023
  }
1988
2024
  async refreshTokens() {
@@ -1996,7 +2032,7 @@ var AuthService = class AuthService2 {
1996
2032
  permissions: generated.permissions
1997
2033
  });
1998
2034
  }
1999
- const { userId: _userId, roles: _roles, permissions: _permissions, ...tokens } = generated;
2035
+ const { userId: _userId, tokenFamily: _tokenFamily, roles: _roles, permissions: _permissions, ...tokens } = generated;
2000
2036
  return tokens;
2001
2037
  }
2002
2038
  async logoutUser(userId, authorization) {
@@ -2005,6 +2041,15 @@ var AuthService = class AuthService2 {
2005
2041
  this.cookieManager.clearSessionCookie();
2006
2042
  return { data: null, message: this.t("auth.success.logout") };
2007
2043
  }
2044
+ /**
2045
+ * Prune expired refresh sessions for every user. Login already prunes
2046
+ * opportunistically; expose this so consumers can also run it from a
2047
+ * scheduled job (cron / queue) to reclaim rows from users who never return.
2048
+ * Best-effort — safe to call repeatedly.
2049
+ */
2050
+ async pruneExpiredSessions() {
2051
+ await this.tokenService.deleteExpiredSessions();
2052
+ }
2008
2053
  async getUserProfile(userData) {
2009
2054
  const lang = this.i18nService.getCurrentLanguage();
2010
2055
  return {
@@ -2077,7 +2122,7 @@ var AuthService = class AuthService2 {
2077
2122
  this.userValidator.validatePasswordStrength(newPassword);
2078
2123
  await this.userService.update(userId, { password: newPassword });
2079
2124
  await this.tokenService.invalidateUserAccessTokens(userId);
2080
- await this.tokenService.revokeToken(userId);
2125
+ await this.tokenService.revokeAllForUser(userId);
2081
2126
  this.cookieManager.clearRefreshToken();
2082
2127
  this.cookieManager.clearSessionCookie();
2083
2128
  return { message: this.t("success.passwordChanged") };
@@ -2087,7 +2132,7 @@ var AuthService = class AuthService2 {
2087
2132
  this.userValidator.validatePasswordStrength(newPassword);
2088
2133
  await this.userService.update(userId, { password: newPassword });
2089
2134
  await this.tokenService.invalidateUserAccessTokens(userId);
2090
- await this.tokenService.revokeToken(userId);
2135
+ await this.tokenService.revokeAllForUser(userId);
2091
2136
  this.cookieManager.clearRefreshToken();
2092
2137
  this.cookieManager.clearSessionCookie();
2093
2138
  return { message: this.t("success.passwordReset") };
@@ -3577,7 +3622,7 @@ var revokeTokenDto = z4.object({
3577
3622
  });
3578
3623
 
3579
3624
  // src/ownership/scopedOwnership.ts
3580
- import { aliasedTable, eq as eq6, getTableColumns, sql as sql5 } from "drizzle-orm";
3625
+ import { aliasedTable, eq as eq6, getTableColumns, sql as sql4 } from "drizzle-orm";
3581
3626
  var DEFAULT_ADMIN_ROLES = ["admin"];
3582
3627
  var DRIZZLE_NAME = /* @__PURE__ */ Symbol.for("drizzle:Name");
3583
3628
  var DRIZZLE_BASE_NAME = /* @__PURE__ */ Symbol.for("drizzle:BaseName");
@@ -3714,7 +3759,7 @@ var OwnershipToken = class {
3714
3759
  return query;
3715
3760
  const rule = this._rules[role];
3716
3761
  if (!rule)
3717
- return query.where(sql5`1 = 0`);
3762
+ return query.where(sql4`1 = 0`);
3718
3763
  const { query: q, condition } = rule(uid, query);
3719
3764
  return q.where(condition);
3720
3765
  }
@@ -3729,7 +3774,7 @@ var OwnershipToken = class {
3729
3774
  return { query, condition: null };
3730
3775
  const rule = this._rules[role];
3731
3776
  if (!rule)
3732
- return { query, condition: sql5`1 = 0` };
3777
+ return { query, condition: sql4`1 = 0` };
3733
3778
  return rule(uid, query);
3734
3779
  }
3735
3780
  };
@@ -4088,7 +4133,7 @@ __name(Policy, "Policy");
4088
4133
 
4089
4134
  // src/ownership/OwnedDecorator.ts
4090
4135
  import "reflect-metadata";
4091
- import { sql as sql6, and as and3 } from "drizzle-orm";
4136
+ import { sql as sql5, and as and3 } from "drizzle-orm";
4092
4137
  import { Injectable as Injectable12, Inject as Inject12, DI as DI2, Container as Container2, REQUEST_ID } from "najm-core";
4093
4138
  import { USER as USER2 } from "najm-guard";
4094
4139
  var __decorate24 = function(decorators, target, key, desc) {
@@ -4177,7 +4222,7 @@ function Owned(token) {
4177
4222
  return query;
4178
4223
  const user = getUser(this);
4179
4224
  if (!user)
4180
- return query.where(sql6`1 = 0`);
4225
+ return query.where(sql5`1 = 0`);
4181
4226
  return token.applyScope(user.id, user.role, query);
4182
4227
  };
4183
4228
  },
@@ -4365,14 +4410,12 @@ var selectSchema = /* @__PURE__ */ __name((config) => {
4365
4410
  switch (dialect) {
4366
4411
  case "sqlite":
4367
4412
  return authSchema2;
4368
- case "mysql":
4369
- return authSchema3;
4370
4413
  case "pg":
4371
4414
  default:
4372
4415
  return authSchema;
4373
4416
  }
4374
4417
  }, "selectSchema");
4375
- var auth = /* @__PURE__ */ __name((config) => plugin("auth").version("1.0.0").depends(cache(), cookies(), i18n(), guards(), validation(config?.validation), rateLimit(config?.rateLimit), email()).requires("database").contributes(I18N_CONTRIBUTIONS, AUTH_LOCALES).services(auth_exports, users_exports, roles_exports, permissions_exports, tokens_exports, ScopeContext).config(AUTH_CONFIG, mergeConfig(config)).set(AUTH_SCHEMA, selectSchema(config)).set(AUTH_ENCRYPTION_KEY, config?.encryptionKey ?? null).build(), "auth");
4418
+ var auth = /* @__PURE__ */ __name((config) => plugin("auth").version("1.0.0").depends(cache(), cookies(), i18n(), guards(), validation(config?.validation), rateLimit(config?.rateLimit), email()).requires("database").contributes(I18N_CONTRIBUTIONS, AUTH_LOCALES).services(AUTH_MODULE, users_exports, roles_exports, permissions_exports, tokens_exports, ScopeContext).config(AUTH_CONFIG, mergeConfig(config)).set(AUTH_SCHEMA, selectSchema(config)).set(AUTH_ENCRYPTION_KEY, config?.encryptionKey ?? null).build(), "auth");
4376
4419
 
4377
4420
  // src/seed.ts
4378
4421
  var toSeedId = /* @__PURE__ */ __name((prefix, value) => {