najm-auth 3.1.1 → 3.1.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.
@@ -11,7 +11,15 @@ interface FetchClientConfig {
11
11
  }
12
12
  declare class FetchClient {
13
13
  private config;
14
+ private authenticatedRequests;
15
+ private authenticatedRequestsBlocked;
14
16
  constructor(config: FetchClientConfig);
17
+ /** Abort requests carrying the current session before logout invalidates it. */
18
+ abortAuthenticatedRequests(): void;
19
+ /** Block new authenticated traffic and abort anything already in flight. */
20
+ blockAuthenticatedRequests(): void;
21
+ /** Reopen authenticated traffic after login or authoritative hydration. */
22
+ allowAuthenticatedRequests(): void;
15
23
  get<T>(path: string, opts?: RequestOptions): Promise<T>;
16
24
  post<T>(path: string, opts?: RequestOptions): Promise<T>;
17
25
  put<T>(path: string, opts?: RequestOptions): Promise<T>;
@@ -37,8 +45,11 @@ declare class NajmAuthClient {
37
45
  private refreshTimer;
38
46
  private refreshCircuitTimer;
39
47
  private refreshPromise;
48
+ private refreshAbortController;
40
49
  private fetchUserPromise;
41
50
  private refreshFailures;
51
+ private authGeneration;
52
+ private refreshBlocked;
42
53
  private _hydrated;
43
54
  private listeners;
44
55
  private eventListeners;
@@ -1,4 +1,4 @@
1
- export { F as FetchClient, H as HydrateSession, N as NajmAuthClient, c as createAuthClient } from '../NajmAuthClient-DqGucYXi.js';
1
+ export { F as FetchClient, H as HydrateSession, N as NajmAuthClient, c as createAuthClient } from '../NajmAuthClient-ZtXTIUSF.js';
2
2
  import { D as DecodedToken, T as TabSyncMessage, S as SyncPayload } from '../types-BaSfgxqE.js';
3
3
  export { b as AuthClientConfig, h as AuthError, f as AuthEvent, g as AuthEventHandler, i as AuthEventMap, e as AuthState, A as AuthUser, j as AuthenticatedLogin, C as CredentialSetupPending, L as LoginCredentials, c as LoginResult, d as OAuthLoginOptions, O as OAuthProvider, a as RequestOptions, R as RetryConfig, k as ServerResponse, l as TokenPair } from '../types-BaSfgxqE.js';
4
4
 
@@ -32,6 +32,24 @@ var FetchClient = class {
32
32
  static {
33
33
  __name(this, "FetchClient");
34
34
  }
35
+ authenticatedRequests = /* @__PURE__ */ new Set();
36
+ authenticatedRequestsBlocked = false;
37
+ /** Abort requests carrying the current session before logout invalidates it. */
38
+ abortAuthenticatedRequests() {
39
+ for (const controller of this.authenticatedRequests) {
40
+ controller.abort();
41
+ }
42
+ this.authenticatedRequests.clear();
43
+ }
44
+ /** Block new authenticated traffic and abort anything already in flight. */
45
+ blockAuthenticatedRequests() {
46
+ this.authenticatedRequestsBlocked = true;
47
+ this.abortAuthenticatedRequests();
48
+ }
49
+ /** Reopen authenticated traffic after login or authoritative hydration. */
50
+ allowAuthenticatedRequests() {
51
+ this.authenticatedRequestsBlocked = false;
52
+ }
35
53
  async get(path, opts) {
36
54
  return this.request("GET", path, opts);
37
55
  }
@@ -79,7 +97,10 @@ var FetchClient = class {
79
97
  throw err;
80
98
  }
81
99
  }
82
- doFetch(method, path, opts) {
100
+ async doFetch(method, path, opts) {
101
+ if (!opts?.skipAuth && this.authenticatedRequestsBlocked) {
102
+ throw new Error("Authenticated requests unavailable after logout");
103
+ }
83
104
  const url = `${this.config.baseURL}${path}`;
84
105
  const headers = {
85
106
  "Accept": "application/json",
@@ -97,18 +118,35 @@ var FetchClient = class {
97
118
  headers["Content-Type"] = "application/json";
98
119
  body = JSON.stringify(opts.body);
99
120
  }
100
- let signal = opts?.signal;
121
+ const controller = new AbortController();
122
+ const forwardedSignals = [];
123
+ if (opts?.signal) forwardedSignals.push(opts.signal);
101
124
  const timeout = opts?.timeout ?? this.config.timeout;
102
- if (timeout && !signal && typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) {
103
- signal = AbortSignal.timeout(timeout);
104
- }
105
- return fetch(url, {
106
- method,
107
- headers,
108
- body,
109
- signal,
110
- credentials: this.config.credentials ?? "include"
111
- });
125
+ if (timeout && typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) {
126
+ forwardedSignals.push(AbortSignal.timeout(timeout));
127
+ }
128
+ const abort = /* @__PURE__ */ __name(() => controller.abort(), "abort");
129
+ for (const signal of forwardedSignals) {
130
+ if (signal.aborted) abort();
131
+ else signal.addEventListener("abort", abort, { once: true });
132
+ }
133
+ if (!opts?.skipAuth) {
134
+ this.authenticatedRequests.add(controller);
135
+ }
136
+ try {
137
+ return await fetch(url, {
138
+ method,
139
+ headers,
140
+ body,
141
+ signal: controller.signal,
142
+ credentials: this.config.credentials ?? "include"
143
+ });
144
+ } finally {
145
+ this.authenticatedRequests.delete(controller);
146
+ for (const signal of forwardedSignals) {
147
+ signal.removeEventListener("abort", abort);
148
+ }
149
+ }
112
150
  }
113
151
  async parseBody(res) {
114
152
  const ct = res.headers.get("content-type") ?? "";
@@ -230,8 +268,11 @@ var NajmAuthClient = class _NajmAuthClient {
230
268
  refreshTimer = null;
231
269
  refreshCircuitTimer = null;
232
270
  refreshPromise = null;
271
+ refreshAbortController = null;
233
272
  fetchUserPromise = null;
234
273
  refreshFailures = 0;
274
+ authGeneration = 0;
275
+ refreshBlocked = false;
235
276
  _hydrated = false;
236
277
  // Subscriptions (for React useSyncExternalStore)
237
278
  listeners = /* @__PURE__ */ new Set();
@@ -253,6 +294,8 @@ var NajmAuthClient = class _NajmAuthClient {
253
294
  return { ...setup };
254
295
  }
255
296
  const authenticated = res.data;
297
+ this.refreshBlocked = false;
298
+ this.api.allowAuthenticatedRequests();
256
299
  this.applyTokens(authenticated);
257
300
  if (authenticated.user) {
258
301
  this.state = { ...this.state, user: authenticated.user };
@@ -301,6 +344,8 @@ var NajmAuthClient = class _NajmAuthClient {
301
344
  window.location.assign(res.data.authorizationUrl);
302
345
  }
303
346
  async completeOAuthLogin() {
347
+ this.refreshBlocked = false;
348
+ this.api.allowAuthenticatedRequests();
304
349
  await this.refresh();
305
350
  const user = await this.fetchUser();
306
351
  if (!user) throw new Error("OAuth session could not be completed");
@@ -309,22 +354,37 @@ var NajmAuthClient = class _NajmAuthClient {
309
354
  return user;
310
355
  }
311
356
  async logout() {
357
+ this.authGeneration += 1;
358
+ this.refreshBlocked = true;
359
+ const pendingRefresh = this.refreshPromise;
360
+ this.refreshAbortController?.abort();
361
+ this.api.blockAuthenticatedRequests();
312
362
  this.resetState();
313
363
  this.tabSync?.broadcastLogout();
314
364
  this.emit("logout", null);
315
365
  try {
316
- await this.api.post(`${this.prefix}/logout`);
366
+ await pendingRefresh?.catch(() => void 0);
367
+ await this.api.post(`${this.prefix}/logout`, { skipAuth: true });
317
368
  } catch (err) {
318
369
  this.emit("logoutError", err);
319
370
  }
320
371
  }
321
372
  async refresh() {
373
+ if (this.refreshBlocked) {
374
+ throw new Error("Refresh unavailable after logout");
375
+ }
322
376
  if (this.refreshFailures >= _NajmAuthClient.MAX_REFRESH_FAILURES) {
323
377
  throw new Error("Session expired (circuit open)");
324
378
  }
325
379
  if (!this.refreshPromise) {
326
- this.refreshPromise = this._refreshWithCircuit().finally(() => {
380
+ const generation = this.authGeneration;
381
+ const controller = new AbortController();
382
+ this.refreshAbortController = controller;
383
+ this.refreshPromise = this._refreshWithCircuit(generation, controller.signal).finally(() => {
327
384
  this.refreshPromise = null;
385
+ if (this.refreshAbortController === controller) {
386
+ this.refreshAbortController = null;
387
+ }
328
388
  });
329
389
  }
330
390
  return this.refreshPromise;
@@ -403,10 +463,14 @@ var NajmAuthClient = class _NajmAuthClient {
403
463
  this._hydrated = true;
404
464
  this.resetRefreshFailures();
405
465
  if (!session || !session.user) {
466
+ if (this.refreshBlocked) this.api.blockAuthenticatedRequests();
467
+ else this.api.allowAuthenticatedRequests();
406
468
  this.state = { ...INITIAL_STATE };
407
469
  this.notify();
408
470
  return;
409
471
  }
472
+ this.refreshBlocked = false;
473
+ this.api.allowAuthenticatedRequests();
410
474
  this.state = {
411
475
  ...this.state,
412
476
  user: session.user,
@@ -460,6 +524,10 @@ var NajmAuthClient = class _NajmAuthClient {
460
524
  // Cleanup
461
525
  // =========================================================================
462
526
  destroy() {
527
+ this.authGeneration += 1;
528
+ this.refreshAbortController?.abort();
529
+ this.refreshAbortController = null;
530
+ this.api.blockAuthenticatedRequests();
463
531
  this.clearRefreshTimer();
464
532
  this.clearRefreshCircuitTimer();
465
533
  this.tabSync?.destroy();
@@ -472,11 +540,13 @@ var NajmAuthClient = class _NajmAuthClient {
472
540
  // =========================================================================
473
541
  // Internals
474
542
  // =========================================================================
475
- async _refreshWithCircuit() {
543
+ async _refreshWithCircuit(generation, signal) {
476
544
  try {
477
- await this._doRefresh();
545
+ const applied = await this._doRefresh(generation, signal);
546
+ if (!applied) return;
478
547
  this.resetRefreshFailures();
479
548
  } catch (err) {
549
+ if (generation !== this.authGeneration) return;
480
550
  const shouldOpenCircuit = this.registerRefreshFailure(err);
481
551
  if (shouldOpenCircuit) {
482
552
  this.resetState();
@@ -489,14 +559,16 @@ var NajmAuthClient = class _NajmAuthClient {
489
559
  throw err;
490
560
  }
491
561
  }
492
- async _doRefresh() {
562
+ async _doRefresh(generation, signal) {
493
563
  const res = await this.api.post(
494
564
  `${this.prefix}/refresh`,
495
- { skipAuth: true }
565
+ { skipAuth: true, signal }
496
566
  );
567
+ if (generation !== this.authGeneration) return false;
497
568
  this.applyTokens(res.data);
498
569
  this.tabSync?.broadcastSync(this.getSyncPayload());
499
570
  this.emit("tokenRefresh", null);
571
+ return true;
500
572
  }
501
573
  async handleUnauthorized() {
502
574
  try {
@@ -578,12 +650,22 @@ var NajmAuthClient = class _NajmAuthClient {
578
650
  handleTabMessage(msg) {
579
651
  switch (msg.type) {
580
652
  case "logout":
653
+ this.authGeneration += 1;
654
+ this.refreshBlocked = true;
655
+ this.refreshAbortController?.abort();
656
+ this.api.blockAuthenticatedRequests();
581
657
  this.clearRefreshTimer();
582
658
  this.state = { ...INITIAL_STATE };
583
659
  this.notify();
584
660
  this.emit("logout", null);
585
661
  break;
586
662
  case "sync":
663
+ if (msg.state.isAuthenticated) {
664
+ this.refreshBlocked = false;
665
+ this.api.allowAuthenticatedRequests();
666
+ } else {
667
+ this.api.blockAuthenticatedRequests();
668
+ }
587
669
  this.state = {
588
670
  ...this.state,
589
671
  accessToken: msg.state.accessToken,
@@ -1,7 +1,7 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import * as react from 'react';
3
3
  import { ReactNode, CSSProperties, ReactElement } from 'react';
4
- import { N as NajmAuthClient, H as HydrateSession } from '../../NajmAuthClient-DqGucYXi.js';
4
+ import { N as NajmAuthClient, H as HydrateSession } from '../../NajmAuthClient-ZtXTIUSF.js';
5
5
  import { e as AuthState, A as AuthUser, c as LoginResult, h as AuthError, L as LoginCredentials, d as OAuthLoginOptions, f as AuthEvent, i as AuthEventMap } from '../../types-BaSfgxqE.js';
6
6
 
7
7
  interface AuthProviderProps {
@@ -1,5 +1,5 @@
1
1
  import { A as AuthUser, R as RetryConfig } from '../../types-BaSfgxqE.js';
2
- import { F as FetchClient, N as NajmAuthClient } from '../../NajmAuthClient-DqGucYXi.js';
2
+ import { F as FetchClient, N as NajmAuthClient } from '../../NajmAuthClient-ZtXTIUSF.js';
3
3
  export { withAuthMiddleware } from '../edge.js';
4
4
  import { G as GetSessionConfig, S as ServerSession$1 } from '../../getSession-BthP85UA.js';
5
5
  export { A as AuthConfigError, a as AuthTransportError, N as NoSessionError, g as getSession } from '../../getSession-BthP85UA.js';
@@ -595,6 +595,24 @@ var FetchClient = class {
595
595
  static {
596
596
  __name(this, "FetchClient");
597
597
  }
598
+ authenticatedRequests = /* @__PURE__ */ new Set();
599
+ authenticatedRequestsBlocked = false;
600
+ /** Abort requests carrying the current session before logout invalidates it. */
601
+ abortAuthenticatedRequests() {
602
+ for (const controller of this.authenticatedRequests) {
603
+ controller.abort();
604
+ }
605
+ this.authenticatedRequests.clear();
606
+ }
607
+ /** Block new authenticated traffic and abort anything already in flight. */
608
+ blockAuthenticatedRequests() {
609
+ this.authenticatedRequestsBlocked = true;
610
+ this.abortAuthenticatedRequests();
611
+ }
612
+ /** Reopen authenticated traffic after login or authoritative hydration. */
613
+ allowAuthenticatedRequests() {
614
+ this.authenticatedRequestsBlocked = false;
615
+ }
598
616
  async get(path, opts) {
599
617
  return this.request("GET", path, opts);
600
618
  }
@@ -642,7 +660,10 @@ var FetchClient = class {
642
660
  throw err;
643
661
  }
644
662
  }
645
- doFetch(method, path, opts) {
663
+ async doFetch(method, path, opts) {
664
+ if (!opts?.skipAuth && this.authenticatedRequestsBlocked) {
665
+ throw new Error("Authenticated requests unavailable after logout");
666
+ }
646
667
  const url = `${this.config.baseURL}${path}`;
647
668
  const headers = {
648
669
  "Accept": "application/json",
@@ -660,18 +681,35 @@ var FetchClient = class {
660
681
  headers["Content-Type"] = "application/json";
661
682
  body = JSON.stringify(opts.body);
662
683
  }
663
- let signal = opts?.signal;
684
+ const controller = new AbortController();
685
+ const forwardedSignals = [];
686
+ if (opts?.signal) forwardedSignals.push(opts.signal);
664
687
  const timeout = opts?.timeout ?? this.config.timeout;
665
- if (timeout && !signal && typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) {
666
- signal = AbortSignal.timeout(timeout);
688
+ if (timeout && typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) {
689
+ forwardedSignals.push(AbortSignal.timeout(timeout));
690
+ }
691
+ const abort = /* @__PURE__ */ __name(() => controller.abort(), "abort");
692
+ for (const signal of forwardedSignals) {
693
+ if (signal.aborted) abort();
694
+ else signal.addEventListener("abort", abort, { once: true });
695
+ }
696
+ if (!opts?.skipAuth) {
697
+ this.authenticatedRequests.add(controller);
698
+ }
699
+ try {
700
+ return await fetch(url, {
701
+ method,
702
+ headers,
703
+ body,
704
+ signal: controller.signal,
705
+ credentials: this.config.credentials ?? "include"
706
+ });
707
+ } finally {
708
+ this.authenticatedRequests.delete(controller);
709
+ for (const signal of forwardedSignals) {
710
+ signal.removeEventListener("abort", abort);
711
+ }
667
712
  }
668
- return fetch(url, {
669
- method,
670
- headers,
671
- body,
672
- signal,
673
- credentials: this.config.credentials ?? "include"
674
- });
675
713
  }
676
714
  async parseBody(res) {
677
715
  const ct = res.headers.get("content-type") ?? "";
@@ -939,8 +977,11 @@ var NajmAuthClient = class _NajmAuthClient {
939
977
  refreshTimer = null;
940
978
  refreshCircuitTimer = null;
941
979
  refreshPromise = null;
980
+ refreshAbortController = null;
942
981
  fetchUserPromise = null;
943
982
  refreshFailures = 0;
983
+ authGeneration = 0;
984
+ refreshBlocked = false;
944
985
  _hydrated = false;
945
986
  // Subscriptions (for React useSyncExternalStore)
946
987
  listeners = /* @__PURE__ */ new Set();
@@ -962,6 +1003,8 @@ var NajmAuthClient = class _NajmAuthClient {
962
1003
  return { ...setup };
963
1004
  }
964
1005
  const authenticated2 = res.data;
1006
+ this.refreshBlocked = false;
1007
+ this.api.allowAuthenticatedRequests();
965
1008
  this.applyTokens(authenticated2);
966
1009
  if (authenticated2.user) {
967
1010
  this.state = { ...this.state, user: authenticated2.user };
@@ -1010,6 +1053,8 @@ var NajmAuthClient = class _NajmAuthClient {
1010
1053
  window.location.assign(res.data.authorizationUrl);
1011
1054
  }
1012
1055
  async completeOAuthLogin() {
1056
+ this.refreshBlocked = false;
1057
+ this.api.allowAuthenticatedRequests();
1013
1058
  await this.refresh();
1014
1059
  const user = await this.fetchUser();
1015
1060
  if (!user) throw new Error("OAuth session could not be completed");
@@ -1018,22 +1063,37 @@ var NajmAuthClient = class _NajmAuthClient {
1018
1063
  return user;
1019
1064
  }
1020
1065
  async logout() {
1066
+ this.authGeneration += 1;
1067
+ this.refreshBlocked = true;
1068
+ const pendingRefresh = this.refreshPromise;
1069
+ this.refreshAbortController?.abort();
1070
+ this.api.blockAuthenticatedRequests();
1021
1071
  this.resetState();
1022
1072
  this.tabSync?.broadcastLogout();
1023
1073
  this.emit("logout", null);
1024
1074
  try {
1025
- await this.api.post(`${this.prefix}/logout`);
1075
+ await pendingRefresh?.catch(() => void 0);
1076
+ await this.api.post(`${this.prefix}/logout`, { skipAuth: true });
1026
1077
  } catch (err) {
1027
1078
  this.emit("logoutError", err);
1028
1079
  }
1029
1080
  }
1030
1081
  async refresh() {
1082
+ if (this.refreshBlocked) {
1083
+ throw new Error("Refresh unavailable after logout");
1084
+ }
1031
1085
  if (this.refreshFailures >= _NajmAuthClient.MAX_REFRESH_FAILURES) {
1032
1086
  throw new Error("Session expired (circuit open)");
1033
1087
  }
1034
1088
  if (!this.refreshPromise) {
1035
- this.refreshPromise = this._refreshWithCircuit().finally(() => {
1089
+ const generation = this.authGeneration;
1090
+ const controller = new AbortController();
1091
+ this.refreshAbortController = controller;
1092
+ this.refreshPromise = this._refreshWithCircuit(generation, controller.signal).finally(() => {
1036
1093
  this.refreshPromise = null;
1094
+ if (this.refreshAbortController === controller) {
1095
+ this.refreshAbortController = null;
1096
+ }
1037
1097
  });
1038
1098
  }
1039
1099
  return this.refreshPromise;
@@ -1112,10 +1172,14 @@ var NajmAuthClient = class _NajmAuthClient {
1112
1172
  this._hydrated = true;
1113
1173
  this.resetRefreshFailures();
1114
1174
  if (!session || !session.user) {
1175
+ if (this.refreshBlocked) this.api.blockAuthenticatedRequests();
1176
+ else this.api.allowAuthenticatedRequests();
1115
1177
  this.state = { ...INITIAL_STATE };
1116
1178
  this.notify();
1117
1179
  return;
1118
1180
  }
1181
+ this.refreshBlocked = false;
1182
+ this.api.allowAuthenticatedRequests();
1119
1183
  this.state = {
1120
1184
  ...this.state,
1121
1185
  user: session.user,
@@ -1169,6 +1233,10 @@ var NajmAuthClient = class _NajmAuthClient {
1169
1233
  // Cleanup
1170
1234
  // =========================================================================
1171
1235
  destroy() {
1236
+ this.authGeneration += 1;
1237
+ this.refreshAbortController?.abort();
1238
+ this.refreshAbortController = null;
1239
+ this.api.blockAuthenticatedRequests();
1172
1240
  this.clearRefreshTimer();
1173
1241
  this.clearRefreshCircuitTimer();
1174
1242
  this.tabSync?.destroy();
@@ -1181,11 +1249,13 @@ var NajmAuthClient = class _NajmAuthClient {
1181
1249
  // =========================================================================
1182
1250
  // Internals
1183
1251
  // =========================================================================
1184
- async _refreshWithCircuit() {
1252
+ async _refreshWithCircuit(generation, signal) {
1185
1253
  try {
1186
- await this._doRefresh();
1254
+ const applied = await this._doRefresh(generation, signal);
1255
+ if (!applied) return;
1187
1256
  this.resetRefreshFailures();
1188
1257
  } catch (err) {
1258
+ if (generation !== this.authGeneration) return;
1189
1259
  const shouldOpenCircuit = this.registerRefreshFailure(err);
1190
1260
  if (shouldOpenCircuit) {
1191
1261
  this.resetState();
@@ -1198,14 +1268,16 @@ var NajmAuthClient = class _NajmAuthClient {
1198
1268
  throw err;
1199
1269
  }
1200
1270
  }
1201
- async _doRefresh() {
1271
+ async _doRefresh(generation, signal) {
1202
1272
  const res = await this.api.post(
1203
1273
  `${this.prefix}/refresh`,
1204
- { skipAuth: true }
1274
+ { skipAuth: true, signal }
1205
1275
  );
1276
+ if (generation !== this.authGeneration) return false;
1206
1277
  this.applyTokens(res.data);
1207
1278
  this.tabSync?.broadcastSync(this.getSyncPayload());
1208
1279
  this.emit("tokenRefresh", null);
1280
+ return true;
1209
1281
  }
1210
1282
  async handleUnauthorized() {
1211
1283
  try {
@@ -1287,12 +1359,22 @@ var NajmAuthClient = class _NajmAuthClient {
1287
1359
  handleTabMessage(msg) {
1288
1360
  switch (msg.type) {
1289
1361
  case "logout":
1362
+ this.authGeneration += 1;
1363
+ this.refreshBlocked = true;
1364
+ this.refreshAbortController?.abort();
1365
+ this.api.blockAuthenticatedRequests();
1290
1366
  this.clearRefreshTimer();
1291
1367
  this.state = { ...INITIAL_STATE };
1292
1368
  this.notify();
1293
1369
  this.emit("logout", null);
1294
1370
  break;
1295
1371
  case "sync":
1372
+ if (msg.state.isAuthenticated) {
1373
+ this.refreshBlocked = false;
1374
+ this.api.allowAuthenticatedRequests();
1375
+ } else {
1376
+ this.api.blockAuthenticatedRequests();
1377
+ }
1296
1378
  this.state = {
1297
1379
  ...this.state,
1298
1380
  accessToken: msg.state.accessToken,
package/dist/index.d.ts CHANGED
@@ -980,6 +980,19 @@ declare class TokenRepository {
980
980
  previousValidUntil?: string | null;
981
981
  previousUsedAt?: string | null;
982
982
  }): Promise<any>;
983
+ /**
984
+ * Rotate an existing refresh-token family with compare-and-swap semantics.
985
+ * This can never insert a family deleted by a concurrent logout.
986
+ */
987
+ rotateRefreshToken(tokenData: {
988
+ userId: string;
989
+ token: string;
990
+ tokenFamily: string;
991
+ expiresAt: string;
992
+ previousHash: string;
993
+ previousValidUntil: string;
994
+ previousUsedAt?: string | null;
995
+ }, expectedCurrentHash: string): Promise<any>;
983
996
  /**
984
997
  * Claim the previous-token grace slot for a single family. Conditional on
985
998
  * BOTH the stored previousHash still matching the presented token AND
@@ -1110,6 +1123,7 @@ declare class TokenService {
1110
1123
  userId: string;
1111
1124
  tokenFamily?: string;
1112
1125
  }): string;
1126
+ private createTokenPair;
1113
1127
  generateTokens(userId: string, tokenFamily?: string): Promise<{
1114
1128
  userId: string;
1115
1129
  tokenFamily: string;
@@ -1140,6 +1154,11 @@ declare class TokenService {
1140
1154
  * This prevents token theft in case of database breach
1141
1155
  */
1142
1156
  storeRefreshToken(userId: string, refreshToken: string, tokenFamily: string): Promise<void>;
1157
+ /**
1158
+ * Rotate only the family row observed by refreshTokens(). This conditional
1159
+ * update fails closed if logout deleted the family or another refresh won.
1160
+ */
1161
+ private rotateTokens;
1143
1162
  /**
1144
1163
  * Refresh tokens with secure token comparison
1145
1164
  * Compares provided token with hashed version in database
package/dist/index.js CHANGED
@@ -1639,6 +1639,19 @@ var TokenRepository = class TokenRepository2 {
1639
1639
  }
1640
1640
  }).returning();
1641
1641
  }
1642
+ /**
1643
+ * Rotate an existing refresh-token family with compare-and-swap semantics.
1644
+ * This can never insert a family deleted by a concurrent logout.
1645
+ */
1646
+ async rotateRefreshToken(tokenData, expectedCurrentHash) {
1647
+ return await this.db.update(this.tokens).set({
1648
+ token: tokenData.token,
1649
+ expiresAt: tokenData.expiresAt,
1650
+ previousHash: tokenData.previousHash,
1651
+ previousValidUntil: tokenData.previousValidUntil,
1652
+ previousUsedAt: tokenData.previousUsedAt ?? null
1653
+ }).where(and(eq4(this.tokens.tokenFamily, tokenData.tokenFamily), eq4(this.tokens.userId, tokenData.userId), eq4(this.tokens.token, expectedCurrentHash))).returning();
1654
+ }
1642
1655
  /**
1643
1656
  * Claim the previous-token grace slot for a single family. Conditional on
1644
1657
  * BOTH the stored previousHash still matching the presented token AND
@@ -2022,8 +2035,7 @@ var TokenService = class TokenService2 {
2022
2035
  generateRefreshToken(data) {
2023
2036
  return this.signRefreshToken({ userId: data.userId, tokenFamily: data.tokenFamily ?? nanoid4(16) }).token;
2024
2037
  }
2025
- async generateTokens(userId, tokenFamily) {
2026
- const family = tokenFamily ?? nanoid4(16);
2038
+ async createTokenPair(userId, family) {
2027
2039
  const { roleName, permissions } = await this.tokenRepository.getRoleAndPermissions(userId);
2028
2040
  const accessTokenData = {
2029
2041
  userId,
@@ -2033,7 +2045,6 @@ var TokenService = class TokenService2 {
2033
2045
  };
2034
2046
  const access = await this.signAccessToken(accessTokenData);
2035
2047
  const refresh = this.signRefreshToken({ userId, tokenFamily: family });
2036
- await this.storeRefreshToken(userId, refresh.token, family);
2037
2048
  return {
2038
2049
  userId,
2039
2050
  tokenFamily: family,
@@ -2046,6 +2057,12 @@ var TokenService = class TokenService2 {
2046
2057
  refreshTokenExpiresAt: refresh.expiresAt
2047
2058
  };
2048
2059
  }
2060
+ async generateTokens(userId, tokenFamily) {
2061
+ const family = tokenFamily ?? nanoid4(16);
2062
+ const generated = await this.createTokenPair(userId, family);
2063
+ await this.storeRefreshToken(userId, generated.refreshToken, family);
2064
+ return generated;
2065
+ }
2049
2066
  // ============ TOKEN BLACKLIST (Cache) ============
2050
2067
  /**
2051
2068
  * Blacklist an access token by its jti
@@ -2100,6 +2117,27 @@ var TokenService = class TokenService2 {
2100
2117
  previousUsedAt: null
2101
2118
  });
2102
2119
  }
2120
+ /**
2121
+ * Rotate only the family row observed by refreshTokens(). This conditional
2122
+ * update fails closed if logout deleted the family or another refresh won.
2123
+ */
2124
+ async rotateTokens(userId, tokenFamily, expectedCurrentHash) {
2125
+ const generated = await this.createTokenPair(userId, tokenFamily);
2126
+ const expireInSecond = timestring2(this.config.jwt.refreshExpiresIn, "s");
2127
+ const rotated = await this.tokenRepository.rotateRefreshToken({
2128
+ userId,
2129
+ token: this.hashToken(generated.refreshToken),
2130
+ tokenFamily,
2131
+ expiresAt: new Date(Date.now() + expireInSecond * 1e3).toISOString(),
2132
+ previousHash: expectedCurrentHash,
2133
+ previousValidUntil: new Date(Date.now() + TokenService_1.PREVIOUS_GRACE_SECONDS * 1e3).toISOString(),
2134
+ previousUsedAt: null
2135
+ }, expectedCurrentHash);
2136
+ if (!rotated?.length) {
2137
+ Err7(this.t("errors.refreshTokenInvalid"), 401);
2138
+ }
2139
+ return generated;
2140
+ }
2103
2141
  /**
2104
2142
  * Refresh tokens with secure token comparison
2105
2143
  * Compares provided token with hashed version in database
@@ -2117,7 +2155,7 @@ var TokenService = class TokenService2 {
2117
2155
  const presentedHash = this.hashToken(refreshToken);
2118
2156
  await this.requireActiveRefreshUser(userId, tokenFamily);
2119
2157
  if (presentedHash === stored.token) {
2120
- return this.generateTokens(userId, tokenFamily);
2158
+ return this.rotateTokens(userId, tokenFamily, stored.token);
2121
2159
  }
2122
2160
  const canRecover = stored.previousHash && presentedHash === stored.previousHash && stored.previousValidUntil && new Date(stored.previousValidUntil).getTime() > Date.now() && !stored.previousUsedAt;
2123
2161
  if (canRecover) {
@@ -2125,7 +2163,7 @@ var TokenService = class TokenService2 {
2125
2163
  if (!claimed?.length) {
2126
2164
  Err7(this.t("errors.refreshTokenInvalid"), 401);
2127
2165
  }
2128
- return this.generateTokens(userId, tokenFamily);
2166
+ return this.rotateTokens(userId, tokenFamily, stored.token);
2129
2167
  }
2130
2168
  await this.revokeSuspectRefreshFamily(userId, tokenFamily);
2131
2169
  Err7(this.t("errors.refreshTokenInvalid"), 401);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [