Back to main page Traveling Coderman

Node.js Microservice Architecture Axios HTTP request authentication with JWT

963 views

We want to implement authentication of outgoing HTTP requests with JWT in a multi-tenancy setup.

Our example todo service operates in a microservice environment with multi-tenancy. A user is using one JWT to authenticate with the whole microservice environment but only for one workspace. An authority issues JWTs for the microservice environment. The JWT contains the workspace ID that all services need to consider to limit the operating scope of the user.

Let's assume a user makes an HTTP request to our service authenticating with a JWT. Then our service could use that JWT to authenticate against other services in the microservice environment.

However.

There are several issues with that approach:

  • It limits the interactions of our service with other services to what the user is permitted to do.
  • It binds us to the expiration time of the JWT. We can't make requests after that.
  • Other services can't trivially distinguish between requests from our service and requests from the user. These services might apply rate-limiting to us because they think our service is the user.

To bypass these issues, we request separate JWTs that authenticate our service as itself. Depending on the nature of our request, we request a different scope for the JWT.

  • For the majority of requests we want to have a JWT restricted to the workspace of the user.
  • For some requests we want to have a JWT that can act across workspaces.
  • For other requests we might want to have a JWT that not only acts within a workspace but also with the permissions of the user.

Requesting authentication 🔗

On deletion of a todo, we send an HTTP request to the notification service to create a notification. The todo is deleted within a workspace and the notification should be created in the same workspace.

The endpoint POST /notify/notifications requires a header Authorization with a JWT limited to a workspace.

We already have a module with a function sendNotification that encapsulates the logic of sending this request without authentication. We could retrieve a JWT here and pass it to Axios in the Authorization header.

// ...

export function sendNotification(
workspaceId: WorkspaceId,
message: string
): Promise<void> {
const Authorization = await getJWT(workspaceId);
return axios.post(
`${config.http.servicesUrl}/notify/notifications`,
{ message },
{
headers: {
Authorization,
},
}
);
}

However, this exposes details about authentication to every module sending HTTP requests. Also, in every of these tests we would always need to mock the function getJWT.

Instead, we only want to pass the desired scope of the JWT. We leave it up to an Axios interceptor to retrieve the JWT and attach the authorization header.

import axios, { AxiosRequestConfig } from "axios";
import { config } from "../../configuration/config";
import { workspaceAccess } from "../../http/service-access.type";
import { WorkspaceId } from "../../workspace-id.type";

export function sendNotification(
workspaceId: WorkspaceId,
message: string
): Promise<void> {
return axios.post(
`${config.http.servicesUrl}/notify/notifications`,
{ message },
{
access: {
type: "workspace",
workspaceId,
},
} as AxiosRequestConfig
);
}

The service access type 🔗

In a module src/http/service-access.type.ts, we define the different options that can be passed as parameter access to an Axios request.

import { WorkspaceId } from "../workspace-id.type";

export type ServiceAccess = AdminAccess | WorkspaceAccess;

export interface AdminAccess {
type: "admin";
}

export type WorkspaceAccess = {
type: "workspace";
workspaceId: WorkspaceId;
};

We allow an object { type: "admin" } to be passed to express full access across workspaces. An object { type: "workspace", workspaceId: "<uuid>" } expresses access to one workspace. Additionally, we could define user access with { type: "user", userId: "<uuid>", workspaceId: "<uuid>" }. We leave this option out for simplicity.

Additionally, in the same module, we create two constructors.

export function workspaceAccess(workspaceId: WorkspaceId): WorkspaceAccess {
return {
type: "workspace",
workspaceId,
};
}

export const adminAccess: AdminAccess = {
type: "admin",
};

The usage of these constructors ensures that a valid service access object ServiceAccess is passed to the Axios interceptor.

axios.post(url, body, {
access: workspaceAccess(workspaceId),
} as AxiosRequestConfig);

axios.post(url, body, {
access: adminAccess,
} as AxiosRequestConfig);

Retrieving a JWT 🔗

We add a module src/http/get-jwt.ts to retrieve a JWT. This logic might vary a lot depending on your way of issuing tokens.

In this setup, there exists a service auth with an endpoint POST /token. From the configuration src/configuration/config.ts, our service receives a client ID clientId and a client secret clientSecret.

