fix(feature-auth): use AUTH_PATH_PREFIX to skip the /me endpoint in the 401 interceptor #135

Merged
julien merged 1 commits from fix/portal-admin-auth-me-refresh-loop into main 2026-05-14 17:23:45 +02:00
2 changed files with 44 additions and 6 deletions
@@ -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
@@ -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.