Stop refreshing access tokens manually!!

Stop refreshing access tokens manually!!

Featured on Hashnode

Everyone knows the annoying process of detecting an expired access token -> refreshing it using your (hopefully) saved refresh token -> updating your access token to the new value.

Usually, this ends up as a lot of repeated code wherever you use an access token, and as we all know: repeated code is the spawn of satan.

So how does one deal with this dilemma? Well if you're using Spotify access tokens specifically (as I was a couple of months ago) just jump here.

Alternatively, if you're not using Spotify tokens or you just want to know exactly how to deal with this issue, continue reading. I'll be covering the principles behind some of the better solutions and going through a step-by-step solution in JavaScript.

Basic Principles

In most OAuth flows (on a web app) it goes something like

  • Redirecting the user to https://xxx.xx/authorize?
  • Receiving the user at your callback URI (often something like https://yourdomain.com/oauth/callback) with a code query parameter
  • Extracting the code param (https://yourdomain.com/oauth/callback?code=....)
  • Sending a post request to https://xxx.xx/api/token with your client credentials and the code received from the previous steps.
  • Receiving a JSON object with an access_token, refresh_token and expires_in fields

For example, this is Spotify's OAuth flow: spotify-auth-flow.png

This is just an example. Your flow may look different. If it does, don't worry. As long as you have access and refresh tokens you're all good

Different Angles

Now, there are two ways of approaching the problem.

A. Using the very conveniently placed expires_in field and the current timestamp to calculate if your access token is expired.

B. (For people who hate maths) Ignoring the expires_in field and determining if your access token is expired by the response code of a request (usually a 401 or 403 if the access token is expired) and its error message.

There are pros and cons for each of these approaches.

A is often more accurate but does leave room for the occasional missed expiry. Let me explain.

Say your access token expires in 3,600,000 milliseconds (one hour) and you make your request 3,599,999 milliseconds after your received your token. Your calculations will say that your access token is still valid, but the time between your calculations and the server handling your request is likely to be more than 1 millisecond (depending on your internet speed). The server will then see your token as expired and send you an error to prove its point.

Now some of you smartasses will point out that you can just factor that into your calculations. For example, the token is expired if it's been expires_in - 1 since the token was received. But you can never tell exactly how much buffer time you'll need. You could be waiting seconds for the server to handle your request.

In practice, this never happens, but it's likely enough to warrant a good deal of lost sleep.

That brings us to solution número dos, lovingly named Fuck Maths

This approach boils down to the token being expired if the server says so, or in more common speech: I'm the server's bitch.

Every time you make a request and received a response, you check if the response is an error (i.e. the status code isn't 2xx) and if it is you check if the error says that your access token is expired.

If it is you can refresh your token and resend the request.

If it isn't you can throw an error because no one has time for proper error handling.

Okay okay, but give me the code

I'll be using Axios in these examples, if you aren't it shouldn't be too hard to figure out what's going on.

Solution A

Let's start with a simple JavaScript file that sends a request:

const axios = require("axios");

async function sendRequest(accessToken) {
  const response = await axios.get("https://myapi.com/gib_user_data", {
    headers: {
      Authorization: `Bearer ${accessToken}`
    }
  });
}

Remember in A we used the expires_in field and the timestamp of when we received the token? Let's add that now:

const axios = require("axios");

async function sendRequest(accessToken, expiresIn, receivedAt) {
  // calculation time!

  const response = await axios.get("https://myapi.com/gib_user_data", {
    headers: {
      Authorization: `Bearer ${accessToken}`
    }
  });
}

Now, what maths do we need to do? Well, the token is expired if the current timestamp is greater than or equal to receivedAt + expiresIn.

const axios = require("axios");

async function sendRequest(accessToken, expiresIn, receivedAt) {
  const expired = Date.now() >= (receivedAt + expiresIn);

  if (expired) {
    // what do we do here?
  } else {
    const response = await axios.get("https://myapi.com/gib_user_data", {
      headers: {
        Authorization: `Bearer ${accessToken}`
      }
    });
  }
}

If the token is expired what do we do? We refresh it! We'll need the refresh token in our function arguments:

async function sendRequest(accessToken, refreshToken, expiresIn, receivedAt)

I'll leave you to write your own refreshing function since refreshing a token often depends on the platform you're using (Discord, Spotify, Google, etc...). We should get a new accessToken, expiresIn, and receivedAt from our fetchNewAccessToken function.

Our function should also take the refreshToken as an argument. Here's the code for that:

const axios = require("axios");

async function sendRequest(accessToken, expiresIn, receivedAt) {
  const expired = Date.now() >= (receivedAt + expiresIn);

  if (expired) {
    const { accessToken, expiresIn, receivedAt } = await getNewAccessToken(refreshToken);
  } else {
    const response = await axios.get("https://myapi.com/gib_user_data", {
      headers: {
        Authorization: `Bearer ${accessToken}`
      }
    });
  }
}

Once we have a new access token we would probably want to continue sending the request. We should also get rid of that else statement and redefine our accessToken, expiresIn, and receivedAt variables:

const axios = require("axios");

async function sendRequest(accessToken, expiresIn, receivedAt) {
  const expired = Date.now() >= (receivedAt + expiresIn);

  if (expired) {
    const data = await getNewAccessToken(refreshToken);

    accessToken = data.accessToken;
    expiresIn = data.expiresIn;
    receivedAt = data.receivedAt;
  }

  const response = await axios.get("https://myapi.com/gib_user_data", {
    headers: {
      Authorization: `Bearer ${accessToken}`
    }
  });
}

That is the essence of A. You should never have to manually refresh your access token again.

Solution B

B is a little bit more complex, but at least you'll be able to sleep at night.

Lets start with that same function. We'll only need the accessToken and refreshToken arguments this time:

const axios = require("axios");

async function sendRequest(accessToken, refreshToken) {
  const response = await axios.get("https://myapi.com/gib_user_data", {
    headers: {
      Authorization: `Bearer ${accessToken}`
    }
  });
}

If the request fails because our token is expired, we want to refresh our token and resend the request. Let us start with detecting a "token expired" response:

const axios = require("axios");

async function sendRequest(accessToken, refreshToken) {
  const response = await axios.get("https://myapi.com/gib_user_data", {
    headers: {
      Authorization: `Bearer ${accessToken}`
    }
  });

  if (response.status === 401 || response.status === 403) {
    // refresh the token
  }
}

There is one problem with this code though. Not all 401/403 responses are because our token is expired. Maybe we're trying to access content we aren't allowed to. We definitely need to change how we detect the error.

An (arguably) better way would be to check the error message supplied by the server. Now lots of people will be outraged by my insolence. And they are right. If the server changes its error message our entire program will break. Luckily, the likelihood of that change actually happening is very low.

Let's go ahead and deal with breaking changes later:

const axios = require("axios");

async function sendRequest(accessToken, refreshToken) {
  const response = await axios.get("https://myapi.com/gib_user_data", {
    headers: {
      Authorization: `Bearer ${accessToken}`
    }
  });

  if (response.data.error.message === "Access token is expired") {
    // refresh the token
  }
}

Now the Access token is expired string is entirely dependent on the platform you're using. Make sure to check what message your platform's server provides.

We can now refresh our token and resend the request.

const axios = require("axios");

async function sendRequest(accessToken, refreshToken) {
  const response = await axios.get("https://myapi.com/gib_user_data", {
    headers: {
      Authorization: `Bearer ${accessToken}`
    }
  });

  if (response.data.error.message === "Access token is expired") {
    const { accessToken, refreshToken } = await fetchNewAccessToken(refreshToken);

    sendRequest(accessToken, refreshToken);
  } else {
    // use the data you fetched
  }
}

Some warning flags might pop up when you see the recursion we're using. You're right, we probably should prevent infinite recursion by making sure we can only retry a certain amount of times, but that's beyond the scope of this post.

Now you can hopefully sleep at night.

Spotify OAuth Refresher

If you're using Spotify access and refresh tokens there is a much simpler solution for you. Just use the spotify-oauth-refresher package.

Here is an example from the project README:

const Updater = require("spotify-oauth-refresher");
const api = new Updater({ clientId: "xxxxx", clientSecret: "xxxxx" });

api.setAccessToken("xxxxx");
api.setRefreshToken("xxxxx");

// You will never need to manually refresh your access token again!
const me = await api.request({
  url: "https://api.spotify.com/v1/me",
  method: "get",
  authType: "bearer",
});

console.log(me.id);

The project uses the second solution covered in this post.