// ...

export type Environment =
// The service running in a production cluster available for customers
| "production"
// The service running locally on a development machine
| "local"
// The service running with Jest executing tests
| "test";

export interface Config {
// ...
http: {
// ...
clientId: string;
clientSecret: string;
};
}

export interface ProcessVariables {
// ...
CLIENT_ID?: string;
CLIENT_SECRET?: string;
}

For more information on this, checkout 'Managing configs with TypeScript code in Node.js'

We are using client ID and secret to authenticate with basic authentication against the auth service. This way, the auth service recognizes our service and allows it to issue the JWT.

import axios from "axios";
// ...
import { config } from "../configuration/config";

function toBase64(data: string): string {
return Buffer.from(data).toString("base64");
}

async function retrieveJWT(scope?: string): Promise<string> {
const tokenResponse = await axios.post(
`${config.http.servicesUrl}/auth/token`,
"",
{
params: {
grant_type: "client_credentials",
scope,
},
headers: {
Authorization:
"Basic " +
toBase64(`${config.http.clientId}:${config.http.clientSecret}`),
"content-type": "application/x-www-form-urlencoded;charset=utf-8",
},
}
);
return tokenResponse.data.access_token;
}

With the parameter scope, we decide what permissions the JWT should have. If set to undefined, then the JWT is not limited to a single workspace. If set to workspaceId=<uuid>, then the JWT is limited to the workspace.

Caching the JWT 🔗

We want to avoid asking for a new JWT with every incoming HTTP request. For speed, to avoid load on the token issuer and to avoid a source of HTTP errors.

There is an npm package memoizee that caches the return value of a function given that the input parameters are identical.

npm install --save memoizee
npm install --save-dev @types/memoizee

In the same module src/http/get-jwt.ts, we create a function getJWT. It caches the JWT of the function retrieveJWT for 50 minutes. This time assumes a JWT to become invalid after 60 minutes. You might want to adjust this time depending on the expiration time of JWT in your setup.

export const getJWT = memoize(retrieveJWT, {
promise: true,
maxAge: 50 * 60 * 1000, // 50 minutes
max: 100,
});

We test this function to correctly assemble a JWT request.

import { getJWT } from "./get-jwt";

jest.mock("../configuration/config", () => ({
config: {
http: {
servicesUrl: "https://eu.company.org",
clientId: "todos",
clientSecret: "secret",
},
},
}));
jest.mock("axios");

describe("the retrieval of JWT", () => {
it("should retrieve a JWT with admin access", async () => {
const post = require("axios").post;
post.mockResolvedValue({
data: {
access_token: "my_admin_token",
},
});
await expect(getJWT()).resolves.toEqual("my_admin_token");
expect(post).toHaveBeenCalledWith("https://eu.company.org/auth/token", "", {
headers: {
Authorization: "Basic dG9kb3M6c2VjcmV0",
"content-type": "application/x-www-form-urlencoded;charset=utf-8",
},
params: {
grant_type: "client_credentials",
},
});
});

it("should retrieve a JWT with workspace access", async () => {
const post = require("axios").post;
post.mockResolvedValue({
data: {
access_token: "my_token",
},
});
const scope = "workspaceId=1234-5678";
await expect(getJWT(scope)).resolves.toEqual("my_token");
expect(post).toHaveBeenCalledWith("https://eu.company.org/auth/token", "", {
headers: {
Authorization: "Basic dG9kb3M6c2VjcmV0",
"content-type": "application/x-www-form-urlencoded;charset=utf-8",
},
params: {
grant_type: "client_credentials",
scope,
},
});
});
});

Attaching the Authorization header 🔗

We create an Axios interceptor attachAuthorizationHeader that is called before each outgoing request. We register this interceptor in the function configureHttp that is called in the main function of the program.

import axios from "axios";
import { attachAuthorizationHeader } from "./authorization-header.interceptor";
// ...

export function configureHttp(): void {
// ...
axios.interceptors.request.use(attachAuthorizationHeader);
// ...
}

The interceptor attachAuthorizationHeader resides in the module src/http/authorization-header.interceptor.ts.

If it detects that the passed object access is invalid, then it does nothing. If it detects that the header Authorization is already set, then it also does nothing.

