2

I have an existing single page application that I have transitioned to JWT instead of PHP sessions. Its working well but I am trying to figure out how to refresh the JWT (if necessary) BEFORE AJAX requests.

A little background: Upon user login I store a JWT (10 minute expiration) and a Refresh Token (2 hour expiration). I have the following function that I want to run prior to EVERY AJAX call to the API. The function checks if the JWT is expired and if so it retrieves a new one via the Refresh token. Obviously in the API I check the validity of both the JWT and Refresh token before allowing data to be sent back. This part is working perfectly.

    function checkTokenExp(){
     var validToken = false;   // DEFAULT TO EXPIRED
     var apiClaims = localStorage.getItem('apiToken').split('.');
     var api = JSON.parse(atob(apiClaims[1]));
     var apiExpiration = new Date(api['exp'] * 1000);
     var now = new Date();
     if (apiExpiration < now) {
       // API TOKEN EXPIRED - CHECK REFRESH TOKEN
       var refreshClaims = localStorage.getItem('refreshToken').split('.');
       var refresh = JSON.parse(atob(refreshClaims[1]));
       var refreshExpiration = new Date(refresh['exp'] * 1000);
       if (refreshExpiration > now) {
         //  REFRESH TOKEN NOT EXPIRED - NEED NEW API TOKEN
         $.ajax({
           type: "GET",
           url: 'api/session/token',
           contentType: 'application/json',
           beforeSend: function (xhr) {
             xhr.setRequestHeader("Authorization", 'Bearer '+localStorage.getItem('refreshToken'));
           }
         }).done(function (data) {
           var data = jQuery.parseJSON(data);
           if (data.status == 'success'){
             localStorage.setItem('apiToken', data.apiToken);
           }
           validToken = true;
         });
       }else{
         //  REFRESH TOKEN EXPIRED - FORCE LOG OUT
         $("#SessionExpiredModal").modal('show');
       }
     }else{
       //  API TOKEN NOT EXPIRED
       validToken = true;
     }
     return validToken;
   }

Where I run into trouble is with the code below (and every other AJAX call going forward). As you can see, I am trying to run the function above before this AJAX call and validate my JWT (if its invalid a new one is requested and stored) and then run the AJAX call with the new JWT. It all works except the AJAX returns a 401 unauthorized because it is still sending the previous JWT (which is obviously expired). So it seems as though the AJAX is being sent before the above function completes and stores the new JWT. But I know my function is running correctly and the JWT is updated because the second time I click the button it runs correctly and I get the proper reply from the API.

$('#BtnTest').on('click', function(){
     $.ajax({
      type: "GET",
      url: 'api/random',
      contentType: 'application/json',
      beforeSend: function (xhr) {
        checkTokenExp();  // SHOULD BE SAVING NEW TOKEN
        xhr.setRequestHeader("Authorization", 'Bearer '+ localStorage.getItem('apiToken'));  // SHOULD BE SENDING NEW TOKEN
      }
    }).done(function (response) {
      alert(response);
    })
   });

I hope that makes sense and thanks in advance for the help. Also, is there an easy way to make my function run before EVERY ajax call without having to code it in each AJAX statement.

Cœur
  • 37,241
  • 25
  • 195
  • 267
bwlange3
  • 41
  • 3
  • 10

2 Answers2

0

You need to add a callback to checkTokenExp() so your API call is done once the API token has been refreshed.

What's happening now:

  1. You prepare a call to api/random
  2. The beforeSend executes checkTokenExp()
  3. If the token's expired, it asks for another one asynchronously
  4. The actual call to api/random is made, possibly before the server replied to the 3rd step with a new token

Have a look at jQuery's ajaxPrefilter.

You could try something like that:

var currentRequests = {};

$.ajaxPrefilter(function( options, originalOptions, jqXHR ) {
    // Skip URLs that don't need a token
    if( options.url == 'your api/session/token URL' ) {
        console.log("No ajax prefilter on api/session/token");
        return;
    }

    successCb = function() {
        currentRequests[ options.url ] = jqXHR;
    };
    errorCb = function() {
        console.error("Could not refresh token, aborting Ajax call to " + options.url);
        currentRequests[ options.url ].abort();
    };
    checkTokenExp(successCb, errorCb);
});



