Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions frontend/src/app/interceptors/auth-jwt.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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);
})
);
})
);
}
}
6 changes: 6 additions & 0 deletions frontend/src/app/interceptors/error.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ export class ErrorInterceptor implements HttpInterceptor {
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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 {
Expand Down
10 changes: 8 additions & 2 deletions frontend/src/app/interceptors/unauthorized.interceptor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,18 @@ export class UnauthorizedInterceptor implements HttpInterceptor {
public intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
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);
})
);
}
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/app/modules/auth/auth.functions.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> => {
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']);
Expand Down
28 changes: 28 additions & 0 deletions frontend/src/app/services/app-resume.service.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
46 changes: 46 additions & 0 deletions frontend/src/app/services/token-refresh.service.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> | null = null;

public isAuthenticatedOrRefresh(): Observable<boolean> {
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<boolean> {
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$;
}
}
9 changes: 7 additions & 2 deletions frontend/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -83,6 +85,9 @@ bootstrapApplication(AppComponent, {
provideAppInitializer(() => {
inject(ThemePreferenceService).initialize();
}),
provideAppInitializer(() => {
inject(AppResumeService).initialize();
}),
{
provide: LOCALE_ID,
useFactory: (appInitializer: AppInitializer): string => appInitializer.getLocaleId(),
Expand All @@ -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,
Expand Down
Loading