Note: It is crucial that an existing Authorization header is not overridden. Otherwise, the basic authentication for the JWT retrieval would be overridden.

If the object access is valid and the header Authorization is not yet set, then the interceptor retrieves the JWT with the requested scope. Once the retrieval succeeded, the interceptor attaches the header Authorization to the request.

import { AxiosRequestConfig } from "axios";
import { ServiceAccess, WorkspaceAccess } from "./service-access.type";
import { getJWT } from "./get-jwt";

function isValidServiceAccess(access: unknown): access is ServiceAccess {
return typeof access === "object" && !!access && "type" in access;
}

function isWorkspaceAccess(access: ServiceAccess): access is WorkspaceAccess {
return "type" in access && access.type === "workspace";
}

export async function attachAuthorizationHeader(
axiosRequestConfig: AxiosRequestConfig
): Promise<AxiosRequestConfig> {
const access = (axiosRequestConfig as { access: unknown }).access;
if (
!axiosRequestConfig.headers?.Authorization &&
isValidServiceAccess(access)
) {
const jwt = await getJWT(
isWorkspaceAccess(access)
? `workspaceId=${access.workspaceId}`
: undefined
);
if (!axiosRequestConfig.headers) {
axiosRequestConfig.headers = {};
}
axiosRequestConfig.headers["Authorization"] = `Bearer ${jwt}`;
}
return axiosRequestConfig;
}

We can verify the different scenarios with Jest.

import { AxiosRequestConfig } from "axios";
import { attachAuthorizationHeader } from "./authorization-header.interceptor";
import { adminAccess, workspaceAccess } from "./service-access.type";

jest.mock("./get-jwt");

describe("the authorization header interceptor", () => {
it("should attach an admin token if access type is admin", async () => {
const getJWT = require("./get-jwt").getJWT;
getJWT.mockResolvedValue("my_admin_token");
const request = await attachAuthorizationHeader({
access: adminAccess,
} as AxiosRequestConfig);
expect(getJWT).toHaveBeenCalledWith(undefined);
expect(request.headers).toHaveProperty(
"Authorization",
"Bearer my_admin_token"
);
});

it("should attach a workspace token if target access is requested", async () => {
const getJWT = require("./get-jwt").getJWT;
getJWT.mockResolvedValue("my_token");
const request = await attachAuthorizationHeader({
access: workspaceAccess("1234"),
} as AxiosRequestConfig);
expect(getJWT).toHaveBeenCalledWith("workspaceId=1234");
expect(request.headers).toHaveProperty("Authorization", "Bearer my_token");
});

it("should not attach a token if the access type is invalid", async () => {
const getJWT = require("./get-jwt").getJWT;
getJWT.mockResolvedValue("my_token");
const request = await attachAuthorizationHeader({
access: {},
} as AxiosRequestConfig);
expect(getJWT).not.toHaveBeenCalled();
expect(request.headers).toEqual(undefined);
});

it("should fail if a JWT can not be retrieved", async () => {
const getJWT = require("./get-jwt").getJWT;
getJWT.mockRejectedValue("Failure");
await expect(
attachAuthorizationHeader({
access: workspaceAccess("1234"),
} as AxiosRequestConfig)
).rejects.toEqual("Failure");
});
});

Mocking on local environment 🔗

In an earlier post, we setup mockserver to mock the requests to other services locally.

For more information on this, checkout 'Organizing and testing HTTP requests'

In the folder mocks, we create a file auth/token/POST.mock. This file is interpreted as the response to requests to the endpoint POST http://localhost:3001/auth/token.

HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8

{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiY2xpZW50SWQiOiJ0b2RvcyIsImlhdCI6MTUxNjIzOTAyMn0.YD_HJROnQHJIROzVBagK27D862QoNvokSDFxDbA8_Ug"
}

Locally, we always send the returned JWT to the mock server. Hence, it suffices if it decodes to a dummy payload.

{
"sub": "1234567890",
"clientId": "todos",
"iat": 1516239022
}

Conclusion 🔗

We added a request interceptor to Axios to add an Authorization header. This header is populated with a JWT with the specified scope. For each scope, the JWT is cached until 10 minutes before expiration. Locally, we mock the JWT retrieval to return a dummy JWT.

Become a GitHub sponsor to access the code of this post as a GitHub repo.