herondale
herondale

Reputation: 799

How to automatically refresh access token with interceptor + route guard?

I've browsed a ton of articles & questions but none seem to cover automatic token refresh in an interceptor WHILE also having a route guard that waits for a request to* the server to complete to verify that the access token is valid?

auth.guard.ts

canActivate(
    route: ActivatedRouteSnapshot,
    state: RouterStateSnapshot): Observable<boolean | UrlTree> | Promise<boolean | UrlTree> | boolean | UrlTree {

    return this.authService.isAuthenticated()
      .pipe(
        map(
          res => {
            if (res.success === true) {
              return true;
            } else {
              this.authService.deleteToken();
              this.router.navigateByUrl('/login');
              return false;
            }
          }
        ), catchError(err => {
          // NOTE: If I put the code for refresh-token request here, it works -- user stays
          // logged in after getting new access token

          this.authService.deleteToken();
          this.router.navigate(['/login']);
          return of(false);
        })
      );
  }

auth.interceptor.ts

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    if (req.headers.get('noauth')) {
      return next.handle(req.clone());
    } else {
      const clonedReq = req.clone({
        headers: req.headers.set('Authorization', 'Bearer ' + this.authService.getToken())
      });

      return next.handle(clonedReq).pipe(
        tap(
          event => {},
          err => {
            if (err.error.code && err.error.code === 'EXPIRED') {
                return this.handle401Error(clonedReq, next);
            }
          }
        )
      );
    }
  }

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

      return this.authService.refreshToken().pipe(
        switchMap((token: any) => {
          this.isRefreshing = false;
          this.refreshTokenSubject.next(token.new_access_token);
          return next.handle(this.addToken(request, token.new_access_token));
        }),
        catchError(err => {
          return throwError(err);
        }))
        .subscribe(r => r);

    } else {
      return this.refreshTokenSubject.pipe(
        filter(token => token != null),
        take(1),
        switchMap(jwt => {
          return next.handle(this.addToken(request, jwt));
        }));
    }
  }

  private addToken(request: HttpRequest<any>, token: string) {
    return request.clone({
      setHeaders: {
        'Authorization': `Bearer ${token}`
      }
    });
  }

auth.service.ts

refreshToken() {
    return this.http.post<any>(`${environment.api}/refresh-token`, {
      'refreshToken': this.getRefreshToken()
    }, this.noAuthHeader).pipe(tap((res) => {
      if (res.success === true) {
        this.setToken(res.new_access_token);
        return true;
      } else {
        this.deleteToken();
        this.router.navigateByUrl('/login');
        return false;
      }
    }));
  }

What happens is:

  1. User tries to access a secured route
  2. auth.guard.ts sends request to server to verify access token validity
  3. Access token has expired, so the server responds with 401
  4. The request for refreshing the token gets sent, but the initial request for simply validating the access token gets completed first -- the user gets redirected to login page, instead of staying logged in
  5. The request for refresh token completes

I'm not sure how it's supposed to happen but what I'm thinking is that the request for getting a new access token should get completed first, and then the authentication request/auth.guard should base on that?

Upvotes: 1

Views: 3391

Answers (1)

herondale
herondale

Reputation: 799

After being stuck for a day and a half, I think I finally fixed it on my own:

auth.guard.ts

return this.authService.isAuthenticated()
      .pipe(
        map(
          res => {
            if (res.success === true) {
              return true;
            } else {
              this.authService.deleteToken();
              this.router.navigateByUrl('/login');
              return false;
            }
          }
        ), catchError(
          err => {

            // Absolutely needed this handler, 
            // but removed the code for redirection to login and deletion of tokens
            return of(false);
          }
        )
      );

auth.interceptor.ts

return next.handle(clonedReq)
        .pipe(
          catchError((err: HttpErrorResponse) => {
            if (err.error.code === 'EXPIRED') {
                  return this.handle401Error(clonedReq, next);
            } else if (err.error.status === 401) {
                  this.router.navigateByUrl('/login');
                  return throwError(err.error.message);
            }

            // Redirect to an error landing page
            return throwError(err.error.message);
          })
        );

private handle401Error(request: HttpRequest<any>, next: HttpHandler) {

    if (!this.isRefreshing) {
      this.isRefreshing = true;
      this.refreshTokenSubject.next(null);

      return this.authService.refreshToken()
        .pipe(
          switchMap((token: any) => {
          this.refreshTokenSubject.next(token.new_access_token);
          this.authService.setToken(token.new_access_token);

          return next.handle(this.addToken(request, token.new_access_token));
        }), catchError(err => { 

          // .subscribe() not needed!Just an error handler
          if (err.error.code === 'EXPIRED') {
                this.authService.deleteToken();
                this.router.navigateByUrl('/login');
                return throwError(err.error.message);
          }
          
          // Redirect to error landing page
          return throwError(err.error.message);
      }),
      finalize(() => {
        this.isRefreshing = false;
      })
    );
...

I hope you find this helpful!

Upvotes: 2

Related Questions