@opensourcekd/ng-common-libs 2.0.10 → 2.1.0

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/dist/index.mjs CHANGED
@@ -446,6 +446,7 @@ function e(e,t){var n={};for(var o in e)Object.prototype.hasOwnProperty.call(e,o
446
446
  class AuthService {
447
447
  auth0Client = null;
448
448
  initializationPromise = null;
449
+ /** Remains false if callback processing fails, allowing the callback to be retried. */
449
450
  callbackHandled = false;
450
451
  callbackPromise = null;
451
452
  userSubject;
@@ -480,6 +481,14 @@ class AuthService {
480
481
  const existingUserInfo = this.getUserInfoFromStorage();
481
482
  this.userSubject = new BehaviorSubject(existingUserInfo);
482
483
  this.user$ = this.userSubject.asObservable();
484
+ // Eagerly begin Auth0 client initialization so logs are visible immediately.
485
+ // The .catch() only suppresses an unhandled-rejection warning; the rejected
486
+ // promise is stored in initializationPromise and re-throws when awaited by
487
+ // ensureInitialized() or ensureAuth0Client(), surfacing the error to callers.
488
+ this.initializationPromise = this.initializeAuth0();
489
+ this.initializationPromise.catch(error => {
490
+ console.error('[AuthService] Failed to initialize Auth0 client:', error);
491
+ });
483
492
  }
484
493
  /**
485
494
  * Get the identifier of this AuthService instance
@@ -519,6 +528,25 @@ class AuthService {
519
528
  console.log('[AuthService] Auth0 client initialized successfully');
520
529
  this.emitAuthEvent('init', null);
521
530
  }
531
+ /**
532
+ * Ensure the Auth0 client instance is created, without triggering callback detection.
533
+ * Used internally by {@link handleCallback} to avoid a circular async dependency:
534
+ * `ensureInitialized` → `checkAndHandleCallback` → `handleCallback` → `ensureInitialized`.
535
+ * @throws {Error} When the Auth0 client fails to initialize
536
+ */
537
+ async ensureAuth0Client() {
538
+ if (this.auth0Client)
539
+ return;
540
+ if (this.initializationPromise) {
541
+ await this.initializationPromise;
542
+ return;
543
+ }
544
+ this.initializationPromise = this.initializeAuth0();
545
+ await this.initializationPromise;
546
+ if (!this.auth0Client) {
547
+ throw new Error('[AuthService] Auth0 client failed to initialize');
548
+ }
549
+ }
522
550
  /**
523
551
  * Ensure the Auth0 client is initialized before use
524
552
  * Lazy-initializes on the first call and auto-handles OAuth callbacks when detected
@@ -547,6 +575,10 @@ class AuthService {
547
575
  * The Auth0 SDK's `handleRedirectCallback` validates the `state` parameter
548
576
  * to prevent CSRF attacks. This method only detects presence of callback
549
577
  * params before delegating securely to the SDK.
578
+ *
579
+ * Uses an async IIFE to store the in-flight promise for concurrency deduplication
580
+ * (concurrent callers await the same promise) while still using async/await
581
+ * internally instead of `.then()/.catch()` chains.
550
582
  */
551
583
  async checkAndHandleCallback() {
552
584
  if (this.callbackHandled || typeof window === 'undefined')
@@ -556,15 +588,22 @@ class AuthService {
556
588
  return;
557
589
  }
558
590
  const urlParams = new URLSearchParams(window.location.search);
559
- if (urlParams.has('code') && urlParams.has('state')) {
560
- console.log('[AuthService] Auth0 callback detected in URL, processing...');
561
- this.emitAuthEvent('callback_detected', null);
562
- this.callbackPromise = this.handleCallback()
563
- .then(() => { this.callbackHandled = true; })
564
- .catch(error => { throw error; })
565
- .finally(() => { this.callbackPromise = null; });
591
+ if (!urlParams.has('code') || !urlParams.has('state'))
592
+ return;
593
+ console.log('[AuthService] Auth0 callback detected in URL, processing...');
594
+ this.emitAuthEvent('callback_detected', null);
595
+ // Store the promise before the first await so concurrent callers see it
596
+ // and deduplicate by awaiting the same in-flight promise.
597
+ this.callbackPromise = (async () => {
598
+ await this.handleCallback();
599
+ this.callbackHandled = true;
600
+ })();
601
+ try {
566
602
  await this.callbackPromise;
567
603
  }
604
+ finally {
605
+ this.callbackPromise = null;
606
+ }
568
607
  }
569
608
  /**
570
609
  * Redirect the user to Auth0 Universal Login
@@ -601,11 +640,15 @@ class AuthService {
601
640
  /**
602
641
  * Handle the OAuth2 redirect callback after successful authorization
603
642
  * Stores the user info and access token, then cleans up the callback URL
643
+ *
644
+ * Uses {@link ensureAuth0Client} (not {@link ensureInitialized}) to avoid a circular
645
+ * async dependency: `ensureInitialized` → `checkAndHandleCallback` → `handleCallback`
646
+ * → `ensureInitialized`. Only the Auth0 client instance is needed here.
604
647
  * @returns {@link CallbackResult} with `success` flag and optional `appState`
605
648
  */
606
649
  async handleCallback() {
607
650
  try {
608
- await this.ensureInitialized();
651
+ await this.ensureAuth0Client();
609
652
  console.log('[AuthService] Processing Auth0 redirect callback...');
610
653
  const result = await this.auth0Client.handleRedirectCallback();
611
654
  const user = await this.auth0Client.getUser();
@@ -629,6 +672,11 @@ class AuthService {
629
672
  }
630
673
  /**
631
674
  * Log the user out, clear all stored auth data, and redirect to the logout URI
675
+ *
676
+ * Uses {@link ensureAuth0Client} (not {@link ensureInitialized}) to avoid triggering
677
+ * callback detection during logout. Calling `ensureInitialized` here would invoke
678
+ * `checkAndHandleCallback`, which re-authenticates the user if callback URL params
679
+ * are present, causing them to be sent back to Auth0 instead of the logout URI.
632
680
  */
633
681
  async logout() {
634
682
  console.log('[AuthService] User logging out...');
@@ -638,7 +686,7 @@ class AuthService {
638
686
  this.userSubject.next(null);
639
687
  this.emitAuthEvent('logout', null);
640
688
  try {
641
- await this.ensureInitialized();
689
+ await this.ensureAuth0Client();
642
690
  await this.auth0Client.logout({
643
691
  logoutParams: { returnTo: this.config.logoutUri }
644
692
  });
@@ -1023,5 +1071,316 @@ class Logger {
1023
1071
  }
1024
1072
  }
1025
1073
 
1026
- export { APP_CONFIG, AUTH0_CONFIG, AuthService, EventBus, LogSeverity, Logger, STANDARD_JWT_CLAIMS, STORAGE_CONFIG, STORAGE_KEYS, buildUserData, configureAuth0, createAuthService, decodeAndStoreToken, extractClaimValue, getCustomClaims, getDecodedToken, getStorageItem, isNamespacedClaim, removeStorageItem, resetAuth0Config, setStorageItem };
1074
+ /**
1075
+ * BearerTokenInterceptor — HTTP interceptor for bearer token injection.
1076
+ * Patches both `window.fetch` and `XMLHttpRequest` (for Angular's default
1077
+ * HttpClient backend), pure TypeScript, framework-agnostic, truly singleton
1078
+ * per identifier.
1079
+ */
1080
+ /**
1081
+ * BearerTokenInterceptor
1082
+ *
1083
+ * A per-identifier singleton that patches the global `window.fetch` to
1084
+ * automatically attach an `Authorization: Bearer <token>` header to every
1085
+ * request whose URL begins with the configured API base URL.
1086
+ *
1087
+ * **Singleton behaviour**: the first call to {@link BearerTokenInterceptor.getInstance}
1088
+ * for a given `id` creates the instance; subsequent calls with the same `id`
1089
+ * return the previously created instance, regardless of the `config` argument.
1090
+ *
1091
+ * Designed for Module Federation micro-frontend environments where both the
1092
+ * fetch wrapper and the XHR prototype must be shared across the shell and all
1093
+ * remote applications.
1094
+ *
1095
+ * @example
1096
+ * ```typescript
1097
+ * import { BearerTokenInterceptor, APP_CONFIG } from '@opensourcekd/ng-common-libs';
1098
+ *
1099
+ * // In the shell app — creates the instance
1100
+ * const interceptor = BearerTokenInterceptor.getInstance('shell', {
1101
+ * apiUrl: APP_CONFIG.apiUrl,
1102
+ * getToken: () => authService.getTokenSync(),
1103
+ * });
1104
+ * interceptor.activate();
1105
+ *
1106
+ * // In any MFE — same instance is returned
1107
+ * const same = BearerTokenInterceptor.getInstance('shell', { getToken: () => null });
1108
+ * console.log(same === interceptor); // true
1109
+ * ```
1110
+ */
1111
+ class BearerTokenInterceptor {
1112
+ static instances = new Map();
1113
+ static originalFetch = null;
1114
+ static originalXhrOpen = null;
1115
+ static originalXhrSend = null;
1116
+ id;
1117
+ apiUrl;
1118
+ tokenFn;
1119
+ active = false;
1120
+ constructor(id, config) {
1121
+ this.id = id;
1122
+ this.apiUrl = config.apiUrl ?? APP_CONFIG.apiUrl;
1123
+ this.tokenFn = config.getToken;
1124
+ }
1125
+ /**
1126
+ * Get or create the singleton interceptor for the given identifier.
1127
+ *
1128
+ * The first invocation with a given `id` creates the instance using the
1129
+ * supplied `config`. Subsequent calls with the same `id` return the existing
1130
+ * instance — the `config` argument is ignored on subsequent calls.
1131
+ *
1132
+ * @param id - Unique identifier for this interceptor (e.g. `'shell'`, `'mfe-orders'`)
1133
+ * @param config - Configuration used only when creating a new instance
1134
+ * @returns The singleton {@link BearerTokenInterceptor} for the given `id`
1135
+ *
1136
+ * @example
1137
+ * ```typescript
1138
+ * const interceptor = BearerTokenInterceptor.getInstance('shell', {
1139
+ * apiUrl: 'https://api.example.com',
1140
+ * getToken: () => authService.getTokenSync(),
1141
+ * });
1142
+ * ```
1143
+ */
1144
+ static getInstance(id, config) {
1145
+ if (BearerTokenInterceptor.instances.has(id)) {
1146
+ return BearerTokenInterceptor.instances.get(id);
1147
+ }
1148
+ const instance = new BearerTokenInterceptor(id, config);
1149
+ BearerTokenInterceptor.instances.set(id, instance);
1150
+ return instance;
1151
+ }
1152
+ /**
1153
+ * Get the identifier of this interceptor instance.
1154
+ * @returns The `id` string supplied when the instance was created
1155
+ */
1156
+ getId() {
1157
+ return this.id;
1158
+ }
1159
+ /**
1160
+ * Get the API base URL that this interceptor matches against.
1161
+ * @returns The configured API URL string
1162
+ */
1163
+ getApiUrl() {
1164
+ return this.apiUrl;
1165
+ }
1166
+ /**
1167
+ * Check whether this interceptor is currently active.
1168
+ * @returns `true` if the interceptor has been activated and not yet deactivated
1169
+ */
1170
+ isActive() {
1171
+ return this.active;
1172
+ }
1173
+ /**
1174
+ * Activate the interceptor.
1175
+ *
1176
+ * Patches `window.fetch` and `XMLHttpRequest` once (on the first active
1177
+ * interceptor) so that matching requests receive the bearer token.
1178
+ * Subsequent calls are no-ops. No-op in non-browser environments.
1179
+ */
1180
+ activate() {
1181
+ if (this.active || typeof window === 'undefined')
1182
+ return;
1183
+ this.active = true;
1184
+ BearerTokenInterceptor.patchFetch();
1185
+ BearerTokenInterceptor.patchXhr();
1186
+ }
1187
+ /**
1188
+ * Deactivate the interceptor.
1189
+ *
1190
+ * Stops attaching the bearer token for this interceptor's URL pattern.
1191
+ * Both the global fetch wrapper and the XHR prototype patches are removed
1192
+ * automatically once all registered interceptors have been deactivated.
1193
+ * No-op when already inactive.
1194
+ */
1195
+ deactivate() {
1196
+ if (!this.active)
1197
+ return;
1198
+ this.active = false;
1199
+ if (typeof window !== 'undefined') {
1200
+ BearerTokenInterceptor.maybeRestoreFetch();
1201
+ BearerTokenInterceptor.maybeRestoreXhr();
1202
+ }
1203
+ }
1204
+ /**
1205
+ * Return the effective bearer token for a given request URL, or `null` if
1206
+ * this interceptor should not modify the request.
1207
+ *
1208
+ * Returns `null` when the interceptor is inactive, the URL does not begin
1209
+ * with the configured API base URL, or the token getter returns `null`.
1210
+ *
1211
+ * @param url - The absolute URL of the outgoing request
1212
+ * @returns Bearer token string or `null`
1213
+ */
1214
+ getEffectiveToken(url) {
1215
+ if (!this.active || !url.startsWith(this.apiUrl))
1216
+ return null;
1217
+ return this.tokenFn();
1218
+ }
1219
+ /**
1220
+ * Extract headers from a `RequestInit` object into a plain key-value map.
1221
+ * Handles `Headers` instances, `[key, value]` arrays, and plain objects.
1222
+ *
1223
+ * @param init - Optional `RequestInit` whose headers to extract
1224
+ * @returns A plain `Record<string, string>` copy of the headers
1225
+ */
1226
+ static extractHeaders(init) {
1227
+ if (!init?.headers)
1228
+ return {};
1229
+ if (init.headers instanceof Headers) {
1230
+ const h = {};
1231
+ init.headers.forEach((v, k) => { h[k] = v; });
1232
+ return h;
1233
+ }
1234
+ if (Array.isArray(init.headers)) {
1235
+ return Object.fromEntries(init.headers);
1236
+ }
1237
+ return { ...init.headers };
1238
+ }
1239
+ /**
1240
+ * Find the bearer token for a given URL by consulting all active interceptors.
1241
+ * Uses first-match semantics to avoid ambiguity when multiple interceptors
1242
+ * share the same API URL prefix.
1243
+ *
1244
+ * @param url - Absolute URL of the outgoing request
1245
+ * @returns The first matching bearer token string, or `null`
1246
+ */
1247
+ static findToken(url) {
1248
+ return ([...BearerTokenInterceptor.instances.values()]
1249
+ .map((i) => i.getEffectiveToken(url))
1250
+ .find((t) => t !== null) ?? null);
1251
+ }
1252
+ /**
1253
+ * Apply the `Authorization: Bearer` header from the first active interceptor
1254
+ * that matches the given URL. Uses first-match semantics to avoid ambiguity
1255
+ * when multiple interceptors share the same API URL prefix.
1256
+ *
1257
+ * @param url - Absolute URL of the outgoing fetch request
1258
+ * @param init - Original `RequestInit` options
1259
+ * @returns Modified `RequestInit` with the `Authorization` header added when applicable
1260
+ */
1261
+ static applyBearerToken(url, init) {
1262
+ const headers = BearerTokenInterceptor.extractHeaders(init);
1263
+ const token = BearerTokenInterceptor.findToken(url);
1264
+ if (token) {
1265
+ headers['Authorization'] = `Bearer ${token}`;
1266
+ }
1267
+ return { ...init, headers };
1268
+ }
1269
+ /**
1270
+ * Install the global fetch wrapper exactly once.
1271
+ * The wrapper delegates to {@link applyBearerToken} for every outgoing request.
1272
+ * No-op when already patched or in a non-browser environment.
1273
+ */
1274
+ static patchFetch() {
1275
+ if (BearerTokenInterceptor.originalFetch !== null || typeof window === 'undefined')
1276
+ return;
1277
+ // Store without bind so the original reference is preserved for identity checks on restore.
1278
+ BearerTokenInterceptor.originalFetch = window.fetch;
1279
+ window.fetch = (input, init) => {
1280
+ // Extract the URL string from all three possible input types:
1281
+ // • Request-like objects (e.g. the Fetch API Request) have a `.url` string property.
1282
+ // • URL objects do NOT have `.url`; String(urlObj) yields the full href (e.g. "https://…").
1283
+ // • Plain strings pass through String() unchanged.
1284
+ const hasUrl = typeof input.url === 'string';
1285
+ const url = hasUrl ? input.url : String(input);
1286
+ return BearerTokenInterceptor.originalFetch(input, BearerTokenInterceptor.applyBearerToken(url, init));
1287
+ };
1288
+ }
1289
+ /**
1290
+ * Restore the original `window.fetch` if no interceptors remain active.
1291
+ * No-op when the fetch has not been patched or some interceptors are still active.
1292
+ */
1293
+ static maybeRestoreFetch() {
1294
+ if (BearerTokenInterceptor.originalFetch === null)
1295
+ return;
1296
+ const anyActive = [...BearerTokenInterceptor.instances.values()].some((i) => i.active);
1297
+ if (anyActive)
1298
+ return;
1299
+ window.fetch = BearerTokenInterceptor.originalFetch;
1300
+ BearerTokenInterceptor.originalFetch = null;
1301
+ }
1302
+ /**
1303
+ * Install a global `XMLHttpRequest` prototype patch exactly once.
1304
+ *
1305
+ * Overrides `XMLHttpRequest.prototype.open` to capture the request URL on
1306
+ * the XHR instance, and `XMLHttpRequest.prototype.send` to inject the
1307
+ * `Authorization: Bearer` header (via `setRequestHeader`) before dispatching
1308
+ * the request. Supports Angular's default XHR-based `HttpClient` backend.
1309
+ *
1310
+ * No-op when already patched or in a non-browser environment.
1311
+ */
1312
+ static patchXhr() {
1313
+ if (BearerTokenInterceptor.originalXhrOpen !== null ||
1314
+ typeof window === 'undefined' ||
1315
+ typeof XMLHttpRequest === 'undefined')
1316
+ return;
1317
+ const originalOpen = XMLHttpRequest.prototype.open;
1318
+ const originalSend = XMLHttpRequest.prototype.send;
1319
+ BearerTokenInterceptor.originalXhrOpen = originalOpen;
1320
+ BearerTokenInterceptor.originalXhrSend = originalSend;
1321
+ // Capture the request URL so send() can match it against registered interceptors.
1322
+ // The `async` parameter is made optional to faithfully match both XHR `open()` overloads.
1323
+ XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
1324
+ const urlStr = url instanceof URL ? url.href : url;
1325
+ try {
1326
+ this._interceptorUrl = new URL(urlStr, window.location.href).href;
1327
+ }
1328
+ catch {
1329
+ this._interceptorUrl = urlStr;
1330
+ }
1331
+ // `async` defaults to true per the XHR spec when not supplied by the caller.
1332
+ originalOpen.call(this, method, url, async ?? true, username, password);
1333
+ };
1334
+ // Inject the Authorization header before dispatching the request.
1335
+ XMLHttpRequest.prototype.send = function (body) {
1336
+ const url = this._interceptorUrl;
1337
+ if (url) {
1338
+ const token = BearerTokenInterceptor.findToken(url);
1339
+ if (token) {
1340
+ this.setRequestHeader('Authorization', `Bearer ${token}`);
1341
+ }
1342
+ }
1343
+ originalSend.call(this, body);
1344
+ };
1345
+ }
1346
+ /**
1347
+ * Restore `XMLHttpRequest.prototype.open` and `.send` to their original
1348
+ * values if no interceptors remain active.
1349
+ * No-op when XHR has not been patched or some interceptors are still active.
1350
+ */
1351
+ static maybeRestoreXhr() {
1352
+ if (BearerTokenInterceptor.originalXhrOpen === null)
1353
+ return;
1354
+ const anyActive = [...BearerTokenInterceptor.instances.values()].some((i) => i.active);
1355
+ if (anyActive)
1356
+ return;
1357
+ XMLHttpRequest.prototype.open = BearerTokenInterceptor.originalXhrOpen;
1358
+ XMLHttpRequest.prototype.send = BearerTokenInterceptor.originalXhrSend;
1359
+ BearerTokenInterceptor.originalXhrOpen = null;
1360
+ BearerTokenInterceptor.originalXhrSend = null;
1361
+ }
1362
+ /**
1363
+ * Remove all registered instances and restore `window.fetch` and
1364
+ * `XMLHttpRequest` prototype methods to their original values.
1365
+ * Intended for use in test teardown only.
1366
+ * @internal
1367
+ */
1368
+ static _reset() {
1369
+ if (typeof window !== 'undefined') {
1370
+ if (BearerTokenInterceptor.originalFetch !== null) {
1371
+ window.fetch = BearerTokenInterceptor.originalFetch;
1372
+ }
1373
+ if (BearerTokenInterceptor.originalXhrOpen !== null && typeof XMLHttpRequest !== 'undefined') {
1374
+ XMLHttpRequest.prototype.open = BearerTokenInterceptor.originalXhrOpen;
1375
+ XMLHttpRequest.prototype.send = BearerTokenInterceptor.originalXhrSend;
1376
+ }
1377
+ }
1378
+ BearerTokenInterceptor.originalFetch = null;
1379
+ BearerTokenInterceptor.originalXhrOpen = null;
1380
+ BearerTokenInterceptor.originalXhrSend = null;
1381
+ BearerTokenInterceptor.instances.clear();
1382
+ }
1383
+ }
1384
+
1385
+ export { APP_CONFIG, AUTH0_CONFIG, AuthService, BearerTokenInterceptor, EventBus, LogSeverity, Logger, STANDARD_JWT_CLAIMS, STORAGE_CONFIG, STORAGE_KEYS, buildUserData, configureAuth0, createAuthService, decodeAndStoreToken, extractClaimValue, getCustomClaims, getDecodedToken, getStorageItem, isNamespacedClaim, removeStorageItem, resetAuth0Config, setStorageItem };
1027
1386
  //# sourceMappingURL=index.mjs.map