diff --git a/frontend/src/app/interceptors/auth-jwt.interceptor.ts b/frontend/src/app/interceptors/auth-jwt.interceptor.ts new file mode 100644 index 00000000..40155f5d --- /dev/null +++ b/frontend/src/app/interceptors/auth-jwt.interceptor.ts @@ -0,0 +1,54 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; +import { inject, Injectable, Injector } from '@angular/core'; +import { NB_AUTH_TOKEN_INTERCEPTOR_FILTER, NbAuthService, NbAuthToken } from '@nebular/auth'; +import { Observable } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; +import { TokenRefreshService } from '../services/token-refresh.service'; + +/** + * Drop-in replacement for Nebular's NbAuthJWTInterceptor. + * + * Behaves identically (filter public URLs, attach Bearer token, send + * unauthenticated on refresh failure) but routes the authenticate/refresh + * check through TokenRefreshService so concurrent refreshes are serialized. + */ +@Injectable() +export class AuthJWTInterceptor implements HttpInterceptor { + private injector = inject(Injector); + private filter = inject(NB_AUTH_TOKEN_INTERCEPTOR_FILTER); + + // Lazy injection avoids the circular dependency between HTTP_INTERCEPTORS + // and NbAuthService (same pattern Nebular uses in NbAuthJWTInterceptor). + private get tokenRefreshService(): TokenRefreshService { + return this.injector.get(TokenRefreshService); + } + + private get authService(): NbAuthService { + return this.injector.get(NbAuthService); + } + + public intercept(req: HttpRequest, next: HttpHandler): Observable> { + if (this.filter(req)) { + return next.handle(req); + } + + return this.tokenRefreshService.isAuthenticatedOrRefresh().pipe( + switchMap((authenticated: boolean) => { + if (!authenticated) { + return next.handle(req); + } + + return this.authService.getToken().pipe( + switchMap((token: NbAuthToken) => { + const authReq = req.clone({ + setHeaders: { Authorization: `Bearer ${token.getValue()}` }, + }); + + return next.handle(authReq); + }) + ); + }) + ); + } +} diff --git a/frontend/src/app/interceptors/error.interceptor.ts b/frontend/src/app/interceptors/error.interceptor.ts index 4acf1794..bdbc367f 100644 --- a/frontend/src/app/interceptors/error.interceptor.ts +++ b/frontend/src/app/interceptors/error.interceptor.ts @@ -14,6 +14,12 @@ export class ErrorInterceptor implements HttpInterceptor { public intercept(req: HttpRequest, next: HttpHandler): Observable> { return next.handle(req).pipe( catchError((response: HttpErrorResponse) => { + // 401s are expected during token expiry/refresh and are handled + // by the auth layer (UnauthorizedInterceptor); no toast needed. + if (response.status === 401) { + return throwError(() => response); + } + if (response.status === 0) { this.toastrService.danger('Unable to connect to Expensave server!', 'Connection error!'); } else { diff --git a/frontend/src/app/interceptors/unauthorized.interceptor.ts b/frontend/src/app/interceptors/unauthorized.interceptor.ts index e797bdfa..a099ec81 100644 --- a/frontend/src/app/interceptors/unauthorized.interceptor.ts +++ b/frontend/src/app/interceptors/unauthorized.interceptor.ts @@ -14,12 +14,18 @@ export class UnauthorizedInterceptor implements HttpInterceptor { public intercept(req: HttpRequest, next: HttpHandler): Observable> { return next.handle(req).pipe( catchError((error: HttpErrorResponse) => { - if (error.status === 401) { + // A 401 from the refresh endpoint means the refresh token itself + // is invalid/consumed; it is handled by the auth layer, so do not + // clear tokens here (that caused spurious logouts under the + // single-use refresh token race on app resume). + const isRefreshRequest = req.url.includes('/auth/refresh-token'); + + if (error.status === 401 && !isRefreshRequest) { this.tokenService.clear(); this.router.navigate(['auth/login']); } - return throwError(error); + return throwError(() => error); }) ); } diff --git a/frontend/src/app/modules/auth/auth.functions.ts b/frontend/src/app/modules/auth/auth.functions.ts index bef909d3..33a40a11 100644 --- a/frontend/src/app/modules/auth/auth.functions.ts +++ b/frontend/src/app/modules/auth/auth.functions.ts @@ -1,14 +1,14 @@ import { inject } from '@angular/core'; import { CanActivateFn, Router } from '@angular/router'; -import { NbAuthService } from '@nebular/auth'; import { Observable } from 'rxjs'; import { tap } from 'rxjs/operators'; +import { TokenRefreshService } from '../../services/token-refresh.service'; export const canActivateAuthenticated: CanActivateFn = (): Observable => { - const authService: NbAuthService = inject(NbAuthService); + const tokenRefreshService: TokenRefreshService = inject(TokenRefreshService); const router: Router = inject(Router); - return authService.isAuthenticatedOrRefresh().pipe( + return tokenRefreshService.isAuthenticatedOrRefresh().pipe( tap((authenticated: boolean) => { if (!authenticated) { router.navigate(['auth/login']); diff --git a/frontend/src/app/services/app-resume.service.ts b/frontend/src/app/services/app-resume.service.ts new file mode 100644 index 00000000..6a8618e1 --- /dev/null +++ b/frontend/src/app/services/app-resume.service.ts @@ -0,0 +1,28 @@ +import { DestroyRef, inject, Injectable } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { fromEvent } from 'rxjs'; +import { filter, switchMap } from 'rxjs/operators'; +import { TokenRefreshService } from './token-refresh.service'; + +/** + * Proactively refreshes the access token when the app returns from background. + * + * On mobile PWAs the access token (10 min TTL) is usually expired by the time + * the user comes back. Refreshing on the visibilitychange event, before the + * first API call fires, keeps the session alive and avoids the refresh race. + */ +@Injectable({ providedIn: 'root' }) +export class AppResumeService { + private tokenRefreshService = inject(TokenRefreshService); + private destroyRef = inject(DestroyRef); + + public initialize(): void { + fromEvent(document, 'visibilitychange') + .pipe( + filter(() => !document.hidden), + switchMap(() => this.tokenRefreshService.isAuthenticatedOrRefresh()), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(); + } +} diff --git a/frontend/src/app/services/token-refresh.service.ts b/frontend/src/app/services/token-refresh.service.ts new file mode 100644 index 00000000..a3a8dd3c --- /dev/null +++ b/frontend/src/app/services/token-refresh.service.ts @@ -0,0 +1,46 @@ +import { inject, Injectable } from '@angular/core'; +import { NbAuthResult, NbAuthService, NbAuthToken } from '@nebular/auth'; +import { Observable, of } from 'rxjs'; +import { catchError, finalize, map, shareReplay, switchMap } from 'rxjs/operators'; + +/** + * Serializes concurrent token refresh calls into a single HTTP request. + * + * The backend issues single-use refresh tokens, so firing several refresh + * requests in parallel (e.g. when the PWA resumes from background and multiple + * API calls detect the expired access token at once) makes all but the first + * fail with 401 and log the user out. This service ensures only one refresh is + * in flight at a time; all callers share its result. + */ +@Injectable({ providedIn: 'root' }) +export class TokenRefreshService { + private authService = inject(NbAuthService); + private refreshInFlight$: Observable | null = null; + + public isAuthenticatedOrRefresh(): Observable { + return this.authService.getToken().pipe( + switchMap((token: NbAuthToken) => { + if (token.getValue() && !token.isValid()) { + return this.refreshWithDedup(token); + } + + return of(token.isValid()); + }) + ); + } + + private refreshWithDedup(token: NbAuthToken): Observable { + if (!this.refreshInFlight$) { + this.refreshInFlight$ = this.authService.refreshToken(token.getOwnerStrategyName(), token).pipe( + map((result: NbAuthResult) => result.isSuccess()), + catchError(() => of(false)), + finalize(() => { + this.refreshInFlight$ = null; + }), + shareReplay(1) + ); + } + + return this.refreshInFlight$; + } +} diff --git a/frontend/src/main.ts b/frontend/src/main.ts index a292952f..c6948bcc 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -9,13 +9,15 @@ import { import { MainService } from './app/modules/main/main.service'; import { environment } from './environments/environment'; import { AppInitializer } from './app/app.initializer'; -import { NB_AUTH_TOKEN_INTERCEPTOR_FILTER, NbAuthJWTInterceptor } from '@nebular/auth'; +import { NB_AUTH_TOKEN_INTERCEPTOR_FILTER } from '@nebular/auth'; import { tokenFilter } from './app/modules/auth/token.filter'; import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'; import { ErrorInterceptor } from './app/interceptors/error.interceptor'; import { DateInterceptor } from './app/interceptors/date.interceptor'; import { ApiInterceptor } from './app/interceptors/api.interceptor'; import { UnauthorizedInterceptor } from './app/interceptors/unauthorized.interceptor'; +import { AuthJWTInterceptor } from './app/interceptors/auth-jwt.interceptor'; +import { AppResumeService } from './app/services/app-resume.service'; import { AuthStrategy } from './app/modules/auth/auth-strategy'; import { AuthOptionsService } from './app/services/auth-options.service'; import { CommonModule } from '@angular/common'; @@ -83,6 +85,9 @@ bootstrapApplication(AppComponent, { provideAppInitializer(() => { inject(ThemePreferenceService).initialize(); }), + provideAppInitializer(() => { + inject(AppResumeService).initialize(); + }), { provide: LOCALE_ID, useFactory: (appInitializer: AppInitializer): string => appInitializer.getLocaleId(), @@ -97,7 +102,7 @@ bootstrapApplication(AppComponent, { { provide: HTTP_INTERCEPTORS, useClass: ErrorInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: DateInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: ApiInterceptor, multi: true }, - { provide: HTTP_INTERCEPTORS, useClass: NbAuthJWTInterceptor, multi: true }, + { provide: HTTP_INTERCEPTORS, useClass: AuthJWTInterceptor, multi: true }, { provide: HTTP_INTERCEPTORS, useClass: UnauthorizedInterceptor, multi: true }, AuthStrategy, AuthOptionsService,