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/README.md +12 -12
- package/dist/index.d.ts +99 -18
- package/dist/index.js +209 -166
- package/dist/schema/pg.d.ts +2 -2
- package/dist/schema/pg.js +3 -2
- package/dist/schema/sqlite.d.ts +2 -2
- package/dist/schema/sqlite.js +3 -2
- package/package.json +10 -15
- package/dist/schema/mysql.d.ts +0 -1535
- package/dist/schema/mysql.js +0 -85
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" }).
|
|
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" }).
|
|
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
|
|
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:
|
|
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
|
|
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 ||
|
|
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:
|
|
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
|
|
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.
|
|
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
|
|
1428
|
-
* previousHash still matching the presented token AND
|
|
1429
|
-
* NULL. Gating on the hash (not just the flag) closes
|
|
1430
|
-
* winner's rotation rewrites previousHash via
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
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(
|
|
1435
|
-
return await this.db.update(this.tokens).set({ previousUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(and(eq4(this.tokens.
|
|
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
|
-
|
|
1438
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
]
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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 =
|
|
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 ??
|
|
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.
|
|
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.
|
|
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,
|
|
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(
|
|
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,
|
|
1729
|
+
return this.generateTokens(userId, tokenFamily);
|
|
1784
1730
|
}
|
|
1785
|
-
await this.revokeSuspectRefreshFamily(userId,
|
|
1731
|
+
await this.revokeSuspectRefreshFamily(userId, tokenFamily);
|
|
1786
1732
|
Err5(this.t("errors.refreshTokenInvalid"));
|
|
1787
1733
|
}
|
|
1788
|
-
|
|
1789
|
-
|
|
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.
|
|
1790
|
+
await this.revokeFamily(tokenFamily);
|
|
1809
1791
|
return;
|
|
1810
1792
|
}
|
|
1811
|
-
await this.
|
|
1793
|
+
await this.invalidateUserAccessTokens(userId);
|
|
1794
|
+
await this.revokeAllForUser(userId);
|
|
1812
1795
|
}
|
|
1813
1796
|
/**
|
|
1814
|
-
* Logout
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
|
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(
|
|
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:
|
|
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
|
|
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(
|
|
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(
|
|
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) => {
|