function checkTokenExp(successCb, errorCb){
    // ...
    if (refreshExpiration > now) {
        //  REFRESH TOKEN NOT EXPIRED - NEED NEW API TOKEN
        $.ajax({
            type: "GET",
            url: 'api/session/token',
            contentType: 'application/json',
        }).done(function (data) {
            var data = jQuery.parseJSON(data);
            if (data.status == 'success'){
                localStorage.setItem('apiToken', data.apiToken);
                successCb(); // the new token is set, you can make ajax calls
            }
            // TODO: log error here, don't do validToken = true
        }).fail(errorCb(data));
    }else{
        //  REFRESH TOKEN EXPIRED - FORCE LOG OUT
        $("#SessionExpiredModal").modal('show');
    }
    }else{
        //  API TOKEN NOT EXPIRED
        successCb();
    }
}
pyb
  • 4,813
  • 2
  • 27
  • 45
  • Have a look at those: https://stackoverflow.com/questions/11793430/retry-a-jquery-ajax-request-which-has-callbacks-attached-to-its-deferred https://stackoverflow.com/questions/21025552/refreshing-oauth2-tokens-in-async-js https://stackoverflow.com/questions/41118347/prevent-ajax-request-till-new-token-is-obtained/41118703 – pyb Aug 31 '17 at 20:10
  • Thanks for the tip. Its not quite working yet. As I said this is somewhat new to me and I was unfamiliar with ajaxPrefilter. Please tell me if my understanding of this is correct. The Prefilter runs my function before each ajax call. Somewhere in checkTokenExp() it fires successCb() and allows the ajax request to continue. Is that correct. It works fine with added Prefilter until the token is expired and then it fails just as before. It looks as though the first ajax call is still completing before the new token is saved. – bwlange3 Aug 31 '17 at 22:49
  • I was able to solve this with the code in one of your suggested links - [https://stackoverflow.com/questions/41118347/prevent-ajax-request-till-new-token-is-obtained/41118703](https://stackoverflow.com/questions/41118347/prevent-ajax-request-till-new-token-is-obtained/41118703). My next question would be is this solution secure. If an access token was altered in some way the server would return a 401 and then request a new token via the refresh token. Is this something to be concerned with? – bwlange3 Sep 01 '17 at 01:04
  • Bugs: I don't have the full code so it's hard to help further. If you haven't already, learn how to use the developer tools and JavaScript debugging of your browser. You'll be able to see what's happening. Question: please mark your question as answered so the community knows you don't need help any more. Security: the server may implement some kind of temporary ban. – pyb Sep 01 '17 at 14:03
0

in JQuery, use Ajax Prefilter

function setUpAjaxPrefilter() {
if (!ajaxPrefiltered) {
    ajaxPrefiltered = true; // once
    const origOptsList = [];

    $.ajaxPrefilter(function onPrefilter(opts, ajaxOpts, jqXHR) {
        if (opts.authRefresh) {
            return; // don't do it again
        }
        const dfd = $.Deferred(); 
        // if the non refresh request was done just resolve our deferred
        jqXHR.done(dfd.resolve); 
        // if the request failed, handle

        jqXHR.fail(function onAjaxFail(...args) {
            const { status, url } = jqXHR;
            // keep list of failed requests while refresh token request still outstanding
            origOptsList.push(ajaxOpts);

            window.XHR = {
                ...jqXHR,
                ...opts,
            }; 
            // keep copy of XHR in window global for logging purpose
            const { browserSession } = APPLICATION;

            if (!url && !refreshing && browserSession && status === 401) {
                refreshing = true;
                return browserSession
                    .refresh()
                    .then((response) => {
                        refreshing = false;
                        const { access_token: accessToken } = response;

                        // retry failed requests
                        const retries = [];
                        while (origOptsList.length && accessToken) {
                            const origOpts = origOptsList.shift();
                            const newOpts = {
                                ...origOpts,
                                authRefresh: true, // prevent infinite loop in case of bad token
                                headers: { Authorization: `Bearer ${accessToken}` },
                            };

                            retries.push($.ajax(newOpts).then(dfd.resolve, dfd.reject));
                        }
                        return Promise.allSettled(retries);
                    })
                    .catch((error) => {
                        refreshing = false;

                        jqXHR.status = error.status || 401;
                        jqXHR.statusText = error.message || 'token expired';
                        dfd.rejectWith(jqXHR, [...args]);
                    });
            } else {
                if (jqXHR.status === 200) {
                    return dfd.resolve(args);
                    // status cancel happens when beforeSend cancels $.ajax request
                } else if (jqXHR.statusText && !jqXHR.statusText.includes('cancel')) {
                    // eslint-disable-next-line no-console
                    console.error(jqXHR, jqXHR.responseText);
                }
                return dfd.rejectWith(jqXHR, [...args]);
            }
        });
    });
}

}

dhwang
  • 57
  • 8