najm-auth 4.2.1 → 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";
@@ -2118,6 +2118,24 @@ CredentialSetupRequirementRepository = __decorate11([
2118
2118
  // src/credentialSetup/types.ts
2119
2119
  var PASSWORD_SETUP_PURPOSE = "password";
2120
2120
 
2121
+ // src/shared/storedTime.ts
2122
+ function storedTimeMs(value) {
2123
+ if (value instanceof Date)
2124
+ return value.getTime();
2125
+ const text3 = value.trim().replace(" ", "T");
2126
+ const time = text3.split("T")[1];
2127
+ if (!time)
2128
+ return Date.parse(text3);
2129
+ if (/Z$/i.test(time))
2130
+ return Date.parse(text3);
2131
+ const offset = /([+-])(\d{2}):?(\d{2})?$/.exec(time);
2132
+ if (!offset)
2133
+ return Date.parse(`${text3}Z`);
2134
+ const [zone, sign, hours, minutes = "00"] = offset;
2135
+ return Date.parse(`${text3.slice(0, -zone.length)}${sign}${hours}:${minutes}`);
2136
+ }
2137
+ __name(storedTimeMs, "storedTimeMs");
2138
+
2121
2139
  // src/tokens/TokenService.ts
2122
2140
  import { Err as Err7 } from "najm-core";
2123
2141
  var __decorate12 = function(decorators, target, key, desc) {
@@ -2294,11 +2312,7 @@ var TokenService = class TokenService2 {
2294
2312
  this.rejectRefreshSession();
2295
2313
  }
2296
2314
  const presentedHash = this.hashToken(refreshToken);
2297
- if (presentedHash === stored.token) {
2298
- return { userId, tokenFamily };
2299
- }
2300
- const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && new Date(stored.previousValidUntil).getTime() > Date.now() && !stored.previousUsedAt;
2301
- if (canRecover) {
2315
+ if (presentedHash === stored.token || this.withinGraceWindow(stored, presentedHash)) {
2302
2316
  return { userId, tokenFamily };
2303
2317
  }
2304
2318
  this.rejectRefreshSession();
@@ -2425,10 +2439,22 @@ var TokenService = class TokenService2 {
2425
2439
  const token = jwt.sign({ ...data, jti, type: "refresh", exp: expiresAt }, this.config.jwt.refreshSecret);
2426
2440
  return { token, expiresAt };
2427
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
+ }
2428
2454
  generateRefreshToken(data) {
2429
2455
  return this.signRefreshToken({ userId: data.userId, tokenFamily: data.tokenFamily ?? nanoid4(16) }).token;
2430
2456
  }
2431
- async createTokenPair(userId, family) {
2457
+ async createTokenPair(userId, family, replaced) {
2432
2458
  const { roleName, permissions } = await this.tokenRepository.getRoleAndPermissions(userId);
2433
2459
  const accessTokenData = {
2434
2460
  userId,
@@ -2437,7 +2463,7 @@ var TokenService = class TokenService2 {
2437
2463
  tokenFamily: family
2438
2464
  };
2439
2465
  const access = await this.signAccessToken(accessTokenData);
2440
- 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 });
2441
2467
  return {
2442
2468
  userId,
2443
2469
  tokenFamily: family,
@@ -2519,35 +2545,44 @@ var TokenService = class TokenService2 {
2519
2545
  }
2520
2546
  /**
2521
2547
  * Rotate only the family row observed by refreshTokens(). This conditional
2522
- * 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.
2523
2551
  */
2524
- async rotateTokens(userId, tokenFamily, expectedCurrentHash) {
2525
- const generated = await this.createTokenPair(userId, tokenFamily);
2526
- const expireInSecond = timestring3(this.config.jwt.refreshExpiresIn, "s");
2552
+ async rotateTokens(userId, tokenFamily, replaced, expectedCurrentHash) {
2553
+ const generated = await this.createTokenPair(userId, tokenFamily, replaced);
2527
2554
  const rotated = await this.tokenRepository.rotateRefreshToken({
2528
2555
  userId,
2529
2556
  token: this.hashToken(generated.refreshToken),
2530
2557
  tokenFamily,
2531
- expiresAt: new Date(Date.now() + expireInSecond * 1e3).toISOString(),
2558
+ expiresAt: new Date(generated.refreshTokenExpiresAt * 1e3).toISOString(),
2532
2559
  previousHash: expectedCurrentHash,
2533
2560
  previousValidUntil: new Date(Date.now() + TokenService_1.PREVIOUS_GRACE_SECONDS * 1e3).toISOString(),
2534
2561
  previousUsedAt: null
2535
2562
  }, expectedCurrentHash);
2536
- if (!rotated?.length) {
2537
- Err7(this.t("errors.refreshTokenInvalid"), 401);
2538
- }
2563
+ if (!rotated?.length)
2564
+ return null;
2539
2565
  if (!await this.invalidation.markFamilyIssued(tokenFamily, userId)) {
2540
2566
  this.rejectRefreshSession();
2541
2567
  }
2542
2568
  return generated;
2543
2569
  }
2544
2570
  /**
2545
- * Refresh tokens with secure token comparison
2546
- * 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.
2547
2582
  */
2548
2583
  async refreshTokens() {
2549
2584
  const { refreshToken, userId, tokenFamily } = this.readRefreshSessionCookie();
2550
- const stored = await this.tokenRepository.getByFamily(tokenFamily);
2585
+ let stored = await this.tokenRepository.getByFamily(tokenFamily);
2551
2586
  await this.assertRefreshFamilyAllowed(tokenFamily, userId);
2552
2587
  if (!stored || stored.userId !== userId) {
2553
2588
  this.rejectRefreshSession();
@@ -2555,19 +2590,52 @@ var TokenService = class TokenService2 {
2555
2590
  const presentedHash = this.hashToken(refreshToken);
2556
2591
  await this.requireActiveRefreshUser(userId, tokenFamily);
2557
2592
  if (presentedHash === stored.token) {
2558
- return this.rotateTokens(userId, tokenFamily, stored.token);
2559
- }
2560
- const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && new Date(stored.previousValidUntil).getTime() > Date.now() && !stored.previousUsedAt;
2561
- if (canRecover) {
2562
- const claimed = await this.tokenRepository.markPreviousUsed(tokenFamily, presentedHash);
2563
- if (!claimed?.length) {
2564
- 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);
2565
2599
  }
2566
- 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);
2567
2604
  }
2568
2605
  await this.revokeSuspectRefreshFamily(userId, tokenFamily);
2569
2606
  this.rejectRefreshSession();
2570
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
+ }
2571
2639
  async requireActiveRefreshUser(userId, tokenFamily) {
2572
2640
  const user = await this.tokenRepository.getUser(userId);
2573
2641
  if (!user || user.status !== "active") {
@@ -2692,11 +2760,7 @@ var TokenService = class TokenService2 {
2692
2760
  if (!stored || stored.userId !== userId)
2693
2761
  return null;
2694
2762
  const presentedHash = this.hashToken(refreshToken);
2695
- if (presentedHash === stored.token) {
2696
- return decoded.tokenFamily;
2697
- }
2698
- const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && new Date(stored.previousValidUntil).getTime() > Date.now() && !stored.previousUsedAt;
2699
- return canRecover ? decoded.tokenFamily : null;
2763
+ return presentedHash === stored.token || this.withinGraceWindow(stored, presentedHash) ? decoded.tokenFamily : null;
2700
2764
  } catch {
2701
2765
  return null;
2702
2766
  }
@@ -3503,7 +3567,7 @@ var AuthService = class AuthService2 {
3503
3567
  isLockoutActive(lockoutUntil) {
3504
3568
  if (!lockoutUntil)
3505
3569
  return false;
3506
- return new Date(lockoutUntil).getTime() > Date.now();
3570
+ return storedTimeMs(lockoutUntil) > Date.now();
3507
3571
  }
3508
3572
  nextLockoutUntil() {
3509
3573
  const durationMs = timestring4(this.config.lockout.duration, "ms");
@@ -3783,7 +3847,8 @@ var AuthService = class AuthService2 {
3783
3847
  }
3784
3848
  async refreshTokens() {
3785
3849
  const generated = await this.tokenService.refreshTokens();
3786
- this.cookieManager.setRefreshToken(generated.refreshToken);
3850
+ if (generated.refreshToken)
3851
+ this.cookieManager.setRefreshToken(generated.refreshToken);
3787
3852
  const user = await this.tokenService.getUserById(generated.userId);
3788
3853
  if (user) {
3789
3854
  this.cookieManager.setSessionCookie({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "4.2.1",
3
+ "version": "4.2.3",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [