27
votes

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);
        }
    };
});
2
can you post the code snippet of your interceptor and how you resend the requests? - Kevin Hakanson
@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

2 Answers

54
votes

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);
        }
    };
});
6
votes

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);
                }
            }
        }
    }

})();