Refreshing OAuth Access Tokens using Axios Interceptors

HTTP Clients like Axios, HTTPX, etc, allow you to hook into the HTTP request lifecycle so you can add custom logic before sending a request or after receiving a response. These “hooks” or “interceptors” are useful for things like adding logs, propagating distributed tracing IDs etc.

Interceptors can also be used to refresh OAuth Access Tokens. Access Tokens are used to authenticate the client application, and are sent in HTTP requests to the resource server. Access Tokens are typically short-lived for better security, and when they expire, new tokens can be generated by the authorization server.

For this post, we’ll be working with the Microsoft Graph API and using the client credentials flow, but the same logic can be applied for other endpoints as well.

We’ll be talking to two servers:

  1. Resource Server: The endpoints we want to access.
  2. Token Server: The server which gives us Access Tokens to authenticate with the Resource Server.

Let’s start by creating the API client module for Microsoft Graph.

// ./ApiClient.js
import axios from "axios";

const TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"; // replace 'tenant' before using
const RESOURCE_BASE_URL = "https://graph.microsoft.com/v1.0";

const ApiClient = axios.create({
    baseURL: RESOURCE_BASE_URL
});

ApiClient.interceptors.request.use(injectAccessToken);

async function injectAccessToken(config) {
    return {
        ...config
    };
}

export default ApiClient;

We’ve left the injectAccessToken function as a no-op for now. This API client module can be used in other modules to make calls to resource server endpoints. For example, let’s call the get all users endpoint in Microsoft Graph:

// ./index.js
import ApiClient from './ApiClient.js';

const response = await ApiClient.get("/users");
console.log(response.data.value);

Let’s flesh out the injectAccessToken function. We want this function to request Access Token and store it somewhere. If the Access Token has not expired, we use it in all the requests to Resource Server. If it has expired, we fetch new token and update the store. For token storage, we can just create a module-level variable called token as it would be overkill to use a database.

Here’s the high-level flow as code:

// ./ApiClient.js
let token = "";

async function injectAccessToken(config) {
  // Don't use the interceptor for TOKEN_URL
  if (config.url === TOKEN_URL) {
    return config;
  }

  if (hasAccessTokenExpired(token)) {
    token = await acquireAccessToken();
  }

  const headers = config.headers.concat({Authorization: `Bearer ${token}`});

  return {
    ...config,
    headers,
  };
}

Microsoft Graph uses JWT as Access Tokens, so we can inspect the payload to check token expiry:

// ./ApiClient.js
import { jwtDecode } from 'jwt-decode';

function hasAccessTokenExpired(token) {
  if (token === "") {
    return true;
  }

  const payload = jwtDecode(token);
  return Date.now() >= payload.exp * 1000;
}

We can follow the Microsoft Graph docs for making a token request:

// ./ApiClient.js
import qs from 'qs';

const CLIENT_ID = "";
const CLIENT_SECRET = "";

async function acquireAccessToken() {
  const config = {
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
  };
  const body = qs.stringify({
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    grant_type: "client_credentials",
    scope: "https://graph.microsoft.com/.default",
  });

  const response = await axios.post(TOKEN_URL, body, config);
  return response.data.access_token;
}

Putting them all together:

// ./ApiClient.js
import axios from "axios";
import { jwtDecode } from "jwt-decode";
import qs from "qs";

// Store these in environment variables
const TOKEN_URL = "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token"; // replace 'tenant' before using
const RESOURCE_BASE_URL = "https://graph.microsoft.com/v1.0";
const CLIENT_ID = "";
const CLIENT_SECRET = "";

let token = "";

const ApiClient = axios.create({
  baseURL: RESOURCE_BASE_URL,
});

ApiClient.interceptors.request.use(injectAccessToken);

async function injectAccessToken(config) {
  // Don't use the interceptor for TOKEN_URL
  if (config.url === TOKEN_URL) {
    return config;
  }

  if (hasAccessTokenExpired(token)) {
    token = await acquireAccessToken();
  }

  const headers = config.headers.concat({ Authorization: `Bearer ${token}` });

  return {
    ...config,
    headers,
  };
}

function hasAccessTokenExpired(token) {
  if (token === "") {
    return true;
  }

  const payload = jwtDecode(token);
  return Date.now() >= payload.exp * 1000;
}

async function acquireAccessToken() {
  const config = {
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
  };
  const body = qs.stringify({
    client_id: CLIENT_ID,
    client_secret: CLIENT_SECRET,
    grant_type: "client_credentials",
    scope: "https://graph.microsoft.com/.default",
  });

  const response = await axios.post(TOKEN_URL, body, config);
  return response.data.access_token;
}

export default ApiClient;