From a1d02051de5782670f757a9045a13fb7a4dcaea7 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Thu, 14 May 2026 17:22:06 +0200 Subject: [PATCH] fix(feature-auth): use AUTH_PATH_PREFIX to skip the /me endpoint in the 401 interceptor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `bffUnauthorizedInterceptor` calls `AuthService.refresh()` on every 401 from a BFF route, and explicitly skips the `/me` endpoint to avoid an infinite loop (the bootstrap /me legitimately 401s when anonymous). The skip target was hardcoded to `/auth/me`, which matched portal-shell but missed portal-admin's `/admin/auth/me`. Symptom: opening portal-admin while anonymous fires the bootstrap /me → 401 → interceptor sees the URL is NOT `/auth/me` → calls refresh() → fires another /me → 401 → loop. Hits the 120/min general rate limiter in seconds; the user lands on a `rate_limited` error instead of the "Sign in" panel. Fix: derive the skip URL from `AUTH_PATH_PREFIX` so it matches the surface the host is on (`/auth/me` for portal-shell, `/admin/auth/me` for portal-admin). The token is already provided by both apps via `app.config.ts`. Tests: +1 regression spec exercising the admin path. The new spec asserts that no follow-up /me is issued after the bootstrap 401 when the prefix is `/admin/auth`. --- .../lib/bff-unauthorized.interceptor.spec.ts | 35 ++++++++++++++++++- .../src/lib/bff-unauthorized.interceptor.ts | 15 +++++--- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/libs/feature/auth/src/lib/bff-unauthorized.interceptor.spec.ts b/libs/feature/auth/src/lib/bff-unauthorized.interceptor.spec.ts index 6b750e6..4e65aa0 100644 --- a/libs/feature/auth/src/lib/bff-unauthorized.interceptor.spec.ts +++ b/libs/feature/auth/src/lib/bff-unauthorized.interceptor.spec.ts @@ -2,7 +2,7 @@ import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { TestBed } from '@angular/core/testing'; import { firstValueFrom } from 'rxjs'; -import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR } from './auth.config'; +import { AUTH_BFF_BASE_URL, AUTH_NAVIGATOR, AUTH_PATH_PREFIX } from './auth.config'; import { AuthService } from './auth.service'; import { bffUnauthorizedInterceptor } from './bff-unauthorized.interceptor'; @@ -106,6 +106,39 @@ describe('bffUnauthorizedInterceptor', () => { expect(auth.state().kind).toBe('authenticated'); }); + it('does NOT fire refresh on a 401 from the admin /me when AUTH_PATH_PREFIX is /admin/auth (would loop)', async () => { + // Regression for PR #134 follow-up: the original interceptor + // hard-coded `/auth/me` as the skip target. With portal-admin + // overriding the path prefix to `/admin/auth`, the 401 from + // `/admin/auth/me` fell through, triggering AuthService.refresh() + // which re-fired the same request — a tight loop that exhausted + // the BFF rate limiter on every admin landing. + TestBed.configureTestingModule({ + providers: [ + provideHttpClient(withInterceptors([bffUnauthorizedInterceptor])), + provideHttpClientTesting(), + { provide: AUTH_BFF_BASE_URL, useValue: BFF_BASE }, + { provide: AUTH_PATH_PREFIX, useValue: '/admin/auth' }, + { provide: AUTH_NAVIGATOR, useValue: vi.fn() }, + ], + }); + const adminMeUrl = `${BFF_BASE}/admin/auth/me`; + const httpCtrl = TestBed.inject(HttpTestingController); + const auth = TestBed.inject(AuthService); + const refreshSpy = vi.spyOn(auth, 'refresh'); + httpCtrl + .expectOne(adminMeUrl) + .flush({ error: 'unauthenticated' }, { status: 401, statusText: 'Unauthorized' }); + await Promise.resolve(); + expect(refreshSpy).not.toHaveBeenCalled(); + expect(auth.state().kind).toBe('anonymous'); + // Crucially: no follow-up /me request was issued by the + // interceptor. If one had been, the testing backend would + // either record a pending request or this assertion would + // fail trying to verify(). + httpCtrl.verify(); + }); + it('does NOT touch 401s from non-BFF origins', async () => { const { http, httpCtrl, auth } = setup(); httpCtrl diff --git a/libs/feature/auth/src/lib/bff-unauthorized.interceptor.ts b/libs/feature/auth/src/lib/bff-unauthorized.interceptor.ts index 13a80d6..cf7c895 100644 --- a/libs/feature/auth/src/lib/bff-unauthorized.interceptor.ts +++ b/libs/feature/auth/src/lib/bff-unauthorized.interceptor.ts @@ -6,7 +6,7 @@ import { } from '@angular/common/http'; import { Injector, inject } from '@angular/core'; import { catchError, throwError } from 'rxjs'; -import { AUTH_BFF_BASE_URL } from './auth.config'; +import { AUTH_BFF_BASE_URL, AUTH_PATH_PREFIX } from './auth.config'; import { AuthService } from './auth.service'; /** @@ -24,9 +24,12 @@ import { AuthService } from './auth.service'; * its failure and can show its own fallback UI / route guards * can react on the next navigation. * - * **Skips `/auth/me` itself.** `AuthService.refresh()` calls `/me`, - * which legitimately 401s when anonymous. Triggering `refresh()` - * again on that response would loop indefinitely. + * **Skips the `/me` endpoint itself.** `AuthService.refresh()` calls + * `/me`, which legitimately 401s when anonymous. Triggering + * `refresh()` again on that response would loop indefinitely. The + * skip target is composed from `AUTH_PATH_PREFIX` so it matches + * whichever surface this app runs on (`/auth/me` for portal-shell, + * `/admin/auth/me` for portal-admin). * * Other 4xx / 5xx pass through untouched — they are domain errors, * not session-state signals. @@ -36,6 +39,8 @@ export const bffUnauthorizedInterceptor: HttpInterceptorFn = ( next: HttpHandlerFn, ) => { const bffBaseUrl = inject(AUTH_BFF_BASE_URL); + const pathPrefix = inject(AUTH_PATH_PREFIX); + const meUrl = `${bffBaseUrl}${pathPrefix}/me`; // Inject the parent injector here rather than `AuthService` directly: // the bootstrap `/me` round-trip is fired from `AuthService`'s own // constructor, so a synchronous `inject(AuthService)` here would @@ -52,7 +57,7 @@ export const bffUnauthorizedInterceptor: HttpInterceptorFn = ( err instanceof HttpErrorResponse && err.status === 401 && req.url.startsWith(bffBaseUrl) && - !req.url.startsWith(`${bffBaseUrl}/auth/me`) + !req.url.startsWith(meUrl) ) { // Best-effort sync — swallow the refresh-side error so the // original 401 is what bubbles up to the caller. -- 2.30.2