mesauth-angular 1.23.0 → 1.24.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.
@@ -1,16 +1,16 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { signal, Injectable, InjectionToken, makeEnvironmentProviders, provideAppInitializer, inject, NgModule, afterNextRender, input, booleanAttribute, computed, HostBinding, Component, output, Injector, ChangeDetectionStrategy, effect, HostListener, ElementRef, Pipe, viewChild, ViewChild, DestroyRef, Directive } from '@angular/core';
3
3
  import { toObservable, toSignal, takeUntilDestroyed, rxResource } from '@angular/core/rxjs-interop';
4
- import { catchError, of, Subject, EMPTY, timer, throwError, firstValueFrom, distinctUntilChanged, switchMap as switchMap$1, forkJoin } from 'rxjs';
4
+ import { catchError, of, Subject, EMPTY, BehaviorSubject, throwError, timer, firstValueFrom, distinctUntilChanged, switchMap as switchMap$1, forkJoin } from 'rxjs';
5
5
  import { HttpClient, HttpResponse } from '@angular/common/http';
6
6
  import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
7
- import { map, tap, catchError as catchError$1, switchMap } from 'rxjs/operators';
7
+ import { map, tap, catchError as catchError$1, switchMap, filter, take } from 'rxjs/operators';
8
8
  import { Router } from '@angular/router';
9
9
  import { DomSanitizer } from '@angular/platform-browser';
10
10
  import { DatePipe, JsonPipe } from '@angular/common';
11
11
 
12
12
  /** Current installed package version — keep in sync with package.json. */
13
- const PACKAGE_VERSION = '1.23.0';
13
+ const PACKAGE_VERSION = '1.24.0';
14
14
  /**
15
15
  * Provides server-driven UI configuration loaded from the hosted manifest.
16
16
  * Components read `labels()` and `features()` signals instead of hardcoded strings.
@@ -446,20 +446,32 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.13", ngImpo
446
446
  type: Injectable
447
447
  }], ctorParameters: () => [] });
448
448
 
449
- // Track if we're currently redirecting to prevent loopback
449
+ // Module-scope state single instance shared across all requests because
450
+ // HttpInterceptorFn is provided once per injector.
451
+ // True while a 401-triggered refresh is in flight; concurrent 401 retries wait for it.
452
+ const refreshing$ = new BehaviorSubject(false);
453
+ // Last access token written to localStorage from an X-Refreshed-Token response header.
454
+ // Used to collapse N duplicate writes (one per parallel response) into one notifySignedIn call.
455
+ let lastWrittenAccessToken = null;
456
+ // Track if we're currently redirecting to login to prevent loopback storms.
450
457
  let isRedirecting = false;
451
458
  /**
452
- * Functional HTTP interceptor for handling 401/403 auth errors.
453
- * Redirects to login page on 401, and to 403 page on 403.
454
- * Includes loopback prevention to avoid infinite redirects.
459
+ * Functional HTTP interceptor.
460
+ *
461
+ * Responsibilities:
462
+ * 1. Attach `Authorization: Bearer` + `X-Refresh-Token` from localStorage to requests
463
+ * targeting the auth server / trusted hosts (so the server-side Authorizer middleware
464
+ * can perform silent refresh and emit X-Refreshed-* response headers).
465
+ * 2. On every successful response, if `X-Refreshed-Token` is present, write it to
466
+ * localStorage exactly once (dedup across parallel responses).
467
+ * 3. On 401, queue concurrent failures behind a single 1.5s wait so only ONE retry
468
+ * fires per refresh window. Retries strip the stale auth headers so the interceptor
469
+ * re-reads the freshly-stored tokens from localStorage.
470
+ * 4. On 403, redirect to /403.
455
471
  */
