Back to main page Traveling Coderman

Node.js Microservice Architecture JWT authentication with JWKS in Express.js

505 views

We discuss how to implement JWT authentication with JWKS, how to disable auth locally and how to suppress logging tokens in production logs.

There are multiple ways to approach authentication in an application. A currently popular way is the usage of JSON web token (JWT).

JWT are created and signed by a central authority like a dedicated authentication service. A user receives a JWT from that service, the application receives the signing key from the service. The user attaches the JWT on every request to our application. Our application validates the signature against the signing key.

For authorization, the JWT can contain some structured information on user and permission. We will cover authorization in a later post.

Authenticating endpoints 🔗

In our application, the access to some endpoints should be authenticated, to others not. Unauthenticated endpoints include for example the endpoint GET /openapi.json. If these endpoint would require authentication, tools like Swagger UI or ReDoc could not access it. Additionally, endpoints like healthchecks, readiness probes and metrics should rather be unauthenticated.

We write a middleware authenticate that is registered on the relevant routes. It is registered before the input validation such that a 401 (unauthenticated) or 403 (forbidden) are prioritized.

import express from "express";
import { authenticate } from "../../pre-request-handlers/authenticate";
import { validateInputs } from "../../pre-request-handlers/openapi";
import { getTodoController } from "./get-todo.controller";

export const todoRoute = express.Router({ mergeParams: true });

todoRoute.use(authenticate);
todoRoute.use(validateInputs);

todoRoute.get("/:id", getTodoController);
// ...

Configuration 🔗

Before we implement the authenticate middleware, we extend the configuration to contain authentication information.

export interface Config {
environment: Environment;
logLevel: Level;
authentication: {
enabled: boolean;
jwksUrl: string;
};
}

Locally, authentication should be turned off and the JWKS URL is irrelevant.

export function getLocalConfig(processVariables: ProcessVariables): Config {
return {
environment: "local",
logLevel: processVariables.LOG_LEVEL ?? "debug",
authentication: {
enabled: false,
jwksUrl: "",
},
};
}

On production, authentication is enabled and the JWKS URL is passed as an environment variable.

export function getProductionConfig(
processVariables: ProcessVariables
): Config {
return {
environment: "production",
logLevel: processVariables.LOG_LEVEL ?? "info",
authentication: {
enabled: true,
jwksUrl:
processVariables.JWKS_URL ??
"<JWKS_URL> needs to be set in production environment",
},
};
}

Authentication middleware 🔗

The middleware authenticate resides in the file src/pre-request-handlers/authenticate.ts. It defines a jwksClient that retrieves and caches the signing keys from the external authentication service.

const jwksClient = JwksRsa({
jwksUri: config.authentication.jwksUrl,
});

For JWT and JSON web key set (JWKS), we use the libraries jsonwebtoken and jwks-rsa.

npm install --save jsonwebtoken jwks-rsa
npm install --save-dev @types/jsonwebtoken

The middleware function authenticate consists of four steps executed in sequence.

export async function authenticate(
request: Request,
response: Response,
next: NextFunction
): Promise<unknown> {
try {
// 1. Extract JWT from header and return 401 on failure
// 2. Decode JWT and return 403 on failure
// 3. If auth is enabled, then verify JWT and return 403 on failure
// 4. Make decoded JWT payload accessible to controllers
next();
} catch (error) {
next(error);
}
}

The first step extracts the JWT from the header Authorization and removes the Bearer prefix. If such a header does not exist, then the user is unauthorized.

const encodedToken =
request.headers.authorization?.replace("Bearer ", "") || "";
if (!encodedToken) {
return response.status(401).end();
}

The second step decodes the JWT. It does not verify the signature, hence it only fails if the JWT is wrongly structured. In that case, the user is forbidden to access the resource.

const decodedToken = jwt.decode(encodedToken, { complete: true });
if (!decodedToken) {
return response.status(403).end();
}

The third step verifies the JWT against the public signing key of the authentication service. This step should only be performed if authentication is enabled. If the JWT can't be verified, then the user is forbidden to access the resource.

if (config.authentication.enabled) {
try {
const signingKey = await jwksClient.getSigningKey(decodedToken.header.kid);
jwt.verify(encodedToken, signingKey.getPublicKey(), {
algorithms: ["RS256"],
});
} catch {
return response.status(403).end();
}
}

