najm-auth 3.1.1 → 3.1.2
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 +99 -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 +98 -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,13 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
403
463
|
this._hydrated = true;
|
|
404
464
|
this.resetRefreshFailures();
|
|
405
465
|
if (!session || !session.user) {
|
|
466
|
+
this.api.blockAuthenticatedRequests();
|
|
406
467
|
this.state = { ...INITIAL_STATE };
|
|
407
468
|
this.notify();
|
|
408
469
|
return;
|
|
409
470
|
}
|
|
471
|
+
this.refreshBlocked = false;
|
|
472
|
+
this.api.allowAuthenticatedRequests();
|
|
410
473
|
this.state = {
|
|
411
474
|
...this.state,
|
|
412
475
|
user: session.user,
|
|
@@ -460,6 +523,10 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
460
523
|
// Cleanup
|
|
461
524
|
// =========================================================================
|
|
462
525
|
destroy() {
|
|
526
|
+
this.authGeneration += 1;
|
|
527
|
+
this.refreshAbortController?.abort();
|
|
528
|
+
this.refreshAbortController = null;
|
|
529
|
+
this.api.blockAuthenticatedRequests();
|
|
463
530
|
this.clearRefreshTimer();
|
|
464
531
|
this.clearRefreshCircuitTimer();
|
|
465
532
|
this.tabSync?.destroy();
|
|
@@ -472,11 +539,13 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
472
539
|
// =========================================================================
|
|
473
540
|
// Internals
|
|
474
541
|
// =========================================================================
|
|
475
|
-
async _refreshWithCircuit() {
|
|
542
|
+
async _refreshWithCircuit(generation, signal) {
|
|
476
543
|
try {
|
|
477
|
-
await this._doRefresh();
|
|
544
|
+
const applied = await this._doRefresh(generation, signal);
|
|
545
|
+
if (!applied) return;
|
|
478
546
|
this.resetRefreshFailures();
|
|
479
547
|
} catch (err) {
|
|
548
|
+
if (generation !== this.authGeneration) return;
|
|
480
549
|
const shouldOpenCircuit = this.registerRefreshFailure(err);
|
|
481
550
|
if (shouldOpenCircuit) {
|
|
482
551
|
this.resetState();
|
|
@@ -489,14 +558,16 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
489
558
|
throw err;
|
|
490
559
|
}
|
|
491
560
|
}
|
|
492
|
-
async _doRefresh() {
|
|
561
|
+
async _doRefresh(generation, signal) {
|
|
493
562
|
const res = await this.api.post(
|
|
494
563
|
`${this.prefix}/refresh`,
|
|
495
|
-
{ skipAuth: true }
|
|
564
|
+
{ skipAuth: true, signal }
|
|
496
565
|
);
|
|
566
|
+
if (generation !== this.authGeneration) return false;
|
|
497
567
|
this.applyTokens(res.data);
|
|
498
568
|
this.tabSync?.broadcastSync(this.getSyncPayload());
|
|
499
569
|
this.emit("tokenRefresh", null);
|
|
570
|
+
return true;
|
|
500
571
|
}
|
|
501
572
|
async handleUnauthorized() {
|
|
502
573
|
try {
|
|
@@ -578,12 +649,22 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
578
649
|
handleTabMessage(msg) {
|
|
579
650
|
switch (msg.type) {
|
|
580
651
|
case "logout":
|
|
652
|
+
this.authGeneration += 1;
|
|
653
|
+
this.refreshBlocked = true;
|
|
654
|
+
this.refreshAbortController?.abort();
|
|
655
|
+
this.api.blockAuthenticatedRequests();
|
|
581
656
|
this.clearRefreshTimer();
|
|
582
657
|
this.state = { ...INITIAL_STATE };
|
|
583
658
|
this.notify();
|
|
584
659
|
this.emit("logout", null);
|
|
585
660
|
break;
|
|
586
661
|
case "sync":
|
|
662
|
+
if (msg.state.isAuthenticated) {
|
|
663
|
+
this.refreshBlocked = false;
|
|
664
|
+
this.api.allowAuthenticatedRequests();
|
|
665
|
+
} else {
|
|
666
|
+
this.api.blockAuthenticatedRequests();
|
|
667
|
+
}
|
|
587
668
|
this.state = {
|
|
588
669
|
...this.state,
|
|
589
670
|
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,13 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
1112
1172
|
this._hydrated = true;
|
|
1113
1173
|
this.resetRefreshFailures();
|
|
1114
1174
|
if (!session || !session.user) {
|
|
1175
|
+
this.api.blockAuthenticatedRequests();
|
|
1115
1176
|
this.state = { ...INITIAL_STATE };
|
|
1116
1177
|
this.notify();
|
|
1117
1178
|
return;
|
|
1118
1179
|
}
|
|
1180
|
+
this.refreshBlocked = false;
|
|
1181
|
+
this.api.allowAuthenticatedRequests();
|
|
1119
1182
|
this.state = {
|
|
1120
1183
|
...this.state,
|
|
1121
1184
|
user: session.user,
|
|
@@ -1169,6 +1232,10 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
1169
1232
|
// Cleanup
|
|
1170
1233
|
// =========================================================================
|
|
1171
1234
|
destroy() {
|
|
1235
|
+
this.authGeneration += 1;
|
|
1236
|
+
this.refreshAbortController?.abort();
|
|
1237
|
+
this.refreshAbortController = null;
|
|
1238
|
+
this.api.blockAuthenticatedRequests();
|
|
1172
1239
|
this.clearRefreshTimer();
|
|
1173
1240
|
this.clearRefreshCircuitTimer();
|
|
1174
1241
|
this.tabSync?.destroy();
|
|
@@ -1181,11 +1248,13 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
1181
1248
|
// =========================================================================
|
|
1182
1249
|
// Internals
|
|
1183
1250
|
// =========================================================================
|
|
1184
|
-
async _refreshWithCircuit() {
|
|
1251
|
+
async _refreshWithCircuit(generation, signal) {
|
|
1185
1252
|
try {
|
|
1186
|
-
await this._doRefresh();
|
|
1253
|
+
const applied = await this._doRefresh(generation, signal);
|
|
1254
|
+
if (!applied) return;
|
|
1187
1255
|
this.resetRefreshFailures();
|
|
1188
1256
|
} catch (err) {
|
|
1257
|
+
if (generation !== this.authGeneration) return;
|
|
1189
1258
|
const shouldOpenCircuit = this.registerRefreshFailure(err);
|
|
1190
1259
|
if (shouldOpenCircuit) {
|
|
1191
1260
|
this.resetState();
|
|
@@ -1198,14 +1267,16 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
1198
1267
|
throw err;
|
|
1199
1268
|
}
|
|
1200
1269
|
}
|
|
1201
|
-
async _doRefresh() {
|
|
1270
|
+
async _doRefresh(generation, signal) {
|
|
1202
1271
|
const res = await this.api.post(
|
|
1203
1272
|
`${this.prefix}/refresh`,
|
|
1204
|
-
{ skipAuth: true }
|
|
1273
|
+
{ skipAuth: true, signal }
|
|
1205
1274
|
);
|
|
1275
|
+
if (generation !== this.authGeneration) return false;
|
|
1206
1276
|
this.applyTokens(res.data);
|
|
1207
1277
|
this.tabSync?.broadcastSync(this.getSyncPayload());
|
|
1208
1278
|
this.emit("tokenRefresh", null);
|
|
1279
|
+
return true;
|
|
1209
1280
|
}
|
|
1210
1281
|
async handleUnauthorized() {
|
|
1211
1282
|
try {
|
|
@@ -1287,12 +1358,22 @@ var NajmAuthClient = class _NajmAuthClient {
|
|
|
1287
1358
|
handleTabMessage(msg) {
|
|
1288
1359
|
switch (msg.type) {
|
|
1289
1360
|
case "logout":
|
|
1361
|
+
this.authGeneration += 1;
|
|
1362
|
+
this.refreshBlocked = true;
|
|
1363
|
+
this.refreshAbortController?.abort();
|
|
1364
|
+
this.api.blockAuthenticatedRequests();
|
|
1290
1365
|
this.clearRefreshTimer();
|
|
1291
1366
|
this.state = { ...INITIAL_STATE };
|
|
1292
1367
|
this.notify();
|
|
1293
1368
|
this.emit("logout", null);
|
|
1294
1369
|
break;
|
|
1295
1370
|
case "sync":
|
|
1371
|
+
if (msg.state.isAuthenticated) {
|
|
1372
|
+
this.refreshBlocked = false;
|
|
1373
|
+
this.api.allowAuthenticatedRequests();
|
|
1374
|
+
} else {
|
|
1375
|
+
this.api.blockAuthenticatedRequests();
|
|
1376
|
+
}
|
|
1296
1377
|
this.state = {
|
|
1297
1378
|
...this.state,
|
|
1298
1379
|
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);
|