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.
- package/dist/{NajmAuthClient-DqGucYXi.d.ts → NajmAuthClient-ZtXTIUSF.d.ts} +11 -0
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.js +100 -18
- package/dist/client/react/index.d.ts +1 -1
- package/dist/client/server/index.d.ts +1 -1
- package/dist/client/server/index.js +99 -17
- package/dist/index.d.ts +19 -0
- package/dist/index.js +43 -5
- package/package.json +1 -1
|
@@ -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;
|
package/dist/client/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { F as FetchClient, H as HydrateSession, N as NajmAuthClient, c as createAuthClient } from '../NajmAuthClient-
|
|
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
|
|
package/dist/client/index.js
CHANGED
|
@@ -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
|
-
|
|
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 &&
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
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
|
-
|
|
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-
|
|
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-
|
|
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
|
-
|
|
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 &&
|
|
666
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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);
|