@gandalan/weblibs 2.0.15 → 2.0.16
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/JSDOC.md +9 -0
- package/README.md +18 -0
- package/api/authEvents.js +58 -0
- package/api/fluentApi.js +16 -2
- package/api/fluentAuthManager.js +626 -48
- package/index.d.ts +21 -1
- package/index.js +1 -0
- package/package.json +1 -1
- package/scripts/generate-dts.mjs +4 -1
package/JSDOC.md
CHANGED
|
@@ -213,6 +213,15 @@ Rule of thumb:
|
|
|
213
213
|
- runtime export alone is not enough
|
|
214
214
|
- consumers need the root declaration too
|
|
215
215
|
|
|
216
|
+
Root constants work the same way: export them from `index.js` and add the
|
|
217
|
+
declaration to `rootValueExportStatements` in `scripts/generate-dts.mjs`,
|
|
218
|
+
e.g. the auth event names from `api/authEvents.js`:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
export const AUTH_REFRESHED_EVENT: "idas-auth-refreshed";
|
|
222
|
+
export const AUTH_EXPIRED_EVENT: "idas-auth-expired";
|
|
223
|
+
```
|
|
224
|
+
|
|
216
225
|
|
|
217
226
|
### Add a public class for consumption
|
|
218
227
|
|
package/README.md
CHANGED
|
@@ -69,11 +69,29 @@ async function initializeApis() {
|
|
|
69
69
|
- Versucht bei vorhandenem Refresh-Token ein JWT zu erneuern
|
|
70
70
|
- Kann bei fehlender gueltiger Session auf Login umleiten und dabei werfen
|
|
71
71
|
|
|
72
|
+
### Token-Erneuerung und Auth-Ereignisse
|
|
73
|
+
- Das JWT wird vor jedem Request erneuert, wenn es weniger als 30 s gilt, und zusaetzlich proaktiv (nur bei sichtbarem Dokument; war es verborgen, beim naechsten `visibilitychange`)
|
|
74
|
+
- Der proaktive Zeitpunkt ergibt sich aus der Token-Laufzeit (`exp - iat`) ab Empfang, nicht aus der Client-Uhr: `max(Laufzeit - 60 s, Laufzeit / 2, 30 s)`; zwischen zwei proaktiven Refreshes liegen mindestens 30 s (auch bei falsch gehender Uhr oder sehr kurzlebigen JWTs keine Refresh-Schleife)
|
|
75
|
+
- Der gesamte Sitzungszustand (Tokens, `userInfo`, Timer, Listener, Ablauf-Flag) liegt in einer Closure; `token`, `refreshToken` und `userInfo` sind Accessoren darauf. Ein tiefer Proxy auf den Auth-Manager (z. B. Svelte 5 `$state`) teilt sich deshalb Sitzung, Timer und Ereignisse mit dem Original
|
|
76
|
+
- Refreshes laufen tab-uebergreifend serialisiert (`navigator.locks`, Name `idas-refresh`); vor dem Refresh wird `localStorage["idas-refresh-token"]` neu gelesen, damit ein von einem anderen Tab rotiertes Token uebernommen wird
|
|
77
|
+
- Gehoert das mit einem uebernommenen Token erneuerte JWT zu einem anderen Benutzer (`benutzerGuid`/`id`) oder Mandanten (`mandantGuid`), wird es nicht uebernommen: die Sitzung endet mit `reason: "identity-changed"` (neu laden bzw. neu anmelden)
|
|
78
|
+
- Lehnt der Server den Refresh ab (jeder 4xx ausser 408/429), wird genau einmal mit einem inzwischen geaenderten Token aus `localStorage` erneut versucht; danach gilt die Sitzung als abgelaufen: Tokens werden geleert, das abgelehnte Token wird aus `localStorage` entfernt, bis zum naechsten Login/Refresh gehen keine Refresh-Requests mehr raus
|
|
79
|
+
- Netzwerkfehler, Timeouts, 408/429 und 5xx behalten das Refresh-Token; `init()` wirft sie weiter, statt zur Anmeldung umzuleiten
|
|
80
|
+
- Ein 401 wird nur wiederholt, wenn der Refresh ein neues Token geliefert hat
|
|
81
|
+
- Ereignisse auf `window` (ohne Tokens), Namen auch als Konstanten exportiert:
|
|
82
|
+
- `AUTH_REFRESHED_EVENT` = `"idas-auth-refreshed"`, `detail: { expiresAt }` (ms) — nach jedem erfolgreichen Refresh/Login
|
|
83
|
+
- `AUTH_EXPIRED_EVENT` = `"idas-auth-expired"`, `detail: { reason }` (`"refresh-rejected"`, `"no-refresh-token"`, `"identity-changed"`) — einmal je Ablauf und Auth-Manager, wenn die Sitzung nicht mehr erneuert werden kann
|
|
84
|
+
|
|
85
|
+
```js
|
|
86
|
+
window.addEventListener("idas-auth-expired", () => authManager.redirectToLogin());
|
|
87
|
+
```
|
|
88
|
+
|
|
72
89
|
### Wichtige Exporte
|
|
73
90
|
- `fetchEnvConfig(env)`
|
|
74
91
|
- `fluentIdasAuthManager(appToken, authBaseUrl)`
|
|
75
92
|
- `fluentApi(baseUrl, authManager, serviceName)`
|
|
76
93
|
- `idasFluentApi(baseUrl, authManager, serviceName)`
|
|
94
|
+
- `AUTH_REFRESHED_EVENT`, `AUTH_EXPIRED_EVENT`
|
|
77
95
|
|
|
78
96
|
### Wichtige Hinweise
|
|
79
97
|
- `idasFluentApi(...)` fuer IDAS-Business-Routinen verwenden
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth lifecycle events dispatched by the FluentAuthManager on `globalThis`
|
|
3
|
+
* (the `window` in a browser; no-op elsewhere).
|
|
4
|
+
*
|
|
5
|
+
* Listen with the plain string names, e.g.
|
|
6
|
+
* `window.addEventListener("idas-auth-expired", (e) => ...)`.
|
|
7
|
+
* The events never carry tokens.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Dispatched after every successful token refresh or login.
|
|
12
|
+
* `event.detail` is an {@link AuthRefreshedEventDetail}.
|
|
13
|
+
* @type {"idas-auth-refreshed"}
|
|
14
|
+
*/
|
|
15
|
+
export const AUTH_REFRESHED_EVENT = "idas-auth-refreshed";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Dispatched once when the session can definitely no longer be renewed
|
|
19
|
+
* (refresh rejected with a 4xx other than 408/429, also after re-reading
|
|
20
|
+
* localStorage; no refresh token; or a token from localStorage belongs to
|
|
21
|
+
* another user/mandant), exactly once per document and expiry — shared by all
|
|
22
|
+
* wrappers/proxies of an auth manager. Not dispatched again until a successful
|
|
23
|
+
* login or refresh happened. `event.detail` is an {@link AuthExpiredEventDetail}.
|
|
24
|
+
* @type {"idas-auth-expired"}
|
|
25
|
+
*/
|
|
26
|
+
export const AUTH_EXPIRED_EVENT = "idas-auth-expired";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {Object} AuthRefreshedEventDetail
|
|
30
|
+
* @property {number} expiresAt - Expiry of the new JWT (`exp` claim) in milliseconds since epoch.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @typedef {Object} AuthExpiredEventDetail
|
|
35
|
+
* @property {string} reason - Why the session ended, "refresh-rejected", "no-refresh-token" or "identity-changed".
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Dispatches an auth event on `globalThis` if it can receive DOM events.
|
|
40
|
+
* Errors in listeners never propagate into the auth flow.
|
|
41
|
+
*
|
|
42
|
+
* @private
|
|
43
|
+
* @param {string} name - Event name, one of the constants above.
|
|
44
|
+
* @param {AuthRefreshedEventDetail|AuthExpiredEventDetail} detail
|
|
45
|
+
* @returns {void}
|
|
46
|
+
*/
|
|
47
|
+
export function dispatchAuthEvent(name, detail) {
|
|
48
|
+
const target = typeof window !== "undefined" ? window : null;
|
|
49
|
+
if (!target || typeof target.dispatchEvent !== "function" || typeof CustomEvent !== "function") {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
target.dispatchEvent(new CustomEvent(name, { detail }));
|
|
55
|
+
} catch (e) {
|
|
56
|
+
console.error(`dispatching ${name} failed`, e);
|
|
57
|
+
}
|
|
58
|
+
}
|
package/api/fluentApi.js
CHANGED
|
@@ -131,7 +131,8 @@ export function createApi() {
|
|
|
131
131
|
/**
|
|
132
132
|
* Runs a request with authentication and retries it once if the server
|
|
133
133
|
* answered 401 Unauthorized: the cached token is discarded, a fresh one
|
|
134
|
-
* is obtained via the auth manager and the request is repeated
|
|
134
|
+
* is obtained via the auth manager and the request is repeated — but
|
|
135
|
+
* only if the refresh actually delivered a new token. This
|
|
135
136
|
* covers tokens that expired server-side (e.g. after hours of
|
|
136
137
|
* inactivity) as well as refresh races between parallel requests.
|
|
137
138
|
*
|
|
@@ -143,6 +144,7 @@ export function createApi() {
|
|
|
143
144
|
*/
|
|
144
145
|
async _executeRequest(auth, executeRequest) {
|
|
145
146
|
await this.preCheck(auth);
|
|
147
|
+
const usedToken = this.authManager?.token;
|
|
146
148
|
try {
|
|
147
149
|
return await executeRequest();
|
|
148
150
|
} catch (e) {
|
|
@@ -150,8 +152,20 @@ export function createApi() {
|
|
|
150
152
|
throw e;
|
|
151
153
|
}
|
|
152
154
|
|
|
153
|
-
|
|
155
|
+
// Only discard the token this request was sent with — a parallel
|
|
156
|
+
// request may already have replaced it with a fresh one.
|
|
157
|
+
if (this.authManager.token === usedToken) {
|
|
158
|
+
this.authManager.token = "";
|
|
159
|
+
}
|
|
154
160
|
await this.authManager.ensureAuthenticated();
|
|
161
|
+
|
|
162
|
+
// Retry only with a new token: never repeat the request without
|
|
163
|
+
// an Authorization header or with the token that just failed.
|
|
164
|
+
const freshToken = this.authManager.token;
|
|
165
|
+
if (!freshToken || freshToken === usedToken) {
|
|
166
|
+
throw e;
|
|
167
|
+
}
|
|
168
|
+
|
|
155
169
|
return await executeRequest();
|
|
156
170
|
}
|
|
157
171
|
},
|
package/api/fluentAuthManager.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jwtDecode } from "jwt-decode";
|
|
2
2
|
import validator from "validator";
|
|
3
3
|
import { popRefreshTokenFromUrl } from "./fluentAuthUtils";
|
|
4
|
+
import { AUTH_EXPIRED_EVENT, AUTH_REFRESHED_EVENT, dispatchAuthEvent } from "./authEvents";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Decoded JWT claims used by this auth manager.
|
|
@@ -14,17 +15,17 @@ import { popRefreshTokenFromUrl } from "./fluentAuthUtils";
|
|
|
14
15
|
* @typedef {Object} FluentAuthManager
|
|
15
16
|
* @property {string} appToken - The application token.
|
|
16
17
|
* @property {string} authUrl - The authentication URL.
|
|
17
|
-
* @property {string} token - The JWT token for authorization.
|
|
18
|
-
* @property {string} refreshToken - The refresh token.
|
|
19
|
-
* @property {JwtUserInfo} userInfo - Decoded JWT claims for role/right checks.
|
|
18
|
+
* @property {string} token - The JWT token for authorization (accessor, shared by every proxy/wrapper of this instance).
|
|
19
|
+
* @property {string} refreshToken - The refresh token (accessor, shared by every proxy/wrapper of this instance).
|
|
20
|
+
* @property {JwtUserInfo} userInfo - Decoded JWT claims for role/right checks (accessor, shared by every proxy/wrapper of this instance).
|
|
20
21
|
* @property {(appToken?: string) => FluentAuthManager|null} useAppToken - Sets the application token and returns the FluentApi object.
|
|
21
22
|
* @property {(url?: string) => FluentAuthManager} useBaseUrl - Sets the base URL for authentication and returns the FluentApi object.
|
|
22
23
|
* @property {(jwtToken?: string|null) => FluentAuthManager} useToken - Sets the JWT token and returns the FluentApi object. Only intended for usage with Service Tokens.
|
|
23
24
|
* @property {(storedRefreshToken?: string|null) => FluentAuthManager} useRefreshToken - Sets the refresh token and returns the FluentApi object.
|
|
24
25
|
* @property {() => Promise<void>} ensureAuthenticated - Ensures the user is authenticated before making a request.
|
|
25
26
|
* @property {() => Promise<void>} authenticate - Authenticates the user with username and password, or refreshes the token.
|
|
26
|
-
* @property {() => Promise<void>} _doAuthenticate - Performs the actual token refresh (single-flight worker behind authenticate).
|
|
27
|
-
* @property {Promise<void>|null} _authenticatePromise - In-flight authentication promise shared by concurrent callers.
|
|
27
|
+
* @property {(force?: boolean) => Promise<void>} _doAuthenticate - Performs the actual token refresh (single-flight worker behind authenticate).
|
|
28
|
+
* @property {Promise<void>|null} _authenticatePromise - In-flight authentication promise shared by concurrent callers (read-only).
|
|
28
29
|
* @property {() => Promise<FluentAuthManager>} init - Returns promise for authManager.
|
|
29
30
|
* @property {(username?: string, password?: string) => Promise<void>} login - Logs in with the provided credentials.
|
|
30
31
|
* @property {(refreshToken?: string) => Promise<string|null>} tryRefreshToken - Attempts to refresh the authentication token using the refresh token.
|
|
@@ -32,22 +33,78 @@ import { popRefreshTokenFromUrl } from "./fluentAuthUtils";
|
|
|
32
33
|
* @property {() => void} redirectToLogin - Redirects to the login page.
|
|
33
34
|
* @property {(code: string) => boolean} hasRight - Checks if the user has the specific right.
|
|
34
35
|
* @property {(code: string) => boolean} hasRole - Checks if the user has the specific role.
|
|
36
|
+
* @property {(force?: boolean, notifyExpired?: boolean) => Promise<void>} [_runRefresh] - Single-flight entry for (forced) refreshes; each caller decides about AUTH_EXPIRED_EVENT itself.
|
|
37
|
+
* @property {(force: boolean) => Promise<void>} [_refreshLocked] - Refresh step executed while holding the cross-tab lock.
|
|
38
|
+
* @property {() => string|null} [_currentRefreshToken] - Picks the refresh token to send (own or newer one from localStorage).
|
|
39
|
+
* @property {(refreshToken: string) => Promise<{token: string|null, status: number}>} [_requestRefresh] - Calls LoginJwt/Refresh and reports the HTTP status.
|
|
40
|
+
* @property {(reason: string, rejectedRefreshToken?: string|null) => void} [_expire] - Ends the session for good (clears tokens, stops the timer).
|
|
41
|
+
* @property {(reason: string) => void} [_notifyExpired] - Dispatches AUTH_EXPIRED_EVENT once per expiry.
|
|
42
|
+
* @property {(delay?: number) => void} [_scheduleProactiveRefresh] - (Re)starts the proactive refresh timer for the current JWT.
|
|
43
|
+
* @property {() => void} [_stopProactiveRefresh] - Stops the proactive refresh timer.
|
|
44
|
+
* @property {() => void} [_proactiveRefresh] - Timer callback: refreshes ahead of expiry if the document is visible.
|
|
45
|
+
* @property {() => void} [_installBrowserListeners] - Registers storage/visibilitychange listeners once.
|
|
35
46
|
*/
|
|
36
47
|
|
|
37
48
|
/**
|
|
38
49
|
* Creates a new FluentAuthManager
|
|
39
|
-
*
|
|
50
|
+
*
|
|
51
|
+
* All session state (tokens, user info, refresh timer, single-flight
|
|
52
|
+
* promise, expiry flag, …) lives in this closure and not as plain data
|
|
53
|
+
* properties on the returned object. `token`, `refreshToken` and `userInfo`
|
|
54
|
+
* are accessors onto that state. This way every wrapper of the instance —
|
|
55
|
+
* in particular a deep reactive proxy such as Svelte 5 `$state`, whose
|
|
56
|
+
* set trap would otherwise keep writes to data properties inside the proxy —
|
|
57
|
+
* shares one session: one refresh chain, one timer, one listener set and one
|
|
58
|
+
* AUTH_EXPIRED_EVENT per expiry.
|
|
59
|
+
*
|
|
40
60
|
* @export
|
|
41
61
|
* @returns {FluentAuthManager}
|
|
42
62
|
*/
|
|
43
63
|
export function createAuthManager() {
|
|
44
|
-
|
|
45
|
-
appToken: "",
|
|
46
|
-
authUrl: "",
|
|
64
|
+
const session = {
|
|
47
65
|
token: "",
|
|
48
66
|
refreshToken: "",
|
|
67
|
+
/** @type {JwtUserInfo} */
|
|
49
68
|
userInfo: {},
|
|
50
|
-
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const internal = {
|
|
72
|
+
/** @type {Promise<void>|null} */
|
|
73
|
+
authenticatePromise: null,
|
|
74
|
+
/** AUTH_EXPIRED_EVENT was dispatched and no login/refresh succeeded since */
|
|
75
|
+
expired: false,
|
|
76
|
+
/** a login/refresh succeeded at least once */
|
|
77
|
+
sessionStarted: false,
|
|
78
|
+
/** identity (user + mandant) of the current session, null if unknown */
|
|
79
|
+
identity: /** @type {string|null} */ (null),
|
|
80
|
+
/** refresh tokens the server rejected; never sent again */
|
|
81
|
+
rejectedRefreshTokens: /** @type {Set<string>} */ (new Set()),
|
|
82
|
+
/** last value of localStorage["idas-refresh-token"] written or adopted */
|
|
83
|
+
storedRefreshTokenSeen: /** @type {string|null} */ (null),
|
|
84
|
+
/** @type {ReturnType<typeof setTimeout>|null} */
|
|
85
|
+
refreshTimer: null,
|
|
86
|
+
/** JWT the proactive timer was scheduled for */
|
|
87
|
+
scheduledToken: "",
|
|
88
|
+
/** wall-clock time (ms) from which the proactive refresh is due, 0 = none */
|
|
89
|
+
refreshDueAt: 0,
|
|
90
|
+
/** wall-clock time (ms) of the last proactive refresh attempt */
|
|
91
|
+
lastProactiveRefreshAt: 0,
|
|
92
|
+
refreshDueWhileHidden: false,
|
|
93
|
+
browserListenersInstalled: false,
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** @type {FluentAuthManager} */
|
|
97
|
+
const self = {
|
|
98
|
+
appToken: "",
|
|
99
|
+
authUrl: "",
|
|
100
|
+
|
|
101
|
+
get token() { return session.token; },
|
|
102
|
+
set token(value) { session.token = value; },
|
|
103
|
+
get refreshToken() { return session.refreshToken; },
|
|
104
|
+
set refreshToken(value) { session.refreshToken = value; },
|
|
105
|
+
get userInfo() { return session.userInfo; },
|
|
106
|
+
set userInfo(value) { session.userInfo = value; },
|
|
107
|
+
get _authenticatePromise() { return internal.authenticatePromise; },
|
|
51
108
|
|
|
52
109
|
/**
|
|
53
110
|
* app token to use for authentication
|
|
@@ -82,7 +139,7 @@ export function createAuthManager() {
|
|
|
82
139
|
* @return {FluentAuthManager}
|
|
83
140
|
*/
|
|
84
141
|
useToken(jwtToken = "") {
|
|
85
|
-
|
|
142
|
+
session.token = jwtToken;
|
|
86
143
|
return this;
|
|
87
144
|
},
|
|
88
145
|
|
|
@@ -93,7 +150,7 @@ export function createAuthManager() {
|
|
|
93
150
|
* @return {FluentAuthManager}
|
|
94
151
|
*/
|
|
95
152
|
useRefreshToken(storedRefreshToken = "") {
|
|
96
|
-
|
|
153
|
+
session.refreshToken = storedRefreshToken;
|
|
97
154
|
return this;
|
|
98
155
|
},
|
|
99
156
|
|
|
@@ -104,7 +161,7 @@ export function createAuthManager() {
|
|
|
104
161
|
* @private
|
|
105
162
|
*/
|
|
106
163
|
async ensureAuthenticated() {
|
|
107
|
-
if (
|
|
164
|
+
if (session.token && isTokenValid(session.token)) {
|
|
108
165
|
return;
|
|
109
166
|
}
|
|
110
167
|
|
|
@@ -123,20 +180,43 @@ export function createAuthManager() {
|
|
|
123
180
|
* Single-flight: concurrent callers (e.g. parallel requests firing after
|
|
124
181
|
* the token expired) share one refresh instead of racing each other with
|
|
125
182
|
* the same refresh token — with token rotation only the first refresh
|
|
126
|
-
* would succeed and all others would end up with a 401.
|
|
183
|
+
* would succeed and all others would end up with a 401. Across tabs and
|
|
184
|
+
* auth manager instances the refresh is additionally serialized with the
|
|
185
|
+
* Web Locks API (see _doAuthenticate).
|
|
127
186
|
*
|
|
128
187
|
* @throws {Error} if JWT token and refreshToken are not set or both are invalid
|
|
129
188
|
* @return {Promise<void>}
|
|
130
189
|
*/
|
|
131
190
|
async authenticate() { // benutzt bei existierendem JWT oder RefreshToken, wenn keins vorhanden ERROR
|
|
132
|
-
if (
|
|
191
|
+
if (session.token && isTokenValid(session.token)) {
|
|
133
192
|
return;
|
|
134
193
|
}
|
|
135
194
|
|
|
136
|
-
|
|
137
|
-
|
|
195
|
+
return this._runRefresh();
|
|
196
|
+
},
|
|
138
197
|
|
|
139
|
-
|
|
198
|
+
/**
|
|
199
|
+
* Single-flight entry point for refreshes (regular and proactive).
|
|
200
|
+
* The shared refresh never dispatches AUTH_EXPIRED_EVENT itself; every
|
|
201
|
+
* caller decides on its own after the shared promise settled, so a
|
|
202
|
+
* caller that does not want the event (init) cannot suppress it for a
|
|
203
|
+
* parallel request.
|
|
204
|
+
*
|
|
205
|
+
* @private
|
|
206
|
+
* @param {boolean} [force=false] - refresh even if the JWT is still valid (proactive refresh)
|
|
207
|
+
* @param {boolean} [notifyExpired=true] - dispatch AUTH_EXPIRED_EVENT if the session ended
|
|
208
|
+
* @return {Promise<void>}
|
|
209
|
+
*/
|
|
210
|
+
_runRefresh(force = false, notifyExpired = true) {
|
|
211
|
+
internal.authenticatePromise ??= this._doAuthenticate(force)
|
|
212
|
+
.finally(() => { internal.authenticatePromise = null; });
|
|
213
|
+
|
|
214
|
+
return internal.authenticatePromise.catch((e) => {
|
|
215
|
+
if (notifyExpired && e?.sessionEnded) {
|
|
216
|
+
this._notifyExpired(e.code);
|
|
217
|
+
}
|
|
218
|
+
throw e;
|
|
219
|
+
});
|
|
140
220
|
},
|
|
141
221
|
|
|
142
222
|
/**
|
|
@@ -144,33 +224,118 @@ export function createAuthManager() {
|
|
|
144
224
|
* always go through authenticate(), which ensures only one refresh
|
|
145
225
|
* runs at a time.
|
|
146
226
|
*
|
|
227
|
+
* - Serialized across tabs via `navigator.locks` ("idas-refresh") where
|
|
228
|
+
* available, otherwise only the in-instance single-flight applies.
|
|
229
|
+
* - Once the session expired for good, no further refresh request is
|
|
230
|
+
* sent — unless localStorage holds a refresh token that was not
|
|
231
|
+
* rejected yet (e.g. login in another tab).
|
|
232
|
+
*
|
|
147
233
|
* @private
|
|
148
|
-
* @
|
|
234
|
+
* @param {boolean} [force=false] - refresh even if the JWT is still valid
|
|
235
|
+
* @throws {Error} if JWT token and refreshToken are not set or both are invalid;
|
|
236
|
+
* `code` names the reason, `sessionEnded` is true if a running session ended
|
|
149
237
|
* @return {Promise<void>}
|
|
150
238
|
*/
|
|
151
|
-
async _doAuthenticate() {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (this.token && isTokenValid(this.token)) {
|
|
239
|
+
async _doAuthenticate(force = false) {
|
|
240
|
+
if (!force && session.token && isTokenValid(session.token)) {
|
|
155
241
|
return;
|
|
156
242
|
}
|
|
157
243
|
|
|
158
|
-
|
|
159
|
-
|
|
244
|
+
console.log("authenticating:", session.token ? `token set, exp: ${Math.round(((getTokenExpiresAt(session.token) ?? 0) - Date.now()) / 1000)}s` : "no token", session.refreshToken ? "refresh token set" : "no refresh token");
|
|
245
|
+
|
|
246
|
+
if (session.token && !session.refreshToken) {
|
|
247
|
+
session.refreshToken = tryGetRefreshToken(session.token) ?? "";
|
|
160
248
|
}
|
|
161
249
|
|
|
162
|
-
if (!this.
|
|
163
|
-
|
|
250
|
+
if (!this._currentRefreshToken()) {
|
|
251
|
+
const sessionEnded = Boolean(session.token) || internal.sessionStarted;
|
|
252
|
+
if (sessionEnded) {
|
|
253
|
+
this._expire("no-refresh-token");
|
|
254
|
+
}
|
|
255
|
+
throw authError("no-refresh-token", sessionEnded);
|
|
164
256
|
}
|
|
165
257
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
258
|
+
await withRefreshLock(() => this._refreshLocked(force));
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Refresh step running inside the cross-tab lock. Re-reads
|
|
263
|
+
* localStorage first: if another tab/instance rotated the refresh
|
|
264
|
+
* token meanwhile, its token is used instead of our (consumed) one.
|
|
265
|
+
* A rejected refresh (any 4xx except 408/429) is retried exactly once
|
|
266
|
+
* if localStorage then holds a different token; otherwise the session
|
|
267
|
+
* expires. Other failures (network, timeout, 408/429, 5xx) keep the
|
|
268
|
+
* refresh token for a later try.
|
|
269
|
+
*
|
|
270
|
+
* A token obtained with a refresh token taken over from localStorage
|
|
271
|
+
* may belong to another user or mandant (login in another tab). It is
|
|
272
|
+
* only adopted if the identity matches the running session; otherwise
|
|
273
|
+
* the session ends with "identity-changed" (the new refresh token is
|
|
274
|
+
* left in localStorage for the tab it belongs to).
|
|
275
|
+
*
|
|
276
|
+
* @private
|
|
277
|
+
* @param {boolean} force
|
|
278
|
+
* @return {Promise<void>}
|
|
279
|
+
*/
|
|
280
|
+
async _refreshLocked(force) {
|
|
281
|
+
if (!force && session.token && isTokenValid(session.token)) {
|
|
282
|
+
return;
|
|
173
283
|
}
|
|
284
|
+
|
|
285
|
+
let candidate = this._currentRefreshToken();
|
|
286
|
+
let lastRejected = null;
|
|
287
|
+
for (let attempt = 0; candidate && attempt < 2; attempt++) {
|
|
288
|
+
const { token, status } = await this._requestRefresh(candidate);
|
|
289
|
+
if (token) {
|
|
290
|
+
if (internal.identity && getIdentity(token) !== internal.identity) {
|
|
291
|
+
const newRefreshToken = tryGetRefreshToken(token);
|
|
292
|
+
if (newRefreshToken) {
|
|
293
|
+
writeStoredRefreshToken(newRefreshToken);
|
|
294
|
+
internal.storedRefreshTokenSeen = newRefreshToken;
|
|
295
|
+
}
|
|
296
|
+
this._expire("identity-changed");
|
|
297
|
+
throw authError("identity-changed", true);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
this.updateUserSession(token);
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (!isRejectionStatus(status)) {
|
|
305
|
+
throw authError("refresh-failed", false, `token refresh failed: ${status}`, status);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
internal.rejectedRefreshTokens.add(candidate);
|
|
309
|
+
lastRejected = candidate;
|
|
310
|
+
// another tab/instance may have rotated the token in the meantime
|
|
311
|
+
const stored = readStoredRefreshToken();
|
|
312
|
+
candidate = stored && !internal.rejectedRefreshTokens.has(stored) ? stored : null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// a rejected refresh token is dead for good, even without a prior session
|
|
316
|
+
const sessionEnded = Boolean(lastRejected) || Boolean(session.token) || internal.sessionStarted;
|
|
317
|
+
this._expire("refresh-rejected", lastRejected);
|
|
318
|
+
throw authError("refresh-rejected", sessionEnded);
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Picks the refresh token to send: a token in localStorage that changed
|
|
323
|
+
* since this instance last wrote/adopted it (rotated by another tab or
|
|
324
|
+
* instance, or a new login) wins over the own one. Rejected tokens are
|
|
325
|
+
* never returned.
|
|
326
|
+
*
|
|
327
|
+
* @private
|
|
328
|
+
* @return {string|null}
|
|
329
|
+
*/
|
|
330
|
+
_currentRefreshToken() {
|
|
331
|
+
const rejected = internal.rejectedRefreshTokens;
|
|
332
|
+
const stored = readStoredRefreshToken();
|
|
333
|
+
if (stored && stored !== session.refreshToken && stored !== internal.storedRefreshTokenSeen && !rejected.has(stored)) {
|
|
334
|
+
session.refreshToken = stored;
|
|
335
|
+
internal.storedRefreshTokenSeen = stored;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return session.refreshToken && !rejected.has(session.refreshToken) ? session.refreshToken : null;
|
|
174
339
|
},
|
|
175
340
|
|
|
176
341
|
/**
|
|
@@ -182,23 +347,38 @@ export function createAuthManager() {
|
|
|
182
347
|
* Side effect if refreshToken is not set: tries to get the refreshToken from the URL or localStorage.
|
|
183
348
|
*
|
|
184
349
|
* @async
|
|
350
|
+
* @throws {Error} network/server errors of the refresh (no redirect, the session may still be valid)
|
|
185
351
|
* @return {Promise<FluentAuthManager>} the FluentAuthManager
|
|
186
352
|
*/
|
|
187
353
|
async init() {
|
|
188
|
-
|
|
189
|
-
|
|
354
|
+
internal.storedRefreshTokenSeen = readStoredRefreshToken();
|
|
355
|
+
if (!session.refreshToken) {
|
|
356
|
+
session.refreshToken = popRefreshTokenFromUrl() || internal.storedRefreshTokenSeen;
|
|
190
357
|
}
|
|
191
358
|
|
|
192
|
-
|
|
193
|
-
|
|
359
|
+
let refreshed = false;
|
|
360
|
+
if (!session.token && session.refreshToken) {
|
|
361
|
+
try {
|
|
362
|
+
// no AUTH_EXPIRED_EVENT from this call: init redirects to login itself
|
|
363
|
+
await this._runRefresh(false, false);
|
|
364
|
+
refreshed = true;
|
|
365
|
+
} catch (e) {
|
|
366
|
+
// only a rejected/missing token leads to the login page;
|
|
367
|
+
// network errors and 5xx must not log the user out
|
|
368
|
+
if (!e?.code || !SESSION_END_CODES.includes(e.code)) {
|
|
369
|
+
throw e;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
194
372
|
}
|
|
195
373
|
|
|
196
|
-
if (
|
|
197
|
-
|
|
374
|
+
if (session.token && isTokenValid(session.token)) {
|
|
375
|
+
if (!refreshed) {
|
|
376
|
+
this.updateUserSession(session.token);
|
|
377
|
+
}
|
|
198
378
|
return this;
|
|
199
379
|
}
|
|
200
380
|
|
|
201
|
-
if (!isTokenValid(
|
|
381
|
+
if (!isTokenValid(session.token)) {
|
|
202
382
|
this.redirectToLogin();
|
|
203
383
|
throw "Redirect to login...";
|
|
204
384
|
}
|
|
@@ -232,14 +412,29 @@ export function createAuthManager() {
|
|
|
232
412
|
* @returns {Promise<string|null>}
|
|
233
413
|
*/
|
|
234
414
|
async tryRefreshToken(refreshToken = "") {
|
|
415
|
+
return (await this._requestRefresh(refreshToken)).token;
|
|
416
|
+
},
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Calls LoginJwt/Refresh. Throws on network errors, otherwise returns
|
|
420
|
+
* the new JWT (or null) together with the HTTP status.
|
|
421
|
+
*
|
|
422
|
+
* @async
|
|
423
|
+
* @private
|
|
424
|
+
* @param {string} refreshToken
|
|
425
|
+
* @returns {Promise<{token: string|null, status: number}>}
|
|
426
|
+
*/
|
|
427
|
+
async _requestRefresh(refreshToken) {
|
|
235
428
|
const payload = { "Token": refreshToken };
|
|
236
429
|
const res = await fetch(`${this.authUrl}LoginJwt/Refresh`,
|
|
237
430
|
{
|
|
238
431
|
method: "PUT",
|
|
239
432
|
body: JSON.stringify(payload),
|
|
240
433
|
headers: { "Content-Type": "application/json" },
|
|
434
|
+
// never hold the cross-tab refresh lock forever
|
|
435
|
+
...(typeof AbortSignal !== "undefined" && typeof AbortSignal.timeout === "function" && { signal: AbortSignal.timeout(REFRESH_REQUEST_TIMEOUT_MS) }),
|
|
241
436
|
});
|
|
242
|
-
return res.ok ? await res.json() : null;
|
|
437
|
+
return { token: res.ok ? await res.json() : null, status: res.status };
|
|
243
438
|
},
|
|
244
439
|
|
|
245
440
|
/**
|
|
@@ -248,7 +443,7 @@ export function createAuthManager() {
|
|
|
248
443
|
* @returns {boolean}
|
|
249
444
|
*/
|
|
250
445
|
hasRight(code) {
|
|
251
|
-
return (
|
|
446
|
+
return (session.userInfo?.rights || []).includes(code);
|
|
252
447
|
},
|
|
253
448
|
|
|
254
449
|
/**
|
|
@@ -257,22 +452,205 @@ export function createAuthManager() {
|
|
|
257
452
|
* @returns {boolean}
|
|
258
453
|
*/
|
|
259
454
|
hasRole(code) {
|
|
260
|
-
return (
|
|
455
|
+
return (session.userInfo?.role || []).includes(code);
|
|
261
456
|
},
|
|
262
457
|
|
|
263
458
|
/**
|
|
264
459
|
* update the user session with the new token
|
|
460
|
+
* - stores the refresh token in localStorage
|
|
461
|
+
* - remembers the session identity (user + mandant)
|
|
462
|
+
* - (re)starts the proactive refresh timer
|
|
463
|
+
* - dispatches AUTH_REFRESHED_EVENT
|
|
265
464
|
* @private
|
|
266
465
|
* @param {string} token
|
|
267
466
|
* @returns {void}
|
|
268
467
|
*/
|
|
269
468
|
updateUserSession(token) {
|
|
270
469
|
if (token) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
470
|
+
session.token = token;
|
|
471
|
+
session.refreshToken = getRefreshToken(token);
|
|
472
|
+
session.userInfo = jwtDecode(token);
|
|
473
|
+
writeStoredRefreshToken(session.refreshToken);
|
|
474
|
+
internal.storedRefreshTokenSeen = session.refreshToken;
|
|
475
|
+
internal.identity = getIdentity(token);
|
|
476
|
+
internal.expired = false;
|
|
477
|
+
internal.sessionStarted = true;
|
|
478
|
+
internal.rejectedRefreshTokens.clear();
|
|
479
|
+
this._installBrowserListeners();
|
|
480
|
+
this._scheduleProactiveRefresh();
|
|
481
|
+
dispatchAuthEvent(AUTH_REFRESHED_EVENT, { expiresAt: getTokenExpiresAt(token) ?? 0 });
|
|
482
|
+
}
|
|
483
|
+
},
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* End the session for good: clear tokens and stop the proactive timer.
|
|
487
|
+
* A rejected refresh token is also removed from localStorage (if it is
|
|
488
|
+
* still the stored one), so other tabs/instances and the next page
|
|
489
|
+
* load do not send it again. AUTH_EXPIRED_EVENT is dispatched by the
|
|
490
|
+
* callers via _notifyExpired.
|
|
491
|
+
* @private
|
|
492
|
+
* @param {string} reason
|
|
493
|
+
* @param {string|null} [rejectedRefreshToken=null]
|
|
494
|
+
* @returns {void}
|
|
495
|
+
*/
|
|
496
|
+
_expire(reason, rejectedRefreshToken = null) {
|
|
497
|
+
this._stopProactiveRefresh();
|
|
498
|
+
session.token = "";
|
|
499
|
+
session.refreshToken = "";
|
|
500
|
+
if (rejectedRefreshToken && readStoredRefreshToken() === rejectedRefreshToken) {
|
|
501
|
+
removeStoredRefreshToken();
|
|
502
|
+
internal.storedRefreshTokenSeen = null;
|
|
503
|
+
}
|
|
504
|
+
console.warn("session ended:", reason);
|
|
505
|
+
},
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Dispatch AUTH_EXPIRED_EVENT once per expiry (until the next
|
|
509
|
+
* successful login/refresh).
|
|
510
|
+
* @private
|
|
511
|
+
* @param {string} reason
|
|
512
|
+
* @returns {void}
|
|
513
|
+
*/
|
|
514
|
+
_notifyExpired(reason) {
|
|
515
|
+
if (internal.expired) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
internal.expired = true;
|
|
520
|
+
console.warn("session expired:", reason);
|
|
521
|
+
dispatchAuthEvent(AUTH_EXPIRED_EVENT, { reason });
|
|
522
|
+
},
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* (Re)start the proactive refresh timer. The due time is derived from
|
|
526
|
+
* the token lifetime (`exp - iat`) measured from receipt, not from the
|
|
527
|
+
* wall clock, so a skewed client clock or a very short-lived JWT can
|
|
528
|
+
* never lead to back-to-back refreshes:
|
|
529
|
+
* `delay = max(lifetime - PROACTIVE_RENEWAL, lifetime / 2, MIN_PROACTIVE_DELAY)`.
|
|
530
|
+
* Only one timer per instance (shared by all its wrappers); disabled
|
|
531
|
+
* outside the browser.
|
|
532
|
+
* @private
|
|
533
|
+
* @param {number} [delay] - explicit delay (ms), used to resume a capped/deferred timer
|
|
534
|
+
* @returns {void}
|
|
535
|
+
*/
|
|
536
|
+
_scheduleProactiveRefresh(delay) {
|
|
537
|
+
if (internal.refreshTimer) {
|
|
538
|
+
clearTimeout(internal.refreshTimer);
|
|
539
|
+
internal.refreshTimer = null;
|
|
540
|
+
}
|
|
541
|
+
if (!hasBrowserContext() || !session.token) {
|
|
542
|
+
internal.refreshDueAt = 0;
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
if (delay === undefined || internal.scheduledToken !== session.token) {
|
|
547
|
+
const lifetime = getTokenLifetime(session.token);
|
|
548
|
+
if (!lifetime) {
|
|
549
|
+
internal.refreshDueAt = 0;
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
internal.scheduledToken = session.token;
|
|
554
|
+
internal.refreshDueAt = Date.now() + Math.max(lifetime - PROACTIVE_RENEWAL * 1000, lifetime / 2, MIN_PROACTIVE_DELAY_MS);
|
|
555
|
+
delay = internal.refreshDueAt - Date.now();
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
internal.refreshTimer = setTimeout(() => {
|
|
559
|
+
internal.refreshTimer = null;
|
|
560
|
+
self._proactiveRefresh();
|
|
561
|
+
}, Math.min(Math.max(0, delay), MAX_TIMEOUT_MS));
|
|
562
|
+
},
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* stop the proactive refresh timer
|
|
566
|
+
* @private
|
|
567
|
+
* @returns {void}
|
|
568
|
+
*/
|
|
569
|
+
_stopProactiveRefresh() {
|
|
570
|
+
if (internal.refreshTimer) {
|
|
571
|
+
clearTimeout(internal.refreshTimer);
|
|
572
|
+
internal.refreshTimer = null;
|
|
573
|
+
}
|
|
574
|
+
internal.refreshDueAt = 0;
|
|
575
|
+
internal.scheduledToken = "";
|
|
576
|
+
internal.refreshDueWhileHidden = false;
|
|
577
|
+
},
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Timer callback: refresh ahead of expiry, but only while the document
|
|
581
|
+
* is visible. A hidden document defers to the next visibilitychange.
|
|
582
|
+
* Does nothing after logout (tokens cleared) or expiry. Two proactive
|
|
583
|
+
* refreshes are at least MIN_PROACTIVE_INTERVAL_MS apart.
|
|
584
|
+
* @private
|
|
585
|
+
* @returns {void}
|
|
586
|
+
*/
|
|
587
|
+
_proactiveRefresh() {
|
|
588
|
+
if (!hasBrowserContext() || !session.token || !session.refreshToken || internal.expired) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (internal.scheduledToken !== session.token) {
|
|
593
|
+
// token replaced meanwhile (e.g. useToken): schedule for the new one
|
|
594
|
+
self._scheduleProactiveRefresh();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const now = Date.now();
|
|
599
|
+
if (internal.refreshDueAt > now) {
|
|
600
|
+
// not due yet (capped timeout)
|
|
601
|
+
self._scheduleProactiveRefresh(internal.refreshDueAt - now);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const pause = internal.lastProactiveRefreshAt + MIN_PROACTIVE_INTERVAL_MS - now;
|
|
606
|
+
if (pause > 0) {
|
|
607
|
+
self._scheduleProactiveRefresh(pause);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (document.visibilityState !== "visible") {
|
|
612
|
+
internal.refreshDueWhileHidden = true;
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
internal.refreshDueWhileHidden = false;
|
|
617
|
+
internal.lastProactiveRefreshAt = now;
|
|
618
|
+
self._runRefresh(true).catch((e) => console.error("proactive token refresh failed", e));
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Register (once per instance, shared by all its wrappers) the browser listeners:
|
|
623
|
+
* - `storage`: adopt a refresh token rotated by another tab (the JWT
|
|
624
|
+
* is renewed with it on the next request / proactive refresh; a
|
|
625
|
+
* different identity is detected there)
|
|
626
|
+
* - `visibilitychange`: run a proactive refresh that was due while hidden
|
|
627
|
+
* @private
|
|
628
|
+
* @returns {void}
|
|
629
|
+
*/
|
|
630
|
+
_installBrowserListeners() {
|
|
631
|
+
if (internal.browserListenersInstalled || !hasBrowserContext()) {
|
|
632
|
+
return;
|
|
275
633
|
}
|
|
634
|
+
|
|
635
|
+
internal.browserListenersInstalled = true;
|
|
636
|
+
window.addEventListener("storage", (e) => {
|
|
637
|
+
if (e.key !== REFRESH_TOKEN_STORAGE_KEY || !e.newValue || e.newValue === session.refreshToken || internal.rejectedRefreshTokens.has(e.newValue)) {
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
session.refreshToken = e.newValue;
|
|
642
|
+
internal.storedRefreshTokenSeen = e.newValue;
|
|
643
|
+
});
|
|
644
|
+
document.addEventListener("visibilitychange", () => {
|
|
645
|
+
if (document.visibilityState !== "visible") {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
const due = internal.refreshDueAt > 0 && internal.refreshDueAt <= Date.now();
|
|
650
|
+
if (internal.refreshDueWhileHidden || due) {
|
|
651
|
+
self._proactiveRefresh();
|
|
652
|
+
}
|
|
653
|
+
});
|
|
276
654
|
},
|
|
277
655
|
|
|
278
656
|
/**
|
|
@@ -293,6 +671,8 @@ export function createAuthManager() {
|
|
|
293
671
|
window.location.href = loginUrl;
|
|
294
672
|
}
|
|
295
673
|
};
|
|
674
|
+
|
|
675
|
+
return self;
|
|
296
676
|
}
|
|
297
677
|
|
|
298
678
|
/**
|
|
@@ -301,6 +681,204 @@ export function createAuthManager() {
|
|
|
301
681
|
*/
|
|
302
682
|
const JWT_SAFE_RENEWAL = 30; // seconds before token expiry to renew
|
|
303
683
|
|
|
684
|
+
/**
|
|
685
|
+
* seconds before token expiry the proactive refresh timer fires
|
|
686
|
+
* @type {number}
|
|
687
|
+
*/
|
|
688
|
+
const PROACTIVE_RENEWAL = 60;
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* lower bound (ms) for the proactive refresh delay after receiving a JWT
|
|
692
|
+
* @type {number}
|
|
693
|
+
*/
|
|
694
|
+
const MIN_PROACTIVE_DELAY_MS = 30000;
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* minimum pause (ms) between two proactive refreshes
|
|
698
|
+
* @type {number}
|
|
699
|
+
*/
|
|
700
|
+
const MIN_PROACTIVE_INTERVAL_MS = 30000;
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* error codes that mean the refresh token is gone or unusable for this
|
|
704
|
+
* session (init redirects to login only for these)
|
|
705
|
+
* @type {string[]}
|
|
706
|
+
*/
|
|
707
|
+
const SESSION_END_CODES = ["refresh-rejected", "no-refresh-token", "identity-changed"];
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* maximum setTimeout delay (larger values overflow and fire immediately)
|
|
711
|
+
* @type {number}
|
|
712
|
+
*/
|
|
713
|
+
const MAX_TIMEOUT_MS = 2147483647;
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* timeout for the refresh request, so the cross-tab lock is never held forever
|
|
717
|
+
* @type {number}
|
|
718
|
+
*/
|
|
719
|
+
const REFRESH_REQUEST_TIMEOUT_MS = 30000;
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* localStorage key shared by all tabs and auth manager instances
|
|
723
|
+
* @type {string}
|
|
724
|
+
*/
|
|
725
|
+
const REFRESH_TOKEN_STORAGE_KEY = "idas-refresh-token";
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Web Locks name used to serialize refreshes across tabs
|
|
729
|
+
* @type {string}
|
|
730
|
+
*/
|
|
731
|
+
const REFRESH_LOCK_NAME = "idas-refresh";
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* @returns {boolean} true if running in a browser document
|
|
735
|
+
*/
|
|
736
|
+
function hasBrowserContext() {
|
|
737
|
+
return typeof window !== "undefined" && typeof document !== "undefined";
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* @returns {string|null} the refresh token in localStorage, null if unavailable
|
|
742
|
+
*/
|
|
743
|
+
function readStoredRefreshToken() {
|
|
744
|
+
try {
|
|
745
|
+
return typeof localStorage !== "undefined" ? localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY) : null;
|
|
746
|
+
} catch {
|
|
747
|
+
return null;
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* @param {string} refreshToken
|
|
753
|
+
* @returns {void}
|
|
754
|
+
*/
|
|
755
|
+
function writeStoredRefreshToken(refreshToken) {
|
|
756
|
+
try {
|
|
757
|
+
if (typeof localStorage !== "undefined" && refreshToken) {
|
|
758
|
+
localStorage.setItem(REFRESH_TOKEN_STORAGE_KEY, refreshToken);
|
|
759
|
+
}
|
|
760
|
+
} catch {
|
|
761
|
+
// storage blocked – the in-memory token still works for this instance
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* @returns {void}
|
|
767
|
+
*/
|
|
768
|
+
function removeStoredRefreshToken() {
|
|
769
|
+
try {
|
|
770
|
+
if (typeof localStorage !== "undefined") {
|
|
771
|
+
localStorage.removeItem(REFRESH_TOKEN_STORAGE_KEY);
|
|
772
|
+
}
|
|
773
|
+
} catch {
|
|
774
|
+
// storage blocked – nothing to clean up
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* A refresh response that definitely rejects the refresh token: any 4xx
|
|
780
|
+
* except 408 (Request Timeout) and 429 (Too Many Requests), which are
|
|
781
|
+
* transient. IDAS answers a consumed/unknown token with 403.
|
|
782
|
+
* @param {number} status
|
|
783
|
+
* @returns {boolean}
|
|
784
|
+
*/
|
|
785
|
+
function isRejectionStatus(status) {
|
|
786
|
+
return status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* @param {string} code - machine readable reason
|
|
791
|
+
* @param {boolean} sessionEnded - true if a running session ended (AUTH_EXPIRED_EVENT is due)
|
|
792
|
+
* @param {string} [message="not authenticated"]
|
|
793
|
+
* @param {number} [status]
|
|
794
|
+
* @returns {Error & {code: string, sessionEnded: boolean, status?: number}}
|
|
795
|
+
*/
|
|
796
|
+
function authError(code, sessionEnded, message = "not authenticated", status = undefined) {
|
|
797
|
+
const error = /** @type {Error & {code: string, sessionEnded: boolean, status?: number}} */ (new Error(message));
|
|
798
|
+
error.code = code;
|
|
799
|
+
error.sessionEnded = sessionEnded;
|
|
800
|
+
if (status !== undefined) {
|
|
801
|
+
error.status = status;
|
|
802
|
+
}
|
|
803
|
+
return error;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Identity of a session (user + mandant) from the JWT claims, used to detect
|
|
808
|
+
* that a refresh token taken over from localStorage belongs to someone else.
|
|
809
|
+
* @param {string} token
|
|
810
|
+
* @returns {string|null} null if the claims carry no identity
|
|
811
|
+
*/
|
|
812
|
+
function getIdentity(token) {
|
|
813
|
+
try {
|
|
814
|
+
const decoded = /** @type {Record<string, any>} */ (jwtDecode(token));
|
|
815
|
+
const user = decoded?.benutzerGuid ?? decoded?.id ?? decoded?.sub ?? null;
|
|
816
|
+
const mandant = decoded?.mandantGuid ?? null;
|
|
817
|
+
return user || mandant ? JSON.stringify([user, mandant]) : null;
|
|
818
|
+
} catch {
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Lifetime of a JWT in ms: `exp - iat`, independent of the client clock.
|
|
825
|
+
* Without `iat` the remaining time by the client clock is used.
|
|
826
|
+
* @param {string} token
|
|
827
|
+
* @returns {number|null} null if not decodable
|
|
828
|
+
*/
|
|
829
|
+
function getTokenLifetime(token) {
|
|
830
|
+
try {
|
|
831
|
+
const decoded = jwtDecode(token);
|
|
832
|
+
if (!decoded?.exp) {
|
|
833
|
+
return null;
|
|
834
|
+
}
|
|
835
|
+
return decoded.iat ? (decoded.exp - decoded.iat) * 1000 : decoded.exp * 1000 - Date.now();
|
|
836
|
+
} catch {
|
|
837
|
+
return null;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* Run the callback while holding the cross-tab refresh lock
|
|
843
|
+
* (`navigator.locks`); without Web Locks the callback runs directly.
|
|
844
|
+
* @template T
|
|
845
|
+
* @param {() => Promise<T>} callback
|
|
846
|
+
* @returns {Promise<T>}
|
|
847
|
+
*/
|
|
848
|
+
function withRefreshLock(callback) {
|
|
849
|
+
const locks = typeof navigator !== "undefined" ? navigator.locks : undefined;
|
|
850
|
+
if (locks && typeof locks.request === "function") {
|
|
851
|
+
return locks.request(REFRESH_LOCK_NAME, () => callback());
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
return callback();
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* @param {string} token
|
|
859
|
+
* @returns {number|null} JWT expiry (`exp`) in milliseconds, null if not decodable
|
|
860
|
+
*/
|
|
861
|
+
function getTokenExpiresAt(token) {
|
|
862
|
+
try {
|
|
863
|
+
const decoded = jwtDecode(token);
|
|
864
|
+
return decoded?.exp ? decoded.exp * 1000 : null;
|
|
865
|
+
} catch {
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* @param {string} token
|
|
872
|
+
* @returns {string|null} the refresh token claim, null if not decodable
|
|
873
|
+
*/
|
|
874
|
+
function tryGetRefreshToken(token) {
|
|
875
|
+
try {
|
|
876
|
+
return getRefreshToken(token) || null;
|
|
877
|
+
} catch {
|
|
878
|
+
return null;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
304
882
|
/**
|
|
305
883
|
* @typedef {Object} JwtTokenExt
|
|
306
884
|
* @property {string} id
|
package/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from "./index.js";
|
|
2
|
+
export const AUTH_REFRESHED_EVENT: "idas-auth-refreshed";
|
|
3
|
+
export const AUTH_EXPIRED_EVENT: "idas-auth-expired";
|
|
2
4
|
export function createApi(): FluentApi;
|
|
3
5
|
export function fluentApi(url: string, authManager: FluentAuthManager | null, serviceName: string): FluentApi;
|
|
4
6
|
export function createIDASApi(): IDASFluentApi;
|
|
@@ -193,6 +195,14 @@ export type AuthApi = {
|
|
|
193
195
|
getFremdAppAuthToken: (fremdApp: string) => Promise<UserAuthTokenDTO>;
|
|
194
196
|
};
|
|
195
197
|
|
|
198
|
+
export type AuthExpiredEventDetail = {
|
|
199
|
+
reason: string;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
export type AuthRefreshedEventDetail = {
|
|
203
|
+
expiresAt: number;
|
|
204
|
+
};
|
|
205
|
+
|
|
196
206
|
export type AvApi = {
|
|
197
207
|
getAll: (includeOriginalBeleg?: boolean, includeProdDaten?: boolean) => Promise<BelegPositionAVDTO[]>;
|
|
198
208
|
getAllChangedSince: (changedSince: Date, includeOriginalBeleg?: boolean, includeProdDaten?: boolean) => Promise<BelegPositionAVDTO[]>;
|
|
@@ -1137,7 +1147,7 @@ export type FluentAuthManager = {
|
|
|
1137
1147
|
useRefreshToken: (storedRefreshToken?: string|null) => FluentAuthManager;
|
|
1138
1148
|
ensureAuthenticated: () => Promise<void>;
|
|
1139
1149
|
authenticate: () => Promise<void>;
|
|
1140
|
-
_doAuthenticate: () => Promise<void>;
|
|
1150
|
+
_doAuthenticate: (force?: boolean) => Promise<void>;
|
|
1141
1151
|
_authenticatePromise: Promise<void>|null;
|
|
1142
1152
|
init: () => Promise<FluentAuthManager>;
|
|
1143
1153
|
login: (username?: string, password?: string) => Promise<void>;
|
|
@@ -1146,6 +1156,16 @@ export type FluentAuthManager = {
|
|
|
1146
1156
|
redirectToLogin: () => void;
|
|
1147
1157
|
hasRight: (code: string) => boolean;
|
|
1148
1158
|
hasRole: (code: string) => boolean;
|
|
1159
|
+
_runRefresh?: (force?: boolean, notifyExpired?: boolean) => Promise<void>;
|
|
1160
|
+
_refreshLocked?: (force: boolean) => Promise<void>;
|
|
1161
|
+
_currentRefreshToken?: () => string|null;
|
|
1162
|
+
_requestRefresh?: (refreshToken: string) => Promise<{token: string|null, status: number}>;
|
|
1163
|
+
_expire?: (reason: string, rejectedRefreshToken?: string|null) => void;
|
|
1164
|
+
_notifyExpired?: (reason: string) => void;
|
|
1165
|
+
_scheduleProactiveRefresh?: (delay?: number) => void;
|
|
1166
|
+
_stopProactiveRefresh?: () => void;
|
|
1167
|
+
_proactiveRefresh?: () => void;
|
|
1168
|
+
_installBrowserListeners?: () => void;
|
|
1149
1169
|
};
|
|
1150
1170
|
|
|
1151
1171
|
export type FluentRESTClient = {
|
package/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { createApi, fluentApi } from "./api/fluentApi";
|
|
2
2
|
export { createIDASApi, idasFluentApi } from "./api/idasFluentApi";
|
|
3
3
|
export { createAuthManager, fluentIdasAuthManager } from "./api/fluentAuthManager";
|
|
4
|
+
export { AUTH_EXPIRED_EVENT, AUTH_REFRESHED_EVENT } from "./api/authEvents";
|
|
4
5
|
export { fetchEnvConfig } from "./api/fluentEnvUtils";
|
|
5
6
|
export { restClient, RestError } from "./api/fluentRestClient";
|
|
6
7
|
|
package/package.json
CHANGED
package/scripts/generate-dts.mjs
CHANGED
|
@@ -21,7 +21,10 @@ const dtoRootMarkerEnd = "// END GENERATED ROOT DTO TYPEDEFS";
|
|
|
21
21
|
const businessRootMarkerStart = "// BEGIN GENERATED ROOT BUSINESS TYPEDEFS";
|
|
22
22
|
const businessRootMarkerEnd = "// END GENERATED ROOT BUSINESS TYPEDEFS";
|
|
23
23
|
|
|
24
|
-
const rootValueExportStatements = [
|
|
24
|
+
const rootValueExportStatements = [
|
|
25
|
+
"export const AUTH_REFRESHED_EVENT: \"idas-auth-refreshed\";",
|
|
26
|
+
"export const AUTH_EXPIRED_EVENT: \"idas-auth-expired\";"
|
|
27
|
+
];
|
|
25
28
|
|
|
26
29
|
const rootFunctionDeclarationStatements = [
|
|
27
30
|
"export function createApi(): FluentApi;",
|