Search code examples
angularauthenticationangular-http-interceptors

Angular Interceptor to add Token and Automatically Refresh


I'm working with angular interceptors for the first time and I almost have what I want but there is something I can't quite figure out even after googling around for a while. I am storing a refresh token locally and the access token expires every 15 minutes; I want to be able to use the refresh token to automatically refresh their auth token when it expires.

My first attempt went like this:

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (req.url.toLowerCase().includes('/auth')) {
  // It's an auth request, don't get a token
  return next.handle(req);
}

// Not an auth endpoint, should have a token
this.authService.GetCurrentToken().subscribe(token => {
  // Make sure we got something
  if (token == null || token === '') {
    return next.handle(req);
  }

  // Have a token, add it
  const request = req.clone({
    setHeaders: {
      Authorization: `Bearer ${token}`
    }
  });

  return next.handle(request);
});
}

This did not seem to work and I couldn't figure out why (I'm new to Angular and fairly new to JS as well so sorry if it's obvious to others). On a hunch I wondered if it was the observable messing things up and it doesn't like waiting for the observable to return so I tried this:

  intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (req.url.toLowerCase().includes('/auth')) {
  // It's an auth request, don't get a token
  return next.handle(req);
}

const token = this.authService.GetAccessTokenWithoutRefresh();
const request = req.clone({
  setHeaders: {
    Authorization: `Bearer ${token}`
  }
});
return next.handle(request);   
}

And now it seems to work! That suggests that I may have been correct in my hunch (or that it's something else inside the other code that I'm not seeing). Anyway, working is good but this leaves me with the question of how to refresh. The original reason I had it using an observable from the auth service was in case it needed to refresh. Basically the auth service would look at it's current token and see if it was expired or not. If not it would just return of(token) but if it was expired it would reach back out to the server via an http post which is observable, so the string would arrive whenever the server responded.

So I guess my question is two-fold:

  1. Can anyone confirm or refute that I was correct about the observable messing up the interceptor? It seems like that's the issue but would like to be sure.
  2. How do I deal with refreshing the token for them in the background without them having to log back in every 15 minutes?

EDIT

Here is the logic in the auth token method:

GetCurrentToken(): Observable<string> {
if (this.AccessToken == null) {
  return null;
}
if (this.Expiry > new Date()) {
  return of(this.AccessToken);
}

// Need to refresh
return this.RefreshToken().pipe(
  map<LoginResult, string>(result => {
    return result.Success ? result.AccessToken : null;
  })
);
}

and the refresh method:

private RefreshToken(): Observable<LoginResult> {
const refreshToken = localStorage.getItem('rt');
if (refreshToken == null || refreshToken === '') {
  const result = new LoginResult();
  // Set other stuff on result object
  return of(result);
}

const refresh = new RefreshTokenDto();
refresh.MachineId = 'WebPortal';
refresh.TokenId = refreshToken;
return this.http.post(ApiData.baseUrl + '/auth/refresh', refresh)
  .pipe(
    tap<AuthResultDto>(authObject => {
      this.SetLocalData(authObject);
    }),
    map<AuthResultDto, LoginResult>(authObject => {
      const result = new LoginResult();
      // Set other stuff on the result object
      return result;
    }),
    catchError(this.handleError<LoginResult>('Refresh'))
  );
}

EDIT

Ok so with help from the answer below as well as this question here is what I came up with:

intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
if (req.url.toLowerCase().includes('/auth')) {
  // It's an auth request, don't get a token
  return next.handle(req.clone());
}

return this.authService.GetCurrentToken().pipe(
  mergeMap((token: string) => {
    if (token === null || token === '') {
      throw new Error('Refresh failed to get token');
    } else {
      return next.handle(req.clone({setHeaders: {Authorization: `Bearer ${token}`}}));
    }
  }),
  catchError((err: HttpErrorResponse) => {
    if (err.status === 401) {
      this.router.navigateByUrl('/login');
    }
    return throwError(err);
  })
);
}

So basically it ended up that my first attempt was not terribly far off, the 'secret' was to use a pipe and merge map instead of trying to subscribe.


Solution

  • You could try with an approach as follows. I might have exaggerated with the amount of FP in it:

    export class AuthInterceptor {
     ctor(private authService: AuthService){}
     intercept(req: HttpRequest<any>, next: HttpHandler): Observable<HttpEvent<any>> {
    
       return of(req.url.toLowerCase().includes('/auth')).pipe(
         mergeMap(isAuthRequest => !isAuthRequest
           // Missing: handle error when accessing the access token
           ? this.authService.accessToken$.pipe(map(addAuthHeader(req)))
           : of(req)
         ),
         mergeMap(nextReq => next.handle(nextReq))
       );
     }
    }
    
    function addAuthHeader(req: HttpRequest<any>): (token:string)=> HttpRequest<any> {
      return token => req.clone({setHeaders: {Authorization: `Bearer ${token}`}})
    } 
    

    And the auth service:

    export class AuthService {
      ctor(private http: HttpClient){}
    
      get accessToken$(): Observable<string> {
        return of(this.AccessToken).pipe(
           mergeMap(token => token === null
             ? throwError("Access token is missing")
             : of(this.Expiry > new Date())
           ),
           mergeMap(accessTokenValid => accessTokenValid
             ? of(this.AccessToken)
             : this.refreshToken()
           )
        );
      }
    
      refreshToken(): Observable<string> {
        return of(localStorage.getItem('rt')).pipe(
          mergeMap(refreshToken => !refreshToken 
            ? of(extractAccessTokenFromLogin(createLoginResult())
            : this.requestAccessToken(this.createRefreshToken(refreshToken))
          )
        );
      }
    
      private requestAccessToken(refreshToken: RefreshTokenDto): Observable<string> {
        return this.http.post<AuthResultDto>(ApiData.baseUrl + '/auth/refresh', refreshToken)
         .pipe(
           tap(auth => this.SetLocalData(auth )),
           map(auth => this.mapAuthObjToLoginRes(auth)),
           map(extractAccessTokenFromLogin)
           catchError(this.handleError<string>('Refresh'))
         )
      }
    
      private createRefreshToken(tokenId: string): RefreshTokenDto{...}
    
      private createLoginRes(): LoginResult {...}
    
      private mapAuthObjToLoginRes(val: AuthResultDto): LoginResult{...}
    }
    
    function extractAccessTokenFromLogin(login: LoginResult): string 
         => login.Success ? login.AccessToken : null;