Search code examples
angularjsjwtangular-promisecsrf-token

AngularJS: Refresh expired JWT token on 401 response


I'm facing issue on refreshing expired JWT token based on 401 (unauthorized) header response. What i want is when user get 401 (header) response, than a new (refresh) JWT should generated by calling specific service (api).

I'm sending XSRF-TOKEN & access_token (JWT) in header response and these are working fine. I even also can get refresh (expired) token by calling api manually. But can't get it worked with 401 (header) response.

I've a factory that take care of this promise and intercepts header requests. My (factory) code looks like this.

angular.module('myApp').factory('httpRequestInterceptor', httpRequestInterceptor);

function httpRequestInterceptor($cookies, $rootScope, $q, $location, $injector) {

  var replays = [];
  var refreshTokenPromise;

  var factory = {
    request: request,
    responseError: responseError
  };

  return factory;

  //////////

  function requestTodoWhenDone() {
    var token = store.get('token');

    return $http({
      method: 'POST',
      url: ApiEndpoint.url,
      params: {
        grant_type: 'refresh',
        id_token: $cookies.get('access_token')
      }
    })
      .success(function(response) {
        // Set the refreshed token.
        $cookies.put('access_token', response.data.access_token);
      })
      .then(function(){

        // Attempt to retry the request if request config is passed.
        if( !angular.isUndefined(requestTodoWhenDone) && requestTodoWhenDone.length > 0 ) {

          // Set the new token for the authorization header.
          requestTodoWhenDone.headers = {
            'Authorization': 'Bearer ' + $cookies.get('access_token')
          };

          // Run the request again.
          return $http(requestTodoWhenDone);
        }

      });
  }

  //////////

  // Add authorization token to headers
  function request(config) {
    config.headers = config.headers || {};

    if ($cookies.get('access_token')) {
      config.headers.Authorization = 'Bearer ' + $cookies.get('access_token');
    }

    return config;
  }

  // Intercept 401s and redirect you to login
  function responseError(response, requestTodoWhenDone) {
    if (response.status === 401 && $cookies.get('access_token')) {
      return checkAuthorization(response);
    }

    return $q.reject(response);

    /////////

    function checkAuthorization(res) {
      return $q(function(resolve, reject) {

        var replay = {
          success: function(){
            $injector.get('$http')(res.config).then(resolve, reject);
          },
          cancel: function(){
            reject(res);
          }
        };

        replays.push(replay);
        console.log(replays);

        if (!refreshTokenPromise) {
          refreshTokenPromise = $injector.get('requestTodoWhenDone') // REFRESH TOKEN HERE
            .refreshToken()
            .then(clearRefreshTokenPromise)
            .then(replayRequests)
            .catch(cancelRequestsAndRedirect);
        }
      });

      ////////////

      function clearRefreshTokenPromise(auth) {
        refreshTokenPromise = null;
        return auth;
      }

      function replayRequests(auth) {
        replays.forEach(function(replay) {  
          replay.success();
        });

        replays.length = 0;

        return auth;
      }

      function cancelRequestsAndRedirect() {

        refreshTokenPromise = null;
        replays.forEach(function(replay) {  
          replay.cancel();
        });

        replays.length = 0;

        $cookies.remove('token');
        var $state = $injector.get('$state');

        // SET YOUR LOGIN PAGE
        $location.path('/login');
      }
    }
  }  
}

Based on above code I'm getting following error in console when token expires (401 response).

Console Error

Error: "[$injector:unpr] Unknown provider: requestTodoWhenDoneProvider <- requestTodoWhenDone

Any help on this would be highly appreciable. Thanks.


