21

I am trying to intercept the 401 and 403 errors to refresh the user token, but I can't get it working well. All I have achieved is this interceptor:

app.config(function ($httpProvider) {

  $httpProvider.interceptors.push(function ($q, $injector) {

    return {
      // On request success
      request: function (config) {
        var deferred = $q.defer();

        if ((config.url.indexOf('API URL') !== -1)) {
          // If any API resource call, get the token firstly
          $injector.get('AuthenticationFactory').getToken().then(function (token) {
            config.headers.Authorization = token;

            deferred.resolve(config);
          });
        } else {
          deferred.resolve(config);
        }

        return deferred.promise;
      },

      response: function (response) {
        // Return the promise response.
        return response || $q.when(response);
      },

      responseError: function (response) {
        // Access token invalid or expired
        if (response.status == 403 || response.status == 401) {
          var $http = $injector.get('$http');
          var deferred = $q.defer();

          // Refresh token!
          $injector.get('AuthenticationFactory').getToken().then(function (token) {
            response.config.headers.Authorization = token;

            $http(response.config).then(deferred.resolve, deferred.reject);
          });

          return deferred.promise;
        }

        return $q.reject(response);
      }
    }
  });
});

The issue is that the responseError does an infinite loop of 'refreshes' because by Authorization header with the updated token, that is not being received by $http(response.config) call.

1.- App has an invalid token stored.
2.- App needs to do an API call
  2.1 Interceptor catch the `request`.
  2.2 Get the (invalid) stored token and set the Authorization header.
  2.3 Interceptor does the API call with the (invalid) token setted.
3.- API respond that used token is invalid or expired (403 or 401 statuses)
  3.1 Interceptor catch the `responseError`
  3.2 Refresh the expired token, get a new VALID token and set it in the Authorization header.
  3.3 Retry the point (2) with the valid refreshed token `$http(response.config)`

The loop is happening in point (3.3) because the Authorization header NEVER has the new refreshed valid token, it has the expired token instead. I don't know why because it supposed to be setted in the responseError

AuthenticationFactory

app.factory('AuthenticationFactory', function($rootScope, $q, $http, $location, $log, URI, SessionService) {

  var deferred = $q.defer();

  var cacheSession   = function(tokens) {
    SessionService.clear();

    // Then, we set the tokens
    $log.debug('Setting tokens...');
    SessionService.set('authenticated', true);
    SessionService.set('access_token', tokens.access_token);
    SessionService.set('token_type', tokens.token_type);
    SessionService.set('expires', tokens.expires);
    SessionService.set('expires_in', tokens.expires_in);
    SessionService.set('refresh_token', tokens.refresh_token);
    SessionService.set('user_id', tokens.user_id);

    return true;
  };

  var uncacheSession = function() {
    $log.debug('Logging out. Clearing all');
    SessionService.clear();
  };

  return {
    login: function(credentials) {
      var login = $http.post(URI+'/login', credentials).then(function(response) {
        cacheSession(response.data);
      }, function(response) {
        return response;
      });

      return login;
    },
    logout: function() {
      uncacheSession();
    },
    isLoggedIn: function() {
      if(SessionService.get('authenticated')) {
        return true;
      }
      else {
        return false;
      }
    },
    isExpired: function() {
      var unix = Math.round(+new Date()/1000);

      if (unix < SessionService.get('expires')) {
        // not expired
        return false;
      }

      // If not authenticated or expired
      return true;
    },
    refreshToken: function() {
      var request_params = {
        grant_type:     "refresh_token",
        refresh_token:  SessionService.get('refresh_token')
      };

      return $http({
          method: 'POST',
          url: URI+'/refresh',
          data: request_params
        });
    },
    getToken: function() {
      if( ! this.isExpired()) {
        deferred.resolve(SessionService.get('access_token'));
      } else {
        this.refreshToken().then(function(response) {
          $log.debug('Token refreshed!');

          if(angular.isUndefined(response.data) || angular.isUndefined(response.data.access_token))
          {
            $log.debug('Error while trying to refresh token!');
            uncacheSession();
          }
          else {

            SessionService.set('access_token', response.data.access_token);
            SessionService.set('token_type', response.data.token_type);
            SessionService.set('expires', tokens.expires);
            SessionService.set('expires_in', response.data.expires_in);

            deferred.resolve(response.data.access_token);
          }
        }, function() {
          // Error
          $log.debug('Error while trying to refresh token!');
          uncacheSession();
        });
      }

      return deferred.promise;
    }
  };
});

PLUNKER

I made a plunker & backend to try to reproduce this issue.

http://plnkr.co/edit/jaJBEohqIJayk4yVP2iN?p=preview

