@franzzemen/brokenstock-shell 0.4.3 → 0.4.4
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.
|
@@ -2,7 +2,7 @@ import * as i0 from '@angular/core';
|
|
|
2
2
|
import { InjectionToken, makeEnvironmentProviders, signal, computed, Injectable, inject, input, Directive, isDevMode, effect, untracked, HostListener, ViewChild, ViewEncapsulation, ChangeDetectionStrategy, Component, NgZone, viewChild, afterNextRender } from '@angular/core';
|
|
3
3
|
import * as i2$1 from '@angular/router';
|
|
4
4
|
import { Router, RouterLink, RouterLinkActive, ActivatedRoute, RouterOutlet } from '@angular/router';
|
|
5
|
-
import {
|
|
5
|
+
import { BEARER_TOKEN_REGEX, HttpHeader, USERNAME_REGEX, PASSWORD_REGEX } from '@franzzemen/utility';
|
|
6
6
|
import { validateCredentials } from '@franzzemen/identity';
|
|
7
7
|
import { HttpClient } from '@angular/common/http';
|
|
8
8
|
import { catchError, from, switchMap, throwError, firstValueFrom } from 'rxjs';
|
|
@@ -41,6 +41,18 @@ const SHELL_AUTH_URLS = new InjectionToken('SHELL_AUTH_URLS');
|
|
|
41
41
|
*/
|
|
42
42
|
const SHELL_COMMANDS = new InjectionToken('SHELL_COMMANDS');
|
|
43
43
|
const SHELL_POST_LOGIN_ROUTE = new InjectionToken('SHELL_POST_LOGIN_ROUTE');
|
|
44
|
+
/**
|
|
45
|
+
* Whether this app shares one session across every window of the browser
|
|
46
|
+
* (multiple-windows PRD D2/D7).
|
|
47
|
+
*
|
|
48
|
+
* ⚠ OFF unless an app opts in, and only `broken-stock` does. Turning it on puts
|
|
49
|
+
* the sign-in token in browser storage, where any script on the origin can read
|
|
50
|
+
* it and it persists on disk. The admin apps send their renewal cookie
|
|
51
|
+
* cross-site and can change other people's data, which makes that a materially
|
|
52
|
+
* different exposure — extending this to them is a separate decision, not a
|
|
53
|
+
* default.
|
|
54
|
+
*/
|
|
55
|
+
const SHELL_SHARED_SESSION = new InjectionToken('SHELL_SHARED_SESSION');
|
|
44
56
|
|
|
45
57
|
function provideShellConfig(config) {
|
|
46
58
|
return makeEnvironmentProviders([
|
|
@@ -48,6 +60,7 @@ function provideShellConfig(config) {
|
|
|
48
60
|
{ provide: SHELL_MENU_ITEMS, useValue: config.menuItems },
|
|
49
61
|
{ provide: SHELL_AUTH_URLS, useValue: config.authUrls },
|
|
50
62
|
{ provide: SHELL_COMMANDS, useValue: config.commands ?? [] },
|
|
63
|
+
{ provide: SHELL_SHARED_SESSION, useValue: config.sharedSession ?? false },
|
|
51
64
|
]);
|
|
52
65
|
}
|
|
53
66
|
|
|
@@ -76,7 +89,214 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
76
89
|
args: [{ providedIn: 'root' }]
|
|
77
90
|
}] });
|
|
78
91
|
|
|
92
|
+
/*
|
|
93
|
+
Created by Franz Zemen 2026-08-29
|
|
94
|
+
License Type: UNLICENSED
|
|
95
|
+
|
|
96
|
+
The sign-in token's home in browser storage (multiple-windows PRD D2).
|
|
97
|
+
|
|
98
|
+
WHY THIS IS ITS OWN FILE, AND WHY IT DOES NOT USE `LocalStorageService`.
|
|
99
|
+
That service swallows a failed write on purpose — losing a remembered pane width
|
|
100
|
+
must never break the screen that was trying to remember it. That policy is
|
|
101
|
+
exactly wrong here. A silently dropped token write is a silently lost session:
|
|
102
|
+
the window keeps working on the token it holds in memory, every other window
|
|
103
|
+
never learns it, and the failure surfaces minutes later somewhere else entirely.
|
|
104
|
+
So this writes directly and THROWS.
|
|
105
|
+
|
|
106
|
+
READS ARE THE SOURCE OF TRUTH, NOT A CACHE. `AuthenticationService.auth` reads
|
|
107
|
+
through to this on every access rather than keeping a copy. That is the whole
|
|
108
|
+
reason the design works with several windows open: the tape socket asks for the
|
|
109
|
+
token on a sixty-second heartbeat and a window that is frozen or backgrounded may
|
|
110
|
+
never receive a queued `storage` event, so a copy refreshed by an event is only
|
|
111
|
+
as fresh as the last event delivered. Reading through removes the question.
|
|
112
|
+
|
|
113
|
+
⚠ NOT under the `bs.pref.` namespace. `LocalStorageService` wipes that namespace
|
|
114
|
+
wholesale, and a preference wipe must never sign the user out.
|
|
115
|
+
*/
|
|
116
|
+
/** Deliberately outside `bs.pref.` — see the header. */
|
|
117
|
+
const SESSION_TOKEN_KEY = 'bs.auth.token';
|
|
118
|
+
class SessionTokenStore {
|
|
119
|
+
/** False during prerendering, where there is no `window` at all. */
|
|
120
|
+
#available = typeof window !== 'undefined' && !!window.localStorage;
|
|
121
|
+
get available() {
|
|
122
|
+
return this.#available;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The stored token, or undefined.
|
|
126
|
+
*
|
|
127
|
+
* A stored value that is not a well-formed bearer is treated as absent and
|
|
128
|
+
* removed. It cannot be authenticated with, and leaving it there would make
|
|
129
|
+
* every later read fail the same way with nothing to explain it.
|
|
130
|
+
*/
|
|
131
|
+
read() {
|
|
132
|
+
if (!this.#available)
|
|
133
|
+
return undefined;
|
|
134
|
+
let raw;
|
|
135
|
+
try {
|
|
136
|
+
raw = window.localStorage.getItem(SESSION_TOKEN_KEY);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// Storage disabled by the browser. Not recoverable here, and not fatal —
|
|
140
|
+
// the caller falls back to its in-memory token and this window works
|
|
141
|
+
// alone, which is exactly today's behaviour.
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
if (!raw)
|
|
145
|
+
return undefined;
|
|
146
|
+
if (!BEARER_TOKEN_REGEX.test(raw)) {
|
|
147
|
+
this.clear();
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
return raw;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Stores the token. THROWS on failure — see the header.
|
|
154
|
+
*
|
|
155
|
+
* @throws if the value is not a bearer token, or the write does not stick.
|
|
156
|
+
*/
|
|
157
|
+
write(token) {
|
|
158
|
+
if (!this.#available)
|
|
159
|
+
return;
|
|
160
|
+
if (!BEARER_TOKEN_REGEX.test(token)) {
|
|
161
|
+
throw new Error('SessionTokenStore.write refused a value that is not a bearer token');
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
window.localStorage.setItem(SESSION_TOKEN_KEY, token);
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
throw new Error(`Could not store the session token: ${String(error)}`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
clear() {
|
|
171
|
+
if (!this.#available)
|
|
172
|
+
return;
|
|
173
|
+
try {
|
|
174
|
+
window.localStorage.removeItem(SESSION_TOKEN_KEY);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// Nothing useful to do. The in-memory state is cleared by the caller
|
|
178
|
+
// regardless, so this window is signed out either way.
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Calls back when ANOTHER window changes the token.
|
|
183
|
+
*
|
|
184
|
+
* `storage` does not fire in the window that made the change, which is
|
|
185
|
+
* correct — that window is the one doing it and has already acted.
|
|
186
|
+
*
|
|
187
|
+
* ⚠ `event.key === null` is what `localStorage.clear()` produces, and it means
|
|
188
|
+
* everything went, this key included. Treating it as "not our key" is how a
|
|
189
|
+
* whole-storage clear silently fails to sign the user out.
|
|
190
|
+
*/
|
|
191
|
+
onExternalChange(handler) {
|
|
192
|
+
if (!this.#available)
|
|
193
|
+
return () => undefined;
|
|
194
|
+
const listener = (event) => {
|
|
195
|
+
if (event.key !== null && event.key !== SESSION_TOKEN_KEY)
|
|
196
|
+
return;
|
|
197
|
+
handler(this.read());
|
|
198
|
+
};
|
|
199
|
+
window.addEventListener('storage', listener);
|
|
200
|
+
return () => window.removeEventListener('storage', listener);
|
|
201
|
+
}
|
|
202
|
+
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SessionTokenStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
203
|
+
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SessionTokenStore, providedIn: 'root' }); }
|
|
204
|
+
}
|
|
205
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: SessionTokenStore, decorators: [{
|
|
206
|
+
type: Injectable,
|
|
207
|
+
args: [{ providedIn: 'root' }]
|
|
208
|
+
}] });
|
|
209
|
+
|
|
210
|
+
/*
|
|
211
|
+
Created by Franz Zemen 2026-08-29
|
|
212
|
+
License Type: UNLICENSED
|
|
213
|
+
|
|
214
|
+
Run something in exactly one window (multiple-windows PRD D5).
|
|
215
|
+
|
|
216
|
+
THE PROBLEM. Several windows share one session. When the token expires they all
|
|
217
|
+
notice at once and all try to renew. The server serialises renewal correctly and
|
|
218
|
+
REFUSES the losers, so without coordination every window but one is signed out —
|
|
219
|
+
and it happens every token lifetime, several times a day.
|
|
220
|
+
|
|
221
|
+
WHY `ifAvailable` RATHER THAN WAITING ON THE LOCK. A waiter blocks on a peer it
|
|
222
|
+
cannot supervise. If the window holding the lock is frozen, backgrounded or its
|
|
223
|
+
request hangs, the waiters wait forever — screens simply stop updating, with
|
|
224
|
+
nothing logged and nothing on screen. `ifAvailable` refuses immediately instead,
|
|
225
|
+
which turns "wait on a peer" into "wait for a value to appear, with my own
|
|
226
|
+
bound". A bound you own is a bound you can honour.
|
|
227
|
+
|
|
228
|
+
⚠ A lock held by a DESTROYED document is released by the browser, so closing a
|
|
229
|
+
window is safe. A merely frozen document is NOT destroyed — which is why every
|
|
230
|
+
path here is bounded regardless.
|
|
231
|
+
|
|
232
|
+
⚠ `navigator.locks` needs a secure context and is absent in some. Absent means
|
|
233
|
+
fall back to running uncoordinated — today's behaviour — never throw. A missing
|
|
234
|
+
API must not be the reason nobody can sign in.
|
|
235
|
+
*/
|
|
236
|
+
const LOCKS_AVAILABLE = typeof navigator !== 'undefined'
|
|
237
|
+
&& typeof navigator.locks?.request === 'function';
|
|
238
|
+
/**
|
|
239
|
+
* Runs `work` if this window can take the lock right now.
|
|
240
|
+
*
|
|
241
|
+
* Returns `{ran: false}` when another window holds it — the caller decides what
|
|
242
|
+
* to do instead, which is the point: only the caller knows whether waiting for
|
|
243
|
+
* the peer's result or proceeding alone is correct.
|
|
244
|
+
*
|
|
245
|
+
* When `navigator.locks` is unavailable the work RUNS, uncoordinated. That is a
|
|
246
|
+
* deliberate degradation to today's behaviour rather than a refusal.
|
|
247
|
+
*/
|
|
248
|
+
async function runExclusive(name, work) {
|
|
249
|
+
if (!LOCKS_AVAILABLE) {
|
|
250
|
+
console.warn(`[shell] navigator.locks unavailable; "${name}" runs without cross-window coordination`);
|
|
251
|
+
return { ran: true, value: await work() };
|
|
252
|
+
}
|
|
253
|
+
let outcome = { ran: false };
|
|
254
|
+
await navigator.locks.request(name, { ifAvailable: true }, async (lock) => {
|
|
255
|
+
if (!lock)
|
|
256
|
+
return;
|
|
257
|
+
outcome = { ran: true, value: await work() };
|
|
258
|
+
});
|
|
259
|
+
return outcome;
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Polls `read` until it returns something other than `unchanged`, or the bound
|
|
263
|
+
* elapses.
|
|
264
|
+
*
|
|
265
|
+
* Polling rather than listening for `storage` on purpose: the event does not
|
|
266
|
+
* fire in a window that is frozen at the moment it is dispatched, and this is
|
|
267
|
+
* the path that recovers such a window. A poll asks the question again.
|
|
268
|
+
*
|
|
269
|
+
* Resolves `undefined` on timeout — "the peer did not deliver", which is a
|
|
270
|
+
* legitimate answer the caller must handle, not an error.
|
|
271
|
+
*/
|
|
272
|
+
async function awaitChange(read, unchanged, timeoutMs, pollMs = 100) {
|
|
273
|
+
const deadline = Date.now() + timeoutMs;
|
|
274
|
+
for (;;) {
|
|
275
|
+
const current = read();
|
|
276
|
+
if (current !== unchanged)
|
|
277
|
+
return current;
|
|
278
|
+
if (Date.now() >= deadline)
|
|
279
|
+
return undefined;
|
|
280
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Name of the cross-window renewal lock (PRD D5). */
|
|
285
|
+
const RENEWAL_LOCK = 'brokenstock.session.renew';
|
|
286
|
+
/** How long a window waits for the renewing window to publish a token (PRD D5). */
|
|
287
|
+
const PEER_RENEWAL_WAIT_MS = 10_000;
|
|
288
|
+
/** How long the renewal request itself may take before it is abandoned (PRD D5). */
|
|
289
|
+
const RENEWAL_TIMEOUT_MS = 15_000;
|
|
79
290
|
class AuthenticationService {
|
|
291
|
+
#refreshing;
|
|
292
|
+
#auth;
|
|
293
|
+
#httpClient;
|
|
294
|
+
#thinkingService;
|
|
295
|
+
#router;
|
|
296
|
+
#authUrls;
|
|
297
|
+
#store;
|
|
298
|
+
/** Off by default: an app opts in to sharing its session across windows (PRD D7). */
|
|
299
|
+
#sharedSession;
|
|
80
300
|
constructor() {
|
|
81
301
|
this.authenticated = signal(false, /* @ts-ignore */
|
|
82
302
|
...(ngDevMode ? [{ debugName: "authenticated" }] : /* istanbul ignore next */ []));
|
|
@@ -94,16 +314,65 @@ class AuthenticationService {
|
|
|
94
314
|
this.#thinkingService = inject(ThinkingService);
|
|
95
315
|
this.#router = inject(Router);
|
|
96
316
|
this.#authUrls = inject(SHELL_AUTH_URLS);
|
|
317
|
+
this.#store = inject(SessionTokenStore);
|
|
318
|
+
/** Off by default: an app opts in to sharing its session across windows (PRD D7). */
|
|
319
|
+
this.#sharedSession = inject(SHELL_SHARED_SESSION, { optional: true }) ?? false;
|
|
320
|
+
if (!this.#sharedSession)
|
|
321
|
+
return;
|
|
322
|
+
// Adopt a token this browser already holds. This runs during construction,
|
|
323
|
+
// and `authGuard` injecting this service IS the first read of `authenticated`
|
|
324
|
+
// — a field initializer completes before `inject()` returns — so the guard
|
|
325
|
+
// sees the restored state without becoming asynchronous (PRD D4).
|
|
326
|
+
//
|
|
327
|
+
// The token may well be expired. That is not this constructor's problem: the
|
|
328
|
+
// first request 401s and the interceptor renews and retries, which is the
|
|
329
|
+
// path that already works today.
|
|
330
|
+
const stored = this.#store.read();
|
|
331
|
+
if (stored) {
|
|
332
|
+
this.#auth = stored;
|
|
333
|
+
this.authenticated.set(true);
|
|
334
|
+
}
|
|
335
|
+
// Another window signing out must sign this one out too (PRD D6). A token
|
|
336
|
+
// that merely CHANGED needs no action here — `auth` reads through, so the
|
|
337
|
+
// next caller gets the new value without anyone copying it.
|
|
338
|
+
this.#store.onExternalChange(token => {
|
|
339
|
+
if (!token && this.authenticated())
|
|
340
|
+
this.#clearSession();
|
|
341
|
+
});
|
|
97
342
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
343
|
+
/**
|
|
344
|
+
* The bearer for outbound calls.
|
|
345
|
+
*
|
|
346
|
+
* READ-THROUGH, not a cached copy, whenever the session is shared across
|
|
347
|
+
* windows (PRD D2/D10). A copy is only as fresh as the last `storage` event
|
|
348
|
+
* delivered, and the consumer that matters most is outside the request path:
|
|
349
|
+
* the market-tape socket re-presents this token on a sixty-second heartbeat,
|
|
350
|
+
* and the server closes the socket with a code the client does NOT retry if it
|
|
351
|
+
* is stale. A window that was frozen when the event fired would lose its live
|
|
352
|
+
* prices permanently. Reading through removes the question of which window
|
|
353
|
+
* holds which token.
|
|
354
|
+
*/
|
|
104
355
|
get auth() {
|
|
356
|
+
if (this.#sharedSession) {
|
|
357
|
+
const stored = this.#store.read();
|
|
358
|
+
if (stored)
|
|
359
|
+
return stored;
|
|
360
|
+
}
|
|
105
361
|
return this.#auth;
|
|
106
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* Adopts a freshly issued token: in memory, and in shared storage so every
|
|
365
|
+
* other window of this browser sees it on their next read.
|
|
366
|
+
*
|
|
367
|
+
* A failed write THROWS out of here rather than being absorbed — see
|
|
368
|
+
* `SessionTokenStore`. A session the other windows cannot see is the failure
|
|
369
|
+
* this whole design exists to prevent.
|
|
370
|
+
*/
|
|
371
|
+
#adoptToken(token) {
|
|
372
|
+
this.#auth = token;
|
|
373
|
+
if (this.#sharedSession)
|
|
374
|
+
this.#store.write(token);
|
|
375
|
+
}
|
|
107
376
|
// Legacy hasRole/hasAnyRole removed (legacy-authz-teardown): sessions carry no
|
|
108
377
|
// roles, and every gate moved to hasFeature (commerce) with the BE enforcing the
|
|
109
378
|
// security axis.
|
|
@@ -129,6 +398,23 @@ class AuthenticationService {
|
|
|
129
398
|
this.conflictSessions.set(undefined);
|
|
130
399
|
this.pendingCredentials.set(undefined);
|
|
131
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* The session is gone and cannot be recovered — renewal failed permanently,
|
|
403
|
+
* or another window signed out.
|
|
404
|
+
*
|
|
405
|
+
* ⚠ THIS EXISTS BECAUSE THERE ARE TWO SIGN-OUT PATHS AND ONLY ONE USED TO
|
|
406
|
+
* CLEAR THE TOKEN. `logout()` went through `#clearSession()`; the 401
|
|
407
|
+
* interceptor set the signals and navigated but left `#auth` alone. Harmless
|
|
408
|
+
* while the token lived only in memory and died with the tab — but once it is
|
|
409
|
+
* in storage, a dead token left behind means every later window marks itself
|
|
410
|
+
* signed in, passes the guard, renders, 401s and bounces to the login screen.
|
|
411
|
+
* The reload fix would have become "the app flashes, then throws me out",
|
|
412
|
+
* which is harder to diagnose than the behaviour it replaced.
|
|
413
|
+
*/
|
|
414
|
+
sessionLost() {
|
|
415
|
+
this.#thinkingService.reset();
|
|
416
|
+
this.#clearSession();
|
|
417
|
+
}
|
|
132
418
|
logout() {
|
|
133
419
|
const subscription = this.#httpClient
|
|
134
420
|
.get(this.#authUrls.logout, {
|
|
@@ -180,7 +466,7 @@ class AuthenticationService {
|
|
|
180
466
|
reject('Unable to login');
|
|
181
467
|
return;
|
|
182
468
|
}
|
|
183
|
-
this.#auth
|
|
469
|
+
this.#adoptToken(auth);
|
|
184
470
|
this.authenticated.set(true);
|
|
185
471
|
this.pendingCredentials.set(undefined);
|
|
186
472
|
resolve();
|
|
@@ -214,15 +500,81 @@ class AuthenticationService {
|
|
|
214
500
|
});
|
|
215
501
|
});
|
|
216
502
|
}
|
|
503
|
+
/**
|
|
504
|
+
* Renews the session, coordinating with the other windows of this browser.
|
|
505
|
+
*
|
|
506
|
+
* WHY COORDINATION IS REQUIRED, NOT AN OPTIMISATION. Every window shares one
|
|
507
|
+
* session, so they all find the token expired at the same moment and all try
|
|
508
|
+
* to renew. The server serialises rotation on the session row and REFUSES the
|
|
509
|
+
* losers — so uncoordinated, every window but one is signed out, every token
|
|
510
|
+
* lifetime, several times a day.
|
|
511
|
+
*
|
|
512
|
+
* The lock is taken with `ifAvailable`, so a window that does not get it is
|
|
513
|
+
* told so immediately and then waits for the WINNER'S RESULT with a bound of
|
|
514
|
+
* its own, rather than blocking on a peer it cannot supervise. A frozen
|
|
515
|
+
* window holding the lock would otherwise stop every other window forever,
|
|
516
|
+
* with nothing on screen and nothing logged.
|
|
517
|
+
*/
|
|
217
518
|
async #doRefresh() {
|
|
519
|
+
if (!this.#sharedSession)
|
|
520
|
+
return this.#renew();
|
|
521
|
+
// Captured BEFORE asking for the lock: it is what "did someone else renew
|
|
522
|
+
// while I waited?" is measured against.
|
|
523
|
+
const before = this.#store.read();
|
|
524
|
+
const outcome = await runExclusive(RENEWAL_LOCK, () => this.#renewOrAdopt(before));
|
|
525
|
+
if (outcome.ran)
|
|
526
|
+
return outcome.value;
|
|
527
|
+
// Another window is renewing. Wait for its token to appear.
|
|
528
|
+
const published = await awaitChange(() => this.#store.read(), before, PEER_RENEWAL_WAIT_MS);
|
|
529
|
+
if (published) {
|
|
530
|
+
this.#auth = published;
|
|
531
|
+
return true;
|
|
532
|
+
}
|
|
533
|
+
// Either the winner cleared the token (its renewal failed permanently), or
|
|
534
|
+
// it never delivered inside the bound. Re-read to tell those apart: an
|
|
535
|
+
// absent token is a definite answer and must not be retried, whereas a
|
|
536
|
+
// still-unchanged one means the winner is gone and this window should try.
|
|
537
|
+
if (before && !this.#store.read())
|
|
538
|
+
return false;
|
|
539
|
+
return this.#renewOrAdopt(before);
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* The three-way rule, applied once this window holds the lock (PRD D5).
|
|
543
|
+
*
|
|
544
|
+
* Stated precisely because "retry with a token different from the one you
|
|
545
|
+
* failed with" is not well defined: the in-window single-flight already
|
|
546
|
+
* collapses requests that were sent with different tokens.
|
|
547
|
+
*/
|
|
548
|
+
async #renewOrAdopt(before) {
|
|
549
|
+
const current = this.#store.read();
|
|
550
|
+
// Gone: whoever held the lock before us renewed and then failed permanently,
|
|
551
|
+
// or signed out. Retrying would present nothing and 401 again.
|
|
552
|
+
if (before && !current)
|
|
553
|
+
return false;
|
|
554
|
+
// Someone renewed while we queued. Adopt their work; do not rotate again —
|
|
555
|
+
// a second rotation would invalidate the token every other window just took.
|
|
556
|
+
if (current && current !== before) {
|
|
557
|
+
this.#auth = current;
|
|
558
|
+
return true;
|
|
559
|
+
}
|
|
560
|
+
return this.#renew();
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* The renewal request itself.
|
|
564
|
+
*
|
|
565
|
+
* ⚠ THE TIMEOUT IS LOAD-BEARING. Without it a hung request holds the
|
|
566
|
+
* cross-window lock indefinitely and every other window waits behind it.
|
|
567
|
+
*/
|
|
568
|
+
async #renew() {
|
|
218
569
|
try {
|
|
219
570
|
const response = await fetch(this.#authUrls.refresh, {
|
|
220
571
|
method: 'POST',
|
|
221
572
|
headers: {
|
|
222
|
-
[HttpHeader.Authorization]: this
|
|
573
|
+
[HttpHeader.Authorization]: this.auth,
|
|
223
574
|
[HttpHeader.AppContext]: 'brokenstock',
|
|
224
575
|
},
|
|
225
576
|
credentials: 'include',
|
|
577
|
+
signal: AbortSignal.timeout(RENEWAL_TIMEOUT_MS),
|
|
226
578
|
});
|
|
227
579
|
if (!response.ok)
|
|
228
580
|
return false;
|
|
@@ -230,20 +582,33 @@ class AuthenticationService {
|
|
|
230
582
|
if (!auth || !BEARER_TOKEN_REGEX.test(auth))
|
|
231
583
|
return false;
|
|
232
584
|
const body = await response.json();
|
|
233
|
-
this.#auth
|
|
234
|
-
|
|
585
|
+
this.#adoptToken(auth);
|
|
586
|
+
// The raw token is stripped exactly as `login()` strips it. `/refresh`
|
|
587
|
+
// returns `{session: {token}}`, and assigning that straight through put
|
|
588
|
+
// the bearer into a PUBLIC signal — a second copy of the credential in a
|
|
589
|
+
// place nothing needs it, which contradicts the care taken everywhere
|
|
590
|
+
// else about where this token may live.
|
|
591
|
+
const session = { ...(body.session ?? {}) };
|
|
592
|
+
delete session.token;
|
|
593
|
+
this.userSession.set(session);
|
|
235
594
|
return true;
|
|
236
595
|
}
|
|
237
596
|
catch {
|
|
597
|
+
// Includes the abort. A timed-out renewal is a failed renewal.
|
|
238
598
|
return false;
|
|
239
599
|
}
|
|
240
600
|
}
|
|
601
|
+
/**
|
|
602
|
+
* THE ONE PLACE a session ends. Both sign-out paths route through here so the
|
|
603
|
+
* stored token cannot outlive the session it belongs to — see `sessionLost`.
|
|
604
|
+
*/
|
|
241
605
|
#clearSession() {
|
|
242
606
|
this.authenticated.set(false);
|
|
243
607
|
this.userSession.set(undefined);
|
|
244
608
|
this.conflictSessions.set(undefined);
|
|
245
609
|
this.pendingCredentials.set(undefined);
|
|
246
610
|
this.#auth = 'Bearer ..';
|
|
611
|
+
this.#store.clear();
|
|
247
612
|
this.#router.navigate(['/login']).catch(console.error);
|
|
248
613
|
}
|
|
249
614
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
@@ -252,7 +617,7 @@ class AuthenticationService {
|
|
|
252
617
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticationService, decorators: [{
|
|
253
618
|
type: Injectable,
|
|
254
619
|
args: [{ providedIn: 'root' }]
|
|
255
|
-
}] });
|
|
620
|
+
}], ctorParameters: () => [] });
|
|
256
621
|
|
|
257
622
|
const authGuard = (_route, state) => {
|
|
258
623
|
const authService = inject(AuthenticationService);
|
|
@@ -277,9 +642,7 @@ const guestGuard = () => {
|
|
|
277
642
|
|
|
278
643
|
const authInterceptor = (req, next) => {
|
|
279
644
|
const authService = inject(AuthenticationService);
|
|
280
|
-
const thinkingService = inject(ThinkingService);
|
|
281
645
|
const authUrls = inject(SHELL_AUTH_URLS);
|
|
282
|
-
const router = inject(Router);
|
|
283
646
|
return next(req).pipe(catchError((error) => {
|
|
284
647
|
const isRefreshOrLogin = req.url === authUrls.refresh || req.url === authUrls.login;
|
|
285
648
|
if (error.status === 401 && !isRefreshOrLogin) {
|
|
@@ -290,16 +653,13 @@ const authInterceptor = (req, next) => {
|
|
|
290
653
|
});
|
|
291
654
|
return next(retryReq);
|
|
292
655
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
656
|
+
// `sessionLost()` and not the three lines that used to be here: it
|
|
657
|
+
// also clears the STORED token. Leaving a dead token behind makes
|
|
658
|
+
// every later window sign itself in, render, 401 and bounce.
|
|
659
|
+
authService.sessionLost();
|
|
297
660
|
return throwError(() => error);
|
|
298
661
|
}), catchError(() => {
|
|
299
|
-
|
|
300
|
-
authService.authenticated.set(false);
|
|
301
|
-
authService.userSession.set(undefined);
|
|
302
|
-
router.navigate(['/login']);
|
|
662
|
+
authService.sessionLost();
|
|
303
663
|
return throwError(() => error);
|
|
304
664
|
}));
|
|
305
665
|
}
|
|
@@ -2805,5 +3165,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
2805
3165
|
* Generated bundle index. Do not edit.
|
|
2806
3166
|
*/
|
|
2807
3167
|
|
|
2808
|
-
export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RegexValidatorDirective, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_POST_LOGIN_ROUTE, Shell, SideMenu, StatusBar, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, guestGuard, provideShellConfig, tokenize };
|
|
3168
|
+
export { AuthenticationService, CLEAR_USAGE, CommandRegistryService, CommandService, CommandTerminal, ConfirmDialog, FAQ_CONTENT_LOADER, FAQ_DEV_EDITOR, FAQ_DEV_MODE, FAQ_DEV_WORKSPACE_ROOT, FAQ_PUBLIC_PUBLISHER, FAQ_PUBLIC_SITE_BASE_URL, FaqAuthoringPanel, FaqHistoryService, FaqMarkdownRendererService, FaqModal, FaqService, HELP_USAGE, HttpFaqContentLoader, KeyboardShortcutService, Login, MainContent, PublicFaqIndex, PublicFaqPage, PublicFaqToc, RegexValidatorDirective, SESSION_TOKEN_KEY, SHELL_APP_NAME, SHELL_AUTH_URLS, SHELL_COMMANDS, SHELL_MENU_ITEMS, SHELL_POST_LOGIN_ROUTE, SHELL_SHARED_SESSION, SessionTokenStore, Shell, SideMenu, StatusBar, TerminalFocusService, TerminalVisibilityService, ThemeService, ThinkingService, audienceAllowed, authGuard, authInterceptor, buildFallbackChain, guestGuard, provideShellConfig, tokenize };
|
|
2809
3169
|
//# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map
|