456
472
  const mesAuthInterceptor = (req, next) => {
457
473
  const authService = inject(MesAuthService);
458
474
  const router = inject(Router);
459
- // Attach access + refresh tokens from localStorage to requests going to the auth server
460
- // or any backend host running MesAuth.Authorizer (trustedHosts). This lets the authorizer
461
- // middleware silently refresh tokens and emit X-Refreshed-Token response headers.
462
- // Relative URLs are always considered trusted (same origin).
463
475
  const config = authService.getConfig();
464
476
  const apiBase = config?.apiBaseUrl ?? '';
465
477
  const trustedHosts = config?.trustedHosts ?? [];
@@ -473,9 +485,11 @@ const mesAuthInterceptor = (req, next) => {
473
485
  return true; // relative URL — same origin
474
486
  }
475
487
  }
476
- let authReq = req;
477
- if (typeof localStorage !== 'undefined' && isTrusted(req.url)) {
478
- let headers = req.headers;
488
+ // ---- Build the outgoing request, attaching localStorage tokens for trusted hosts.
489
+ function attachAuthHeaders(r) {
490
+ if (typeof localStorage === 'undefined' || !isTrusted(r.url))
491
+ return r;
492
+ let headers = r.headers;
479
493
  if (!headers.has('Authorization')) {
480
494
  const accessToken = localStorage.getItem('mes_auth_token');
481
495
  if (accessToken)
@@ -486,39 +500,50 @@ const mesAuthInterceptor = (req, next) => {
486
500
  if (refreshToken)
487
501
  headers = headers.set('X-Refresh-Token', refreshToken);
488
502
  }
489
- if (headers !== req.headers)
490
- authReq = req.clone({ headers });
503
+ return headers !== r.headers ? r.clone({ headers }) : r;
504
+ }
505
+ // Strip the headers we set so a retry forces re-read from localStorage (which by then
506
+ // contains the refreshed tokens written by the X-Refreshed-Token response handler).
507
+ function stripAuthHeaders(r) {
508
+ return r.clone({
509
+ headers: r.headers.delete('Authorization').delete('X-Refresh-Token')
510
+ });
491
511
  }
512
+ const authReq = attachAuthHeaders(req);
492
513
  return next(authReq).pipe(tap(event => {
493
514
  if (event instanceof HttpResponse) {
494
515
  const newAccessToken = event.headers.get('X-Refreshed-Token');
495
- if (newAccessToken) {
516
+ if (newAccessToken && newAccessToken !== lastWrittenAccessToken) {
517
+ lastWrittenAccessToken = newAccessToken;
496
518
  const newRefreshToken = event.headers.get('X-Refreshed-Refresh-Token') ?? undefined;
497
519
  authService.notifySignedIn(newAccessToken, newRefreshToken);
498
520
  }
499
521
  }
500
522
  }), catchError$1((error) => {
501
523
  const status = error.status;
502
- // Check if we should handle this error and prevent loopback
503
- if ((status === 401 || status === 403) && !isRedirecting) {
504
- const config = authService.getConfig();
505
- const baseUrl = config?.userBaseUrl || '';
506
- const currentUrl = router.url + (window.location.hash || '');
507
- const returnUrl = encodeURIComponent(currentUrl);
508
- // Avoid loops if already on auth/unauth pages
509
- const isLoginPage = currentUrl.includes('/login');
510
- const is403Page = currentUrl.includes('/403');
511
- const isAuthPage = currentUrl.includes('/auth');
512
- // Public pages that should never trigger a 401 redirect (e.g., register, password reset)
513
- const isPublicPage = currentUrl.includes('/register')
514
- || currentUrl.includes('/forgot-password')
515
- || currentUrl.includes('/reset-password');
516
- // Skip redirect for the initial /auth/me check (app startup when not logged in)
517
- const isMeAuthPage = req.url.includes('/auth/me');
518
- if (status === 401 && !isLoginPage && !isAuthPage && !isMeAuthPage && !isPublicPage) {
519
- // Wait 1.5s for the concurrent refresh's Set-Cookie to be processed, then retry once.
520
- // If retry also gets 401, redirect to login.
521
- return timer(1500).pipe(switchMap(() => next(req)), catchError$1((retryError) => {
524
+ if (!(status === 401 || status === 403) || isRedirecting) {
525
+ return throwError(() => error);
526
+ }
527
+ const baseUrl = config?.userBaseUrl || '';
528
+ const currentUrl = router.url + (window.location.hash || '');
529
+ const returnUrl = encodeURIComponent(currentUrl);
530
+ const isLoginPage = currentUrl.includes('/login');
531
+ const is403Page = currentUrl.includes('/403');
532
+ const isAuthPage = currentUrl.includes('/auth');
533
+ const isPublicPage = currentUrl.includes('/register')
534
+ || currentUrl.includes('/forgot-password')
535
+ || currentUrl.includes('/reset-password');
536
+ const isMeAuthPage = req.url.includes('/auth/me');
537
+ if (status === 401 && !isLoginPage && !isAuthPage && !isMeAuthPage && !isPublicPage) {
538
+ // Single-flight: the first 401 opens a 1.5s window during which all other 401s
539
+ // wait for the refresh signal to flip back to false, then retry once.
540
+ if (!refreshing$.value) {
541
+ refreshing$.next(true);
542
+ return timer(1500).pipe(switchMap(() => next(stripAuthHeaders(req))), tap({
543
+ next: () => refreshing$.next(false),
544
+ error: () => refreshing$.next(false),
545
+ complete: () => refreshing$.next(false)
546
+ }), catchError$1((retryError) => {
522
547
  if (retryError.status === 401) {
523
548
  isRedirecting = true;
524
549
  setTimeout(() => { isRedirecting = false; }, 5000);
@@ -527,32 +552,29 @@ const mesAuthInterceptor = (req, next) => {
527
552
  return throwError(() => retryError);
528
553
  }));
529
554
  }
530
- else if (status === 403 && !is403Page) {
531
- isRedirecting = true;
532
- setTimeout(() => { isRedirecting = false; }, 5000);
533
- let redirectUrl = `${baseUrl}/403?returnUrl=${returnUrl}`;
534
- if (error.error && error.error.required) {
535
- redirectUrl += `&required=${encodeURIComponent(error.error.required)}`;
555
+ // Refresh already in flight wait for it to finish, then retry once with
556
+ // headers stripped so the interceptor re-reads the now-fresh tokens.
557
+ return refreshing$.pipe(filter(v => !v), take(1), switchMap(() => next(stripAuthHeaders(req))), catchError$1((retryError) => {
558
+ if (retryError.status === 401 && !isRedirecting) {
559
+ isRedirecting = true;
560
+ setTimeout(() => { isRedirecting = false; }, 5000);
561
+ window.location.href = `${baseUrl}/login?returnUrl=${returnUrl}`;
536
562
  }
537
- window.location.href = redirectUrl;
563
+ return throwError(() => retryError);
564
+ }));
565
+ }
566
+ if (status === 403 && !is403Page) {
567
+ isRedirecting = true;
568
+ setTimeout(() => { isRedirecting = false; }, 5000);
569
+ let redirectUrl = `${baseUrl}/403?returnUrl=${returnUrl}`;
570
+ if (error.error && error.error.required) {
571
+ redirectUrl += `&required=${encodeURIComponent(error.error.required)}`;
538
572
  }
573
+ window.location.href = redirectUrl;
539
574
  }
540
575
  return throwError(() => error);
541
576
  }));
542
577
  };
543
- function appendPermissions(body, allowedActions) {
544
- if (body === null || body === undefined) {
545
- return { 'x-ma-perms': allowedActions };
546
- }
547
- if (Array.isArray(body)) {
548
- return { data: body, 'x-ma-perms': allowedActions };
549
- }
550
- if (typeof body === 'object') {
551
- return { ...body, 'x-ma-perms': allowedActions };
552
- }
553
- // Primitive (string, number, boolean)
554
- return { data: body, 'x-ma-perms': allowedActions };
555
- }
556
578
 
557
579
  class MesAuthModule {
558
580
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.13", ngImport: i0, type: MesAuthModule, deps: [], target: i0.ɵɵFactoryTarget.NgModule });