VanPersie
  • 65
  • 3
  • 13
  • 2
    What does you last statement mean? Do you mean that after having received the refresh token, the next call to `$http` again results in 403 or 401?. And, btw, there a huge information gap for us in `AuthenticationFactory` - even, conceptually, it's not clear happens between refresh and token cases. Does a call to refresh renews the token, such that next time `.getToken` returns the new token? – New Dev May 04 '15 at 16:44
  • 1
    Last statement means that the app is doing the API call with the PREVIOUS (expired) token, instead of the updated one as I could guess because I set it with `response.config.headers.Authorization = token;`. Yes, the refresh renews and set the token for the following `.getToken` calls – VanPersie May 04 '15 at 18:16
  • Which API call? The one `$http(response.config)` or the one that refreshes the token? – New Dev May 04 '15 at 18:32
  • The one `$http(response.config)` – VanPersie May 05 '15 at 06:40
  • I updated the question with detailed info about the issue – VanPersie May 05 '15 at 09:43
  • Have you tried putting a `console.log` call in `refreshToken().then` to confirm that a new token is being provided? – Ben Thielker May 06 '15 at 16:59
  • still missing AuthenticationFactory – huysentruitw May 06 '15 at 17:13
  • If you *are* getting a new token, you could try applying it to `$http.defaults.headers.common.Authorization` in case setting `response.config.headers.Authorization` isn't providing the necessary change to subsequent calls. – Ben Thielker May 06 '15 at 17:23
  • 1
    `$http.defaults.headers.common.Authorization` will set the header for every $http call, not only API call. It's pretty insecure... Man-in-the-middle for example – VanPersie May 06 '15 at 18:21
  • Please show `AuthenticationFactory` code. – artur grzesiak May 07 '15 at 08:30
  • Updated with `AuthenticationFactory` code. I omitted it for brevity – VanPersie May 07 '15 at 12:42
  • I've had to solve a a similar request interception problem (checking for expired tokens in the client), and [This Question](http://stackoverflow.com/questions/13928057/how-to-cancel-an-http-request-in-angularjs) helped me code a solution. I use a periodic timer to fire token check requests periodically and monitor token expiry that way. – Josh Greifer May 08 '15 at 08:58
  • I updated the question with a Plunkr to reproduce it – VanPersie May 08 '15 at 16:54
  • @VanPersie: Your API returns the "invalid token" response even when "validToken" is passed in the `Authorization` header. Just confirmed with Postman. Your Angular code appears to work as it should, but the server isn't. So long as the server gives that response the loop will continue. You might want to build in a counter to prevent an infinite loop. – Ben Thielker May 08 '15 at 21:32

1 Answers1

9

Your interceptor needs to keep track of whether or not it has a request for a new authentication token "in flight". If so, you need to wait on the result of the in-flight request rather than initiating a new one. You can do this by caching the promise returned by your AuthRequest and using the cached promise instead of creating a new one for every API requests.

Here is an answer to a similar question that demonstrates this.

For you example - here is an example implementation:

app.config(function ($httpProvider) {

$httpProvider.interceptors.push(function ($q, $injector) {
    var inFlightRequest = null;
    return {
      // On request success
      request: function (config) {
        var deferred = $q.defer();

        if ((config.url.indexOf('API URL') !== -1)) {
          // If any API resource call, get the token firstly
          $injector.get('AuthenticationFactory').getToken().then(function (token) {
            config.headers.Authorization = token;

            deferred.resolve(config);
          });
        } else {
          deferred.resolve(config);
        }

        return deferred.promise;
      },

      response: function (response) {
        // Return the promise response.
        return response || $q.when(response);
      },

      responseError: function (response) {
        // Access token invalid or expired
        if (response.status == 403 || response.status == 401) {
          var $http = $injector.get('$http');
          var deferred = $q.defer();

          // Refresh token!
          if(!inFlightRequest){
             inFlightRequest = $injector.get('AuthenticationFactory').refreshToken();
          }
          //all requests will wait on the same auth request now:
          inFlightRequest.then(function (token) {
            //clear the inFlightRequest so that new errors will generate a new AuthRequest.
            inFlightRequest = null;
            response.config.headers.Authorization = token;

            $http(response.config).then(deferred.resolve, deferred.reject);
          }, function(err){
              //error handling omitted for brevity
          });

          return deferred.promise;
        }

        return $q.reject(response);
      }
    }
  });
});

UPDATE:

It's not clear to me from your plunk exactly what the problem is, but there is a problem with your AuthenticationService. Recommended changes are below and here is a Plunkr that is a bit more complete (and includes tracking inflight requests):

