najm-auth 2.0.3 → 2.0.5

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.d.ts CHANGED
@@ -9,6 +9,7 @@ import { User, NewUser, RoleEntity, NewRoleEntity, Permission, NewPermission, Ro
9
9
  export { NewOAuthAccount, NewRolePermission, NewToken, OAuthAccount, Token, authSchema, baseFields, oauthAccountsTable, permissionsTable, rolePermissionsTable, rolesTable, tokenStatusEnum, tokenTypeEnum, tokensTable, userStatusEnum, usersTable } from './schema/pg.js';
10
10
  import { CacheService } from 'najm-cache';
11
11
  import { z } from 'zod';
12
+ import { Context } from 'hono';
12
13
  import { GuardResult } from 'najm-guard';
13
14
  import 'drizzle-orm';
14
15
  import 'drizzle-orm/pg-core';
@@ -42,7 +43,7 @@ interface SessionCookieConfig {
42
43
  name: string;
43
44
  /** Max age in seconds (default: 300 = 5 min) */
44
45
  maxAge: number;
45
- /** Secret used to HMAC-sign the cookie. Defaults to jwt.accessSecret. */
46
+ /** HMAC secret. Falls back to NAJM_SESSION_SECRET, then jwt.accessSecret. */
46
47
  secret?: string;
47
48
  }
48
49
  type OAuthProvider = 'google';
@@ -279,6 +280,7 @@ var auth = {
279
280
  passwordReset: "Password has been reset successfully",
280
281
  accountInviteSent: "Invitation sent successfully",
281
282
  tokenRefreshed: "Token refreshed successfully",
283
+ sessionRecovered: "Session recovered successfully",
282
284
  oauthLogin: "Google sign-in successful",
283
285
  oauthLinked: "Google account linked successfully"
284
286
  },
@@ -396,6 +398,7 @@ declare const AUTH_LOCALES: {
396
398
  passwordReset: string;
397
399
  accountInviteSent: string;
398
400
  tokenRefreshed: string;
401
+ sessionRecovered: string;
399
402
  oauthLogin: string;
400
403
  oauthLinked: string;
401
404
  };
@@ -518,7 +521,7 @@ declare class CookieManager {
518
521
  getCookieName(): string;
519
522
  /**
520
523
  * Write a signed session cookie containing user data, roles, and permissions.
521
- * The cookie is HMAC-signed with the access secret so it is tamper-proof
524
+ * The cookie is HMAC-signed with the configured session secret so it is tamper-proof
522
525
  * but readable without a database query. Short TTL (5 min) ensures freshness.
523
526
  */
524
527
  setSessionCookie(data: Omit<SessionCookieData, 'iat'>): void;
@@ -932,7 +935,21 @@ declare class TokenService {
932
935
  * the active session. Reuse detection and revocation belong to the
933
936
  * rotation path only.
934
937
  */
938
+ private resolveRefreshSessionFromCookie;
935
939
  resolveUserFromCookie(): Promise<string>;
940
+ /**
941
+ * Resolve authoritative claims for signed-session recovery.
942
+ *
943
+ * This deliberately bypasses the 30-second user cache so status, role, and
944
+ * permission changes are reflected when the short session snapshot expires.
945
+ * It validates but never rotates or consumes the refresh token.
946
+ */
947
+ recoverSessionFromCookie(): Promise<{
948
+ user: any;
949
+ roles: any[];
950
+ permissions: any;
951
+ sessionVersion: number;
952
+ }>;
936
953
  getUser(auth: string): Promise<any>;
937
954
  getUserById(userId: string): Promise<any>;
938
955
  private hashToken;
@@ -1009,6 +1026,7 @@ declare class TokenService {
1009
1026
  accessTokenExpiresAt: number;
1010
1027
  refreshTokenExpiresAt: number;
1011
1028
  }>;
1029
+ private requireActiveRefreshUser;
1012
1030
  /** Revoke every refresh session for a user (password change/reset, logout-all). */
1013
1031
  revokeAllForUser(userId: string): Promise<any>;
1014
1032
  /** Revoke a single refresh session (one family). */
@@ -1242,6 +1260,14 @@ declare class AuthService {
1242
1260
  user: SanitizedUser;
1243
1261
  }>;
1244
1262
  refreshTokens(): Promise<TokenPair>;
1263
+ /**
1264
+ * Reissue the short-lived signed session snapshot from a fully validated
1265
+ * refresh session. This path never creates or returns access/refresh tokens
1266
+ * and never rotates the refresh family.
1267
+ */
1268
+ recoverSession(): Promise<{
1269
+ recovered: true;
1270
+ }>;
1245
1271
  logoutUser(userId: string, authorization?: string): Promise<{
1246
1272
  data: any;
1247
1273
  message: string;
@@ -1310,6 +1336,9 @@ declare class AuthController {
1310
1336
  emailSent: boolean;
1311
1337
  }>;
1312
1338
  refreshTokens(): Promise<TokenPair>;
1339
+ recoverSession(recoveryRequest: string | undefined, ctx: Context): Promise<{
1340
+ recovered: true;
1341
+ }>;
1313
1342
  logoutUser(userId: string, authorization?: string): Promise<{
1314
1343
  data: any;
1315
1344
  message: string;
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ var __export = (target, all) => {
6
6
  };
7
7
 
8
8
  // src/AuthPlugin.ts
9
- import { Err as Err10, plugin } from "najm-core";
9
+ import { Err as Err11, plugin } from "najm-core";
10
10
  import { cache } from "najm-cache";
11
11
 
12
12
  // src/auth.tokens.ts
@@ -293,6 +293,47 @@ EncryptionService = __decorate([
293
293
  import { Service, Inject as Inject2 } from "najm-core";
294
294
  import { CookieService } from "najm-cookies";
295
295
  import timestring from "timestring";
296
+
297
+ // src/client/sessionCookie.ts
298
+ var DEFAULT_SESSION_MAX_AGE_SECONDS = 300;
299
+ var MAX_CLOCK_SKEW_MS = 3e4;
300
+ function parseSessionCookiePayload(payload, maxAgeSeconds = DEFAULT_SESSION_MAX_AGE_SECONDS, now = Date.now()) {
301
+ if (!Number.isFinite(maxAgeSeconds) || maxAgeSeconds <= 0) return null;
302
+ try {
303
+ const data = JSON.parse(payload);
304
+ if (!isRecord(data) || !isValidUser(data.user)) return null;
305
+ if (!isStringArray(data.roles) || !isStringArray(data.permissions)) return null;
306
+ if (!Number.isInteger(data.sessionVersion) || data.sessionVersion < 0) return null;
307
+ if (!Number.isFinite(data.iat) || !Number.isInteger(data.iat) || data.iat <= 0) return null;
308
+ const issuedAt = data.iat;
309
+ if (issuedAt > now + MAX_CLOCK_SKEW_MS) return null;
310
+ if (now - issuedAt >= maxAgeSeconds * 1e3) return null;
311
+ return {
312
+ user: data.user,
313
+ roles: [...data.roles],
314
+ permissions: [...data.permissions],
315
+ sessionVersion: data.sessionVersion,
316
+ iat: issuedAt
317
+ };
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
322
+ __name(parseSessionCookiePayload, "parseSessionCookiePayload");
323
+ function isRecord(value) {
324
+ return typeof value === "object" && value !== null && !Array.isArray(value);
325
+ }
326
+ __name(isRecord, "isRecord");
327
+ function isValidUser(value) {
328
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.email === "string" && value.email.length > 0 && (value.role === void 0 || value.role === null || typeof value.role === "string");
329
+ }
330
+ __name(isValidUser, "isValidUser");
331
+ function isStringArray(value) {
332
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
333
+ }
334
+ __name(isStringArray, "isStringArray");
335
+
336
+ // src/auth/CookieManager.ts
296
337
  var __decorate2 = function(decorators, target, key, desc) {
297
338
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
298
339
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -348,7 +389,7 @@ var CookieManager = class CookieManager2 {
348
389
  // =========================================================================
349
390
  /**
350
391
  * Write a signed session cookie containing user data, roles, and permissions.
351
- * The cookie is HMAC-signed with the access secret so it is tamper-proof
392
+ * The cookie is HMAC-signed with the configured session secret so it is tamper-proof
352
393
  * but readable without a database query. Short TTL (5 min) ensures freshness.
353
394
  */
354
395
  setSessionCookie(data) {
@@ -368,15 +409,7 @@ var CookieManager = class CookieManager2 {
368
409
  const raw = this.cookieService.getSigned(this.sessionCookieName, this.sessionSecret);
369
410
  if (!raw)
370
411
  return null;
371
- try {
372
- const data = JSON.parse(raw);
373
- const age = Date.now() - data.iat;
374
- if (age > this.sessionMaxAge * 1e3)
375
- return null;
376
- return data;
377
- } catch {
378
- return null;
379
- }
412
+ return parseSessionCookiePayload(raw, this.sessionMaxAge);
380
413
  }
381
414
  /**
382
415
  * Clear the session cookie (on logout, password change, etc.)
@@ -398,9 +431,9 @@ CookieManager = __decorate2([
398
431
  ], CookieManager);
399
432
 
400
433
  // src/auth/AuthController.ts
401
- import { Controller } from "najm-core";
434
+ import { Controller, Err as Err9 } from "najm-core";
402
435
  import { Get, Post, ResMsg } from "najm-core";
403
- import { Body, User as User2, Headers } from "najm-core";
436
+ import { Body, User as User2, Headers, Ctx } from "najm-core";
404
437
 
405
438
  // src/auth/AuthService.ts
406
439
  import { Injectable as Injectable8, Inject as Inject8 } from "najm-core";
@@ -1605,7 +1638,7 @@ var TokenService = class TokenService2 {
1605
1638
  * the active session. Reuse detection and revocation belong to the
1606
1639
  * rotation path only.
1607
1640
  */
1608
- async resolveUserFromCookie() {
1641
+ async resolveRefreshSessionFromCookie() {
1609
1642
  const refreshToken = this.cookieManager.getRefreshToken();
1610
1643
  if (!refreshToken) {
1611
1644
  Err6(this.t("errors.refreshTokenMissing"));
@@ -1617,14 +1650,35 @@ var TokenService = class TokenService2 {
1617
1650
  }
1618
1651
  const presentedHash = this.hashToken(refreshToken);
1619
1652
  if (presentedHash === stored.token) {
1620
- return userId;
1653
+ return { userId, tokenFamily };
1621
1654
  }
1622
1655
  const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && new Date(stored.previousValidUntil).getTime() > Date.now() && !stored.previousUsedAt;
1623
1656
  if (canRecover) {
1624
- return userId;
1657
+ return { userId, tokenFamily };
1625
1658
  }
1626
1659
  Err6(this.t("errors.refreshTokenInvalid"));
1627
1660
  }
1661
+ async resolveUserFromCookie() {
1662
+ return (await this.resolveRefreshSessionFromCookie()).userId;
1663
+ }
1664
+ /**
1665
+ * Resolve authoritative claims for signed-session recovery.
1666
+ *
1667
+ * This deliberately bypasses the 30-second user cache so status, role, and
1668
+ * permission changes are reflected when the short session snapshot expires.
1669
+ * It validates but never rotates or consumes the refresh token.
1670
+ */
1671
+ async recoverSessionFromCookie() {
1672
+ const { userId, tokenFamily } = await this.resolveRefreshSessionFromCookie();
1673
+ const user = await this.requireActiveRefreshUser(userId, tokenFamily);
1674
+ const sessionVersion = await this.getUserSessionVersion(userId);
1675
+ return {
1676
+ user,
1677
+ roles: user.role ? [user.role] : [],
1678
+ permissions: Array.isArray(user.permissions) ? user.permissions : [],
1679
+ sessionVersion
1680
+ };
1681
+ }
1628
1682
  // ============ USER RETRIEVAL (MAIN METHOD) ============
1629
1683
  async getUser(auth2) {
1630
1684
  if (!auth2)
@@ -1787,6 +1841,7 @@ var TokenService = class TokenService2 {
1787
1841
  Err6(this.t("errors.refreshTokenInvalid"));
1788
1842
  }
1789
1843
  const presentedHash = this.hashToken(refreshToken);
1844
+ await this.requireActiveRefreshUser(userId, tokenFamily);
1790
1845
  if (presentedHash === stored.token) {
1791
1846
  return this.generateTokens(userId, tokenFamily);
1792
1847
  }
@@ -1801,6 +1856,14 @@ var TokenService = class TokenService2 {
1801
1856
  await this.revokeSuspectRefreshFamily(userId, tokenFamily);
1802
1857
  Err6(this.t("errors.refreshTokenInvalid"));
1803
1858
  }
1859
+ async requireActiveRefreshUser(userId, tokenFamily) {
1860
+ const user = await this.tokenRepository.getUser(userId);
1861
+ if (!user || user.status !== "active") {
1862
+ await this.revokeFamily(tokenFamily);
1863
+ Err6(this.t("errors.refreshTokenInvalid"));
1864
+ }
1865
+ return user;
1866
+ }
1804
1867
  /** Revoke every refresh session for a user (password change/reset, logout-all). */
1805
1868
  async revokeAllForUser(userId) {
1806
1869
  return this.tokenRepository.revokeAllForUser(userId);
@@ -2243,6 +2306,27 @@ var AuthService = class AuthService2 {
2243
2306
  const { userId: _userId, tokenFamily: _tokenFamily, roles: _roles, permissions: _permissions, sessionVersion: _sv, ...tokens } = generated;
2244
2307
  return tokens;
2245
2308
  }
2309
+ /**
2310
+ * Reissue the short-lived signed session snapshot from a fully validated
2311
+ * refresh session. This path never creates or returns access/refresh tokens
2312
+ * and never rotates the refresh family.
2313
+ */
2314
+ async recoverSession() {
2315
+ const recovered = await this.tokenService.recoverSessionFromCookie();
2316
+ this.cookieManager.setSessionCookie({
2317
+ user: {
2318
+ id: recovered.user.id,
2319
+ email: recovered.user.email,
2320
+ name: recovered.user.name,
2321
+ role: recovered.user.role ?? void 0,
2322
+ status: recovered.user.status ?? void 0
2323
+ },
2324
+ roles: recovered.roles,
2325
+ permissions: recovered.permissions,
2326
+ sessionVersion: recovered.sessionVersion
2327
+ });
2328
+ return { recovered: true };
2329
+ }
2246
2330
  async logoutUser(userId, authorization) {
2247
2331
  await this.tokenService.logout(userId, authorization);
2248
2332
  this.cookieManager.clearRefreshToken();
@@ -2571,6 +2655,14 @@ var AuthController = class AuthController2 {
2571
2655
  async refreshTokens() {
2572
2656
  return this.authService.refreshTokens();
2573
2657
  }
2658
+ async recoverSession(recoveryRequest, ctx) {
2659
+ if (recoveryRequest !== "1") {
2660
+ Err9("Invalid session recovery request", 400);
2661
+ }
2662
+ ctx.header("Cache-Control", "private, no-store");
2663
+ ctx.header("Vary", "Cookie");
2664
+ return this.authService.recoverSession();
2665
+ }
2574
2666
  async logoutUser(userId, authorization) {
2575
2667
  return this.authService.logoutUser(userId, authorization);
2576
2668
  }
@@ -2626,6 +2718,16 @@ __decorate15([
2626
2718
  __metadata15("design:paramtypes", []),
2627
2719
  __metadata15("design:returntype", Promise)
2628
2720
  ], AuthController.prototype, "refreshTokens", null);
2721
+ __decorate15([
2722
+ Post("/session/recover"),
2723
+ RateLimit({ limit: 120, window: "1m", key: cookieFingerprint() }),
2724
+ ResMsg("auth.success.sessionRecovered"),
2725
+ __param5(0, Headers("x-najm-session-recovery")),
2726
+ __param5(1, Ctx()),
2727
+ __metadata15("design:type", Function),
2728
+ __metadata15("design:paramtypes", [String, Object]),
2729
+ __metadata15("design:returntype", Promise)
2730
+ ], AuthController.prototype, "recoverSession", null);
2629
2731
  __decorate15([
2630
2732
  Post("/logout"),
2631
2733
  isAuth(),
@@ -3438,7 +3540,7 @@ import { Injectable as Injectable11 } from "najm-core";
3438
3540
  // src/permissions/PermissionValidator.ts
3439
3541
  import { Injectable as Injectable10 } from "najm-core";
3440
3542
  import { I18n as I18n7 } from "najm-i18n";
3441
- import { Err as Err9 } from "najm-core";
3543
+ import { Err as Err10 } from "najm-core";
3442
3544
  var __decorate21 = function(decorators, target, key, desc) {
3443
3545
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3444
3546
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -3467,7 +3569,7 @@ var PermissionValidator = class PermissionValidator2 {
3467
3569
  async checkPermissionExists(id) {
3468
3570
  const permission = await this.permissionRepository.getById(id);
3469
3571
  if (!permission) {
3470
- Err9(this.t("errors.notFound"), 404);
3572
+ Err10(this.t("errors.notFound"), 404);
3471
3573
  }
3472
3574
  return permission;
3473
3575
  }
@@ -3477,7 +3579,7 @@ var PermissionValidator = class PermissionValidator2 {
3477
3579
  async checkPermissionExistsByName(name) {
3478
3580
  const permission = await this.permissionRepository.getByName(name);
3479
3581
  if (!permission) {
3480
- Err9(this.t("errors.notFound"), 404);
3582
+ Err10(this.t("errors.notFound"), 404);
3481
3583
  }
3482
3584
  return permission;
3483
3585
  }
@@ -3489,7 +3591,7 @@ var PermissionValidator = class PermissionValidator2 {
3489
3591
  return;
3490
3592
  const existingPermission = await this.permissionRepository.getByName(name);
3491
3593
  if (existingPermission && existingPermission.id !== excludeId) {
3492
- Err9(this.t("errors.nameExists"), 409);
3594
+ Err10(this.t("errors.nameExists"), 409);
3493
3595
  }
3494
3596
  }
3495
3597
  /**
@@ -3512,7 +3614,7 @@ var PermissionValidator = class PermissionValidator2 {
3512
3614
  await this.checkPermissionExists(permissionId);
3513
3615
  const hasPermission = await this.permissionRepository.checkRoleHasPermission(roleId, permissionId);
3514
3616
  if (hasPermission) {
3515
- Err9(this.t("errors.roleAlreadyHasPermission"), 409);
3617
+ Err10(this.t("errors.roleAlreadyHasPermission"), 409);
3516
3618
  }
3517
3619
  }
3518
3620
  };
@@ -4561,6 +4663,7 @@ var en_default = {
4561
4663
  passwordReset: "Password has been reset successfully",
4562
4664
  accountInviteSent: "Invitation sent successfully",
4563
4665
  tokenRefreshed: "Token refreshed successfully",
4666
+ sessionRecovered: "Session recovered successfully",
4564
4667
  oauthLogin: "Google sign-in successful",
4565
4668
  oauthLinked: "Google account linked successfully"
4566
4669
  },
@@ -4977,7 +5080,7 @@ OAuthAccountService = __decorate29([
4977
5080
 
4978
5081
  // src/oauth/OAuthController.ts
4979
5082
  import { createHash as createHash4 } from "crypto";
4980
- import { Controller as Controller5, Ctx, Get as Get5, Post as Post5, Query as Query2, User as User5 } from "najm-core";
5083
+ import { Controller as Controller5, Ctx as Ctx2, Get as Get5, Post as Post5, Query as Query2, User as User5 } from "najm-core";
4981
5084
  import { RateLimit as RateLimit2 } from "najm-rate";
4982
5085
 
4983
5086
  // src/oauth/OAuthService.ts
@@ -5263,7 +5366,7 @@ var OAuthController = class OAuthController2 {
5263
5366
  __decorate32([
5264
5367
  Get5("/start"),
5265
5368
  RateLimit2({ limit: 20, window: "15m", key: "ip" }),
5266
- __param11(0, Ctx()),
5369
+ __param11(0, Ctx2()),
5267
5370
  __param11(1, Query2("returnTo")),
5268
5371
  __metadata32("design:type", Function),
5269
5372
  __metadata32("design:paramtypes", [Object, String]),
@@ -5272,7 +5375,7 @@ __decorate32([
5272
5375
  __decorate32([
5273
5376
  Get5("/callback"),
5274
5377
  RateLimit2({ limit: 20, window: "15m", key: callbackKey }),
5275
- __param11(0, Ctx()),
5378
+ __param11(0, Ctx2()),
5276
5379
  __param11(1, Query2("code")),
5277
5380
  __param11(2, Query2("state")),
5278
5381
  __param11(3, Query2("error")),
@@ -5327,9 +5430,9 @@ var resolveGoogleConfig = /* @__PURE__ */ __name((config) => {
5327
5430
  const clientId = google.clientId ?? process.env.GOOGLE_CLIENT_ID ?? "";
5328
5431
  const clientSecret = google.clientSecret ?? process.env.GOOGLE_CLIENT_SECRET ?? "";
5329
5432
  if (!clientId)
5330
- throw Err10.configRequired("auth.oauth.google", "GOOGLE_CLIENT_ID");
5433
+ throw Err11.configRequired("auth.oauth.google", "GOOGLE_CLIENT_ID");
5331
5434
  if (!clientSecret)
5332
- throw Err10.configRequired("auth.oauth.google", "GOOGLE_CLIENT_SECRET");
5435
+ throw Err11.configRequired("auth.oauth.google", "GOOGLE_CLIENT_SECRET");
5333
5436
  const frontendUrl = config?.frontendUrl ?? process.env.FRONTEND_URL ?? "http://localhost:3000";
5334
5437
  const callbackUrl = google.callbackUrl ?? process.env.GOOGLE_CALLBACK_URL ?? `${frontendUrl.replace(/\/$/, "")}/api/auth/oauth/google/callback`;
5335
5438
  let callback;
@@ -5379,18 +5482,19 @@ var resolveAuthConfig = /* @__PURE__ */ __name((config) => {
5379
5482
  session: {
5380
5483
  name: config?.session?.name ?? "najm.session",
5381
5484
  maxAge: config?.session?.maxAge ?? 300,
5382
- secret: config?.session?.secret
5383
- // fallback to jwt.accessSecret at use site
5485
+ // Keep Edge/server readers aligned with the documented secret order.
5486
+ // CookieManager falls back to jwt.accessSecret when this is undefined.
5487
+ secret: config?.session?.secret ?? process.env.NAJM_SESSION_SECRET
5384
5488
  },
5385
5489
  oauth: {
5386
5490
  google: resolveGoogleConfig(config)
5387
5491
  }
5388
5492
  };
5389
5493
  if (!finalConfig.jwt.accessSecret) {
5390
- throw Err10.configRequired("auth", "JWT_ACCESS_SECRET");
5494
+ throw Err11.configRequired("auth", "JWT_ACCESS_SECRET");
5391
5495
  }
5392
5496
  if (!finalConfig.jwt.refreshSecret) {
5393
- throw Err10.configRequired("auth", "JWT_REFRESH_SECRET");
5497
+ throw Err11.configRequired("auth", "JWT_REFRESH_SECRET");
5394
5498
  }
5395
5499
  return finalConfig;
5396
5500
  }, "resolveAuthConfig");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [