angularaccess-tokenrefresh-tokenangular-http-interceptors

Angular error interceptor - Multiple 401 responses


I'm currently working on implementing a refresh token mechanism in my Angular frontend. When my backend returns a 401 error, my error interceptor handles it. However, I'm facing an issue: when my access token (JWT token) expires, and I reload the page, three requests are sent to the backend to retrieve data. All of them result in a 401 error since they require authorization.

The role of my interceptor is to use the expired access token from local storage and send it to the refresh token endpoint to obtain both a new access token and refresh token. The problem arises when multiple requests are triggered simultaneously upon reloading the page. The first request is caught by the interceptor, which stores a new access token in local storage. However, the two subsequent requests fail to obtain a new access token because they still use the old token from storage. All three 401 responses trigger the error interceptor simultaneously.

Is there a solution to address this behavior? I'm seeking guidance on how to handle simultaneous requests and ensure they all use the refreshed access token.

Thank you for your help!

Error interceptor

@Injectable({ providedIn: 'root' })
export class ErrorInterceptor implements HttpInterceptor {
  constructor(private readonly authService: AuthService) {}

  intercept(
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<HttpEvent<any>> {
    return next.handle(req).pipe(
      catchError((error: HttpErrorResponse) => {
        if (error.status === 401 && !req.url.includes('auth/login')) {
          return this.handle401HttpError(error, req, next);
        }
        return next.handle(req);
      })
    );
  }

  private handle401HttpError(
    error: HttpErrorResponse,
    req: HttpRequest<any>,
    next: HttpHandler
  ): Observable<any> {
    var currentAccessToken: string = localStorage.getItem('token') as string;
    var currentRefreshToken: string = localStorage.getItem(
      'refreshToken'
    ) as string;

    var request: IRefreshTokenRequest = {
      accessToken: currentAccessToken,
      refreshToken: currentRefreshToken,
    };

    this.authService
      .refreshToken(request)
      .pipe(
        switchMap((user: IUser | null) => {
          if (user) {
            localStorage.setItem('token', user.token);
            localStorage.setItem('refreshToken', user.refreshToken);

            req = req.clone({
              setHeaders: {
                Authorization: `Bearer ${user?.token}`,
              },
            });

            return next.handle(req);
          }
          return throwError(() => error);
        }),
        catchError((err: HttpErrorResponse) => {
          this.authService.logout();
          return throwError(() => err);
        })
      )
      .subscribe();

    return throwError(() => error);
  }
}

Solution

  • You need to somehow inform the interceptor that the refreshing process is proceeded and the requests should be queued.

    I think you can find the solution out there:

    1. https://www.bezkoder.com/angular-12-refresh-token/
    2. https://medium.com/@an.sajinsatheesan/refresh-token-interceptor-angular-10-d876d01561be

    Some pieces of the code:

    Interceptor:

    @Injectable()
    export class AuthInterceptor implements HttpInterceptor {
      private isRefreshing = false;
      private refreshTokenSubject: BehaviorSubject<any> = new BehaviorSubject<any>(null);
    
      constructor(private tokenService: TokenStorageService, private authService: AuthService) { }
    
      intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<Object>> {
        let authReq = req;
        const token = this.tokenService.getToken();
        if (token != null) {
          authReq = this.addTokenHeader(req, token);
        }
    
        return next.handle(authReq).pipe(catchError(error => {
          if (error instanceof HttpErrorResponse && !authReq.url.includes('auth/signin') && error.status === 401) {
            return this.handle401Error(authReq, next);
          }
    
          return throwError(error);
        }));
      }
    
      private handle401Error(request: HttpRequest<any>, next: HttpHandler) {
        if (!this.isRefreshing) {
          this.isRefreshing = true;
          this.refreshTokenSubject.next(null);
    
          const token = this.tokenService.getRefreshToken();
    
          if (token)
            return this.authService.refreshToken(token).pipe(
              switchMap((token: any) => {
                this.isRefreshing = false;
    
                this.tokenService.saveToken(token.accessToken);
                this.refreshTokenSubject.next(token.accessToken);
                
                return next.handle(this.addTokenHeader(request, token.accessToken));
              }),
              catchError((err) => {
                this.isRefreshing = false;
                
                this.tokenService.signOut();
                return throwError(err);
              })
            );
        }
    
        return this.refreshTokenSubject.pipe(
          filter(token => token !== null),
          take(1),
          switchMap((token) => next.handle(this.addTokenHeader(request, token)))
        );
      }
    
      private addTokenHeader(request: HttpRequest<any>, token: string) {
        /* for Spring Boot back-end */
        // return request.clone({ headers: request.headers.set(TOKEN_HEADER_KEY, 'Bearer ' + token) });
    
        /* for Node.js Express back-end */
        return request.clone({ headers: request.headers.set(TOKEN_HEADER_KEY, token) });
      }
    }
    

    Service:

    @Injectable({
      providedIn: 'root'
    })
    export class AuthService {
      constructor(private http: HttpClient) { }
    
      // login, register
    
      refreshToken(token: string) {
        return this.http.post(AUTH_API + 'refreshtoken', {
          refreshToken: token
        }, httpOptions);
      }
    }