app.factory('AuthenticationFactory', function($rootScope, $q, $http, $location, $log, URI, SessionService) {

  //this deferred declaration should be moved.  As it is, it's created once and re-resolved many times, which isn't how promises work.  Subsequent calls to resolve essentially are noops.  

  //var deferred = $q.defer();

  var cacheSession   = function(tokens) {
    SessionService.clear();

    // Then, we set the tokens
    $log.debug('Setting tokens...');
    SessionService.set('authenticated', true);
    SessionService.set('access_token', tokens.access_token);
    SessionService.set('token_type', tokens.token_type);
    SessionService.set('expires', tokens.expires);
    SessionService.set('expires_in', tokens.expires_in);
    SessionService.set('refresh_token', tokens.refresh_token);
    SessionService.set('user_id', tokens.user_id);

    return true;
  };

  var uncacheSession = function() {
    $log.debug('Logging out. Clearing all');
    SessionService.clear();
  };

  return {
    login: function(credentials) {
      var login = $http.post(URI+'/login', credentials).then(function(response) {
        cacheSession(response.data);
      }, function(response) {
        return response;
      });

      return login;
    },
    logout: function() {
      uncacheSession();
    },
    isLoggedIn: function() {
      if(SessionService.get('authenticated')) {
        return true;
      }
      else {
        return false;
      }
    },
    isExpired: function() {
      var unix = Math.round(+new Date()/1000);

      if (unix < SessionService.get('expires')) {
        // not expired
        return false;
      }

      // If not authenticated or expired
      return true;
    },
    refreshToken: function() {
      var request_params = {
        grant_type:     "refresh_token",
        refresh_token:  SessionService.get('refresh_token')
      };

      return $http({
          method: 'POST',
          url: URI+'/refresh',
          data: request_params
        });
    },
    getToken: function() {

      //It should be moved here - a new defer should be created for each invocation of getToken();
      var deferred = $q.defer();          

      if( ! this.isExpired()) {
        deferred.resolve(SessionService.get('access_token'));
      } else {
        this.refreshToken().then(function(response) {
          $log.debug('Token refreshed!');

          if(angular.isUndefined(response.data) || angular.isUndefined(response.data.access_token))
          {
            $log.debug('Error while trying to refresh token!');
            uncacheSession();
          }
          else {

            SessionService.set('access_token', response.data.access_token);
            SessionService.set('token_type', response.data.token_type);
            SessionService.set('expires', tokens.expires);
            SessionService.set('expires_in', response.data.expires_in);

            deferred.resolve(response.data.access_token);
          }
        }, function() {
          // Error
          $log.debug('Error while trying to refresh token!');
          uncacheSession();
        });
      }

      return deferred.promise;
    }
  };
});

As a final note, keeping track of both inflight getToken requests and inflight refreshToken requests will keep you from making too many calls to your server. Under high load you might be creating way more access tokens than you need.

UPDATE 2:

Also, reviewing the code, when you get a 401 error you are calling refreshToken(). However, refreshToken does not put the new token information in the session cache, so new requests are going to continue using the old token. Updated the Plunkr.

Community
  • 1
  • 1
Joe Enzminger
  • 11,110
  • 3
  • 50
  • 75
  • If you don't mind my asking, does your server logic invalidate existing tokens when it issues a new one? – Joe Enzminger May 08 '15 at 05:11
  • The token is invalidated by expiration time, so the server responses with a `401` or `403` status code and the client catches it at `responseError` to replace the token with the `refresh_token` – VanPersie May 08 '15 at 06:47
  • Here is a Plunker with your solution @joe-enzminger http://plnkr.co/edit/8cvVvKDyTBxobbIR3qpe – VanPersie May 08 '15 at 18:31
  • 2
    Looked at your Plunkr. Its not clear to me that it reproduces your problem. However, I did find an issue involving your Authentication factory reusing a defer when it should be creating a new one. Calling resolve on an already resolve promise will is a noop. I've posted the recommended change. Without knowing your exact issue, I can only speculate that it will solve your problem, but I suspect it will. – Joe Enzminger May 09 '15 at 03:11
  • you should be right I suppose, me too thinking the same that OP was reusing the same deferred which was already resolved to a value. – code-jaff May 09 '15 at 03:26
  • Found one more problem - which is probably actually what is causing the loop. See Update 2. – Joe Enzminger May 09 '15 at 03:29
  • 3
    @JoeEnzminger, I would just get rid of all these `deferred` and chain the actual `$http`-generated promises. And also make the `refreshToken` API internal since `getToken` fallbacks to it anyway – New Dev May 09 '15 at 03:57
  • Don't disagree - trying not to change too much so that I get the points across without confusing the issue. – Joe Enzminger May 09 '15 at 05:01
  • 1
    Ok Joe, that made the trick!! The issue was to reuse the defer. Now it works like a charm. Thanks you so much! – VanPersie May 09 '15 at 07:05