Why only disabling the verification? The payload of a JWT is also used for authorization. If no JWT is sent locally, then the controllers can't inspect the payload to decide if an admin or regular user accesses the endpoint. That's why we expect a JWT locally but don't check for expiration and validity against the signing key. A set of expired JWTs with invalid signature can be stored in version control and used by engineers.

The fourth step assigns the decoded JWT payload to the object response.locals. This way, the controllers can access it to make authorization decisions.

response.locals.token = decodedToken.payload;

Testing the authentication middleware 🔗

For the tests of controllers, we introduced a helper function server that spins up an HTTP server. For the testing of middleware, we create a helper function mockControllerInputs that allows us to test the middleware in isolation.

import express, { Express, NextFunction, Request, Response } from "express";
import { sendErrorResponse } from "./error-handling/error-handler";
import { logRequest } from "./pre-request-handlers/log-request";
import { validateInputs } from "./pre-request-handlers/openapi";

export function mockControllerInputs(request: Partial<Request> = {}): {
request: Request;
response: Response;
next: NextFunction;
} {
return {
request: {
log: {
error: jest.fn().mockImplementation(),
},
params: {},
...request,
} as unknown as Request,
response: {
locals: {},
status: jest.fn().mockReturnThis(),
sendStatus: jest.fn(),
send: jest.fn(),
end: jest.fn().mockReturnThis(),
} as unknown as Response,
next: jest.fn() as NextFunction,
};
}

The tests of the authentication middleware mock the configuration and the jwks-rsa library.

jest.mock("../configuration/config", () => ({
config: {
authentication: {
enabled: true,
jwksUrl: "https://some-url.com",
},
},
}));

jest.mock("jwks-rsa", () => () => ({
getSigningKey: () =>
Promise.resolve({
getPublicKey: () => `-----BEGIN RSA PUBLIC KEY-----
abc
-----END RSA PUBLIC KEY-----
`
,
}),
}));

We create a stub JWT.

const jwtToken: jwt.Jwt = {
payload: {
id: "42",
username: "adm@company.org",
},
header: {
kid: "kid",
alg: "RS256",
},
signature: "",
};

In the individual tests, we can adjust the return values of decode and verify and define the expectations.

it("returns a 401 if no authorization header is present", async () => {
const { request, response, next } = mockControllerInputs({
headers: {},
});
jest.spyOn(jwt, "decode").mockReturnValue(jwtToken);
jest.spyOn(jwt, "verify").mockReturnValue();
await authenticate(request, response, next);
expect(response.status).toHaveBeenCalledWith(401);
expect(next).not.toHaveBeenCalled();
});

The full code examples on GitHub contains three more tests of the authentication middleware.

Local exploration testing 🔗

Locally, authentication should not create a barrier for engineers. Since authentication verification is turned off locally, also expired and invalid JWT are accepted. Therefore, such a JWT can be stored in a shell script local.sh exporting it to an environment variable TOKEN.

export TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Note: You need to call this script either with . ./local.sh or source ./local.sh for the variable $TOKEN to be accessible in the following commands.

Then, a request can be made against the running server npm start.

http http://localhost:3000/todos Authorization:"Bearer $TOKEN"
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 2
Content-Type: application/json; charset=utf-8
Date: Mon, 07 Mar 2022 20:33:18 GMT
ETag: W/"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w"
Keep-Alive: timeout=5
X-Powered-By: Express

[]

Suppressing token logging 🔗

When expecting the request logging of the server, you might have noticed that the JWT is logged. On the production server, a JWT must not be logged. This is to protect personal identifiable information (PII) as well as to prevent the unauthorized usage of that JWT.

{
"authorization": "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
}

To prevent this, we can add a custom serializer for requests.

import expressPino from "express-pino-logger";
import { config } from "../configuration/config";

export const logRequest = (enabled: boolean) =>
expressPino({
level: config.logLevel,
enabled,
serializers: {
req: (request) => ({
method: request.method,
url: request.url,
}),
},
});

This reduces the log output and does not log the headers.

INFO: request completed
req: {
"method": "GET",
"url": "/todos"
}
res: {
"statusCode": 200,
"headers": {
"x-powered-by": "Express",
"content-type": "application/json; charset=utf-8",
"content-length": "2",
"etag": "W/\"2-l9Fw4VUO7kr8CvBlt4zaMCqXZ0w\""
}
}
responseTime: 8
v: 1

Conclusion 🔗

We configured JWT authentication with a JWKS leading to 401 and 403 responses. Locally, expired and invalid tokens are accepted, simplifying the life for engineers.

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