@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.
- package/LICENSE +76 -0
- package/README.md +42 -2
- package/fesm2022/syndew-oluso-auth.mjs +1985 -0
- package/fesm2022/syndew-oluso-auth.mjs.map +1 -0
- package/package.json +47 -3
- package/types/syndew-oluso-auth.d.ts +776 -0
|
@@ -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
|