najm-auth 4.2.2 → 4.2.3

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.
@@ -552,7 +552,9 @@ var NajmAuthClient = class _NajmAuthClient {
552
552
  if (generation !== this.authGeneration) return;
553
553
  const shouldOpenCircuit = this.registerRefreshFailure(err);
554
554
  if (shouldOpenCircuit) {
555
- await this.requestServerLogout().catch(() => void 0);
555
+ if (!(err instanceof AuthError && err.status === 401)) {
556
+ await this.requestServerLogout().catch(() => void 0);
557
+ }
556
558
  this.resetState();
557
559
  this.emit("sessionExpired", null);
558
560
  if (err instanceof AuthError && err.status === 401) {
@@ -1281,7 +1281,9 @@ var NajmAuthClient = class _NajmAuthClient {
1281
1281
  if (generation !== this.authGeneration) return;
1282
1282
  const shouldOpenCircuit = this.registerRefreshFailure(err);
1283
1283
  if (shouldOpenCircuit) {
1284
- await this.requestServerLogout().catch(() => void 0);
1284
+ if (!(err instanceof AuthError && err.status === 401)) {
1285
+ await this.requestServerLogout().catch(() => void 0);
1286
+ }
1285
1287
  this.resetState();
1286
1288
  this.emit("sessionExpired", null);
1287
1289
  if (err instanceof AuthError && err.status === 401) {
package/dist/index.d.ts CHANGED
@@ -1281,6 +1281,22 @@ interface SetPasswordToken {
1281
1281
  userId: string;
1282
1282
  jti: string;
1283
1283
  }
1284
+ /**
1285
+ * The outcome of a refresh. `refreshToken` is absent only when the request
1286
+ * presented a token that a release before 4.2.3 had just replaced: that
1287
+ * rotation's token cannot be rebuilt, and its own response set the cookie.
1288
+ */
1289
+ interface RefreshedSession {
1290
+ userId: string;
1291
+ tokenFamily: string;
1292
+ roles: string[];
1293
+ permissions: string[];
1294
+ sessionVersion: number;
1295
+ accessToken: string;
1296
+ accessTokenExpiresAt: number;
1297
+ refreshToken?: string;
1298
+ refreshTokenExpiresAt?: number;
1299
+ }
1284
1300
  declare class TokenService {
1285
1301
  private tokenRepository;
1286
1302
  private cookieManager;
@@ -1394,6 +1410,13 @@ declare class TokenService {
1394
1410
  * family so rotation/revocation can target a single session.
1395
1411
  */
1396
1412
  private signRefreshToken;
1413
+ /**
1414
+ * The refresh token that replaces `replaced`. Its jti is derived from the
1415
+ * replaced token and its lifetime is the one the family row records, so a
1416
+ * request that presents the replaced token inside the grace window can
1417
+ * rebuild the token its rotation issued and hand the browser that one.
1418
+ */
1419
+ private successorRefreshToken;
1397
1420
  generateRefreshToken(data: {
1398
1421
  userId: string;
1399
1422
  tokenFamily?: string;
@@ -1431,24 +1454,35 @@ declare class TokenService {
1431
1454
  storeRefreshToken(userId: string, refreshToken: string, tokenFamily: string): Promise<void>;
1432
1455
  /**
1433
1456
  * Rotate only the family row observed by refreshTokens(). This conditional
1434
- * update fails closed if logout deleted the family or another refresh won.
1457
+ * update returns null if logout deleted the family or another refresh won.
1458
+ * The row records the new token's own expiry, which serveReplacedToken
1459
+ * reads back to rebuild it.
1435
1460
  */
1436
1461
  private rotateTokens;
1437
1462
  /**
1438
- * Refresh tokens with secure token comparison
1439
- * Compares provided token with hashed version in database
1440
- */
1441
- refreshTokens(): Promise<{
1442
- userId: string;
1443
- tokenFamily: string;
1444
- roles: string[];
1445
- permissions: string[];
1446
- sessionVersion: number;
1447
- accessToken: string;
1448
- refreshToken: string;
1449
- accessTokenExpiresAt: number;
1450
- refreshTokenExpiresAt: number;
1451
- }>;
1463
+ * Rotate the refresh token the cookie presents.
1464
+ *
1465
+ * Browser tabs share one cookie jar, so several requests can present the
1466
+ * same token at once: tabs whose access tokens were issued together, or a
1467
+ * tab and a server render. The first rotates it and sets the new cookie.
1468
+ * The others present the token it replaced within the grace window and get
1469
+ * that same new token back without a second rotation, so however many there
1470
+ * are, and in whatever order their responses land, the browser ends up with
1471
+ * the one current token and none is mistaken for a stolen one. The same path
1472
+ * recovers a rotation whose response never reached the browser. Reuse after
1473
+ * the window, or of a token the family never issued, revokes the family.
1474
+ */
1475
+ refreshTokens(): Promise<RefreshedSession>;
1476
+ /** Whether `presentedHash` is the token this family replaced less than the grace window ago. */
1477
+ private withinGraceWindow;
1478
+ /**
1479
+ * Serve a request that presented the token another request just replaced.
1480
+ * It rotates nothing: it returns a new access token and the refresh token
1481
+ * that rotation issued, rebuilt from the replaced one. A current token that
1482
+ * is not that successor (a rotation by an earlier release) is left alone and
1483
+ * only the access token is returned.
1484
+ */
1485
+ private serveReplacedToken;
1452
1486
  private requireActiveRefreshUser;
1453
1487
  /** Revoke every refresh session for a user (password change/reset, logout-all). */
1454
1488
  revokeAllForUser(userId: string): Promise<any>;
@@ -1917,7 +1951,9 @@ declare class AuthService {
1917
1951
  establishSession(user: SanitizedUser): Promise<TokenPair & {
1918
1952
  user: SanitizedUser;
1919
1953
  }>;
1920
- refreshTokens(): Promise<TokenPair>;
1954
+ refreshTokens(): Promise<Omit<TokenPair, 'refreshToken'> & {
1955
+ refreshToken?: string;
1956
+ }>;
1921
1957
  /**
1922
1958
  * Reissue the short-lived signed session snapshot from a fully validated
1923
1959
  * refresh session. This path never creates or returns access/refresh tokens
@@ -2059,7 +2095,9 @@ declare class AuthController {
2059
2095
  } & {
2060
2096
  emailSent: boolean;
2061
2097
  }>;
2062
- refreshTokens(): Promise<TokenPair>;
2098
+ refreshTokens(): Promise<Omit<TokenPair, "refreshToken"> & {
2099
+ refreshToken?: string;
2100
+ }>;
2063
2101
  recoverSession(recoveryRequest: string | undefined, ctx: Context): Promise<{
2064
2102
  recovered: true;
2065
2103
  }>;
@@ -3207,4 +3245,4 @@ declare const authSeed: (config: AuthSeedConfig) => Record<string, SeedEntry>;
3207
3245
  */
3208
3246
  declare function seedAuthData(config: SeedAuthDataConfig): Promise<SeedAuthDataResult>;
3209
3247
 
3210
- export { AUTH_CONFIG, AUTH_CORE_MODULE, en as AUTH_EN, AUTH_LOCALES, AUTH_LOGIN_RATE_LIMIT_ENV, AUTH_MODULE, AUTH_PERMISSIONS, AUTH_ROLE, AUTH_SCHEMA, AUTH_SUPPORTED_LANGUAGES, AUTH_USER, type AdministrativeDelivery, type AssignPermissionDto, type AssignRoleDto, type AssignRoleParams, type AuthConfig, AuthController, AuthGuard, type AuthLoginRateLimitConfig, type AuthPluginConfig, AuthQueries, type AuthRateLimitEnvironment, AuthResolver, type AuthSchema, type AuthSeedConfig, AuthService, AuthSessionService, type AuthUser, CREDENTIAL_SETUP_CODES, CREDENTIAL_SETUP_MODULE, Can, CanCreate, CanDelete, CanList, CanRead, CanUpdate, type ChainableGuard, type ChangePasswordDto, type CheckPermissionDto, type ConfiguredOwnership, type ConfirmResetPasswordDto, type ConsumedSetPasswordToken, CookieManager, type CreatePermissionDto, type CreateRoleDto, type CreateTokenDto, type CreateUserDto, type CredentialSetupChangeDto, type CredentialSetupCode, type CredentialSetupConfig, CredentialSetupController, type CredentialSetupOptions, type CredentialSetupPasswordOptions, type CredentialSetupPending, CredentialSetupRepository, CredentialSetupRequirementRepository, type CredentialSetupRequirementRow, CredentialSetupRequirementService, CredentialSetupService, type CredentialSetupSessionInfo, type CredentialSetupStarted, DEFAULT_AUTH_LOGIN_RATE_LIMIT, DEFAULT_CREDENTIAL_SETUP_COOKIE_NAME, DEFAULT_CREDENTIAL_SETUP_TTL_MS, type DefineRolesOptions, type EmailParam, EncryptionService, type GitHubOAuthConfig, type GoogleOAuthConfig, IdentityConfig, type IdentityResolver, type InviteUserDto, type JwtConfig, type JwtPayload, type LanguageParam, type LoginDto, type LoginResult, NewPermission, NewRoleEntity, NewUser, type OAuthConfig, type OAuthProvider, Owned, type OwnedMethods, type OwnedWhere, type OwnershipConditionMethods, type OwnershipConfig, type OwnershipProvider, type OwnershipReadContext, type OwnershipRule, OwnershipToken, type OwnershipTokenOptions, PASSWORD_SETUP_PURPOSE, PUBLIC_REGISTRATION_MODULE, PasswordSetupService, Permission, PermissionController, PermissionGuard, type PermissionIdParam, PermissionRepository, PermissionService, PermissionValidator, Policy, type ProvisionUserInput, type ProvisionUserWithPasswordInput, type ProvisionUserWithSetupInput, ROLES, ROLE_GROUPS, type RefreshTokenDto, type RegisterDto, RegistrationController, type ResetPasswordDto, type ResolvedCredentialSetupConfig, ResolvedIdentityConfig, type ResourceAccessor, type ResourceGuards, type ResourceGuardsOptions, type RevokeTokenDto, Role, RoleController, RoleEntity, RoleGuard, type RoleIdParam, type RoleInput, RolePermission, RoleRepository, RoleService, type RoleType, RoleValidator, type RunAsUser, type SanitizedUser, ScopeContext, type ScopeResult, type SeedAuthDataConfig, type SeedAuthDataResult, type SeedUserConfig, type SessionCookieData, SessionInvalidationService, type SetPasswordToken, type SetPasswordTokenType, TOKEN_STATUS, TOKEN_TYPE, TemporaryCredentialInput, type TemporaryCredentialReset, type TokenIdParam, type TokenPair, TokenRepository, TokenService, USER_STATUS, type UpdatePermissionDto, type UpdateRoleDto, type UpdateTokenDto, type UpdateUserDto, User, UserController, type UserIdInParam, type UserIdParam, type UserListQuery, UserRepository, UserService, UserValidator, type UserWithPermissions, type VerifyTokenDto, assignPermissionDto, assignRoleDto, assignRoleParams, auth$1 as auth, authEmailRateLimitKey, authIdentityRateLimitKey, authSeed, avatarsPath, calculateAge, calculateYearsOfExperience, changePasswordDto, checkPermissionDto, clean, configureOwnership, confirmResetPasswordDto, createIdentityResolver, createPermissionDto, createRoleDto, createTokenDto, createUserDto, credentialSetupChangeDto, credentialSetupError, defaultCredentialSetupPasswordSchema, defineRoles, emailParam, formatDate, getAuthLocale, getAvatarFile, inviteUserDto, isAdmin, isAdministrator, isAuth, isEmailIdentifier, isEmpty, isFile, isPath, join, languageParam, loginDto, normalizeAuthIdentifier, normalizeSetupPurpose, own, ownershipCondition, parseSchema, permissionIdParam, pickProps, refreshTokenDto, registerDto, resetPasswordDto, resolveAuthLoginRateLimitConfig, revokeTokenDto, roleIdParam, runAsUser, seedAuthData, tokenIdParam, updatePermissionDto, updateRoleDto, updateTokenDto, updateUserDto, userIdInParam, userIdParam, userListQuery, verifyTokenDto, where };
3248
+ export { AUTH_CONFIG, AUTH_CORE_MODULE, en as AUTH_EN, AUTH_LOCALES, AUTH_LOGIN_RATE_LIMIT_ENV, AUTH_MODULE, AUTH_PERMISSIONS, AUTH_ROLE, AUTH_SCHEMA, AUTH_SUPPORTED_LANGUAGES, AUTH_USER, type AdministrativeDelivery, type AssignPermissionDto, type AssignRoleDto, type AssignRoleParams, type AuthConfig, AuthController, AuthGuard, type AuthLoginRateLimitConfig, type AuthPluginConfig, AuthQueries, type AuthRateLimitEnvironment, AuthResolver, type AuthSchema, type AuthSeedConfig, AuthService, AuthSessionService, type AuthUser, CREDENTIAL_SETUP_CODES, CREDENTIAL_SETUP_MODULE, Can, CanCreate, CanDelete, CanList, CanRead, CanUpdate, type ChainableGuard, type ChangePasswordDto, type CheckPermissionDto, type ConfiguredOwnership, type ConfirmResetPasswordDto, type ConsumedSetPasswordToken, CookieManager, type CreatePermissionDto, type CreateRoleDto, type CreateTokenDto, type CreateUserDto, type CredentialSetupChangeDto, type CredentialSetupCode, type CredentialSetupConfig, CredentialSetupController, type CredentialSetupOptions, type CredentialSetupPasswordOptions, type CredentialSetupPending, CredentialSetupRepository, CredentialSetupRequirementRepository, type CredentialSetupRequirementRow, CredentialSetupRequirementService, CredentialSetupService, type CredentialSetupSessionInfo, type CredentialSetupStarted, DEFAULT_AUTH_LOGIN_RATE_LIMIT, DEFAULT_CREDENTIAL_SETUP_COOKIE_NAME, DEFAULT_CREDENTIAL_SETUP_TTL_MS, type DefineRolesOptions, type EmailParam, EncryptionService, type GitHubOAuthConfig, type GoogleOAuthConfig, IdentityConfig, type IdentityResolver, type InviteUserDto, type JwtConfig, type JwtPayload, type LanguageParam, type LoginDto, type LoginResult, NewPermission, NewRoleEntity, NewUser, type OAuthConfig, type OAuthProvider, Owned, type OwnedMethods, type OwnedWhere, type OwnershipConditionMethods, type OwnershipConfig, type OwnershipProvider, type OwnershipReadContext, type OwnershipRule, OwnershipToken, type OwnershipTokenOptions, PASSWORD_SETUP_PURPOSE, PUBLIC_REGISTRATION_MODULE, PasswordSetupService, Permission, PermissionController, PermissionGuard, type PermissionIdParam, PermissionRepository, PermissionService, PermissionValidator, Policy, type ProvisionUserInput, type ProvisionUserWithPasswordInput, type ProvisionUserWithSetupInput, ROLES, ROLE_GROUPS, type RefreshTokenDto, type RefreshedSession, type RegisterDto, RegistrationController, type ResetPasswordDto, type ResolvedCredentialSetupConfig, ResolvedIdentityConfig, type ResourceAccessor, type ResourceGuards, type ResourceGuardsOptions, type RevokeTokenDto, Role, RoleController, RoleEntity, RoleGuard, type RoleIdParam, type RoleInput, RolePermission, RoleRepository, RoleService, type RoleType, RoleValidator, type RunAsUser, type SanitizedUser, ScopeContext, type ScopeResult, type SeedAuthDataConfig, type SeedAuthDataResult, type SeedUserConfig, type SessionCookieData, SessionInvalidationService, type SetPasswordToken, type SetPasswordTokenType, TOKEN_STATUS, TOKEN_TYPE, TemporaryCredentialInput, type TemporaryCredentialReset, type TokenIdParam, type TokenPair, TokenRepository, TokenService, USER_STATUS, type UpdatePermissionDto, type UpdateRoleDto, type UpdateTokenDto, type UpdateUserDto, User, UserController, type UserIdInParam, type UserIdParam, type UserListQuery, UserRepository, UserService, UserValidator, type UserWithPermissions, type VerifyTokenDto, assignPermissionDto, assignRoleDto, assignRoleParams, auth$1 as auth, authEmailRateLimitKey, authIdentityRateLimitKey, authSeed, avatarsPath, calculateAge, calculateYearsOfExperience, changePasswordDto, checkPermissionDto, clean, configureOwnership, confirmResetPasswordDto, createIdentityResolver, createPermissionDto, createRoleDto, createTokenDto, createUserDto, credentialSetupChangeDto, credentialSetupError, defaultCredentialSetupPasswordSchema, defineRoles, emailParam, formatDate, getAuthLocale, getAvatarFile, inviteUserDto, isAdmin, isAdministrator, isAuth, isEmailIdentifier, isEmpty, isFile, isPath, join, languageParam, loginDto, normalizeAuthIdentifier, normalizeSetupPurpose, own, ownershipCondition, parseSchema, permissionIdParam, pickProps, refreshTokenDto, registerDto, resetPasswordDto, resolveAuthLoginRateLimitConfig, revokeTokenDto, roleIdParam, runAsUser, seedAuthData, tokenIdParam, updatePermissionDto, updateRoleDto, updateTokenDto, updateUserDto, userIdInParam, userIdParam, userListQuery, verifyTokenDto, where };
package/dist/index.js CHANGED
@@ -2040,7 +2040,7 @@ UserService = __decorate10([
2040
2040
  import { Injectable as Injectable7, Inject as Inject9 } from "najm-core";
2041
2041
  import { I18n as I18n5 } from "najm-i18n";
2042
2042
  import { CacheService as CacheService2 } from "najm-cache";
2043
- import { createHash } from "crypto";
2043
+ import { createHash, createHmac } from "crypto";
2044
2044
  import jwt from "jsonwebtoken";
2045
2045
  import { nanoid as nanoid4 } from "nanoid";
2046
2046
  import timestring3 from "timestring";
@@ -2312,11 +2312,7 @@ var TokenService = class TokenService2 {
2312
2312
  this.rejectRefreshSession();
2313
2313
  }
2314
2314
  const presentedHash = this.hashToken(refreshToken);
2315
- if (presentedHash === stored.token) {
2316
- return { userId, tokenFamily };
2317
- }
2318
- const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && storedTimeMs(stored.previousValidUntil) > Date.now() && !stored.previousUsedAt;
2319
- if (canRecover) {
2315
+ if (presentedHash === stored.token || this.withinGraceWindow(stored, presentedHash)) {
2320
2316
  return { userId, tokenFamily };
2321
2317
  }
2322
2318
  this.rejectRefreshSession();
@@ -2443,10 +2439,22 @@ var TokenService = class TokenService2 {
2443
2439
  const token = jwt.sign({ ...data, jti, type: "refresh", exp: expiresAt }, this.config.jwt.refreshSecret);
2444
2440
  return { token, expiresAt };
2445
2441
  }
2442
+ /**
2443
+ * The refresh token that replaces `replaced`. Its jti is derived from the
2444
+ * replaced token and its lifetime is the one the family row records, so a
2445
+ * request that presents the replaced token inside the grace window can
2446
+ * rebuild the token its rotation issued and hand the browser that one.
2447
+ */
2448
+ successorRefreshToken(replaced, data, expiresAt) {
2449
+ const jti = createHmac("sha256", this.config.jwt.refreshSecret).update(`successor:${replaced}`).digest("base64url").slice(0, 16);
2450
+ const iat = expiresAt - Math.floor(timestring3(this.config.jwt.refreshExpiresIn, "s"));
2451
+ const token = jwt.sign({ ...data, jti, type: "refresh", iat, exp: expiresAt }, this.config.jwt.refreshSecret);
2452
+ return { token, expiresAt };
2453
+ }
2446
2454
  generateRefreshToken(data) {
2447
2455
  return this.signRefreshToken({ userId: data.userId, tokenFamily: data.tokenFamily ?? nanoid4(16) }).token;
2448
2456
  }
2449
- async createTokenPair(userId, family) {
2457
+ async createTokenPair(userId, family, replaced) {
2450
2458
  const { roleName, permissions } = await this.tokenRepository.getRoleAndPermissions(userId);
2451
2459
  const accessTokenData = {
2452
2460
  userId,
@@ -2455,7 +2463,7 @@ var TokenService = class TokenService2 {
2455
2463
  tokenFamily: family
2456
2464
  };
2457
2465
  const access = await this.signAccessToken(accessTokenData);
2458
- const refresh = this.signRefreshToken({ userId, tokenFamily: family });
2466
+ const refresh = replaced ? this.successorRefreshToken(replaced, { userId, tokenFamily: family }, this.expiresAt(this.config.jwt.refreshExpiresIn)) : this.signRefreshToken({ userId, tokenFamily: family });
2459
2467
  return {
2460
2468
  userId,
2461
2469
  tokenFamily: family,
@@ -2537,35 +2545,44 @@ var TokenService = class TokenService2 {
2537
2545
  }
2538
2546
  /**
2539
2547
  * Rotate only the family row observed by refreshTokens(). This conditional
2540
- * update fails closed if logout deleted the family or another refresh won.
2548
+ * update returns null if logout deleted the family or another refresh won.
2549
+ * The row records the new token's own expiry, which serveReplacedToken
2550
+ * reads back to rebuild it.
2541
2551
  */
2542
- async rotateTokens(userId, tokenFamily, expectedCurrentHash) {
2543
- const generated = await this.createTokenPair(userId, tokenFamily);
2544
- const expireInSecond = timestring3(this.config.jwt.refreshExpiresIn, "s");
2552
+ async rotateTokens(userId, tokenFamily, replaced, expectedCurrentHash) {
2553
+ const generated = await this.createTokenPair(userId, tokenFamily, replaced);
2545
2554
  const rotated = await this.tokenRepository.rotateRefreshToken({
2546
2555
  userId,
2547
2556
  token: this.hashToken(generated.refreshToken),
2548
2557
  tokenFamily,
2549
- expiresAt: new Date(Date.now() + expireInSecond * 1e3).toISOString(),
2558
+ expiresAt: new Date(generated.refreshTokenExpiresAt * 1e3).toISOString(),
2550
2559
  previousHash: expectedCurrentHash,
2551
2560
  previousValidUntil: new Date(Date.now() + TokenService_1.PREVIOUS_GRACE_SECONDS * 1e3).toISOString(),
2552
2561
  previousUsedAt: null
2553
2562
  }, expectedCurrentHash);
2554
- if (!rotated?.length) {
2555
- Err7(this.t("errors.refreshTokenInvalid"), 401);
2556
- }
2563
+ if (!rotated?.length)
2564
+ return null;
2557
2565
  if (!await this.invalidation.markFamilyIssued(tokenFamily, userId)) {
2558
2566
  this.rejectRefreshSession();
2559
2567
  }
2560
2568
  return generated;
2561
2569
  }
2562
2570
  /**
2563
- * Refresh tokens with secure token comparison
2564
- * Compares provided token with hashed version in database
2571
+ * Rotate the refresh token the cookie presents.
2572
+ *
2573
+ * Browser tabs share one cookie jar, so several requests can present the
2574
+ * same token at once: tabs whose access tokens were issued together, or a
2575
+ * tab and a server render. The first rotates it and sets the new cookie.
2576
+ * The others present the token it replaced within the grace window and get
2577
+ * that same new token back without a second rotation, so however many there
2578
+ * are, and in whatever order their responses land, the browser ends up with
2579
+ * the one current token and none is mistaken for a stolen one. The same path
2580
+ * recovers a rotation whose response never reached the browser. Reuse after
2581
+ * the window, or of a token the family never issued, revokes the family.
2565
2582
  */
2566
2583
  async refreshTokens() {
2567
2584
  const { refreshToken, userId, tokenFamily } = this.readRefreshSessionCookie();
2568
- const stored = await this.tokenRepository.getByFamily(tokenFamily);
2585
+ let stored = await this.tokenRepository.getByFamily(tokenFamily);
2569
2586
  await this.assertRefreshFamilyAllowed(tokenFamily, userId);
2570
2587
  if (!stored || stored.userId !== userId) {
2571
2588
  this.rejectRefreshSession();
@@ -2573,19 +2590,52 @@ var TokenService = class TokenService2 {
2573
2590
  const presentedHash = this.hashToken(refreshToken);
2574
2591
  await this.requireActiveRefreshUser(userId, tokenFamily);
2575
2592
  if (presentedHash === stored.token) {
2576
- return this.rotateTokens(userId, tokenFamily, stored.token);
2577
- }
2578
- const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && storedTimeMs(stored.previousValidUntil) > Date.now() && !stored.previousUsedAt;
2579
- if (canRecover) {
2580
- const claimed = await this.tokenRepository.markPreviousUsed(tokenFamily, presentedHash);
2581
- if (!claimed?.length) {
2582
- Err7(this.t("errors.refreshTokenInvalid"), 401);
2593
+ const rotated = await this.rotateTokens(userId, tokenFamily, refreshToken, stored.token);
2594
+ if (rotated)
2595
+ return rotated;
2596
+ stored = await this.tokenRepository.getByFamily(tokenFamily);
2597
+ if (stored?.userId === userId && this.withinGraceWindow(stored, presentedHash)) {
2598
+ return this.serveReplacedToken(userId, tokenFamily, refreshToken, stored);
2583
2599
  }
2584
- return this.rotateTokens(userId, tokenFamily, stored.token);
2600
+ return Err7(this.t("errors.refreshTokenInvalid"), 401);
2601
+ }
2602
+ if (this.withinGraceWindow(stored, presentedHash)) {
2603
+ return this.serveReplacedToken(userId, tokenFamily, refreshToken, stored);
2585
2604
  }
2586
2605
  await this.revokeSuspectRefreshFamily(userId, tokenFamily);
2587
2606
  this.rejectRefreshSession();
2588
2607
  }
2608
+ /** Whether `presentedHash` is the token this family replaced less than the grace window ago. */
2609
+ withinGraceWindow(stored, presentedHash) {
2610
+ return Boolean(stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && storedTimeMs(stored.previousValidUntil) > Date.now());
2611
+ }
2612
+ /**
2613
+ * Serve a request that presented the token another request just replaced.
2614
+ * It rotates nothing: it returns a new access token and the refresh token
2615
+ * that rotation issued, rebuilt from the replaced one. A current token that
2616
+ * is not that successor (a rotation by an earlier release) is left alone and
2617
+ * only the access token is returned.
2618
+ */
2619
+ async serveReplacedToken(userId, tokenFamily, replaced, stored) {
2620
+ const successor = this.successorRefreshToken(replaced, { userId, tokenFamily }, Math.floor(storedTimeMs(stored.expiresAt) / 1e3));
2621
+ const refresh = this.hashToken(successor.token) === stored.token ? successor : void 0;
2622
+ const { roleName, permissions } = await this.tokenRepository.getRoleAndPermissions(userId);
2623
+ const roles = roleName ? [roleName] : [];
2624
+ const access = await this.signAccessToken({ userId, roles, permissions: permissions ?? [], tokenFamily });
2625
+ if (!await this.invalidation.markFamilyIssued(tokenFamily, userId)) {
2626
+ this.rejectRefreshSession();
2627
+ }
2628
+ return {
2629
+ userId,
2630
+ tokenFamily,
2631
+ roles,
2632
+ permissions: permissions ?? [],
2633
+ sessionVersion: access.sessionVersion,
2634
+ accessToken: access.token,
2635
+ accessTokenExpiresAt: access.expiresAt,
2636
+ ...refresh && { refreshToken: refresh.token, refreshTokenExpiresAt: refresh.expiresAt }
2637
+ };
2638
+ }
2589
2639
  async requireActiveRefreshUser(userId, tokenFamily) {
2590
2640
  const user = await this.tokenRepository.getUser(userId);
2591
2641
  if (!user || user.status !== "active") {
@@ -2710,11 +2760,7 @@ var TokenService = class TokenService2 {
2710
2760
  if (!stored || stored.userId !== userId)
2711
2761
  return null;
2712
2762
  const presentedHash = this.hashToken(refreshToken);
2713
- if (presentedHash === stored.token) {
2714
- return decoded.tokenFamily;
2715
- }
2716
- const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && storedTimeMs(stored.previousValidUntil) > Date.now() && !stored.previousUsedAt;
2717
- return canRecover ? decoded.tokenFamily : null;
2763
+ return presentedHash === stored.token || this.withinGraceWindow(stored, presentedHash) ? decoded.tokenFamily : null;
2718
2764
  } catch {
2719
2765
  return null;
2720
2766
  }
@@ -3801,7 +3847,8 @@ var AuthService = class AuthService2 {
3801
3847
  }
3802
3848
  async refreshTokens() {
3803
3849
  const generated = await this.tokenService.refreshTokens();
3804
- this.cookieManager.setRefreshToken(generated.refreshToken);
3850
+ if (generated.refreshToken)
3851
+ this.cookieManager.setRefreshToken(generated.refreshToken);
3805
3852
  const user = await this.tokenService.getUserById(generated.userId);
3806
3853
  if (user) {
3807
3854
  this.cookieManager.setSessionCookie({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "4.2.2",
3
+ "version": "4.2.3",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [