Search code examples
angulartypescriptrxjsjwtangular-http-interceptors

Multiple 401 Angular Token Interceptor


I have created a Token Interceptor in Angular which I use to refresh my JWT token. Unfortunately, I don't know why, several calls fail (error 401), and when the interceptor retrieves the new token, only the last one that failed is redone. This means that it misses some calls and my UI is not filling properly.

I try to leave you a few pieces of code and a photo.

enter image description here

And this is my token interceptor:

import { HTTP_INTERCEPTORS, HttpErrorResponse, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http';
import { Router } from '@angular/router';

import { BehaviorSubject, Observable, throwError } from 'rxjs';
import { catchError, filter, switchMap, take } from 'rxjs/operators';

import { MatDialog } from '@angular/material/dialog';

import { TranslateService } from '@ngx-translate/core';

import { G3SnackType } from '@cgm/g3-component-lib';
import { G3SnackbarService } from '@cgm/g3-component-lib';

import { AuthFacade } from '@g3p/auth-shell/store/auth.facade';
import { AuthService } from '@g3p/auth-shell/services/auth.service';
import { ITokenError, ITokenErrorModal } from '@g3p/auth-shell/interfaces/token-error.interface';
import { LoggerService } from '@g3p/shared/error-handler/logger.service';
import { SessionStorageJwtService } from '@g3p/auth-shell/services/session-storage-jwt.service';
import { Token } from '@g3p/auth-shell/interfaces/token.interface';
import { TokenErrorType } from '@g3p/auth-shell/interfaces/token-error.enum';
import { TokenErrorComponent } from '@g3p/auth-shell/components/token-error/token-error.component';

const tokenErrorModalSettings = {
  width: '25.75rem',
  height: '15.5rem',
  disableClose: true,
  autoFocus: false,
  data: {} as ITokenErrorModal
};

@Injectable()
export class TokenInterceptor implements HttpInterceptor {
  private isRefreshing = false;
  private refreshTokenSubject: BehaviorSubject<Token> = new BehaviorSubject<Token>(null);

  constructor(
    private authFacade: AuthFacade,
    private authService: AuthService,
    private dialog: MatDialog,
    private loggerService: LoggerService,
    private router: Router,
    private sessionStorageJwtService: SessionStorageJwtService,
    private snackbarService: G3SnackbarService,
    private translateService: TranslateService,
  ) { }

  intercept(request: HttpRequest<any>, next: HttpHandler): Observable<any> {
    let token: Token = {} as Token;
    this.sessionStorageJwtService.getToken().subscribe(t => (token = t));

    let refreshToken = '';
    this.sessionStorageJwtService.getRefreshToken().subscribe(t => (refreshToken = t));

    const path = request.url.split('/');
    if (
      path.includes('assets') ||
      path.includes('workstationregistration') ||
      (this.router.url.includes('rolespermission') && path.includes('users')) ||
      (this.router.url.includes('/auth') && path.includes('token'))
    ) {
      return next.handle(request);
    }

    return next.handle(request).pipe(catchError(error => {
      if (error instanceof HttpErrorResponse && error.status === 401) {
        if ((error.error as ITokenError).error_description.includes(TokenErrorType.ACCESS_TOKEN_EXPIRED)) {
          return this.handle401Error(request, next, refreshToken);
        }
        if ((error.error as ITokenError).error_description.includes(TokenErrorType.INVALID_REFRESH_TOKEN)) {
          this.showSnackbarTokenError(this.translateService.instant('content.invalid-refresh-token'));
        }
        if ((error.error as ITokenError).error_description.includes(TokenErrorType.CANNOT_CONVERT_ACCESS_TOKEN)) {
          this.showSnackbarTokenError(this.translateService.instant('content.cannot-convert-access-token'));
        }
        if ((error.error as ITokenError).error_description === TokenErrorType.USER_LOGGED_INTO_ANOTHER_WORKSTATION) {
          this.showModalTokenError(this.translateService.instant('content.user-logged-into-another-workstation'));
        }
        if ((error.error as ITokenError).error_description === TokenErrorType.USER_DELETED) {
          this.showModalTokenError(this.translateService.instant('content.user-deleted'));
        }
        if ((error.error as ITokenError).error_description === TokenErrorType.USER_CLAIMS_CHANGED) {
          this.showModalTokenError(this.translateService.instant('content.user-claims-changed'));
        }
        if ((error.error as ITokenError).error_description.includes(TokenErrorType.TECHNICAL_LOGOUT)) {
          this.showModalTokenError(this.translateService.instant('content.technical-logout'));
        }
      }
      return throwError(error);
    }));
  }

  private handle401Error(request: HttpRequest<any>, next: HttpHandler, rToken: string) {
    if (!this.isRefreshing) {
      this.isRefreshing = true;
      this.refreshTokenSubject.next(null);

      if (rToken && rToken !== '') {
        const body = new URLSearchParams();
        body.set('grant_type', 'refresh_token');
        body.set('refresh_token', rToken);

        return this.authService.fetchToken$(body).pipe(
          switchMap((newToken: Token) => {
            this.isRefreshing = false;

            this.sessionStorageJwtService.setToken(newToken);
            this.refreshTokenSubject.next(newToken);

            return next.handle(this.addTokenHeader(request, newToken));
          }),
          catchError((err) => {
            this.isRefreshing = false;

            this.authFacade.logout();
            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: Token) {
    return request.clone({ headers: request.headers.set('Authorization', `Bearer ${token.access_token}`) });
  }

  private showSnackbarTokenError(message: string) {
    this.snackbarService.open(message, 5000, G3SnackType.Error);
    return this.authFacade.logout();
  }

  private showModalTokenError(message: string) {
    tokenErrorModalSettings.data.errorMessage = message;

    if (this.dialog.openDialogs.length < 1) {
      const dialogRef = this.dialog.open(TokenErrorComponent, tokenErrorModalSettings);

      dialogRef.afterClosed().subscribe(data => {
        if (data?.errorMessage !== '') {
          return this.authFacade.logout();
        }
      });
    }
  }
}

export const authInterceptorProviders = [
  { provide: HTTP_INTERCEPTORS, useClass: TokenInterceptor, multi: true }
];

Solution

  • You can try to change the interceptor to something like the following:

    intercept(request: HttpRequest<any>, next: HttpHandler): Observable<any> {
      // 1 >>>> This should return the token as string to be injected to the requests directly
      const token: string = this.sessionStorageJwtService.getToken();
    
      // 2 >>>> Move this part to the handle401Error method
      // let refreshToken = "";
      // this.sessionStorageJwtService.getRefreshToken().subscribe((t) => (refreshToken = t));
    
      const path = request.url.split('/');
      if (
        path.includes('assets') ||
        path.includes('workstationregistration') ||
        (this.router.url.includes('rolespermission') && path.includes('users')) ||
        (this.router.url.includes('/auth') && path.includes('token'))
      ) {
        return next.handle(request);
      }
    
      // 3 >>>> Inject the token into the request header
      request = this.addTokenHeader(request, token);
    
      return next.handle(request).pipe(
        catchError((error) => {
          if (error instanceof HttpErrorResponse && error.status === 401) {
            if (
              (error.error as ITokenError).error_description.includes(
                TokenErrorType.ACCESS_TOKEN_EXPIRED
              )
            ) {
              return this.handle401Error(request, next);
            }
            // Handle the other errors here...
          }
          return throwError(error);
        })
      );
    }
    
    private handle401Error(request: HttpRequest<any>, next: HttpHandler) {
      if (!this.isRefreshing) {
        this.isRefreshing = true;
        // Set the refreshTokenSubject to null so that subsequent API calls will wait until the new token has been retrieved
        this.refreshTokenSubject.next(null);
    
        // 4 >>>> Chain the getRefreshToken with the fetchToken$ observables like the following:
        return this.sessionStorageJwtService.getRefreshToken().pipe(
          switchMap((rToken) => {
            if (!!rToken) {
              const body = new URLSearchParams();
              body.set('grant_type', 'refresh_token');
              body.set('refresh_token', rToken);
              return this.authService.fetchToken$(body);
            }
    
            throwError('Refresh token invalid');
          }),
          switchMap((newToken: Token) => {
            // When the call to refreshToken completes we reset the isRefreshing to false
            // for the next time the token needs to be refreshed
            if (newToken) {
              this.sessionStorageJwtService.setToken(newToken);
              this.refreshTokenSubject.next(newToken);
    
              return next.handle(this.addTokenHeader(request, newToken));
            }
    
            throwError('Refresh token invalid');
          }),
          catchError((err) => {
            this.authFacade.logout();
            return throwError(err);
          }),
          finalize(() => {
            this.isRefreshing = false;
          })
        );
      } else {
        // 5 >>>> Move this return to this block instead of above one, to be returned if the toke refresh is still on-progress
        return this.refreshTokenSubject.pipe(
          filter((token) => token !== null),
          take(1),
          switchMap((token) => next.handle(this.addTokenHeader(request, token)))
        );
      }
    }
    

    The interceptor you're trying to use has some logical errors, like:

    • It doesn't inject the fetched token into the request, which should be injected to authenticate the request correctly. It's better to return the current token as a string to be injected directly into the request.
    • It call the getRefreshToken to get the refresh token, but you didn't wait for it to complete. But it should be moved to the handle401Error method and chained with the this.authService.fetchToken$(body) observable.
    • It returns the refreshTokenSubject from the wrong block.