@gandalan/weblibs 2.0.15 → 2.0.17

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 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. This
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
- this.authManager.token = "";
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
  },
@@ -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
- return {
45
- appToken: "",
46
- authUrl: "",
64
+ const session = {
47
65
  token: "",
48
66
  refreshToken: "",
67
+ /** @type {JwtUserInfo} */
49
68
  userInfo: {},
50
- _authenticatePromise: null,
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
- this.token = jwtToken;
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
- this.refreshToken = storedRefreshToken;
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 (this.token && isTokenValid(this.token)) {
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 (this.token && isTokenValid(this.token)) {
191
+ if (session.token && isTokenValid(session.token)) {
133
192
  return;
134
193
  }
135
194
 
136
- this._authenticatePromise ??= this._doAuthenticate()
137
- .finally(() => { this._authenticatePromise = null; });
195
+ return this._runRefresh();
196
+ },
138
197
 
139
- return this._authenticatePromise;
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
- * @throws {Error} if JWT token and refreshToken are not set or both are invalid
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
- console.log("authenticating:", this.token ? `token set, exp: ${jwtDecode(this.token).exp - (Date.now() / 1000)}` : "no token,", this.refreshToken, this.appToken);
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
- if (this.token && !this.refreshToken) {
159
- this.refreshToken = getRefreshToken(this.token);
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.refreshToken) {
163
- throw new Error("not authenticated");
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
- try {
167
- const temptoken = await this.tryRefreshToken(this.refreshToken);
168
- this.updateUserSession(temptoken);
169
- } catch {
170
- // if refresh failed
171
- // - current token should still be valid for a while
172
- // - or user has invalid (refresh) token and needs to login/refresh manually
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
- if (!this.refreshToken) {
189
- this.refreshToken = popRefreshTokenFromUrl() || localStorage.getItem("idas-refresh-token");
354
+ internal.storedRefreshTokenSeen = readStoredRefreshToken();
355
+ if (!session.refreshToken) {
356
+ session.refreshToken = popRefreshTokenFromUrl() || internal.storedRefreshTokenSeen;
190
357
  }
191
358
 
192
- if (!this.token && this.refreshToken) {
193
- this.token = await this.tryRefreshToken(this.refreshToken);
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 (this.token && isTokenValid(this.token)) {
197
- this.updateUserSession(this.token);
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(this.token)) {
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 (this.userInfo?.rights || []).includes(code);
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 (this.userInfo?.role || []).includes(code);
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
- this.token = token;
272
- this.refreshToken = getRefreshToken(token);
273
- this.userInfo = jwtDecode(this.token);
274
- localStorage.setItem("idas-refresh-token", this.refreshToken);
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
@@ -12,45 +12,6 @@
12
12
  * @typedef {0 | 1 | 2} NeherApp3NotifyType
13
13
  */
14
14
 
15
- /**
16
- * @typedef {Object} ArtikelstammEintrag
17
- * @property {string} [KatalogArtikelGuid]
18
- * @property {string} [KatalogNummer]
19
- * @property {string} [Katalognummer]
20
- * @property {string} [Nummer]
21
- */
22
-
23
- /**
24
- * @typedef {Object} Variante
25
- * @property {string} [VarianteGuid]
26
- * @property {string} [Name]
27
- * @property {string} [Kuerzel]
28
- */
29
-
30
- /**
31
- * @typedef {Object} Werteliste
32
- * @property {string} [WerteListeGuid]
33
- * @property {string} [Name]
34
- */
35
-
36
- /**
37
- * @typedef {Object} NeherApp3ArtikelstammCache
38
- * @property {() => Promise<ArtikelstammEintrag[]>} getArtikelStamm
39
- * @property {() => Promise<Object[]>} getWarenGruppen
40
- * @property {(guid: string) => Promise<ArtikelstammEintrag | undefined>} getArtikelByGuid
41
- * @property {(nummer: string) => Promise<ArtikelstammEintrag | undefined>} getArtikelByKatalognummer
42
- */
43
-
44
- /**
45
- * @typedef {Object} NeherApp3ErfassungCache
46
- * @property {() => Promise<Variante[]>} getVarianten
47
- * @property {(variantenNameOderKuerzel: string) => Promise<Variante | undefined>} getVariante
48
- * @property {() => Promise<Werteliste[]>} getWertelisten
49
- * @property {(name: string) => Promise<Werteliste | undefined>} getWerteliste
50
- * @property {() => Promise<Object[]>} getScripts
51
- * @property {(v: Variante) => void} createUIMachine
52
- */
53
-
54
15
  /**
55
16
  * @typedef {Object} NeherApp3Props
56
17
  * @property {import("./fluentApi.js").FluentApi} api
@@ -94,12 +55,6 @@
94
55
  * @property {import("./fluentApi.js").FluentApi} [hostingEnvironment]
95
56
  */
96
57
 
97
- /**
98
- * @typedef {Object} NeherApp3CacheCollection
99
- * @property {NeherApp3ArtikelstammCache} artikelstamm
100
- * @property {NeherApp3ErfassungCache} erfassung
101
- */
102
-
103
58
  /**
104
59
  * A single message envelope delivered to handlers.
105
60
  * @typedef {Object} NeherMessage
@@ -400,7 +355,6 @@
400
355
  * @property {(appModule: NeherApp3Module | string) => Promise<void>} addApp
401
356
  * @property {(message: string, type?: NeherApp3NotifyType, cb?: function) => void} notify - Shows a notification. Type defaults to 0 (info). Callback is optional.
402
357
  * @property {NeherApp3ApiCollection} api
403
- * @property {NeherApp3CacheCollection} cache
404
358
  * @property {NeherApp3Messages} messages - In-realm message bus for module-to-module communication.
405
359
  * @property {NeherApp3I18n} i18n - Localization: register a module's translation catalog, translate, switch language, sort language-aware.
406
360
  * @property {Localize} localize - Shorthand for `i18n.localize` (namespace `shell`): a function for strings, a `use:` action for elements.
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;
@@ -164,13 +166,6 @@ export type ArtikelApi = {
164
166
  resetCacheVariantenListen: () => Promise<void>;
165
167
  };
166
168
 
167
- export type ArtikelstammEintrag = {
168
- KatalogArtikelGuid?: string;
169
- KatalogNummer?: string;
170
- Katalognummer?: string;
171
- Nummer?: string;
172
- };
173
-
174
169
  export type ArtosStartSettingsDTO = {
175
170
  MaterialBedarfLogik: MaterialBedarfLogik;
176
171
  UserAuthToken: UserAuthTokenDTO;
@@ -193,6 +188,14 @@ export type AuthApi = {
193
188
  getFremdAppAuthToken: (fremdApp: string) => Promise<UserAuthTokenDTO>;
194
189
  };
195
190
 
191
+ export type AuthExpiredEventDetail = {
192
+ reason: string;
193
+ };
194
+
195
+ export type AuthRefreshedEventDetail = {
196
+ expiresAt: number;
197
+ };
198
+
196
199
  export type AvApi = {
197
200
  getAll: (includeOriginalBeleg?: boolean, includeProdDaten?: boolean) => Promise<BelegPositionAVDTO[]>;
198
201
  getAllChangedSince: (changedSince: Date, includeOriginalBeleg?: boolean, includeProdDaten?: boolean) => Promise<BelegPositionAVDTO[]>;
@@ -1137,7 +1140,7 @@ export type FluentAuthManager = {
1137
1140
  useRefreshToken: (storedRefreshToken?: string|null) => FluentAuthManager;
1138
1141
  ensureAuthenticated: () => Promise<void>;
1139
1142
  authenticate: () => Promise<void>;
1140
- _doAuthenticate: () => Promise<void>;
1143
+ _doAuthenticate: (force?: boolean) => Promise<void>;
1141
1144
  _authenticatePromise: Promise<void>|null;
1142
1145
  init: () => Promise<FluentAuthManager>;
1143
1146
  login: (username?: string, password?: string) => Promise<void>;
@@ -1146,6 +1149,16 @@ export type FluentAuthManager = {
1146
1149
  redirectToLogin: () => void;
1147
1150
  hasRight: (code: string) => boolean;
1148
1151
  hasRole: (code: string) => boolean;
1152
+ _runRefresh?: (force?: boolean, notifyExpired?: boolean) => Promise<void>;
1153
+ _refreshLocked?: (force: boolean) => Promise<void>;
1154
+ _currentRefreshToken?: () => string|null;
1155
+ _requestRefresh?: (refreshToken: string) => Promise<{token: string|null, status: number}>;
1156
+ _expire?: (reason: string, rejectedRefreshToken?: string|null) => void;
1157
+ _notifyExpired?: (reason: string) => void;
1158
+ _scheduleProactiveRefresh?: (delay?: number) => void;
1159
+ _stopProactiveRefresh?: () => void;
1160
+ _proactiveRefresh?: () => void;
1161
+ _installBrowserListeners?: () => void;
1149
1162
  };
1150
1163
 
1151
1164
  export type FluentRESTClient = {
@@ -2074,7 +2087,6 @@ export type NeherApp3 = {
2074
2087
  addApp: (appModule: NeherApp3Module | string) => Promise<void>;
2075
2088
  notify: (message: string, type?: NeherApp3NotifyType, cb?: Function) => void;
2076
2089
  api: NeherApp3ApiCollection;
2077
- cache: NeherApp3CacheCollection;
2078
2090
  messages: NeherApp3Messages;
2079
2091
  i18n: NeherApp3I18n;
2080
2092
  localize: Localize;
@@ -2089,27 +2101,6 @@ export type NeherApp3ApiCollection = {
2089
2101
  hostingEnvironment?: FluentApi;
2090
2102
  };
2091
2103
 
2092
- export type NeherApp3ArtikelstammCache = {
2093
- getArtikelStamm: () => Promise<ArtikelstammEintrag[]>;
2094
- getWarenGruppen: () => Promise<object[]>;
2095
- getArtikelByGuid: (guid: string) => Promise<ArtikelstammEintrag | undefined>;
2096
- getArtikelByKatalognummer: (nummer: string) => Promise<ArtikelstammEintrag | undefined>;
2097
- };
2098
-
2099
- export type NeherApp3CacheCollection = {
2100
- artikelstamm: NeherApp3ArtikelstammCache;
2101
- erfassung: NeherApp3ErfassungCache;
2102
- };
2103
-
2104
- export type NeherApp3ErfassungCache = {
2105
- getVarianten: () => Promise<Variante[]>;
2106
- getVariante: (variantenNameOderKuerzel: string) => Promise<Variante | undefined>;
2107
- getWertelisten: () => Promise<Werteliste[]>;
2108
- getWerteliste: (name: string) => Promise<Werteliste | undefined>;
2109
- getScripts: () => Promise<object[]>;
2110
- createUIMachine: (v: Variante) => void;
2111
- };
2112
-
2113
2104
  export type NeherApp3I18n = {
2114
2105
  register: (namespace: string, translations?: TranslationCatalogs) => NeherApp3I18nEndpoint;
2115
2106
  localize: Localize;
@@ -3138,12 +3129,6 @@ export type UtilityApi = {
3138
3129
  getOneBenutzerByKunde: (kundeGuid: string, email: string) => Promise<BenutzerDTO | undefined>;
3139
3130
  };
3140
3131
 
3141
- export type Variante = {
3142
- VarianteGuid?: string;
3143
- Name?: string;
3144
- Kuerzel?: string;
3145
- };
3146
-
3147
3132
  export type VarianteDTO = {
3148
3133
  VarianteGuid: string;
3149
3134
  UIDefinitionGuid: string;
@@ -3374,11 +3359,6 @@ export type WebJobHistorieDTO = {
3374
3359
  Text: string;
3375
3360
  };
3376
3361
 
3377
- export type Werteliste = {
3378
- WerteListeGuid?: string;
3379
- Name?: string;
3380
- };
3381
-
3382
3362
  export type WerteListeDTO = {
3383
3363
  Name: string;
3384
3364
  Version: number;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gandalan/weblibs",
3
- "version": "2.0.15",
3
+ "version": "2.0.17",
4
4
  "description": "WebLibs for Gandalan JS/TS projects",
5
5
  "keywords": [
6
6
  "gandalan"
@@ -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;",