@syndew/oluso-auth 0.0.0-stage → 1.30.1

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.
@@ -0,0 +1,1985 @@
1
+ import { of, ReplaySubject, timer, throwError, filter, lastValueFrom, catchError, Subject, defer, tap, take as take$1, timeout as timeout$1, map as map$1, switchMap as switchMap$1, from, firstValueFrom } from 'rxjs';
2
+ import * as i0 from '@angular/core';
3
+ import { Injectable, InjectionToken, Optional, Inject, inject, signal, computed } from '@angular/core';
4
+ import { map, retry, switchMap, finalize, take, timeout, catchError as catchError$1, shareReplay } from 'rxjs/operators';
5
+ import { HttpResponse, HttpErrorResponse, HttpClient, HttpHeaders } from '@angular/common/http';
6
+ import { toSignal, toObservable } from '@angular/core/rxjs-interop';
7
+ import { Router } from '@angular/router';
8
+ import { PermissionsService, ToastService, SKIP_ERROR_TOAST } from '@syndew/oluso-ui-core';
9
+
10
+ /**
11
+ * Implemented by the host app to bridge auth concerns to whatever auth library
12
+ * (angular-auth-oidc-client, MSAL, etc.) is in use. Keeps libs/auth free of
13
+ * library-specific dependencies — swapping providers is a single-file change.
14
+ */
15
+ class AuthProvider {
16
+ }
17
+ class NoOpAuthProvider extends AuthProvider {
18
+ isAuthenticated$ = of(false);
19
+ getAccessToken() { return of(null); }
20
+ forceRefreshSession() {
21
+ return of({ isAuthenticated: false, accessToken: null });
22
+ }
23
+ switchTenant() {
24
+ return of({ isAuthenticated: false, accessToken: null });
25
+ }
26
+ authorize() { }
27
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: NoOpAuthProvider, deps: null, target: i0.ɵɵFactoryTarget.Injectable });
28
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: NoOpAuthProvider, providedIn: 'root' });
29
+ }
30
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: NoOpAuthProvider, decorators: [{
31
+ type: Injectable,
32
+ args: [{ providedIn: 'root' }]
33
+ }] });
34
+
35
+ const ROTATION_ANSWER_TTL_MS = 60_000;
36
+ const CHANNEL_NAME = 'oluso.token-rotation';
37
+ class TokenRotationChannel {
38
+ answers = new Map();
39
+ waiters = new Map();
40
+ publications = new Map();
41
+ listeners = new Set();
42
+ channel;
43
+ constructor(channelName = CHANNEL_NAME) {
44
+ this.channel = typeof BroadcastChannel === 'undefined' ? null : new BroadcastChannel(channelName);
45
+ this.channel?.addEventListener('message', (event) => {
46
+ const message = event.data;
47
+ if (message?.type !== 'rotated')
48
+ return;
49
+ this.remember(message.id, { iv: message.iv, ciphertext: message.ciphertext, at: Date.now() });
50
+ this.listeners.forEach((listener) => listener(message.id));
51
+ });
52
+ }
53
+ /**
54
+ * Records that `spentRefreshToken` was exchanged for `tokens` and tells the other tabs. Resolves once the
55
+ * other tabs have been told.
56
+ */
57
+ async publish(spentRefreshToken, tokens) {
58
+ const id = await rotationId(spentRefreshToken);
59
+ const key = await rotationKey(spentRefreshToken);
60
+ const iv = crypto.getRandomValues(new Uint8Array(12)).buffer;
61
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(JSON.stringify(tokens)));
62
+ this.remember(id, { iv, ciphertext, at: Date.now() });
63
+ this.channel?.postMessage({ type: 'rotated', id, iv, ciphertext });
64
+ this.publication(spentRefreshToken).resolve();
65
+ }
66
+ /** Resolves once this tab has published the rotation of `refreshToken`, or after `timeoutMs`. */
67
+ awaitPublished(refreshToken, timeoutMs) {
68
+ return new Promise((resolve) => {
69
+ const timer = setTimeout(resolve, timeoutMs);
70
+ void this.publication(refreshToken).done.then(() => { clearTimeout(timer); resolve(); });
71
+ });
72
+ }
73
+ /** What `refreshToken` was exchanged for, in this tab or another, while that answer is fresh; else null. */
74
+ async answerFor(refreshToken) {
75
+ const id = await rotationId(refreshToken);
76
+ const answer = this.answers.get(id);
77
+ if (!answer || Date.now() - answer.at > ROTATION_ANSWER_TTL_MS)
78
+ return null;
79
+ try {
80
+ const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: answer.iv }, await rotationKey(refreshToken), answer.ciphertext);
81
+ return JSON.parse(new TextDecoder().decode(plain));
82
+ }
83
+ catch {
84
+ return null;
85
+ }
86
+ }
87
+ /** {@link answerFor}, waiting up to `timeoutMs` for another tab's rotation of `refreshToken` to arrive. */
88
+ async waitForAnswer(refreshToken, timeoutMs) {
89
+ const id = await rotationId(refreshToken);
90
+ if (!this.answers.has(id)) {
91
+ await new Promise((resolve) => {
92
+ const waiter = () => { clearTimeout(timer); this.waiters.get(id)?.delete(waiter); resolve(); };
93
+ const timer = setTimeout(waiter, timeoutMs);
94
+ const set = this.waiters.get(id) ?? new Set();
95
+ set.add(waiter);
96
+ this.waiters.set(id, set);
97
+ });
98
+ }
99
+ return this.answerFor(refreshToken);
100
+ }
101
+ /** Calls `listener` with the id of every rotation that arrives from another tab. Returns the unsubscribe. */
102
+ onRotation(listener) {
103
+ this.listeners.add(listener);
104
+ return () => this.listeners.delete(listener);
105
+ }
106
+ /** Whether `refreshToken` is the token a rotation with `id` spent. */
107
+ async isSpentBy(refreshToken, id) {
108
+ return (await rotationId(refreshToken)) === id;
109
+ }
110
+ remember(id, answer) {
111
+ this.answers.set(id, answer);
112
+ setTimeout(() => { if (this.answers.get(id) === answer)
113
+ this.answers.delete(id); }, ROTATION_ANSWER_TTL_MS);
114
+ this.waiters.get(id)?.forEach((wake) => wake());
115
+ }
116
+ publication(refreshToken) {
117
+ let publication = this.publications.get(refreshToken);
118
+ if (!publication) {
119
+ let resolve;
120
+ const done = new Promise((r) => (resolve = r));
121
+ publication = { done, resolve };
122
+ this.publications.set(refreshToken, publication);
123
+ setTimeout(() => this.publications.delete(refreshToken), ROTATION_ANSWER_TTL_MS);
124
+ }
125
+ return publication;
126
+ }
127
+ }
128
+ /** The channel the app's token storage and refresh interceptor share. */
129
+ const tokenRotationChannel = new TokenRotationChannel();
130
+ /** Overrides {@link tokenRotationChannel} for the token storage (tests stand up one per simulated tab). */
131
+ const TOKEN_ROTATION_CHANNEL = new InjectionToken('TOKEN_ROTATION_CHANNEL');
132
+ async function rotationId(refreshToken) {
133
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`oluso.rotation.id:${refreshToken}`));
134
+ return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
135
+ }
136
+ async function rotationKey(refreshToken) {
137
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`oluso.rotation.key:${refreshToken}`));
138
+ return crypto.subtle.importKey('raw', digest, 'AES-GCM', false, ['encrypt', 'decrypt']);
139
+ }
140
+
141
+ /**
142
+ * Statuses meaning the token endpoint was briefly unavailable — a proxy or load balancer answering while the
143
+ * server restarts or deploys, a gateway timeout, or throttling — rather than refusing the grant.
144
+ */
145
+ const TRANSIENT_STATUSES = new Set([408, 429, 502, 503, 504]);
146
+ /** Longest wait between retries of our own choosing; short, so a renew follows the server back promptly. */
147
+ const MAX_BACKOFF_MS = 10_000;
148
+ /** Longest Retry-After honoured. */
149
+ const MAX_RETRY_AFTER_MS = 30_000;
150
+ /** How long a tab that has just finished a refresh may take to publish its rotation before another tab gives up waiting. */
151
+ const ROTATION_SETTLE_MS$1 = 500;
152
+ /** How long a refresh keeps other tabs waiting for its rotation to be stored and published. */
153
+ const PUBLISH_TIMEOUT_MS = 10_000;
154
+ /** Refresh exchanges in this tab by the refresh token they redeem, while running and for a while after they answer. */
155
+ const exchanges = new Map();
156
+ /**
157
+ * Makes each refresh token redeem exactly once across all of the app's tabs, and keeps that redemption alive
158
+ * through a brief server outage.
159
+ *
160
+ * Refresh tokens are single use. Three things would otherwise present one twice:
161
+ * - the OIDC client abandons a renew it considers slow (its periodic check cancels the request every few
162
+ * seconds) and later starts another with the same token;
163
+ * - in one tab, a 401 and the background renew can both ask for a refresh;
164
+ * - tabs can hold the same token (a duplicated tab copies its opener's session; remembered sessions share one
165
+ * store), and each renews on its own timer.
166
+ * So one exchange runs per refresh token whether or not its caller still listens, refreshes are taken in turn
167
+ * across tabs under a Web Lock, and a request for a token another exchange already redeemed — here or in
168
+ * another tab ({@link tokenRotationChannel}) — is answered with what it was exchanged for, its lifetime
169
+ * counted from when the server issued it.
170
+ *
171
+ * The client also deletes the session on any HTTP error, so a 502 from the proxy while the API restarts would
172
+ * sign the user out although the refresh token is still good. Transient statuses are retried after the
173
+ * server's Retry-After (up to 30 s), else 1 s doubling to 10 s; a refusal such as invalid_grant still reaches
174
+ * the client.
175
+ */
176
+ const tokenRefreshRetryInterceptor = (req, next) => {
177
+ const refreshToken = refreshTokenOf$1(req);
178
+ if (refreshToken === null)
179
+ return next(req);
180
+ if (pendingTenantSwitch !== null) {
181
+ req = withTenant(req, pendingTenantSwitch);
182
+ pendingTenantSwitch = null;
183
+ }
184
+ // A tenant switch is its own request: another exchange of the same refresh token answered for the tenant
185
+ // the session was in, which is not what the switch asked for.
186
+ const key = exchangeKey(req, refreshToken);
187
+ let exchange = exchanges.get(key);
188
+ if (!exchange) {
189
+ exchange = startExchange(req, refreshToken, key, (r) => next(r), tokenRotationChannel);
190
+ exchanges.set(key, exchange);
191
+ }
192
+ const current = exchange;
193
+ return current.events$.pipe(map((event) => (event instanceof HttpResponse && current.receivedAt !== null ? aged(event, Date.now() - current.receivedAt) : event)));
194
+ };
195
+ function startExchange(req, refreshToken, key, send, rotations) {
196
+ const events$ = new ReplaySubject(1);
197
+ const exchange = { events$, receivedAt: null };
198
+ const answer = (response) => {
199
+ exchange.receivedAt = Date.now();
200
+ events$.next(response);
201
+ events$.complete();
202
+ };
203
+ void withRefreshLock(clientIdOf(req), async (waited) => {
204
+ const handedOver = waited
205
+ ? await rotations.waitForAnswer(refreshToken, ROTATION_SETTLE_MS$1)
206
+ : await rotations.answerFor(refreshToken);
207
+ let outgoing = req;
208
+ if (handedOver) {
209
+ // The refresh token was already redeemed. A plain refresh takes what it was exchanged for; a tenant
210
+ // switch is made with the refresh token that exchange left, since redeeming a spent one is refused.
211
+ const rotated = tenantOf(req) ? refreshTokenFrom(handedOver) : null;
212
+ if (!rotated) {
213
+ answer(tokenResponseFrom(handedOver));
214
+ return;
215
+ }
216
+ outgoing = withRefreshToken(req, rotated);
217
+ }
218
+ const exchangeOnce = (request) => send(request).pipe(retry({
219
+ delay: (error, attempt) => isTransient(error) ? timer(refreshRetryDelayMs(error, attempt)) : throwError(() => error),
220
+ }), filter((event) => event instanceof HttpResponse));
221
+ // A refused tenant switch leaves the refresh token unspent, so the session refreshes where it is instead:
222
+ // the client signs the user out on any failed refresh, and a switch that is not allowed must not do that.
223
+ const response = await lastValueFrom(exchangeOnce(outgoing).pipe(catchError((error) => tenantOf(outgoing) && isRefusal(error) ? exchangeOnce(withoutTenant(outgoing)) : throwError(() => error))));
224
+ answer(response);
225
+ await rotations.awaitPublished(refreshToken, PUBLISH_TIMEOUT_MS);
226
+ }).then(() => setTimeout(() => exchanges.delete(key), ROTATION_ANSWER_TTL_MS), (error) => {
227
+ exchanges.delete(key);
228
+ events$.error(error);
229
+ });
230
+ return exchange;
231
+ }
232
+ /**
233
+ * Runs `work` holding the app-wide refresh lock for `clientId`, telling it whether it had to wait for another
234
+ * tab. Browsers without Web Locks run it unserialised, which leaves only this tab's own exchanges deduplicated.
235
+ */
236
+ async function withRefreshLock(clientId, work) {
237
+ const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
238
+ if (!locks)
239
+ return work(false);
240
+ const name = `oluso.token-refresh:${clientId}`;
241
+ const ranImmediately = await locks.request(name, { ifAvailable: true }, async (lock) => {
242
+ if (!lock)
243
+ return false;
244
+ await work(false);
245
+ return true;
246
+ });
247
+ if (!ranImmediately)
248
+ await locks.request(name, () => work(true));
249
+ }
250
+ /** The refresh token a token request redeems, or null for any other request. */
251
+ function refreshTokenOf$1(req) {
252
+ if (req.method !== 'POST' || typeof req.body !== 'string')
253
+ return null;
254
+ const form = new URLSearchParams(req.body);
255
+ return form.get('grant_type') === 'refresh_token' ? form.get('refresh_token') : null;
256
+ }
257
+ /** The tenant the next refresh switches to, set for one request by {@link requestTenantSwitch}. */
258
+ let pendingTenantSwitch = null;
259
+ /**
260
+ * Makes the next refresh a switch to `tenantId` (the token endpoint's `tenant` parameter), or cancels a switch
261
+ * not yet sent. The parameter rides that one request only: the auth library never holds it, so later refreshes
262
+ * carry none (the refresh token remembers the tenant), and a refused switch never reaches the library as a
263
+ * failure — it is answered by a plain refresh instead.
264
+ */
265
+ function requestTenantSwitch(tenantId) {
266
+ pendingTenantSwitch = tenantId;
267
+ }
268
+ /** The tenant a refresh asks to switch to (the token endpoint's `tenant` parameter), or null. */
269
+ function tenantOf(req) {
270
+ return new URLSearchParams(req.body).get('tenant');
271
+ }
272
+ function exchangeKey(req, refreshToken) {
273
+ const tenant = tenantOf(req);
274
+ return tenant ? `${refreshToken}\ntenant:${tenant}` : refreshToken;
275
+ }
276
+ /** The refresh token another exchange left in storage, from the tokens it handed over. */
277
+ function refreshTokenFrom(tokens) {
278
+ const token = tokens['authnResult']?.refresh_token;
279
+ return typeof token === 'string' && token ? token : null;
280
+ }
281
+ function withTenant(req, tenantId) {
282
+ const form = new URLSearchParams(req.body);
283
+ form.set('tenant', tenantId);
284
+ return req.clone({ body: form.toString() });
285
+ }
286
+ function withoutTenant(req) {
287
+ const form = new URLSearchParams(req.body);
288
+ form.delete('tenant');
289
+ return req.clone({ body: form.toString() });
290
+ }
291
+ /** The token endpoint turning the request down (invalid_request, access_denied), as opposed to failing. */
292
+ function isRefusal(error) {
293
+ return error instanceof HttpErrorResponse && error.status === 400;
294
+ }
295
+ function withRefreshToken(req, refreshToken) {
296
+ const form = new URLSearchParams(req.body);
297
+ form.set('refresh_token', refreshToken);
298
+ return req.clone({ body: form.toString() });
299
+ }
300
+ function clientIdOf(req) {
301
+ return new URLSearchParams(req.body).get('client_id') ?? '';
302
+ }
303
+ /** Wait before retry number `attempt` (1-based): the server's Retry-After seconds when it sends them (at most
304
+ * 30 s), else 1 s doubling to 10 s. */
305
+ function refreshRetryDelayMs(error, attempt) {
306
+ const retryAfterSeconds = Number(error.headers?.get('Retry-After'));
307
+ return Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
308
+ ? Math.min(retryAfterSeconds * 1000, MAX_RETRY_AFTER_MS)
309
+ : Math.min(1000 * 2 ** (attempt - 1), MAX_BACKOFF_MS);
310
+ }
311
+ /** The token response a refresh produced, rebuilt from the tokens it left in storage. */
312
+ function tokenResponseFrom(tokens) {
313
+ const authnResult = (tokens['authnResult'] ?? {});
314
+ const expiresAt = Number(tokens['access_token_expires_at']);
315
+ const body = Number.isFinite(expiresAt) && expiresAt > 0
316
+ ? { ...authnResult, expires_in: Math.max(Math.floor((expiresAt - Date.now()) / 1000), 1) }
317
+ : authnResult;
318
+ return new HttpResponse({ status: 200, body });
319
+ }
320
+ /** A token response handed out `elapsedMs` after it arrived: its lifetimes count from when the server issued it. */
321
+ function aged(response, elapsedMs) {
322
+ const body = response.body;
323
+ const elapsedSeconds = Math.floor(elapsedMs / 1000);
324
+ if (elapsedSeconds <= 0 || !body || typeof body.expires_in !== 'number')
325
+ return response;
326
+ return response.clone({ body: { ...body, expires_in: Math.max(body.expires_in - elapsedSeconds, 1) } });
327
+ }
328
+ function isTransient(error) {
329
+ return error instanceof HttpErrorResponse && TRANSIENT_STATUSES.has(error.status);
330
+ }
331
+
332
+ /**
333
+ * Serialises token refreshes against the auth library's own background renew.
334
+ *
335
+ * With silent renew on, the library refreshes on a timer. If an API call fails
336
+ * with 401 while that renew is still in flight, a second refresh request is
337
+ * declined by the library, which reports "no session" even though the renew is
338
+ * about to succeed. Left alone, the interceptor would treat that as a signed-out
339
+ * user and redirect to login. Instead, wait for the running renew to settle and
340
+ * answer with the token it produced.
341
+ *
342
+ * Library-agnostic: the adapter supplies the "renew started" and "renew settled"
343
+ * streams and the single-shot refresh call.
344
+ */
345
+ class RenewAwareAuthProvider extends AuthProvider {
346
+ /** How long to wait for a running renew before giving up; matches the library's own timeout. */
347
+ renewWaitMs = 20_000;
348
+ renewInFlight = false;
349
+ settled$ = new Subject();
350
+ /**
351
+ * Adapters call this once, in their constructor, with the library's events:
352
+ * `started$` whenever a background renew begins, `settled$` with true/false
353
+ * whenever a renew (or any authentication result) completes.
354
+ */
355
+ trackRenew(started$, settled$) {
356
+ started$.subscribe(() => {
357
+ this.renewInFlight = true;
358
+ });
359
+ settled$.subscribe((ok) => {
360
+ this.renewInFlight = false;
361
+ this.settled$.next(ok);
362
+ });
363
+ }
364
+ forceRefreshSession() {
365
+ if (this.renewInFlight) {
366
+ return this.awaitRenew();
367
+ }
368
+ return this.refreshOnce().pipe(switchMap((result) => {
369
+ if (result.isAuthenticated && result.accessToken) {
370
+ return of(result);
371
+ }
372
+ // The library declines a refresh while its own renew is running; if one
373
+ // started since we checked, its outcome is the answer, not this refusal.
374
+ return this.renewInFlight ? this.awaitRenew() : of(result);
375
+ }));
376
+ }
377
+ /**
378
+ * A refresh that switches tenant: {@link requestTenantSwitch} adds the token endpoint's `tenant` parameter to
379
+ * that one refresh request, outside the library, so the library neither keeps it for later refreshes nor sees
380
+ * a refusal (which it would answer by signing the user out). A renew already running is let finish first, so
381
+ * the switch redeems the refresh token it leaves behind. Errors unless the new token is for `tenantId`; the
382
+ * session is left where it was.
383
+ */
384
+ switchTenant(tenantId) {
385
+ const ready = this.renewInFlight ? this.awaitRenew() : of(null);
386
+ return ready.pipe(switchMap(() => defer(() => {
387
+ requestTenantSwitch(tenantId);
388
+ return this.refreshOnce();
389
+ }).pipe(finalize(() => requestTenantSwitch(null)))), switchMap((result) => result.isAuthenticated && result.accessToken && tenantOfToken(result.accessToken) === tenantId
390
+ ? of(result)
391
+ : throwError(() => new Error(`The session was not switched to tenant ${tenantId}`))));
392
+ }
393
+ awaitRenew() {
394
+ return this.settled$.pipe(take(1), timeout(this.renewWaitMs), catchError$1(() => of(false)), switchMap((ok) => ok
395
+ ? this.getAccessToken().pipe(take(1), map((token) => ({ isAuthenticated: !!token, accessToken: token ?? null })))
396
+ : of({ isAuthenticated: false, accessToken: null })));
397
+ }
398
+ }
399
+ /** The tenant an access token is for (its `tenant_id` claim), or null when it names none or is not a JWT. */
400
+ function tenantOfToken(accessToken) {
401
+ const part = accessToken.split('.')[1];
402
+ if (!part)
403
+ return null;
404
+ try {
405
+ const json = atob(part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '='));
406
+ const tenant = JSON.parse(json).tenant_id;
407
+ return typeof tenant === 'string' ? tenant : null;
408
+ }
409
+ catch {
410
+ return null;
411
+ }
412
+ }
413
+
414
+ const REMEMBER_ME_CLAIM = 'remember_me';
415
+ /** The entries of the stored state a refresh replaces; the rest (flow state, endpoints, keys) stays per tab. */
416
+ const TOKEN_ENTRIES = ['authnResult', 'authzData', 'access_token_expires_at', 'userData', 'session_state'];
417
+ /**
418
+ * The OIDC client stores a refresh result in several writes (the token response first, the access token and
419
+ * its expiry after), so a rotation is published once writes to that state have been quiet this long.
420
+ */
421
+ const ROTATION_SETTLE_MS = 100;
422
+ /**
423
+ * Token storage that follows the user's "Remember me" choice at sign-in.
424
+ *
425
+ * The OIDC client hands this one JSON blob per configuration. Until sign-in
426
+ * completes the blob only holds the pending state (nonce, code verifier) and
427
+ * stays in session storage, which the callback in the same tab can read. Once
428
+ * the blob contains a token response, the ID token says whether the sign-in was
429
+ * remembered: remembered sessions move to local storage, so they survive a
430
+ * browser restart and are shared across tabs; session-only sign-ins stay per
431
+ * tab and vanish when it closes. Sign-out removes both copies.
432
+ *
433
+ * Refresh tokens are single use, and tabs can hold the same one (a duplicated
434
+ * tab starts with a copy of its opener's session; remembered sessions share
435
+ * one store). When a write replaces the refresh token, the spent token and the
436
+ * new tokens go to the app's other tabs; a tab still holding the spent token
437
+ * takes the new ones, so it never presents a token the server has retired.
438
+ *
439
+ * Kept free of the OIDC library: it matches the storage contract by shape and
440
+ * is registered by the app's auth provider.
441
+ */
442
+ class RememberMeSecurityStorage {
443
+ rotations;
444
+ pendingRotations = new Map();
445
+ constructor(rotations) {
446
+ this.rotations = rotations ?? tokenRotationChannel;
447
+ this.rotations.onRotation((id) => void this.adoptRotation(id));
448
+ }
449
+ read(key) {
450
+ return safe(() => localStorage.getItem(key)) ?? safe(() => sessionStorage.getItem(key));
451
+ }
452
+ write(key, value) {
453
+ const previous = refreshTokenOf(this.read(key));
454
+ this.store(key, value);
455
+ const current = refreshTokenOf(value);
456
+ const pending = this.pendingRotations.get(key);
457
+ const spent = pending?.spent ?? (previous && current && previous !== current ? previous : null);
458
+ if (!spent)
459
+ return;
460
+ if (pending)
461
+ clearTimeout(pending.timer);
462
+ const timer = setTimeout(() => this.publishRotation(key, spent), ROTATION_SETTLE_MS);
463
+ this.pendingRotations.set(key, { spent, timer });
464
+ }
465
+ publishRotation(key, spent) {
466
+ this.pendingRotations.delete(key);
467
+ const blob = this.read(key);
468
+ const current = refreshTokenOf(blob);
469
+ if (blob && current && current !== spent)
470
+ void this.rotations.publish(spent, tokenEntries(blob));
471
+ }
472
+ /** Takes the tokens another tab rotated to, for every stored session still holding the token it spent. */
473
+ async adoptRotation(id) {
474
+ for (const key of this.authKeys()) {
475
+ const blob = this.read(key);
476
+ const held = refreshTokenOf(blob);
477
+ if (!blob || !held || !(await this.rotations.isSpentBy(held, id)))
478
+ continue;
479
+ const tokens = await this.rotations.answerFor(held);
480
+ if (!tokens || refreshTokenOf(this.read(key)) !== held)
481
+ continue;
482
+ this.store(key, JSON.stringify({ ...JSON.parse(blob), ...tokens }));
483
+ }
484
+ }
485
+ authKeys() {
486
+ const keys = new Set();
487
+ for (const store of [safe(() => localStorage), safe(() => sessionStorage)]) {
488
+ if (!store)
489
+ continue;
490
+ for (let i = 0; i < store.length; i++) {
491
+ const key = store.key(i);
492
+ if (key && isAuthBlob(store.getItem(key)))
493
+ keys.add(key);
494
+ }
495
+ }
496
+ return [...keys];
497
+ }
498
+ store(key, value) {
499
+ if (isRememberedSession(value)) {
500
+ safe(() => localStorage.setItem(key, value));
501
+ safe(() => sessionStorage.removeItem(key));
502
+ }
503
+ else {
504
+ safe(() => sessionStorage.setItem(key, value));
505
+ safe(() => localStorage.removeItem(key));
506
+ }
507
+ }
508
+ remove(key) {
509
+ safe(() => localStorage.removeItem(key));
510
+ safe(() => sessionStorage.removeItem(key));
511
+ }
512
+ clear() {
513
+ // Only our blobs leave local storage; other app preferences live there too.
514
+ safe(() => {
515
+ const ours = [];
516
+ for (let i = 0; i < localStorage.length; i++) {
517
+ const key = localStorage.key(i);
518
+ if (key && isAuthBlob(localStorage.getItem(key)))
519
+ ours.push(key);
520
+ }
521
+ ours.forEach((key) => localStorage.removeItem(key));
522
+ });
523
+ safe(() => sessionStorage.clear());
524
+ }
525
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: RememberMeSecurityStorage, deps: [{ token: TOKEN_ROTATION_CHANNEL, optional: true }], target: i0.ɵɵFactoryTarget.Injectable });
526
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: RememberMeSecurityStorage, providedIn: 'root' });
527
+ }
528
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: RememberMeSecurityStorage, decorators: [{
529
+ type: Injectable,
530
+ args: [{ providedIn: 'root' }]
531
+ }], ctorParameters: () => [{ type: TokenRotationChannel, decorators: [{
532
+ type: Optional
533
+ }, {
534
+ type: Inject,
535
+ args: [TOKEN_ROTATION_CHANNEL]
536
+ }] }] });
537
+ function isRememberedSession(blob) {
538
+ try {
539
+ const parsed = JSON.parse(blob);
540
+ const idToken = parsed?.authnResult?.id_token;
541
+ if (typeof idToken !== 'string')
542
+ return false;
543
+ const payload = decodeJwtPayload(idToken);
544
+ const flag = payload?.[REMEMBER_ME_CLAIM];
545
+ return flag === true || flag === 'true';
546
+ }
547
+ catch {
548
+ return false;
549
+ }
550
+ }
551
+ function refreshTokenOf(blob) {
552
+ if (!blob)
553
+ return null;
554
+ try {
555
+ const token = JSON.parse(blob)?.authnResult?.refresh_token;
556
+ return typeof token === 'string' && token ? token : null;
557
+ }
558
+ catch {
559
+ return null;
560
+ }
561
+ }
562
+ function tokenEntries(blob) {
563
+ const parsed = JSON.parse(blob);
564
+ return Object.fromEntries(TOKEN_ENTRIES.filter((k) => k in parsed).map((k) => [k, parsed[k]]));
565
+ }
566
+ function isAuthBlob(value) {
567
+ if (!value)
568
+ return false;
569
+ try {
570
+ const parsed = JSON.parse(value);
571
+ return !!parsed && typeof parsed === 'object' && ('authnResult' in parsed || 'authWellKnownEndPoints' in parsed);
572
+ }
573
+ catch {
574
+ return false;
575
+ }
576
+ }
577
+ function decodeJwtPayload(token) {
578
+ const part = token.split('.')[1];
579
+ if (!part)
580
+ return null;
581
+ const base64 = part.replace(/-/g, '+').replace(/_/g, '/');
582
+ const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
583
+ return JSON.parse(atob(padded));
584
+ }
585
+ function safe(fn) {
586
+ try {
587
+ return fn();
588
+ }
589
+ catch {
590
+ return null;
591
+ }
592
+ }
593
+
594
+ const CALLBACK_PARAMS = ['code', 'state', 'error'];
595
+ /**
596
+ * The app's startup auth check. A stored access token that has expired (or is inside the renew window) makes the
597
+ * plain check report "signed out", and the app would send the user through sign-in again although the refresh
598
+ * token is good for days. When a refresh token is stored this renews with it instead, which also restarts the
599
+ * library's background renew. A sign-in callback is completed by the plain check alone, and a refresh the server
600
+ * refuses (refresh token expired or revoked) leaves the user signed out, as before.
601
+ *
602
+ * The refresh goes through the app's HttpClient, so the token-refresh interceptor still coordinates it with the
603
+ * app's other tabs.
604
+ */
605
+ function checkAuthRenewingSession(client, url = typeof window !== 'undefined' ? window.location.href : '') {
606
+ return client.checkAuth().pipe(switchMap((result) => {
607
+ if (result.isAuthenticated || isCallback(url))
608
+ return of(result);
609
+ return client.getRefreshToken().pipe(take(1), switchMap((refreshToken) => refreshToken ? client.checkAuthIncludingServer().pipe(catchError$1(() => of(result))) : of(result)));
610
+ }));
611
+ }
612
+ function isCallback(url) {
613
+ if (!url)
614
+ return false;
615
+ const params = new URL(url, 'http://localhost').searchParams;
616
+ return CALLBACK_PARAMS.some((p) => params.has(p));
617
+ }
618
+
619
+ const STORAGE_KEY = 'oluso_auth';
620
+ /**
621
+ * Auth Store
622
+ *
623
+ * Signal-based authentication state management.
624
+ * This mirrors the Zustand authSlice from the React codebase.
625
+ *
626
+ * Features:
627
+ * - Persistent state via localStorage
628
+ * - Tenant/organization context
629
+ * - Super admin support
630
+ */
631
+ class AuthStore {
632
+ authProvider = inject(AuthProvider);
633
+ _user = signal(null, ...(ngDevMode ? [{ debugName: "_user" }] : /* istanbul ignore next */ []));
634
+ _accessToken = signal(null, ...(ngDevMode ? [{ debugName: "_accessToken" }] : /* istanbul ignore next */ []));
635
+ _refreshToken = signal(null, ...(ngDevMode ? [{ debugName: "_refreshToken" }] : /* istanbul ignore next */ []));
636
+ _idToken = signal(null, ...(ngDevMode ? [{ debugName: "_idToken" }] : /* istanbul ignore next */ []));
637
+ _isLoading = signal(false, ...(ngDevMode ? [{ debugName: "_isLoading" }] : /* istanbul ignore next */ []));
638
+ _error = signal(null, ...(ngDevMode ? [{ debugName: "_error" }] : /* istanbul ignore next */ []));
639
+ _currentTenantId = signal(null, ...(ngDevMode ? [{ debugName: "_currentTenantId" }] : /* istanbul ignore next */ []));
640
+ /** True once the user has picked a tenant in the UI; only then do requests carry X-Tenant-ID. */
641
+ _tenantSwitched = signal(false, ...(ngDevMode ? [{ debugName: "_tenantSwitched" }] : /* istanbul ignore next */ []));
642
+ _currentOrganization = signal(null, ...(ngDevMode ? [{ debugName: "_currentOrganization" }] : /* istanbul ignore next */ []));
643
+ _isSuperAdmin = signal(false, ...(ngDevMode ? [{ debugName: "_isSuperAdmin" }] : /* istanbul ignore next */ []));
644
+ _hasHydrated = signal(false, ...(ngDevMode ? [{ debugName: "_hasHydrated" }] : /* istanbul ignore next */ []));
645
+ /** Read-only computed state */
646
+ user = this._user.asReadonly();
647
+ accessToken = this._accessToken.asReadonly();
648
+ refreshToken = this._refreshToken.asReadonly();
649
+ idToken = this._idToken.asReadonly();
650
+ isLoading = this._isLoading.asReadonly();
651
+ error = this._error.asReadonly();
652
+ currentTenantId = this._currentTenantId.asReadonly();
653
+ tenantSwitched = this._tenantSwitched.asReadonly();
654
+ currentOrganization = this._currentOrganization.asReadonly();
655
+ isSuperAdmin = this._isSuperAdmin.asReadonly();
656
+ hasHydrated = this._hasHydrated.asReadonly();
657
+ isAuthenticated = toSignal(this.authProvider.isAuthenticated$, { initialValue: false });
658
+ /** Full state snapshot */
659
+ state = computed(() => ({
660
+ user: this._user(),
661
+ accessToken: this._accessToken(),
662
+ refreshToken: this._refreshToken(),
663
+ idToken: this._idToken(),
664
+ isAuthenticated: this.isAuthenticated(),
665
+ isLoading: this._isLoading(),
666
+ error: this._error(),
667
+ currentTenantId: this._currentTenantId(),
668
+ currentOrganization: this._currentOrganization(),
669
+ isSuperAdmin: this._isSuperAdmin(),
670
+ }), ...(ngDevMode ? [{ debugName: "state" }] : /* istanbul ignore next */ []));
671
+ constructor() {
672
+ this.hydrate();
673
+ // Auth-state clearing is handled explicitly by the host portal:
674
+ // - /logout page's ngOnInit (post-redirect cleanup after oidcService.logoff)
675
+ // - AdminAuthService.redirectToLogin() (silent-renew failure path)
676
+ // - AdminAuthService.initialize() (callback / fresh-load cleanup)
677
+ // An automatic isAuthenticated$ → logout() subscription used to live here,
678
+ // but its synchronous 8-signal-write CD cascade fired during
679
+ // resetAuthorizationData() prevented the browser from committing the
680
+ // cross-origin redirect to /connect/endsession.
681
+ }
682
+ /**
683
+ * Set user and tokens after successful login.
684
+ * Extracts tenant_id from the JWT access token if present.
685
+ */
686
+ login(user, accessToken, refreshToken, idToken) {
687
+ this._user.set(user);
688
+ this._accessToken.set(accessToken);
689
+ this._refreshToken.set(refreshToken ?? null);
690
+ this._idToken.set(idToken ?? null);
691
+ this._error.set(null);
692
+ this.alignTenantWithToken(accessToken);
693
+ this.persist();
694
+ }
695
+ /**
696
+ * Makes the current tenant the one the token is for. A switch mints a token for its target, so a current
697
+ * tenant naming any other was left by an earlier session (or another app on the origin): it is replaced, and
698
+ * the session counts as not switched, so requests carry no X-Tenant-ID.
699
+ */
700
+ alignTenantWithToken(accessToken) {
701
+ const tokenTenantId = tenantOfToken(accessToken);
702
+ if (this._currentTenantId() !== tokenTenantId) {
703
+ this._currentTenantId.set(tokenTenantId);
704
+ this._tenantSwitched.set(false);
705
+ this.persist();
706
+ }
707
+ }
708
+ /**
709
+ * Clear authentication state on logout
710
+ */
711
+ logout() {
712
+ this._user.set(null);
713
+ this._accessToken.set(null);
714
+ this._refreshToken.set(null);
715
+ this._idToken.set(null);
716
+ this._currentTenantId.set(null);
717
+ this._tenantSwitched.set(false);
718
+ this._currentOrganization.set(null);
719
+ this._isSuperAdmin.set(false);
720
+ this._error.set(null);
721
+ this.clearStorage();
722
+ }
723
+ /**
724
+ * Update user information
725
+ */
726
+ setUser(user) {
727
+ this._user.set(user);
728
+ this.persist();
729
+ }
730
+ /**
731
+ * Update access token
732
+ */
733
+ setAccessToken(token) {
734
+ this._accessToken.set(token);
735
+ this.persist();
736
+ }
737
+ /**
738
+ * Update refresh token
739
+ */
740
+ setRefreshToken(token) {
741
+ this._refreshToken.set(token);
742
+ this.persist();
743
+ }
744
+ /**
745
+ * Set current tenant ID (for super admin)
746
+ */
747
+ setCurrentTenantId(tenantId) {
748
+ this._currentTenantId.set(tenantId);
749
+ this._tenantSwitched.set(tenantId !== null);
750
+ this.persist();
751
+ }
752
+ /**
753
+ * Adopt a tenant switch: the new context and the token minted for it.
754
+ *
755
+ * Both move together deliberately. Roles and permissions are per-tenant, so a
756
+ * moment where the header names one tenant and the token carries another
757
+ * either over-grants or under-grants — and a persist() between the two writes
758
+ * would make that state survive a reload.
759
+ */
760
+ switchTenant(tenantId, accessToken) {
761
+ this._currentTenantId.set(tenantId);
762
+ this._tenantSwitched.set(true);
763
+ this._accessToken.set(accessToken);
764
+ this.persist();
765
+ }
766
+ /**
767
+ * Set current organization context
768
+ */
769
+ setCurrentOrganization(organization) {
770
+ this._currentOrganization.set(organization);
771
+ this.persist();
772
+ }
773
+ /**
774
+ * Set super admin status
775
+ */
776
+ setSuperAdmin(isSuperAdmin) {
777
+ this._isSuperAdmin.set(isSuperAdmin);
778
+ this.persist();
779
+ }
780
+ /**
781
+ * Set loading state
782
+ */
783
+ setLoading(isLoading) {
784
+ this._isLoading.set(isLoading);
785
+ }
786
+ /**
787
+ * Set error message
788
+ */
789
+ setError(error) {
790
+ this._error.set(error);
791
+ }
792
+ /**
793
+ * Hydrate state from localStorage
794
+ */
795
+ hydrate() {
796
+ try {
797
+ const stored = localStorage.getItem(STORAGE_KEY);
798
+ if (stored) {
799
+ const data = JSON.parse(stored);
800
+ this._user.set(data.user ?? null);
801
+ this._accessToken.set(data.accessToken ?? null);
802
+ this._refreshToken.set(data.refreshToken ?? null);
803
+ this._idToken.set(data.idToken ?? null);
804
+ this._currentTenantId.set(data.currentTenantId ?? null);
805
+ this._tenantSwitched.set(data.tenantSwitched === true);
806
+ this._currentOrganization.set(data.currentOrganization ?? null);
807
+ this._isSuperAdmin.set(data.isSuperAdmin ?? false);
808
+ }
809
+ }
810
+ catch {
811
+ // Ignore hydration errors
812
+ }
813
+ finally {
814
+ this._hasHydrated.set(true);
815
+ }
816
+ }
817
+ /**
818
+ * Persist state to localStorage
819
+ */
820
+ persist() {
821
+ try {
822
+ const data = {
823
+ user: this._user(),
824
+ accessToken: this._accessToken(),
825
+ refreshToken: this._refreshToken(),
826
+ idToken: this._idToken(),
827
+ currentTenantId: this._currentTenantId(),
828
+ tenantSwitched: this._tenantSwitched(),
829
+ currentOrganization: this._currentOrganization(),
830
+ isSuperAdmin: this._isSuperAdmin(),
831
+ };
832
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
833
+ }
834
+ catch {
835
+ // Ignore persistence errors
836
+ }
837
+ }
838
+ /**
839
+ * Clear localStorage
840
+ */
841
+ clearStorage() {
842
+ try {
843
+ localStorage.removeItem(STORAGE_KEY);
844
+ }
845
+ catch {
846
+ // Ignore errors
847
+ }
848
+ }
849
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: AuthStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
850
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: AuthStore, providedIn: 'root' });
851
+ }
852
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: AuthStore, decorators: [{
853
+ type: Injectable,
854
+ args: [{
855
+ providedIn: 'root',
856
+ }]
857
+ }], ctorParameters: () => [] });
858
+
859
+ /**
860
+ * Auth Service
861
+ *
862
+ * Handles authentication operations including login, logout, token refresh,
863
+ * and user management.
864
+ *
865
+ * This mirrors the auth services from the React codebase.
866
+ */
867
+ class AuthService {
868
+ http = inject(HttpClient);
869
+ authStore = inject(AuthStore);
870
+ apiBaseUrl = '/api/admin';
871
+ /**
872
+ * Configure the API base URL
873
+ */
874
+ setApiBaseUrl(url) {
875
+ this.apiBaseUrl = url;
876
+ }
877
+ /**
878
+ * Login with email and password
879
+ */
880
+ login(request) {
881
+ this.authStore.setLoading(true);
882
+ this.authStore.setError(null);
883
+ return this.http.post(`${this.apiBaseUrl}/auth/login`, request).pipe(tap((response) => {
884
+ this.authStore.login(response.user, response.accessToken, response.refreshToken, response.idToken);
885
+ // Set organization context from login response
886
+ const user = response.user;
887
+ if (user.organizationIds?.length && user.organizationRoles) {
888
+ // Pick the first org where user is owner or admin, otherwise first org
889
+ const adminOrgId = user.organizationIds.find((id) => user.organizationRoles?.[id] === 'owner' || user.organizationRoles?.[id] === 'admin') ?? user.organizationIds[0];
890
+ const role = user.organizationRoles[adminOrgId];
891
+ this.authStore.setCurrentOrganization({
892
+ id: adminOrgId,
893
+ name: adminOrgId, // Will be resolved later if needed
894
+ role,
895
+ });
896
+ }
897
+ }), catchError((error) => {
898
+ this.authStore.setError(error.error?.message ?? 'Login failed');
899
+ this.authStore.setLoading(false);
900
+ return throwError(() => error);
901
+ }), tap(() => {
902
+ this.authStore.setLoading(false);
903
+ }));
904
+ }
905
+ /**
906
+ * Logout the current user
907
+ */
908
+ logout() {
909
+ const refreshToken = this.authStore.refreshToken();
910
+ return this.http
911
+ .post(`${this.apiBaseUrl}/auth/logout`, { refreshToken })
912
+ .pipe(tap(() => {
913
+ this.authStore.logout();
914
+ }), catchError((error) => {
915
+ // Still logout locally even if the API call fails
916
+ this.authStore.logout();
917
+ return throwError(() => error);
918
+ }));
919
+ }
920
+ /**
921
+ * Logout locally without API call
922
+ */
923
+ logoutLocal() {
924
+ this.authStore.logout();
925
+ }
926
+ /**
927
+ * Refresh the access token
928
+ */
929
+ refreshToken() {
930
+ const refreshToken = this.authStore.refreshToken();
931
+ if (!refreshToken) {
932
+ return throwError(() => new Error('No refresh token available'));
933
+ }
934
+ return this.http
935
+ .post(`${this.apiBaseUrl}/auth/refresh`, { refreshToken })
936
+ .pipe(tap((response) => {
937
+ this.authStore.setAccessToken(response.accessToken);
938
+ if (response.refreshToken) {
939
+ this.authStore.setRefreshToken(response.refreshToken);
940
+ }
941
+ }), catchError((error) => {
942
+ // If refresh fails, logout the user
943
+ this.authStore.logout();
944
+ return throwError(() => error);
945
+ }));
946
+ }
947
+ /**
948
+ * Get the current user profile
949
+ */
950
+ getProfile() {
951
+ return this.http.get(`${this.apiBaseUrl}/auth/profile`).pipe(tap((user) => {
952
+ this.authStore.setUser(user);
953
+ }));
954
+ }
955
+ /**
956
+ * Update the current user profile
957
+ */
958
+ updateProfile(data) {
959
+ return this.http.patch(`${this.apiBaseUrl}/auth/profile`, data).pipe(tap((user) => {
960
+ this.authStore.setUser(user);
961
+ }));
962
+ }
963
+ /**
964
+ * Check if the current access token is expired
965
+ */
966
+ isTokenExpired() {
967
+ const token = this.authStore.accessToken();
968
+ if (!token)
969
+ return true;
970
+ try {
971
+ const payload = this.decodeToken(token);
972
+ const currentTime = Math.floor(Date.now() / 1000);
973
+ return payload.exp < currentTime;
974
+ }
975
+ catch {
976
+ return true;
977
+ }
978
+ }
979
+ /**
980
+ * Decode a JWT token
981
+ */
982
+ decodeToken(token) {
983
+ const parts = token.split('.');
984
+ if (parts.length !== 3) {
985
+ throw new Error('Invalid token format');
986
+ }
987
+ const payload = parts[1];
988
+ const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
989
+ return JSON.parse(decoded);
990
+ }
991
+ /**
992
+ * Get the current access token
993
+ */
994
+ getAccessToken() {
995
+ return this.authStore.accessToken();
996
+ }
997
+ /**
998
+ * Check if the user is authenticated
999
+ */
1000
+ isAuthenticated() {
1001
+ return this.authStore.isAuthenticated();
1002
+ }
1003
+ /**
1004
+ * Get the current user
1005
+ */
1006
+ getUser() {
1007
+ return this.authStore.user();
1008
+ }
1009
+ /**
1010
+ * Check if the user is a super admin
1011
+ */
1012
+ isSuperAdmin() {
1013
+ return this.authStore.isSuperAdmin();
1014
+ }
1015
+ /**
1016
+ * Get the current tenant ID
1017
+ */
1018
+ getCurrentTenantId() {
1019
+ return this.authStore.currentTenantId();
1020
+ }
1021
+ /**
1022
+ * Set the current tenant ID (for super admin)
1023
+ */
1024
+ setCurrentTenantId(tenantId) {
1025
+ this.authStore.setCurrentTenantId(tenantId);
1026
+ }
1027
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: AuthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1028
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: AuthService, providedIn: 'root' });
1029
+ }
1030
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.25", ngImport: i0, type: AuthService, decorators: [{
1031
+ type: Injectable,
1032
+ args: [{
1033
+ providedIn: 'root',
1034
+ }]
1035
+ }] });
1036
+
1037
+ /**
1038
+ * Auth Guard
1039
+ *
1040
+ * Protects routes that require authentication.
1041
+ * Redirects to login if user is not authenticated.
1042
+ */
1043
+ const authGuard = (route, state) => {
1044
+ const authStore = inject(AuthStore);
1045
+ const router = inject(Router);
1046
+ if (authStore.isAuthenticated()) {
1047
+ return true;
1048
+ }
1049
+ // Store the attempted URL for redirecting after login
1050
+ const returnUrl = state.url;
1051
+ // Redirect to the login page with the return url
1052
+ return router.createUrlTree(['/login'], {
1053
+ queryParams: { returnUrl },
1054
+ });
1055
+ };
1056
+ /**
1057
+ * Guest Guard
1058
+ *
1059
+ * Protects routes that should only be accessible to non-authenticated users.
1060
+ * Redirects to dashboard if user is already authenticated.
1061
+ */
1062
+ const guestGuard = () => {
1063
+ const authStore = inject(AuthStore);
1064
+ const router = inject(Router);
1065
+ if (!authStore.isAuthenticated()) {
1066
+ return true;
1067
+ }
1068
+ // Redirect to dashboard if already authenticated
1069
+ return router.createUrlTree(['/']);
1070
+ };
1071
+ /**
1072
+ * Super Admin Guard
1073
+ *
1074
+ * Protects routes that require super admin privileges.
1075
+ * Checks PermissionsService which is set during login/permission loading.
1076
+ */
1077
+ const superAdminGuard = () => {
1078
+ const permissionsService = inject(PermissionsService);
1079
+ const authStore = inject(AuthStore);
1080
+ const router = inject(Router);
1081
+ // Check PermissionsService first (set during login), fallback to user roles
1082
+ if (permissionsService.isSuperAdmin()) {
1083
+ return true;
1084
+ }
1085
+ // Fallback: check user roles directly
1086
+ const user = authStore.user();
1087
+ if (user?.roles?.some(r => r.toLowerCase() === 'superadmin' || r.toLowerCase() === 'systemadmin')) {
1088
+ return true;
1089
+ }
1090
+ // Redirect to dashboard if not super admin
1091
+ return router.createUrlTree(['/']);
1092
+ };
1093
+ /**
1094
+ * Org Admin Guard
1095
+ *
1096
+ * Protects routes that require org admin or super admin privileges.
1097
+ * Super admins can also access org admin routes.
1098
+ * Requires a valid organization context (currentOrganization) in the auth store.
1099
+ */
1100
+ const orgAdminGuard = () => {
1101
+ const permissionsService = inject(PermissionsService);
1102
+ const authStore = inject(AuthStore);
1103
+ const router = inject(Router);
1104
+ // Super admins can also access org admin routes
1105
+ if (permissionsService.isSuperAdmin())
1106
+ return true;
1107
+ const user = authStore.user();
1108
+ if (user?.roles?.some(r => r.toLowerCase() === 'superadmin' || r.toLowerCase() === 'systemadmin'))
1109
+ return true;
1110
+ // Org admin: must have a current organization context set
1111
+ const currentOrg = authStore.currentOrganization();
1112
+ if (currentOrg?.id)
1113
+ return true;
1114
+ return router.createUrlTree(['/']);
1115
+ };
1116
+
1117
+ /**
1118
+ * How long a permission guard waits for the app to load the current user's
1119
+ * permissions before denying. Override to tune per app.
1120
+ */
1121
+ const PERMISSION_GUARD_TIMEOUT_MS = new InjectionToken('PERMISSION_GUARD_TIMEOUT_MS', { providedIn: 'root', factory: () => 15000 });
1122
+ /**
1123
+ * Runs `check` once permissions are authoritative for the current user, and
1124
+ * denies (redirect to /unauthorized) if they never arrive, failed to load, or
1125
+ * were cleared. Must be called from a guard's injection context.
1126
+ */
1127
+ function decideWhenPermissionsSettle(check) {
1128
+ const permissionsService = inject(PermissionsService);
1129
+ const router = inject(Router);
1130
+ const waitMs = inject(PERMISSION_GUARD_TIMEOUT_MS);
1131
+ const decide = () => permissionsService.isAuthoritative() && check(permissionsService)
1132
+ ? true
1133
+ : router.createUrlTree(['/unauthorized']);
1134
+ if (permissionsService.isSettled()) {
1135
+ return decide();
1136
+ }
1137
+ return toObservable(permissionsService.isSettled).pipe(filter(Boolean), take$1(1), timeout$1({ first: waitMs, with: () => of(false) }), map$1(() => decide()));
1138
+ }
1139
+ /**
1140
+ * Permission Guard
1141
+ *
1142
+ * Protects routes that require specific permissions.
1143
+ * Configure the required permission in route data.
1144
+ *
1145
+ * Usage:
1146
+ * ```typescript
1147
+ * {
1148
+ * path: 'users',
1149
+ * component: UsersPageComponent,
1150
+ * canActivate: [permissionGuard],
1151
+ * data: { permission: 'users.read' }
1152
+ * }
1153
+ * ```
1154
+ */
1155
+ const permissionGuard = (route) => {
1156
+ const permission = route.data['permission'];
1157
+ const permissions = route.data['permissions'];
1158
+ const requireAll = route.data['requireAll'];
1159
+ // No permission required
1160
+ if (!permission && !permissions) {
1161
+ return true;
1162
+ }
1163
+ return decideWhenPermissionsSettle((permissionsService) => {
1164
+ if (permission && permissionsService.hasPermission(permission)) {
1165
+ return true;
1166
+ }
1167
+ if (permissions) {
1168
+ return requireAll
1169
+ ? permissionsService.hasAllPermissions(permissions)
1170
+ : permissionsService.hasAnyPermission(permissions);
1171
+ }
1172
+ return false;
1173
+ });
1174
+ };
1175
+ /**
1176
+ * Any Permission Guard
1177
+ *
1178
+ * Allows access if user has any of the specified permissions.
1179
+ *
1180
+ * Usage:
1181
+ * ```typescript
1182
+ * {
1183
+ * path: 'resources',
1184
+ * component: ResourcesPageComponent,
1185
+ * canActivate: [anyPermissionGuard],
1186
+ * data: { permissions: ['resources.read', 'resources.write'] }
1187
+ * }
1188
+ * ```
1189
+ */
1190
+ const anyPermissionGuard = (route) => {
1191
+ const permissions = route.data['permissions'];
1192
+ if (!permissions || permissions.length === 0) {
1193
+ return true;
1194
+ }
1195
+ return decideWhenPermissionsSettle((permissionsService) => permissionsService.hasAnyPermission(permissions));
1196
+ };
1197
+ /**
1198
+ * All Permissions Guard
1199
+ *
1200
+ * Allows access only if user has all of the specified permissions.
1201
+ *
1202
+ * Usage:
1203
+ * ```typescript
1204
+ * {
1205
+ * path: 'admin',
1206
+ * component: AdminPageComponent,
1207
+ * canActivate: [allPermissionsGuard],
1208
+ * data: { permissions: ['admin.read', 'admin.write'] }
1209
+ * }
1210
+ * ```
1211
+ */
1212
+ const allPermissionsGuard = (route) => {
1213
+ const permissions = route.data['permissions'];
1214
+ if (!permissions || permissions.length === 0) {
1215
+ return true;
1216
+ }
1217
+ return decideWhenPermissionsSettle((permissionsService) => permissionsService.hasAllPermissions(permissions));
1218
+ };
1219
+
1220
+ /**
1221
+ * Helpers for the tenant-aware SSO landing flow shared across host portals.
1222
+ *
1223
+ * The "Login with SSO" affordance on the landing page resolves a tenant by
1224
+ * identifier and persists its issuer URI to sessionStorage. The host's OIDC
1225
+ * config picks that up at module load (next page navigation) so the OIDC
1226
+ * authority points at the tenant's identity-server subdomain instead of the
1227
+ * platform default. After resolve + persist, the landing reloads with
1228
+ * `pendingAuthorize` set so the post-load handler can call authorize() with
1229
+ * the right authority and the tenant's domain_hint / policy / ui_mode.
1230
+ */
1231
+ const KEY_IDENTIFIER = 'oluso.tenant.identifier';
1232
+ const KEY_ISSUER_URI = 'oluso.tenant.issuerUri';
1233
+ const KEY_DEFAULT_IDP_SCHEME = 'oluso.tenant.defaultIdpScheme';
1234
+ const KEY_DEFAULT_SIGN_IN_POLICY_ID = 'oluso.tenant.defaultSignInPolicyId';
1235
+ const KEY_DEFAULT_SSO_UI_MODE = 'oluso.tenant.defaultSsoUiMode';
1236
+ const KEY_PENDING_AUTHORIZE = 'oluso.tenant.pendingAuthorize';
1237
+ /**
1238
+ * Extract tenant identifier from hostname subdomain.
1239
+ * e.g., "acme.auth.example.com" → "acme"
1240
+ * Returns undefined for localhost, IP addresses, or hostnames with fewer than 3 parts.
1241
+ */
1242
+ function resolveTenantFromHostname() {
1243
+ if (typeof window === 'undefined')
1244
+ return undefined;
1245
+ const host = window.location.hostname;
1246
+ if (host === 'localhost' || host === '127.0.0.1')
1247
+ return undefined;
1248
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host))
1249
+ return undefined;
1250
+ const parts = host.split('.');
1251
+ if (parts.length < 3)
1252
+ return undefined;
1253
+ return parts[0];
1254
+ }
1255
+ /**
1256
+ * Returns the tenant SSO authority persisted in sessionStorage, or the given
1257
+ * default. Safe in SSR / non-browser contexts.
1258
+ */
1259
+ function resolveTenantSsoAuthority(defaultAuthority) {
1260
+ if (typeof sessionStorage === 'undefined')
1261
+ return defaultAuthority;
1262
+ return sessionStorage.getItem(KEY_ISSUER_URI) || defaultAuthority;
1263
+ }
1264
+ /**
1265
+ * Returns the tenant identifier, issuer URI, and any persisted SSO defaults,
1266
+ * or null if none is set.
1267
+ */
1268
+ function readTenantSso() {
1269
+ if (typeof sessionStorage === 'undefined')
1270
+ return null;
1271
+ const identifier = sessionStorage.getItem(KEY_IDENTIFIER);
1272
+ const issuerUri = sessionStorage.getItem(KEY_ISSUER_URI);
1273
+ if (!identifier || !issuerUri)
1274
+ return null;
1275
+ const uiModeRaw = sessionStorage.getItem(KEY_DEFAULT_SSO_UI_MODE);
1276
+ const uiMode = uiModeRaw === 'Journey' || uiModeRaw === 'Standalone' ? uiModeRaw : undefined;
1277
+ return {
1278
+ identifier,
1279
+ issuerUri,
1280
+ defaultIdpScheme: sessionStorage.getItem(KEY_DEFAULT_IDP_SCHEME) ?? undefined,
1281
+ defaultSignInPolicyId: sessionStorage.getItem(KEY_DEFAULT_SIGN_IN_POLICY_ID) ?? undefined,
1282
+ defaultSsoUiMode: uiMode,
1283
+ };
1284
+ }
1285
+ /**
1286
+ * Persists the tenant SSO context resolved by the landing page. Set
1287
+ * `markPendingAuthorize` so the post-reload landing handler kicks off
1288
+ * authorize() with the tenant's authority + the stored hints.
1289
+ *
1290
+ * Wipes angular-auth-oidc-client's cached well-known + state on the way
1291
+ * out — the cache is keyed by config id, not authority, so switching
1292
+ * tenants would otherwise reuse the previous authority's discovery doc
1293
+ * and throw issuer-mismatch on the next authorize call.
1294
+ */
1295
+ function writeTenantSso(sso, markPendingAuthorize = false) {
1296
+ if (typeof sessionStorage === 'undefined')
1297
+ return;
1298
+ clearOidcLibraryCache();
1299
+ sessionStorage.setItem(KEY_IDENTIFIER, sso.identifier);
1300
+ sessionStorage.setItem(KEY_ISSUER_URI, sso.issuerUri);
1301
+ if (sso.defaultIdpScheme) {
1302
+ sessionStorage.setItem(KEY_DEFAULT_IDP_SCHEME, sso.defaultIdpScheme);
1303
+ }
1304
+ else {
1305
+ sessionStorage.removeItem(KEY_DEFAULT_IDP_SCHEME);
1306
+ }
1307
+ if (sso.defaultSignInPolicyId) {
1308
+ sessionStorage.setItem(KEY_DEFAULT_SIGN_IN_POLICY_ID, sso.defaultSignInPolicyId);
1309
+ }
1310
+ else {
1311
+ sessionStorage.removeItem(KEY_DEFAULT_SIGN_IN_POLICY_ID);
1312
+ }
1313
+ if (sso.defaultSsoUiMode) {
1314
+ sessionStorage.setItem(KEY_DEFAULT_SSO_UI_MODE, sso.defaultSsoUiMode);
1315
+ }
1316
+ else {
1317
+ sessionStorage.removeItem(KEY_DEFAULT_SSO_UI_MODE);
1318
+ }
1319
+ if (markPendingAuthorize) {
1320
+ sessionStorage.setItem(KEY_PENDING_AUTHORIZE, '1');
1321
+ }
1322
+ }
1323
+ function clearOidcLibraryCache() {
1324
+ if (typeof sessionStorage === 'undefined')
1325
+ return;
1326
+ // angular-auth-oidc-client bundles a config's entire cache (well-known
1327
+ // endpoints, authn result, in-flight state, user data) into a single
1328
+ // sessionStorage entry keyed "{configIndex}-{clientId}" (e.g. "0-admin-portal").
1329
+ // Match that shape: leading digits, dash, anything else. App-owned keys
1330
+ // here are namespaced "oluso.tenant.*" and never collide.
1331
+ for (const key of Object.keys(sessionStorage)) {
1332
+ if (/^\d+-/.test(key)) {
1333
+ sessionStorage.removeItem(key);
1334
+ }
1335
+ }
1336
+ }
1337
+ /**
1338
+ * Build the customParams object to pass to oidcSecurityService.authorize()
1339
+ * from a remembered tenant SSO. Returns undefined if there's nothing worth
1340
+ * passing. Callers feed this into the `customParams` field of the authorize
1341
+ * call so domain_hint / policy / ui_mode reach /connect/authorize.
1342
+ */
1343
+ function tenantSsoAuthorizeParams(sso) {
1344
+ const params = {};
1345
+ if (sso.defaultIdpScheme)
1346
+ params['domain_hint'] = sso.defaultIdpScheme;
1347
+ if (sso.defaultSignInPolicyId)
1348
+ params['policy'] = sso.defaultSignInPolicyId;
1349
+ if (sso.defaultSsoUiMode)
1350
+ params['ui_mode'] = sso.defaultSsoUiMode;
1351
+ return Object.keys(params).length ? params : undefined;
1352
+ }
1353
+ /**
1354
+ * Returns true (and clears the flag) when a pending authorize call was queued
1355
+ * by the landing's SSO submit. Use on landing init to auto-trigger
1356
+ * oidcSecurityService.authorize() after a reload picked up the new authority.
1357
+ */
1358
+ function consumePendingAuthorize() {
1359
+ if (typeof sessionStorage === 'undefined')
1360
+ return false;
1361
+ if (sessionStorage.getItem(KEY_PENDING_AUTHORIZE) !== '1')
1362
+ return false;
1363
+ sessionStorage.removeItem(KEY_PENDING_AUTHORIZE);
1364
+ return true;
1365
+ }
1366
+ /**
1367
+ * Removes all persisted tenant SSO state. Call from logout flows and the
1368
+ * "Use a different organisation" affordance on the landing.
1369
+ *
1370
+ * Also clears angular-auth-oidc-client's sessionStorage cache (well-known
1371
+ * discovery doc, in-flight state) — those entries are keyed by config id,
1372
+ * not by authority URL, so without wiping them a previously-cached
1373
+ * discovery for a different tenant authority survives the switch and
1374
+ * throws an "issuer mismatch" on the next authorize call.
1375
+ */
1376
+ function clearTenantSso() {
1377
+ if (typeof sessionStorage === 'undefined')
1378
+ return;
1379
+ sessionStorage.removeItem(KEY_IDENTIFIER);
1380
+ sessionStorage.removeItem(KEY_ISSUER_URI);
1381
+ sessionStorage.removeItem(KEY_DEFAULT_IDP_SCHEME);
1382
+ sessionStorage.removeItem(KEY_DEFAULT_SIGN_IN_POLICY_ID);
1383
+ sessionStorage.removeItem(KEY_DEFAULT_SSO_UI_MODE);
1384
+ sessionStorage.removeItem(KEY_PENDING_AUTHORIZE);
1385
+ clearOidcLibraryCache();
1386
+ }
1387
+
1388
+ let isRefreshing = false;
1389
+ let refreshResult$ = new ReplaySubject(1);
1390
+ /**
1391
+ * Auth Interceptor
1392
+ *
1393
+ * Adds the Authorization header with the access token to outgoing requests.
1394
+ * Handles 401 errors by attempting to refresh the token.
1395
+ */
1396
+ const authInterceptor = (req, next) => {
1397
+ const authStore = inject(AuthStore);
1398
+ const authService = inject(AuthService);
1399
+ const router = inject(Router);
1400
+ // Skip auth header for login/refresh endpoints
1401
+ if (isAuthEndpoint(req.url)) {
1402
+ return next(req);
1403
+ }
1404
+ const token = authStore.accessToken();
1405
+ if (token) {
1406
+ req = addAuthHeader(req, token);
1407
+ }
1408
+ return next(req).pipe(catchError((error) => {
1409
+ if (error.status === 401 && token) {
1410
+ return handle401Error(req, next, authService, authStore, router);
1411
+ }
1412
+ return throwError(() => error);
1413
+ }));
1414
+ };
1415
+ /**
1416
+ * Check if the request is to an auth endpoint
1417
+ */
1418
+ function isAuthEndpoint(url) {
1419
+ const authEndpoints = ['/auth/login', '/auth/refresh', '/auth/logout'];
1420
+ return authEndpoints.some((endpoint) => url.includes(endpoint));
1421
+ }
1422
+ /**
1423
+ * Add Authorization header to request
1424
+ */
1425
+ function addAuthHeader(req, token) {
1426
+ return req.clone({
1427
+ setHeaders: {
1428
+ Authorization: `Bearer ${token}`,
1429
+ },
1430
+ });
1431
+ }
1432
+ /**
1433
+ * Handle 401 errors by refreshing the token.
1434
+ *
1435
+ * Uses a ReplaySubject so that:
1436
+ * - On success: all queued requests receive the new token and retry.
1437
+ * - On failure: all queued requests receive the error and stop waiting.
1438
+ * A timeout prevents indefinite hangs if the refresh endpoint is unresponsive.
1439
+ */
1440
+ function handle401Error(req, next, authService, authStore, router) {
1441
+ // If already logged out, don't attempt refresh — just fail the request
1442
+ if (!authStore.isAuthenticated()) {
1443
+ return throwError(() => new HttpErrorResponse({ status: 401 }));
1444
+ }
1445
+ if (!isRefreshing) {
1446
+ isRefreshing = true;
1447
+ refreshResult$ = new ReplaySubject(1);
1448
+ return authService.refreshToken().pipe(timeout$1(15000), switchMap$1((response) => {
1449
+ isRefreshing = false;
1450
+ refreshResult$.next(response.accessToken);
1451
+ refreshResult$.complete();
1452
+ return next(addAuthHeader(req, response.accessToken));
1453
+ }), catchError((error) => {
1454
+ isRefreshing = false;
1455
+ refreshResult$.error(error);
1456
+ authStore.logout();
1457
+ router.navigate(['/login'], {
1458
+ queryParams: { returnUrl: router.url },
1459
+ });
1460
+ return throwError(() => error);
1461
+ }));
1462
+ }
1463
+ // Wait for the current refresh to complete (success or failure)
1464
+ return refreshResult$.pipe(take$1(1), switchMap$1((token) => next(addAuthHeader(req, token))), catchError(() => throwError(() => new HttpErrorResponse({ status: 401 }))));
1465
+ }
1466
+
1467
+ const RETURN_URL_KEY = 'oluso.returnUrl.active';
1468
+ let refreshInFlight = null;
1469
+ /**
1470
+ * Token attachment fallback + 401 retry with refresh deduplication. On refresh
1471
+ * failure, stashes the current route in sessionStorage under an opaque key so
1472
+ * the post-login callback can restore navigation without leaking the route to
1473
+ * URL/history/server logs.
1474
+ *
1475
+ * Skips the OIDC token endpoint to avoid infinite loops if /connect/token 401s.
1476
+ */
1477
+ const authRefreshInterceptor = (req, next) => {
1478
+ const authProvider = inject(AuthProvider);
1479
+ if (req.headers.has('Authorization')) {
1480
+ return next(req).pipe(catchError$1((err) => handle401(err, req, next, authProvider)));
1481
+ }
1482
+ return authProvider.getAccessToken().pipe(take(1), switchMap((token) => {
1483
+ const outReq = token ? addToken(req, token) : req;
1484
+ return next(outReq).pipe(catchError$1((err) => handle401(err, req, next, authProvider)));
1485
+ }));
1486
+ };
1487
+ function addToken(req, token) {
1488
+ return req.clone({
1489
+ setHeaders: { Authorization: `Bearer ${token}` },
1490
+ });
1491
+ }
1492
+ function handle401(error, req, next, authProvider) {
1493
+ if (error instanceof HttpErrorResponse &&
1494
+ error.status === 401 &&
1495
+ !error.url?.includes('/connect/token')) {
1496
+ return sharedRefresh(authProvider).pipe(switchMap((result) => {
1497
+ if (result?.isAuthenticated && result?.accessToken) {
1498
+ return next(addToken(req, result.accessToken));
1499
+ }
1500
+ console.warn('Token refresh returned no valid session — redirecting to login');
1501
+ redirectToLogin(authProvider);
1502
+ return throwError(() => error);
1503
+ }), catchError$1((refreshError) => {
1504
+ console.warn('Token refresh failed — redirecting to login', refreshError);
1505
+ redirectToLogin(authProvider);
1506
+ return throwError(() => error);
1507
+ }));
1508
+ }
1509
+ return throwError(() => error);
1510
+ }
1511
+ function sharedRefresh(authProvider) {
1512
+ if (!refreshInFlight) {
1513
+ refreshInFlight = authProvider.forceRefreshSession().pipe(take(1), finalize(() => { refreshInFlight = null; }), shareReplay({ bufferSize: 1, refCount: false }));
1514
+ }
1515
+ return refreshInFlight;
1516
+ }
1517
+ function redirectToLogin(authProvider) {
1518
+ rememberReturnUrl();
1519
+ authProvider.authorize();
1520
+ }
1521
+ /**
1522
+ * Remembers the page the user is on before a sign-in redirect ends their session, so the callback page can
1523
+ * bring them back to it ({@link readAndClearReturnUrl}). The callback, renew and error pages, and the root,
1524
+ * are not remembered.
1525
+ */
1526
+ function rememberReturnUrl() {
1527
+ try {
1528
+ const currentUrl = window.location.pathname + window.location.search + window.location.hash;
1529
+ if (!currentUrl.startsWith('/callback') &&
1530
+ !currentUrl.startsWith('/silent-renew') &&
1531
+ !currentUrl.startsWith('/error') &&
1532
+ currentUrl !== '/') {
1533
+ sessionStorage.setItem(RETURN_URL_KEY, currentUrl);
1534
+ }
1535
+ }
1536
+ catch {
1537
+ /* sessionStorage unavailable */
1538
+ }
1539
+ }
1540
+ /**
1541
+ * Helper for callback pages to restore the pre-login route.
1542
+ */
1543
+ function readAndClearReturnUrl() {
1544
+ try {
1545
+ const url = sessionStorage.getItem(RETURN_URL_KEY);
1546
+ sessionStorage.removeItem(RETURN_URL_KEY);
1547
+ return url;
1548
+ }
1549
+ catch {
1550
+ return null;
1551
+ }
1552
+ }
1553
+
1554
+ /**
1555
+ * Paths that must never carry the X-Tenant-ID header.
1556
+ * OIDC endpoints use the global issuer; sending a tenant header
1557
+ * can change the resolved issuer/discovery document and break
1558
+ * token validation after a tenant switch.
1559
+ */
1560
+ const TENANT_HEADER_EXCLUDED_PATHS = [
1561
+ '/connect/',
1562
+ '/.well-known/',
1563
+ '/discovery/',
1564
+ // Action-token links carry their own tenant; a signed-in session's tenant
1565
+ // must not leak into a customer-facing request.
1566
+ '/api/public/',
1567
+ ];
1568
+ /**
1569
+ * Tenant Interceptor
1570
+ *
1571
+ * Adds the X-Tenant-ID header to outgoing API requests when the user
1572
+ * has selected a tenant context.
1573
+ *
1574
+ * This is used in the Admin UI to allow super admins to manage
1575
+ * resources across different tenants. The backend validates
1576
+ * whether the user is authorized to access the specified tenant.
1577
+ *
1578
+ * OIDC protocol endpoints are excluded — they must always use the
1579
+ * global (non-tenant) context so the issuer stays consistent.
1580
+ */
1581
+ const tenantInterceptor = (req, next) => {
1582
+ const authStore = inject(AuthStore);
1583
+ // Only a tenant picked in the UI goes out; otherwise the API takes the tenant from the token.
1584
+ const tenantId = authStore.tenantSwitched() ? authStore.currentTenantId() : null;
1585
+ // An explicitly-set header wins. The tenant-switch call has to target the
1586
+ // tenant being switched TO while the store still holds the current one —
1587
+ // setHeaders would otherwise overwrite it and switch you back to yourself.
1588
+ if (tenantId && !req.headers.has('X-Tenant-ID')) {
1589
+ // Don't attach tenant header to OIDC protocol endpoints
1590
+ const urlPath = new URL(req.url, window.location.origin).pathname;
1591
+ const isExcluded = TENANT_HEADER_EXCLUDED_PATHS.some(p => urlPath.includes(p));
1592
+ if (!isExcluded) {
1593
+ req = req.clone({
1594
+ setHeaders: {
1595
+ 'X-Tenant-ID': tenantId,
1596
+ },
1597
+ });
1598
+ }
1599
+ }
1600
+ return next(req);
1601
+ };
1602
+ /**
1603
+ * Organization Interceptor
1604
+ *
1605
+ * Adds the X-Organization-ID header to outgoing requests when the user
1606
+ * has selected an organization context.
1607
+ *
1608
+ * This is used for multi-tenant applications where users can belong
1609
+ * to multiple organizations.
1610
+ */
1611
+ const organizationInterceptor = (req, next) => {
1612
+ const authStore = inject(AuthStore);
1613
+ const organization = authStore.currentOrganization();
1614
+ if (organization) {
1615
+ req = req.clone({
1616
+ setHeaders: {
1617
+ 'X-Organization-ID': organization.id,
1618
+ },
1619
+ });
1620
+ }
1621
+ return next(req);
1622
+ };
1623
+
1624
+ /**
1625
+ * Ngrok Interceptor
1626
+ *
1627
+ * Adds the ngrok-skip-browser-warning header to skip the ngrok interstitial page.
1628
+ * This header is required when making API calls through ngrok tunnels.
1629
+ */
1630
+ const ngrokInterceptor = (req, next) => {
1631
+ // Add ngrok header to skip browser warning
1632
+ const modifiedReq = req.clone({
1633
+ setHeaders: {
1634
+ 'ngrok-skip-browser-warning': 'true',
1635
+ },
1636
+ });
1637
+ return next(modifiedReq);
1638
+ };
1639
+
1640
+ /**
1641
+ * Error Interceptor
1642
+ *
1643
+ * Catches HTTP error responses and displays user-friendly toast notifications.
1644
+ * Extracts error messages from backend responses when available.
1645
+ * Skips 401 errors (handled by the auth interceptor).
1646
+ */
1647
+ const errorInterceptor = (req, next) => {
1648
+ const toast = inject(ToastService);
1649
+ return next(req).pipe(catchError((error) => {
1650
+ // Skip 401s — the auth interceptor handles token refresh / logout
1651
+ if (error.status === 401) {
1652
+ return throwError(() => error);
1653
+ }
1654
+ // Caller opted out of the global toast — it handles this failure itself
1655
+ // (e.g. an optional conversation thread that may 403/404). Still rethrow.
1656
+ if (req.context.get(SKIP_ERROR_TOAST)) {
1657
+ return throwError(() => error);
1658
+ }
1659
+ const message = extractMessage(error);
1660
+ toast.error(message);
1661
+ return throwError(() => error);
1662
+ }));
1663
+ };
1664
+ /**
1665
+ * Extract a human-readable message from the error response.
1666
+ * Module controllers return { message: "..." }, the admin API returns
1667
+ * { error: "..." }, and unhandled failures arrive as ProblemDetails.
1668
+ */
1669
+ function extractMessage(error) {
1670
+ // Try to get the message from the response body
1671
+ const body = error.error;
1672
+ if (body) {
1673
+ // Backend returns { message: "...", requiredPermission?: "..." }
1674
+ if (typeof body === 'object' && body.message) {
1675
+ if (body.requiredPermission) {
1676
+ return `${body.message} (${body.requiredPermission})`;
1677
+ }
1678
+ return body.message;
1679
+ }
1680
+ // Backend returns { Message: "..." } (PascalCase)
1681
+ if (typeof body === 'object' && body.Message) {
1682
+ return body.Message;
1683
+ }
1684
+ // Admin API validation / business errors: { error: "..." }
1685
+ if (typeof body === 'object' && typeof body.error === 'string' && body.error) {
1686
+ return body.error;
1687
+ }
1688
+ // ProblemDetails: { title, detail }
1689
+ if (typeof body === 'object' && typeof body.detail === 'string' && body.detail) {
1690
+ return body.detail;
1691
+ }
1692
+ if (typeof body === 'object' && typeof body.title === 'string' && body.title) {
1693
+ return body.title;
1694
+ }
1695
+ // ASP.NET validation errors: { errors: { field: ["msg"] } }
1696
+ if (typeof body === 'object' && body.errors) {
1697
+ const firstField = Object.keys(body.errors)[0];
1698
+ if (firstField) {
1699
+ const msgs = body.errors[firstField];
1700
+ if (Array.isArray(msgs) && msgs.length > 0) {
1701
+ return msgs[0];
1702
+ }
1703
+ }
1704
+ }
1705
+ // Plain string body
1706
+ if (typeof body === 'string' && body.length < 200) {
1707
+ return body;
1708
+ }
1709
+ }
1710
+ // Fallback based on status code
1711
+ switch (error.status) {
1712
+ // No readable response: the connection dropped, or the browser blocked the response (e.g. CORS on a
1713
+ // cross-origin redirect). Not a server outage; those come back as 502/503/504 below.
1714
+ case 0:
1715
+ return 'The request did not complete. It was blocked by the browser or the connection was lost.';
1716
+ case 400:
1717
+ return 'Invalid request. Please check your input.';
1718
+ case 403:
1719
+ return 'You do not have permission to perform this action.';
1720
+ case 404:
1721
+ return 'The requested resource was not found.';
1722
+ case 409:
1723
+ return 'A conflict occurred. The resource may have been modified.';
1724
+ case 422:
1725
+ return 'The request could not be processed. Please check your input.';
1726
+ case 500:
1727
+ return 'An internal server error occurred. Please try again later.';
1728
+ case 502:
1729
+ case 503:
1730
+ case 504:
1731
+ return 'Unable to reach the server right now. Please try again shortly.';
1732
+ default:
1733
+ return `An error occurred (${error.status}). Please try again.`;
1734
+ }
1735
+ }
1736
+
1737
+ /**
1738
+ * The browser's DPoP key (RFC 9449): an ECDSA P-256 key pair whose private half the page can sign with but
1739
+ * never read, so a token bound to it cannot be used anywhere else.
1740
+ *
1741
+ * The pair is kept in IndexedDB, shared by every tab of the app and surviving reloads, because refresh tokens
1742
+ * are bound to it: a remembered session's next refresh needs the same key. Where IndexedDB is unavailable the
1743
+ * key lives for the page only, and the session ends with it.
1744
+ */
1745
+ const DATABASE = 'oluso.dpop';
1746
+ const STORE = 'keys';
1747
+ const KEY_ID = 'current';
1748
+ let current = null;
1749
+ /** Whether this page can hold a DPoP key: Web Crypto, which browsers offer only in secure contexts. */
1750
+ function dpopSupported() {
1751
+ const secure = typeof globalThis.isSecureContext === 'boolean' ? globalThis.isSecureContext : true;
1752
+ return secure && typeof crypto !== 'undefined' && !!crypto.subtle;
1753
+ }
1754
+ /** The app's DPoP key, loaded or created once per page. */
1755
+ function dpopKey() {
1756
+ current ??= loadOrCreate().catch((error) => {
1757
+ current = null;
1758
+ throw error;
1759
+ });
1760
+ return current;
1761
+ }
1762
+ /** Forgets the key held by this page, so the next {@link dpopKey} reads storage again (tests). */
1763
+ function resetDPoPKeyForTests() {
1764
+ current = null;
1765
+ }
1766
+ async function loadOrCreate() {
1767
+ const generated = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, false, ['sign', 'verify']);
1768
+ const pair = (await keepFirst(generated)) ?? generated;
1769
+ return { privateKey: pair.privateKey, publicJwk: await publicJwkOf(pair.publicKey) };
1770
+ }
1771
+ /**
1772
+ * Stores `candidate` unless a pair is already stored, in one transaction so two tabs creating a key at once
1773
+ * agree on a single one. Returns the stored pair, or null when IndexedDB is unavailable.
1774
+ */
1775
+ function keepFirst(candidate) {
1776
+ if (typeof indexedDB === 'undefined')
1777
+ return Promise.resolve(null);
1778
+ return new Promise((resolve) => {
1779
+ const open = indexedDB.open(DATABASE, 1);
1780
+ open.onupgradeneeded = () => open.result.createObjectStore(STORE);
1781
+ open.onerror = () => resolve(null);
1782
+ open.onsuccess = () => {
1783
+ const db = open.result;
1784
+ const tx = db.transaction(STORE, 'readwrite');
1785
+ const store = tx.objectStore(STORE);
1786
+ let kept = null;
1787
+ const existing = store.get(KEY_ID);
1788
+ existing.onsuccess = () => {
1789
+ if (existing.result) {
1790
+ kept = existing.result;
1791
+ }
1792
+ else {
1793
+ store.put(candidate, KEY_ID);
1794
+ kept = candidate;
1795
+ }
1796
+ };
1797
+ tx.oncomplete = () => { db.close(); resolve(kept); };
1798
+ tx.onerror = () => { db.close(); resolve(null); };
1799
+ };
1800
+ });
1801
+ }
1802
+ async function publicJwkOf(publicKey) {
1803
+ const jwk = await crypto.subtle.exportKey('jwk', publicKey);
1804
+ return { kty: 'EC', crv: 'P-256', x: jwk.x, y: jwk.y };
1805
+ }
1806
+
1807
+ /** A DPoP proof JWT signed by `key` for one request. */
1808
+ async function createDPoPProof(key, target) {
1809
+ const header = { typ: 'dpop+jwt', alg: 'ES256', jwk: key.publicJwk };
1810
+ const payload = {
1811
+ jti: crypto.randomUUID(),
1812
+ htm: target.method.toUpperCase(),
1813
+ htu: proofUri(target.url),
1814
+ iat: Math.floor(Date.now() / 1000),
1815
+ };
1816
+ if (target.accessToken)
1817
+ payload['ath'] = await accessTokenHash(target.accessToken);
1818
+ if (target.nonce)
1819
+ payload['nonce'] = target.nonce;
1820
+ const signingInput = `${base64UrlText(JSON.stringify(header))}.${base64UrlText(JSON.stringify(payload))}`;
1821
+ const signature = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, key.privateKey, new TextEncoder().encode(signingInput));
1822
+ return `${signingInput}.${base64UrlBytes(new Uint8Array(signature))}`;
1823
+ }
1824
+ /** The request URI a proof names: absolute, without query or fragment. */
1825
+ function proofUri(url) {
1826
+ const base = typeof location !== 'undefined' ? location.href : 'http://localhost/';
1827
+ const absolute = new URL(url, base);
1828
+ return `${absolute.origin}${absolute.pathname}`;
1829
+ }
1830
+ /** The <c>ath</c> claim: the base64url SHA-256 of the access token. */
1831
+ async function accessTokenHash(accessToken) {
1832
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(accessToken));
1833
+ return base64UrlBytes(new Uint8Array(digest));
1834
+ }
1835
+ function base64UrlText(text) {
1836
+ return base64UrlBytes(new TextEncoder().encode(text));
1837
+ }
1838
+ function base64UrlBytes(bytes) {
1839
+ let binary = '';
1840
+ bytes.forEach((b) => (binary += String.fromCharCode(b)));
1841
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
1842
+ }
1843
+
1844
+ /** Nonces servers have asked proofs to carry (RFC 9449 §8), by origin. */
1845
+ const nonces = new Map();
1846
+ /**
1847
+ * Sender-constrains the app's tokens with DPoP (RFC 9449).
1848
+ *
1849
+ * - Token requests (any grant) carry a proof, so the server binds the tokens it issues — access and refresh —
1850
+ * to this browser's key ({@link dpopKey}); a copied token is useless without it.
1851
+ * - A request presenting a DPoP-bound access token (one whose <c>cnf.jkt</c> names a key) is sent under the
1852
+ * DPoP scheme with a proof for that request and token instead of as a bearer token, which the server refuses.
1853
+ * - When a server asks for a nonce (<c>use_dpop_nonce</c>), the request is retried once with it.
1854
+ *
1855
+ * Register it after the interceptors that attach the access token, so it sees the final request.
1856
+ * Pages without Web Crypto (non-secure contexts) send requests unchanged; their tokens are not bound.
1857
+ */
1858
+ const dpopInterceptor = (req, next) => {
1859
+ if (!dpopSupported())
1860
+ return next(req);
1861
+ // A token request presents no access token. The auth library attaches the session's token to every URL under its
1862
+ // secure routes, the token endpoint included; sent there, the server's resource check consumes the proof's jti and
1863
+ // the token endpoint then refuses the same proof as replayed. Client authentication (Basic) is left alone.
1864
+ if (isTokenRequest(req) && /^(Bearer|DPoP)\s/i.test(req.headers.get('Authorization') ?? '')) {
1865
+ req = req.clone({ headers: req.headers.delete('Authorization') });
1866
+ }
1867
+ const presented = boundAccessTokenOf(req);
1868
+ if (!presented && !isTokenRequest(req))
1869
+ return next(req);
1870
+ const send = (nonce) => from(withProof(req, presented, nonce)).pipe(switchMap$1((signed) => next(signed)), tap((event) => { if (event instanceof HttpResponse)
1871
+ rememberNonce(req, event.headers); }));
1872
+ return send(nonces.get(originOf(req.url))).pipe(catchError((error) => {
1873
+ if (!(error instanceof HttpErrorResponse))
1874
+ return throwError(() => error);
1875
+ const nonce = error.headers?.get('DPoP-Nonce');
1876
+ if (nonce)
1877
+ nonces.set(originOf(req.url), nonce);
1878
+ return nonce && asksForNonce(error) ? send(nonce) : throwError(() => error);
1879
+ }));
1880
+ };
1881
+ async function withProof(req, boundToken, nonce) {
1882
+ const proof = await createDPoPProof(await dpopKey(), { method: req.method, url: req.url, accessToken: boundToken, nonce });
1883
+ let headers = req.headers.set('DPoP', proof);
1884
+ if (boundToken)
1885
+ headers = headers.set('Authorization', `DPoP ${boundToken}`);
1886
+ return req.clone({ headers });
1887
+ }
1888
+ /** A token endpoint request: a form POST naming a grant. */
1889
+ function isTokenRequest(req) {
1890
+ return req.method === 'POST' && typeof req.body === 'string' && new URLSearchParams(req.body).has('grant_type');
1891
+ }
1892
+ /** The access token a request presents, when it is bound to a DPoP key; else null. */
1893
+ function boundAccessTokenOf(req) {
1894
+ const match = /^(?:Bearer|DPoP)\s+(.+)$/i.exec(req.headers.get('Authorization') ?? '');
1895
+ const token = match?.[1]?.trim();
1896
+ return token && isDPoPBound(token) ? token : null;
1897
+ }
1898
+ let lastChecked = null;
1899
+ /** Whether a JWT access token is bound to a DPoP key (has <c>cnf.jkt</c>). */
1900
+ function isDPoPBound(token) {
1901
+ if (lastChecked?.token === token)
1902
+ return lastChecked.bound;
1903
+ let bound = false;
1904
+ const part = token.split('.')[1];
1905
+ if (part) {
1906
+ try {
1907
+ const json = atob(part.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(part.length / 4) * 4, '='));
1908
+ const jkt = JSON.parse(json)?.cnf?.jkt;
1909
+ bound = typeof jkt === 'string' && jkt.length > 0;
1910
+ }
1911
+ catch {
1912
+ bound = false;
1913
+ }
1914
+ }
1915
+ lastChecked = { token, bound };
1916
+ return bound;
1917
+ }
1918
+ /** A refusal asking for a DPoP nonce: a 401 challenge from an API or a 400 error from the token endpoint. */
1919
+ function asksForNonce(error) {
1920
+ if (error.status === 401)
1921
+ return (error.headers?.get('WWW-Authenticate') ?? '').includes('use_dpop_nonce');
1922
+ return error.status === 400 && error.error?.error === 'use_dpop_nonce';
1923
+ }
1924
+ function rememberNonce(req, headers) {
1925
+ const nonce = headers.get('DPoP-Nonce');
1926
+ if (nonce)
1927
+ nonces.set(originOf(req.url), nonce);
1928
+ }
1929
+ function originOf(url) {
1930
+ return new URL(proofUri(url)).origin;
1931
+ }
1932
+
1933
+ /**
1934
+ * A `fetch` that sends through the app's HttpClient, so its interceptors apply: the signed-in user's access token,
1935
+ * the tenant, DPoP proofs and the refresh-and-retry on an expired token. Give it to the Oluso SDK
1936
+ * (`new OlusoClient({ ..., fetch: httpClientFetch(http) })`) to run journeys the app's backend started for the user.
1937
+ * Answers with an error status resolve like fetch's do, so the SDK reads the server's message.
1938
+ */
1939
+ function httpClientFetch(http) {
1940
+ return async (input, init) => {
1941
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
1942
+ const method = (init?.method ?? 'GET').toUpperCase();
1943
+ let headers = new HttpHeaders();
1944
+ new Headers(init?.headers).forEach((value, name) => {
1945
+ headers = headers.set(name, value);
1946
+ });
1947
+ try {
1948
+ const response = await firstValueFrom(http.request(method, url, {
1949
+ body: init?.body ?? null,
1950
+ headers,
1951
+ observe: 'response',
1952
+ responseType: 'text',
1953
+ withCredentials: init?.credentials === 'include',
1954
+ }));
1955
+ return toResponse(response);
1956
+ }
1957
+ catch (error) {
1958
+ if (error instanceof HttpErrorResponse && error.status > 0)
1959
+ return toResponse(error);
1960
+ throw error;
1961
+ }
1962
+ };
1963
+ }
1964
+ function toResponse(response) {
1965
+ const headers = new Headers();
1966
+ response.headers.keys().forEach((name) => {
1967
+ const value = response.headers.get(name);
1968
+ if (value !== null)
1969
+ headers.set(name, value);
1970
+ });
1971
+ const body = response instanceof HttpErrorResponse
1972
+ ? typeof response.error === 'string' ? response.error : JSON.stringify(response.error ?? null)
1973
+ : response.body;
1974
+ const empty = response.status === 204 || response.status === 205 || response.status === 304;
1975
+ return new Response(empty ? null : body, { status: response.status, statusText: response.statusText, headers });
1976
+ }
1977
+
1978
+ // Models
1979
+
1980
+ /**
1981
+ * Generated bundle index. Do not edit.
1982
+ */
1983
+
1984
+ export { AuthProvider, AuthService, AuthStore, NoOpAuthProvider, PERMISSION_GUARD_TIMEOUT_MS, RememberMeSecurityStorage, RenewAwareAuthProvider, accessTokenHash, allPermissionsGuard, anyPermissionGuard, authGuard, authInterceptor, authRefreshInterceptor, boundAccessTokenOf, checkAuthRenewingSession, clearTenantSso, consumePendingAuthorize, createDPoPProof, dpopInterceptor, dpopKey, dpopSupported, errorInterceptor, guestGuard, httpClientFetch, isDPoPBound, ngrokInterceptor, orgAdminGuard, organizationInterceptor, permissionGuard, proofUri, readAndClearReturnUrl, readTenantSso, refreshRetryDelayMs, refreshTokenOf$1 as refreshTokenOf, rememberReturnUrl, requestTenantSwitch, resetDPoPKeyForTests, resolveTenantFromHostname, resolveTenantSsoAuthority, superAdminGuard, tenantInterceptor, tenantOfToken, tenantSsoAuthorizeParams, tokenRefreshRetryInterceptor, writeTenantSso };
1985
+ //# sourceMappingURL=syndew-oluso-auth.mjs.map