@franzzemen/brokenstock-shell 0.4.3 → 0.4.5
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,257 @@ 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
|
+
/**
|
|
237
|
+
* Checked at CALL time, not once at module load.
|
|
238
|
+
*
|
|
239
|
+
* Module-level would be evaluated during import — before `navigator` exists at
|
|
240
|
+
* all under prerendering, and before a test can arrange anything — permanently
|
|
241
|
+
* fixing the answer to "no" for the life of the process. Found by writing the
|
|
242
|
+
* test for the fallback path, which could not be reached any other way.
|
|
243
|
+
*/
|
|
244
|
+
function locksAvailable() {
|
|
245
|
+
return typeof navigator !== 'undefined' && typeof navigator.locks?.request === 'function';
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Runs `work` if this window can take the lock right now.
|
|
249
|
+
*
|
|
250
|
+
* Returns `{ran: false}` when another window holds it — the caller decides what
|
|
251
|
+
* to do instead, which is the point: only the caller knows whether waiting for
|
|
252
|
+
* the peer's result or proceeding alone is correct.
|
|
253
|
+
*
|
|
254
|
+
* When `navigator.locks` is unavailable the work RUNS, uncoordinated. That is a
|
|
255
|
+
* deliberate degradation to today's behaviour rather than a refusal.
|
|
256
|
+
*/
|
|
257
|
+
async function runExclusive(name, work) {
|
|
258
|
+
if (!locksAvailable()) {
|
|
259
|
+
console.warn(`[shell] navigator.locks unavailable; "${name}" runs without cross-window coordination`);
|
|
260
|
+
return { ran: true, value: await work() };
|
|
261
|
+
}
|
|
262
|
+
let outcome = { ran: false };
|
|
263
|
+
await navigator.locks.request(name, { ifAvailable: true }, async (lock) => {
|
|
264
|
+
if (!lock)
|
|
265
|
+
return;
|
|
266
|
+
outcome = { ran: true, value: await work() };
|
|
267
|
+
});
|
|
268
|
+
return outcome;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Polls `read` until it returns something other than `unchanged`, or the bound
|
|
272
|
+
* elapses.
|
|
273
|
+
*
|
|
274
|
+
* Polling rather than listening for `storage` on purpose: the event does not
|
|
275
|
+
* fire in a window that is frozen at the moment it is dispatched, and this is
|
|
276
|
+
* the path that recovers such a window. A poll asks the question again.
|
|
277
|
+
*
|
|
278
|
+
* Resolves `undefined` on timeout — "the peer did not deliver", which is a
|
|
279
|
+
* legitimate answer the caller must handle, not an error.
|
|
280
|
+
*/
|
|
281
|
+
async function awaitChange(read, unchanged, timeoutMs, pollMs = 100) {
|
|
282
|
+
const deadline = Date.now() + timeoutMs;
|
|
283
|
+
for (;;) {
|
|
284
|
+
const current = read();
|
|
285
|
+
if (current !== unchanged)
|
|
286
|
+
return current;
|
|
287
|
+
if (Date.now() >= deadline)
|
|
288
|
+
return undefined;
|
|
289
|
+
await new Promise(resolve => setTimeout(resolve, pollMs));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/*
|
|
294
|
+
Created by Franz Zemen 2026-08-29
|
|
295
|
+
License Type: UNLICENSED
|
|
296
|
+
|
|
297
|
+
What a window should do once it can act on a session renewal
|
|
298
|
+
(multiple-windows PRD D5).
|
|
299
|
+
|
|
300
|
+
WHY THIS IS A NAMED FUNCTION RATHER THAN THREE `if`s INSIDE THE SERVICE. The
|
|
301
|
+
rule was originally written as "retry with a token different from the one you
|
|
302
|
+
failed with", which is not a well-defined instruction: the in-window
|
|
303
|
+
single-flight already collapses requests that were sent with DIFFERENT tokens,
|
|
304
|
+
so "the one you failed with" is not a single value. Pulling the decision out
|
|
305
|
+
forces the three cases to be named, and lets them be tested without mocking a
|
|
306
|
+
network call — which is what they deserve, because getting this wrong shows up
|
|
307
|
+
hours later as "a window signed me out" with nothing linking cause to effect.
|
|
308
|
+
*/
|
|
309
|
+
/**
|
|
310
|
+
* @param before the token this window held immediately BEFORE asking for the lock
|
|
311
|
+
* @param current the token in shared storage now that this window can act
|
|
312
|
+
*/
|
|
313
|
+
function decideRenewal(before, current) {
|
|
314
|
+
// Gone. Retrying would present nothing and 401 again; worse, it would look
|
|
315
|
+
// like a transient failure when it is a definite answer.
|
|
316
|
+
if (before && !current)
|
|
317
|
+
return 'sign-out';
|
|
318
|
+
// Someone else's fresh token. Rotating again would invalidate the token every
|
|
319
|
+
// OTHER window just adopted — turning one window's recovery into everyone
|
|
320
|
+
// else's sign-out.
|
|
321
|
+
if (current && current !== before)
|
|
322
|
+
return 'adopt';
|
|
323
|
+
// Unchanged, or nothing stored to begin with.
|
|
324
|
+
return 'renew';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Name of the cross-window renewal lock (PRD D5). */
|
|
328
|
+
const RENEWAL_LOCK = 'brokenstock.session.renew';
|
|
329
|
+
/** How long a window waits for the renewing window to publish a token (PRD D5). */
|
|
330
|
+
const PEER_RENEWAL_WAIT_MS = 10_000;
|
|
331
|
+
/** How long the renewal request itself may take before it is abandoned (PRD D5). */
|
|
332
|
+
const RENEWAL_TIMEOUT_MS = 15_000;
|
|
79
333
|
class AuthenticationService {
|
|
334
|
+
#refreshing;
|
|
335
|
+
#auth;
|
|
336
|
+
#httpClient;
|
|
337
|
+
#thinkingService;
|
|
338
|
+
#router;
|
|
339
|
+
#authUrls;
|
|
340
|
+
#store;
|
|
341
|
+
/** Off by default: an app opts in to sharing its session across windows (PRD D7). */
|
|
342
|
+
#sharedSession;
|
|
80
343
|
constructor() {
|
|
81
344
|
this.authenticated = signal(false, /* @ts-ignore */
|
|
82
345
|
...(ngDevMode ? [{ debugName: "authenticated" }] : /* istanbul ignore next */ []));
|
|
@@ -94,16 +357,65 @@ class AuthenticationService {
|
|
|
94
357
|
this.#thinkingService = inject(ThinkingService);
|
|
95
358
|
this.#router = inject(Router);
|
|
96
359
|
this.#authUrls = inject(SHELL_AUTH_URLS);
|
|
360
|
+
this.#store = inject(SessionTokenStore);
|
|
361
|
+
/** Off by default: an app opts in to sharing its session across windows (PRD D7). */
|
|
362
|
+
this.#sharedSession = inject(SHELL_SHARED_SESSION, { optional: true }) ?? false;
|
|
363
|
+
if (!this.#sharedSession)
|
|
364
|
+
return;
|
|
365
|
+
// Adopt a token this browser already holds. This runs during construction,
|
|
366
|
+
// and `authGuard` injecting this service IS the first read of `authenticated`
|
|
367
|
+
// — a field initializer completes before `inject()` returns — so the guard
|
|
368
|
+
// sees the restored state without becoming asynchronous (PRD D4).
|
|
369
|
+
//
|
|
370
|
+
// The token may well be expired. That is not this constructor's problem: the
|
|
371
|
+
// first request 401s and the interceptor renews and retries, which is the
|
|
372
|
+
// path that already works today.
|
|
373
|
+
const stored = this.#store.read();
|
|
374
|
+
if (stored) {
|
|
375
|
+
this.#auth = stored;
|
|
376
|
+
this.authenticated.set(true);
|
|
377
|
+
}
|
|
378
|
+
// Another window signing out must sign this one out too (PRD D6). A token
|
|
379
|
+
// that merely CHANGED needs no action here — `auth` reads through, so the
|
|
380
|
+
// next caller gets the new value without anyone copying it.
|
|
381
|
+
this.#store.onExternalChange(token => {
|
|
382
|
+
if (!token && this.authenticated())
|
|
383
|
+
this.#clearSession();
|
|
384
|
+
});
|
|
97
385
|
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
386
|
+
/**
|
|
387
|
+
* The bearer for outbound calls.
|
|
388
|
+
*
|
|
389
|
+
* READ-THROUGH, not a cached copy, whenever the session is shared across
|
|
390
|
+
* windows (PRD D2/D10). A copy is only as fresh as the last `storage` event
|
|
391
|
+
* delivered, and the consumer that matters most is outside the request path:
|
|
392
|
+
* the market-tape socket re-presents this token on a sixty-second heartbeat,
|
|
393
|
+
* and the server closes the socket with a code the client does NOT retry if it
|
|
394
|
+
* is stale. A window that was frozen when the event fired would lose its live
|
|
395
|
+
* prices permanently. Reading through removes the question of which window
|
|
396
|
+
* holds which token.
|
|
397
|
+
*/
|
|
104
398
|
get auth() {
|
|
399
|
+
if (this.#sharedSession) {
|
|
400
|
+
const stored = this.#store.read();
|
|
401
|
+
if (stored)
|
|
402
|
+
return stored;
|
|
403
|
+
}
|
|
105
404
|
return this.#auth;
|
|
106
405
|
}
|
|
406
|
+
/**
|
|
407
|
+
* Adopts a freshly issued token: in memory, and in shared storage so every
|
|
408
|
+
* other window of this browser sees it on their next read.
|
|
409
|
+
*
|
|
410
|
+
* A failed write THROWS out of here rather than being absorbed — see
|
|
411
|
+
* `SessionTokenStore`. A session the other windows cannot see is the failure
|
|
412
|
+
* this whole design exists to prevent.
|
|
413
|
+
*/
|
|
414
|
+
#adoptToken(token) {
|
|
415
|
+
this.#auth = token;
|
|
416
|
+
if (this.#sharedSession)
|
|
417
|
+
this.#store.write(token);
|
|
418
|
+
}
|
|
107
419
|
// Legacy hasRole/hasAnyRole removed (legacy-authz-teardown): sessions carry no
|
|
108
420
|
// roles, and every gate moved to hasFeature (commerce) with the BE enforcing the
|
|
109
421
|
// security axis.
|
|
@@ -129,6 +441,23 @@ class AuthenticationService {
|
|
|
129
441
|
this.conflictSessions.set(undefined);
|
|
130
442
|
this.pendingCredentials.set(undefined);
|
|
131
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* The session is gone and cannot be recovered — renewal failed permanently,
|
|
446
|
+
* or another window signed out.
|
|
447
|
+
*
|
|
448
|
+
* ⚠ THIS EXISTS BECAUSE THERE ARE TWO SIGN-OUT PATHS AND ONLY ONE USED TO
|
|
449
|
+
* CLEAR THE TOKEN. `logout()` went through `#clearSession()`; the 401
|
|
450
|
+
* interceptor set the signals and navigated but left `#auth` alone. Harmless
|
|
451
|
+
* while the token lived only in memory and died with the tab — but once it is
|
|
452
|
+
* in storage, a dead token left behind means every later window marks itself
|
|
453
|
+
* signed in, passes the guard, renders, 401s and bounces to the login screen.
|
|
454
|
+
* The reload fix would have become "the app flashes, then throws me out",
|
|
455
|
+
* which is harder to diagnose than the behaviour it replaced.
|
|
456
|
+
*/
|
|
457
|
+
sessionLost() {
|
|
458
|
+
this.#thinkingService.reset();
|
|
459
|
+
this.#clearSession();
|
|
460
|
+
}
|
|
132
461
|
logout() {
|
|
133
462
|
const subscription = this.#httpClient
|
|
134
463
|
.get(this.#authUrls.logout, {
|
|
@@ -180,7 +509,7 @@ class AuthenticationService {
|
|
|
180
509
|
reject('Unable to login');
|
|
181
510
|
return;
|
|
182
511
|
}
|
|
183
|
-
this.#auth
|
|
512
|
+
this.#adoptToken(auth);
|
|
184
513
|
this.authenticated.set(true);
|
|
185
514
|
this.pendingCredentials.set(undefined);
|
|
186
515
|
resolve();
|
|
@@ -214,15 +543,79 @@ class AuthenticationService {
|
|
|
214
543
|
});
|
|
215
544
|
});
|
|
216
545
|
}
|
|
546
|
+
/**
|
|
547
|
+
* Renews the session, coordinating with the other windows of this browser.
|
|
548
|
+
*
|
|
549
|
+
* WHY COORDINATION IS REQUIRED, NOT AN OPTIMISATION. Every window shares one
|
|
550
|
+
* session, so they all find the token expired at the same moment and all try
|
|
551
|
+
* to renew. The server serialises rotation on the session row and REFUSES the
|
|
552
|
+
* losers — so uncoordinated, every window but one is signed out, every token
|
|
553
|
+
* lifetime, several times a day.
|
|
554
|
+
*
|
|
555
|
+
* The lock is taken with `ifAvailable`, so a window that does not get it is
|
|
556
|
+
* told so immediately and then waits for the WINNER'S RESULT with a bound of
|
|
557
|
+
* its own, rather than blocking on a peer it cannot supervise. A frozen
|
|
558
|
+
* window holding the lock would otherwise stop every other window forever,
|
|
559
|
+
* with nothing on screen and nothing logged.
|
|
560
|
+
*/
|
|
217
561
|
async #doRefresh() {
|
|
562
|
+
if (!this.#sharedSession)
|
|
563
|
+
return this.#renew();
|
|
564
|
+
// Captured BEFORE asking for the lock: it is what "did someone else renew
|
|
565
|
+
// while I waited?" is measured against.
|
|
566
|
+
const before = this.#store.read();
|
|
567
|
+
const outcome = await runExclusive(RENEWAL_LOCK, () => this.#renewOrAdopt(before));
|
|
568
|
+
if (outcome.ran)
|
|
569
|
+
return outcome.value;
|
|
570
|
+
// Another window is renewing. Wait for its token to appear.
|
|
571
|
+
const published = await awaitChange(() => this.#store.read(), before, PEER_RENEWAL_WAIT_MS);
|
|
572
|
+
if (published) {
|
|
573
|
+
this.#auth = published;
|
|
574
|
+
return true;
|
|
575
|
+
}
|
|
576
|
+
// Either the winner cleared the token (its renewal failed permanently), or
|
|
577
|
+
// it never delivered inside the bound. Re-read to tell those apart: an
|
|
578
|
+
// absent token is a definite answer and must not be retried, whereas a
|
|
579
|
+
// still-unchanged one means the winner is gone and this window should try.
|
|
580
|
+
if (before && !this.#store.read())
|
|
581
|
+
return false;
|
|
582
|
+
return this.#renewOrAdopt(before);
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* The three-way rule, applied once this window holds the lock (PRD D5).
|
|
586
|
+
*
|
|
587
|
+
* Stated precisely because "retry with a token different from the one you
|
|
588
|
+
* failed with" is not well defined: the in-window single-flight already
|
|
589
|
+
* collapses requests that were sent with different tokens.
|
|
590
|
+
*/
|
|
591
|
+
async #renewOrAdopt(before) {
|
|
592
|
+
const current = this.#store.read();
|
|
593
|
+
switch (decideRenewal(before, current)) {
|
|
594
|
+
case 'sign-out':
|
|
595
|
+
return false;
|
|
596
|
+
case 'adopt':
|
|
597
|
+
this.#auth = current;
|
|
598
|
+
return true;
|
|
599
|
+
case 'renew':
|
|
600
|
+
return this.#renew();
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The renewal request itself.
|
|
605
|
+
*
|
|
606
|
+
* ⚠ THE TIMEOUT IS LOAD-BEARING. Without it a hung request holds the
|
|
607
|
+
* cross-window lock indefinitely and every other window waits behind it.
|
|
608
|
+
*/
|
|
609
|
+
async #renew() {
|
|
218
610
|
try {
|
|
219
611
|
const response = await fetch(this.#authUrls.refresh, {
|
|
220
612
|
method: 'POST',
|
|
221
613
|
headers: {
|
|
222
|
-
[HttpHeader.Authorization]: this
|
|
614
|
+
[HttpHeader.Authorization]: this.auth,
|
|
223
615
|
[HttpHeader.AppContext]: 'brokenstock',
|
|
224
616
|
},
|
|
225
617
|
credentials: 'include',
|
|
618
|
+
signal: AbortSignal.timeout(RENEWAL_TIMEOUT_MS),
|
|
226
619
|
});
|
|
227
620
|
if (!response.ok)
|
|
228
621
|
return false;
|
|
@@ -230,20 +623,33 @@ class AuthenticationService {
|
|
|
230
623
|
if (!auth || !BEARER_TOKEN_REGEX.test(auth))
|
|
231
624
|
return false;
|
|
232
625
|
const body = await response.json();
|
|
233
|
-
this.#auth
|
|
234
|
-
|
|
626
|
+
this.#adoptToken(auth);
|
|
627
|
+
// The raw token is stripped exactly as `login()` strips it. `/refresh`
|
|
628
|
+
// returns `{session: {token}}`, and assigning that straight through put
|
|
629
|
+
// the bearer into a PUBLIC signal — a second copy of the credential in a
|
|
630
|
+
// place nothing needs it, which contradicts the care taken everywhere
|
|
631
|
+
// else about where this token may live.
|
|
632
|
+
const session = { ...(body.session ?? {}) };
|
|
633
|
+
delete session.token;
|
|
634
|
+
this.userSession.set(session);
|
|
235
635
|
return true;
|
|
236
636
|
}
|
|
237
637
|
catch {
|
|
638
|
+
// Includes the abort. A timed-out renewal is a failed renewal.
|
|
238
639
|
return false;
|
|
239
640
|
}
|
|
240
641
|
}
|
|
642
|
+
/**
|
|
643
|
+
* THE ONE PLACE a session ends. Both sign-out paths route through here so the
|
|
644
|
+
* stored token cannot outlive the session it belongs to — see `sessionLost`.
|
|
645
|
+
*/
|
|
241
646
|
#clearSession() {
|
|
242
647
|
this.authenticated.set(false);
|
|
243
648
|
this.userSession.set(undefined);
|
|
244
649
|
this.conflictSessions.set(undefined);
|
|
245
650
|
this.pendingCredentials.set(undefined);
|
|
246
651
|
this.#auth = 'Bearer ..';
|
|
652
|
+
this.#store.clear();
|
|
247
653
|
this.#router.navigate(['/login']).catch(console.error);
|
|
248
654
|
}
|
|
249
655
|
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
|
|
@@ -252,7 +658,7 @@ class AuthenticationService {
|
|
|
252
658
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImport: i0, type: AuthenticationService, decorators: [{
|
|
253
659
|
type: Injectable,
|
|
254
660
|
args: [{ providedIn: 'root' }]
|
|
255
|
-
}] });
|
|
661
|
+
}], ctorParameters: () => [] });
|
|
256
662
|
|
|
257
663
|
const authGuard = (_route, state) => {
|
|
258
664
|
const authService = inject(AuthenticationService);
|
|
@@ -277,9 +683,7 @@ const guestGuard = () => {
|
|
|
277
683
|
|
|
278
684
|
const authInterceptor = (req, next) => {
|
|
279
685
|
const authService = inject(AuthenticationService);
|
|
280
|
-
const thinkingService = inject(ThinkingService);
|
|
281
686
|
const authUrls = inject(SHELL_AUTH_URLS);
|
|
282
|
-
const router = inject(Router);
|
|
283
687
|
return next(req).pipe(catchError((error) => {
|
|
284
688
|
const isRefreshOrLogin = req.url === authUrls.refresh || req.url === authUrls.login;
|
|
285
689
|
if (error.status === 401 && !isRefreshOrLogin) {
|
|
@@ -290,16 +694,13 @@ const authInterceptor = (req, next) => {
|
|
|
290
694
|
});
|
|
291
695
|
return next(retryReq);
|
|
292
696
|
}
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
697
|
+
// `sessionLost()` and not the three lines that used to be here: it
|
|
698
|
+
// also clears the STORED token. Leaving a dead token behind makes
|
|
699
|
+
// every later window sign itself in, render, 401 and bounce.
|
|
700
|
+
authService.sessionLost();
|
|
297
701
|
return throwError(() => error);
|
|
298
702
|
}), catchError(() => {
|
|
299
|
-
|
|
300
|
-
authService.authenticated.set(false);
|
|
301
|
-
authService.userSession.set(undefined);
|
|
302
|
-
router.navigate(['/login']);
|
|
703
|
+
authService.sessionLost();
|
|
303
704
|
return throwError(() => error);
|
|
304
705
|
}));
|
|
305
706
|
}
|
|
@@ -2805,5 +3206,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.2", ngImpor
|
|
|
2805
3206
|
* Generated bundle index. Do not edit.
|
|
2806
3207
|
*/
|
|
2807
3208
|
|
|
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 };
|
|
3209
|
+
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, decideRenewal, guestGuard, provideShellConfig, tokenize };
|
|
2809
3210
|
//# sourceMappingURL=franzzemen-brokenstock-shell.mjs.map
|