28

I have an angular app which sometimes does multiple $http.get requests per state. The app usees JWT for user auth with refresh tokens. The API server sends 401 on every request that failed because of auth error. I've made an http interceptor that requests a new token with the refresh token on 401 errors and after that resends the original request.

The problem is, if a state makes for example 2 $http.get requests and both get 401 response then I renew the access token twice. Obviously I only want to refresh the token once, BUT I still want to resend BOTH failed requests.

Is this achievable and if so how?

app.factory('AuthInterceptor', function($q, $injector, RESOURCE_URL, API_BASE, authService) {
    return {
        request: function(config) {
            config.headers = config.headers || {};
            if (authService.getAccessToken()) {
                if (config.url.substring(0, RESOURCE_URL.length) !== RESOURCE_URL) {
                    config.headers.Authorization = 'Bearer ' + authService.getAccessToken();
                }
            }
            return config;
        },
        responseError: function(response) {
            switch (response.status) {
                case 401:
                    var deferred = $q.defer();
                    $injector.get("$http").post(API_BASE + '/api/auth/refresh', {refreshtoken: authService.getRefreshToken()}).then(function(r) {
                        if (r.data.data.accesstoken && r.data.data.refreshtoken && r.data.data.expiresin) {
                            authService.setAccessToken(r.data.data.accesstoken);
                            authService.setRefreshToken(r.data.data.refreshtoken);
                            authService.setExpiresIn(r.data.data.expiresin);
                            $injector.get("$http")(response.config).then(function(resp) {
                                deferred.resolve(resp);
                            },function(resp) {
                                deferred.reject();
                            });
                        } else {
                            deferred.reject();
                        }
                    }, function(response) {
                        deferred.reject();
                        authService.clear();
                        $injector.get("$state").go('guest.login');
                        return;
                    });
                    return deferred.promise;
                    break;
                default:
                    authService.clear();
                    $injector.get("$state").go('guest.login');
                    break;
            }
            return response || $q.when(response);
        }
    };
});
Andrew
  • 2,063
  • 3
  • 24
  • 40
  • 1
    can you post the code snippet of your interceptor and how you resend the requests? – Kevin Hakanson Oct 24 '14 at 17:14
  • does http://stackoverflow.com/questions/18638211/how-can-i-send-request-again-in-response-interceptor help answer? – Kevin Hakanson Oct 24 '14 at 17:16
  • 1
    @KevinHakanson: I don't get that lib either. The `event:auth-loginRequired` would still fire twice making the app refresh the token twice. Am I wrong? What am I missing? – Andrew Oct 24 '14 at 19:18

3 Answers3

55

Your interceptor needs to keep track of whether or not it has an authentication request "in flight". It can do this by keeping a reference to the promise returned by the authentication request. If there is a request in flight and you get another 401, just use that cached promise instead of initiating a new request. Also, you should consider adding logic to account for the case when '/api/auth/refresh' itself returns a 401.

app.factory('AuthInterceptor', function($q, $injector, RESOURCE_URL, API_BASE, authService) {
    var inflightAuthRequest = null;
    return {
        request: function(config) {
            config.headers = config.headers || {};
            if (authService.getAccessToken()) {
                if (config.url.substring(0, RESOURCE_URL.length) !== RESOURCE_URL) {
                    config.headers.Authorization = 'Bearer ' + authService.getAccessToken();
                }
            }
            return config;
        },
        responseError: function(response) {
            switch (response.status) {
                case 401:
                    var deferred = $q.defer();
                    if(!inflightAuthRequest) {
                        inflightAuthRequest = $injector.get("$http").post(API_BASE + '/api/auth/refresh', {refreshtoken: authService.getRefreshToken()});
                    }
                    inflightAuthRequest.then(function(r) {
                        inflightAuthRequest = null;
                        if (r.data.data.accesstoken && r.data.data.refreshtoken && r.data.data.expiresin) {
                            authService.setAccessToken(r.data.data.accesstoken);
                            authService.setRefreshToken(r.data.data.refreshtoken);
                            authService.setExpiresIn(r.data.data.expiresin);
                            $injector.get("$http")(response.config).then(function(resp) {
                                deferred.resolve(resp);
                            },function(resp) {
                                deferred.reject();
                            });
                        } else {
                            deferred.reject();
                        }
                    }, function(response) {
                        inflightAuthRequest = null;
                        deferred.reject();
                        authService.clear();
                        $injector.get("$state").go('guest.login');
                        return;
                    });
                    return deferred.promise;
                    break;
                default:
                    authService.clear();
                    $injector.get("$state").go('guest.login');
                    break;
            }
            return response || $q.when(response);
        }
    };
});
smik
  • 33
  • 7