Solution

  • Ok i ended up with different way that solves the issue. But i still can't be able to redirect user to login page when my token inactive time is also expires (this happens after jwt expires).

    Here is the code.

    authInterceptor.service.js

    angular.module('someApp').factory('AuthorizationTokenService', AuthorizationTokenService);
    
    AuthorizationTokenService.$inject = ['$q', '$injector', '$cookies'];
    function AuthorizationTokenService($q, $injector, $cookies) {
      // Local storage for token
      var tokenVM = {
        accessToken: null
      };
    
      // Subscribed listeners which will get notified when new Access Token is available
      var subscribers = [];
    
      // Promise for getting new Access Token from backend
      var deferedRefreshAccessToken = null;
    
      var service = {
        getLocalAccessToken: getLocalAccessToken,
        refreshAccessToken: refreshAccessToken,
        isAccessTokenExpired: isAccessTokenExpired,
        subscribe: subscribe
      };
    
      return service;
    
      ////////////////////////////////////
    
      // Get the new Access Token from backend
      function refreshAccessToken() {
    
        // If already waiting for the Promise, return it.
        if( deferedRefreshAccessToken ) {
    
          return deferedRefreshAccessToken.promise 
    
        } else {
    
          deferedRefreshAccessToken = $q.defer();
    
          // Get $http service with $injector to avoid circular dependency
          var http = $injector.get('$http');
    
          http({
            method: 'POST',
            url: 'api_url',
            params: {
              grant_type: 'refresh',
              id_token: $cookies.get('access_token')
            }
          })
            .then(function mySucces(response) {
              var data = response.data;
              if( data ){
                // Save new Access Token
                $cookies.put('access_token', data.access_token);
    
                if( $cookies.get('access_token') ) {
    
                  // Resolve Promise
                  deferedRefreshAccessToken.resolve(data.access_token);
    
                  // Notify all subscribers
                  notifySubscribersNewAccessToken(data.access_token);
                  deferedRefreshAccessToken = null;
                }
              }
            }, function myError(error) {
              deferedRefreshAccessToken.reject(error);
              deferedRefreshAccessToken = null;
            });
    
          return deferedRefreshAccessToken.promise;
        } 
    
      }
    
      function getLocalAccessToken() {
        // get accesstoken from storage - $cookies
        if ( $cookies.get('access_token') ) {
          var access_token = $cookies.get('access_token')
          return access_token;
        }
      }
    
      function isAccessTokenExpired() {
        // Check if expiresAt is older then current Date
      }
    
      function saveToken(accessToken) {
        // get accesstoken from storage - $cookies
        var access_token = $cookies.put('access_token');
    
        console.log('access_token ' + access_token);
    
        return access_token;
      }
    
      // This function will call all listeners (callbacks) and notify them that new access token is available
      // This is used to notify the web socket that new access token is available
      function notifySubscribersNewAccessToken(accessToken) {
        angular.forEach(subscribers, function(subscriber) {
          subscriber(accessToken);
        });
      }
    
      // Subscribe to this service. Be notifyed when access token is renewed
      function subscribe(callback) {
        subscribers.push(callback);
      }
    }
    

    Than in config (app.js) I've following code which intercepts appropriate header(s) and refresh (request) api on 401 response.

    Here is the config code

    config.$inject = ['$stateProvider', '$urlRouterProvider', '$httpProvider'];
    function config($stateProvider, $urlRouterProvider, $httpProvider) {
    
      // Push httpRequestInterceptor
      // $httpProvider.interceptors.push('httpRequestInterceptor');
    
      //Intercept all http requests
      $httpProvider.interceptors.push(['$injector', '$q', "AuthorizationTokenService", "$cookies", function ($injector, $q, AuthorizationTokenService, $cookies) {
        var cachedRequest = null;
    
        return {
          request: function (config) {
            //If request if for API attach Authorization header with Access Token
            if (config.url.indexOf("api") != -1) {
              // var accessToken = AuthorizationTokenService.getLocalAccessToken();
              console.log('cookie ' + $cookies.get('access_token'));
              config.headers.Authorization = 'Bearer ' + $cookies.get('access_token');
            }
            return config;
          },
          responseError: function (response) {
            switch (response.status) {
              // Detect if reponse error is 401 (Unauthorized)
              case 401:
    
              // Cache this request
              var deferred = $q.defer();
              if(!cachedRequest) {
                // Cache request for renewing Access Token and wait for Promise
                cachedRequest = AuthorizationTokenService.refreshAccessToken();
              }
    
              // When Promise is resolved, new Access Token is returend 
              cachedRequest.then(function(accessToken) {
                cachedRequest = null;
                if (accessToken) {
                  // Resend this request when Access Token is renewed
                  $injector.get("$http")(response.config).then(function(resp) {
                    // Resolve this request (successfully this time)
                    deferred.resolve(resp);
                  },function(resp) {
                    deferred.reject();
                    console.log('success: refresh token has expired');
                  });
                } else {
                  // If any error occurs reject the Promise
                  console.log('error: refresh token has expired');
                  deferred.reject();
                }
              }, function(response) {
                // If any error occurs reject the Promise
                cachedRequest = null;
                deferred.reject();
                return;
              });
    
              return deferred.promise;
            }
    
            // If any error occurs reject the Promise
            return $q.reject(response);
          }
        };
      }]);
    }
    

    The code is working fine on 401 (response) case which happens when JWT expires. But its not redirecting me to login page (In this case I've added console in promise request in config instead of redirection code)

    Please help on this, thanks...