Joe Enzminger
  • 11,110
  • 3
  • 50
  • 75
  • This solution is great as it returns all the requests back to the controller, where they are interpreted by success and error functions. Thank you very much for this solution! – Klapsa2503 Feb 19 '15 at 16:38
  • 6
    Works well with Access Token expiration but gives error when Refresh token expires. Is anybody observe the same behavior? – S Atah Ahmed Khan Feb 23 '16 at 12:02
  • @SAtahAhmedKhan: It fails because the inteceptor intercepts also 401 that a refresh endpoint returns when the refresh token expires. Thus fail code is never executed. Instead the refresh request is queued on inflightAuthRequest promise to be reexecuted. To solve it there should be a check of response.url. – RKI Aug 13 '16 at 01:47
  • Is this a "safe" solution? Can someone with access to the console (or a malicious browser plugin) get to the refresh token by getting the service and calling authService.getRefreshToken()? – mvermand Sep 15 '16 at 16:42
  • @RKI response.url check? what do you mean by that. – MMK Sep 19 '16 at 13:39
  • 4
    @MMK: This code intercepts all 401 requests, but if your refresh token expires you should fail all waiting requests, but the above code will shedule 401 of refersh failure to reexecution like all other requests. So you should check response.url to figure out if the 401 is from regular request or is directed to token refresh endpoint. If you get 401 from token refresh endpoint you should return fail to all waiting requests. – RKI Sep 20 '16 at 21:48
  • @RKI, yes you are right, how do you fix this. can you please share your exp – MUHAMMAD MUBUSHER ASLAM Apr 15 '17 at 05:31
  • @MUHAMMADMUBUSHERASLAM My code is slightly different. Here is piece that do the check: var tokenEndpoint = ApiConfig.apiURL + '/v1/auth/o/token/'; // This is full URL of token endpoint if (rejection.config.url == tokenEndpoint) { return $q.reject(rejection); } var deferred = $q.defer(); I guess this pice should be before switch(response.status) and response is my rejection, but it is some time ago and I am not 100% sure. – RKI Apr 15 '17 at 18:09
6

The solution of Joe Enzminger is great. But I had a few issues with the callback as it didn't execute. Then I noticed a little typo in inflightAuthRequest/inFlightAuthRequest.

My complete solution is now:

(function() {
'use strict';
    angular.module('app.lib.auth', []);
    angular.module('app.lib.auth')
        .factory('authService', authService);
    angular.module('app.lib.auth')
        .factory('AuthInterceptor', AuthInterceptor);

    function authService($window) {
        return {
            getToken: function() {
                return $window.localStorage.getItem('JWT');
            },
            getRefreshToken: function() {
                return $window.localStorage.getItem('Refresh-JWT');
            },
            setRefreshToken: function(token) {
                $window.localStorage.setItem('Refresh-JWT', token);
            },
            setToken: function(token) {
                $window.localStorage.setItem('JWT', token);
            },
            clearAllToken: function(){
                $window.localStorage.removeItem('JWT');
                $window.localStorage.removeItem('Refresh-JWT');
            },
            clearToken: function(){
                $window.localStorage.removeItem('JWT');
            },
            isLoggedIn: function() {
                if ($window.localStorage.getItem('JWT') === null) {
                    return false;
                }
                else {
                    return true;
                }
            },
            toLogin: function(){
                $window.location.href = "http://" + $window.location.host + "/tprt/login";
            }
        }
    }

    function AuthInterceptor($q, $injector, authService) {
        var inFlightAuthRequest = null;
        return {
            request : function(config) {
                config.headers = config.headers || {};
                if(authService.getToken()){
                    config.headers['Authorization'] = authService.getToken();
                }
                return config;
            },
            responseError : function(response) {
                if(response.config.url == URLS.api_refresh_token){
                    console.log(JSON.stringify(response));
                    authService.clearAllToken();
                    authService.toLogin();
                }else{

                    switch (response.status) {
                    case 401:
                        authService.clearToken();
                        var deferred = $q.defer();
                        if (!inFlightAuthRequest) {
                            inFlightAuthRequest = $injector.get("$http").post(
                                    URLS.api_refresh_token, { 
                                        refreshtoken : authService.getRefreshToken()
                                    });
                        }
                        inFlightAuthRequest.then(function(r) {
                            inFlightAuthRequest = null;
                            console.log(JSON.stringify(r));
                            authService.setToken(r.data.accesstoken);
                            $injector.get("$http")(response.config).then(function(resp) {
                                deferred.resolve(resp);
                            }, function(resp) {
                                deferred.reject(resp);
                            });
                        }, function(error) {
                            inFlightAuthRequest = null;
                            deferred.reject();
                            authService.clearAllToken();
                            authService.toLogin();
                            return;
                        });
                        return deferred.promise;
                        break;
                    default:
                        return $q.reject(response);
                    break;
                    }
                    return response || $q.when(response);
                }
            }
        }
    }

})();
Max B
  • 61
  • 2
  • 7
0

While multiple request coming to interceptor at a time for token refreshing, send the first request only to get the token and await other http requests until the first one comes back with response. Getting the response set the new token info to all the http request headers and let them excecated. This approach will request once for getting new token.

private static accessTokenError$: BehaviorSubject<boolean> = new BehaviorSubject<boolean>(false);

In the interceptor use a static boolean subjectBehaviour to keep track of first request, after sending first request update the subject behaviour status so that the next request perevent executing the same code.

if (!JwtInterceptor.accessTokenError$.getValue()) {
          // isRrefreshing = true;
          JwtInterceptor.accessTokenError$.next(true);

          return this.authService.getNewRefreshToken().pipe(
            switchMap((newTokens: any) => {
              const transformedReq = req.clone({
                headers: req.headers.set(
                  "Authorization",
                  `bearer ${newTokens.data.token}`
                ),
              });
              JwtInterceptor.accessTokenError$.next(false);
              return next.handle(transformedReq);
            }), catchError(error => {
              return throwError(error);
            })
          );
        } else {
           // If it's not the firt error, it has to wait until get the access/refresh token
           return this.waitNewTokens().pipe(
            switchMap((event: any) => {
                // Clone the request with new Access Token
                const newRequest = req.clone({
                    setHeaders: {
                        Authorization: `bearer ${localStorage.getItem('accessToken')}`
                    }
                });
                return next.handle(newRequest);
            })
        );
        }

And this is the method which will await requests until the first one getting response.

 private waitNewTokens(): Observable<any> {
const subject = new Subject<any>();
const waitToken$: Subscription = JwtInterceptor.accessTokenError$.subscribe((error: boolean) => {
    if(!error) {
        subject.next();
        waitToken$.unsubscribe();
    }
});
return subject.asObservable();

}