Skip to content

Commit

Permalink
refactor: add some strong typing in authorization layer code
Browse files Browse the repository at this point in the history
  • Loading branch information
craigzour committed Aug 23, 2024
1 parent 99255ec commit 18540bf
Show file tree
Hide file tree
Showing 6 changed files with 143 additions and 66 deletions.
4 changes: 0 additions & 4 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ function loadOptionalEnvVar(envVarName: string): string | undefined {
}

function loadRequiredEnvVar(envVarName: string): string {
if (process.env.NODE_ENV === "test") {
return "TEST_IN_PROGRESS";
}

const envVar = loadOptionalEnvVar(envVarName);

if (envVar === undefined) {
Expand Down
59 changes: 38 additions & 21 deletions src/lib/idp/introspectToken.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,36 @@
import { SignJWT } from "jose";
import axios from "axios";
import axios, { type AxiosError } from "axios";
// biome-ignore lint/correctness/noNodejsModules: we need the node crypto module
import crypto from "node:crypto";
import { createPrivateKey } from "node:crypto";
import { ZITADEL_APPLICATION_KEY, ZITADEL_DOMAIN } from "@src/config";

export async function introspectToken(token: string) {
const alg = "RS256";
const privateKey = crypto.createPrivateKey({
key: JSON.parse(ZITADEL_APPLICATION_KEY).key,
});
const clientId = JSON.parse(ZITADEL_APPLICATION_KEY).clientId;
const kid = JSON.parse(ZITADEL_APPLICATION_KEY).keyId;
type IntrospectionResult = {
username: string;
exp: number;
};

const algorithm = "RS256";
const keyId = JSON.parse(ZITADEL_APPLICATION_KEY).keyId as string;
const clientId = JSON.parse(ZITADEL_APPLICATION_KEY).clientId as string;
const privateKey = createPrivateKey({
key: JSON.parse(ZITADEL_APPLICATION_KEY).key,
});
const introspectionEndpoint = `${ZITADEL_DOMAIN}/oauth/v2/introspect`;

export async function introspectToken(
token: string,
): Promise<IntrospectionResult | undefined> {
const jwt = await new SignJWT()
.setProtectedHeader({ alg, kid })
.setProtectedHeader({ alg: algorithm, kid: keyId })
.setIssuedAt()
.setIssuer(clientId)
.setSubject(clientId)
.setAudience(ZITADEL_DOMAIN)
.setExpirationTime("1 minute") // long enough for the introspection to happen
.sign(privateKey);

const introspectionEndpoint = `${ZITADEL_DOMAIN}/oauth/v2/introspect`;

const tokenData = await axios
.post(
try {
const response = await axios.post(
introspectionEndpoint,
new URLSearchParams({
client_assertion_type:
Expand All @@ -37,11 +43,22 @@ export async function introspectToken(token: string) {
"Content-Type": "application/x-www-form-urlencoded",
},
},
)
.then((res) => res.data)
.catch((err) => {
console.error(err.response.data);
return null;
});
return tokenData;
);

const introspectionResponse = response.data as Record<string, unknown>;

const isTokenActive = introspectionResponse.active as boolean;

if (!isTokenActive) {
return undefined;
}

return {
username: introspectionResponse.username as string,
exp: introspectionResponse.exp as number,
};
} catch (error) {
console.error((error as AxiosError).response?.data);
return undefined;
}
}
16 changes: 8 additions & 8 deletions src/middleware/authentication/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,25 +6,25 @@ export async function authenticationMiddleware(
response: Response,
next: NextFunction,
) {
const authHeader = request.headers?.authorization;
const token = authHeader?.split(" ")[1];
const accessToken = request.headers.authorization?.split(" ")[1];

if (!token) {
if (!accessToken) {
return response.sendStatus(401);
}

const tokenData = await introspectToken(token);
const introspectionResult = await introspectToken(accessToken);

if (!tokenData || typeof tokenData !== "object") {
if (!introspectionResult) {
return response.sendStatus(403);
}

const { username, exp } = tokenData;
if (request.params.formId !== username) {
const formId = request.params.formId;

if (introspectionResult.username !== formId) {
return response.sendStatus(403);
}

if (!exp || exp < Date.now() / 1000) {
if (introspectionResult.exp < Date.now() / 1000) {
return response.status(401).json({ message: "Token expired" });
}

Expand Down
112 changes: 79 additions & 33 deletions test/middleware/authentication/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -1,65 +1,111 @@
import { vi, describe, it, expect, beforeEach } from "vitest";
import type { NextFunction, Request, Response } from "express";
import { authenticationMiddleware } from "@middleware/authentication/middleware";
// biome-ignore lint/style/noNamespaceImport: To be able to use the Vitest `spyOn` function we need to import all
import * as introspectToken from "@lib/idp/introspectToken";
import { introspectToken } from "@lib/idp/introspectToken";

describe("Authorization middleware", () => {
vi.mock("@lib/idp/introspectToken");
const introspectTokenMock = vi.mocked(introspectToken);

function buildMockResponse() {
const res = {} as Response;

res.sendStatus = vi.fn();
res.json = vi.fn().mockReturnValue(res);
res.status = vi.fn().mockReturnValue(res);

return res;
}

describe("authenticationMiddleware should", () => {
let mockRequest: Partial<Request>;
let mockResponse: Partial<Response>;
const nextFunction: NextFunction = vi.fn();
let mockResponse: Response;
const mockNext: NextFunction = vi.fn();

beforeEach(() => {
mockRequest = {};
mockResponse = {
sendStatus: vi.fn(),
vi.resetAllMocks();

mockRequest = {
headers: {
authorization: "Bearer abc",
},
params: {
formId: "clzsn6tao000611j50dexeob0",
},
};

mockResponse = buildMockResponse();
});

it("without headers", async () => {
it("reject request with there is no authorization header", async () => {
mockRequest.headers = {};

await authenticationMiddleware(
mockRequest as Request,
mockResponse as Response,
nextFunction,
mockResponse,
mockNext,
);

expect(mockNext).not.toHaveBeenCalled();
expect(mockResponse.sendStatus).toHaveBeenCalledWith(401);
});

it("with headers but no bearer token", async () => {
mockRequest = {
headers: {},
};
it("reject request when the authorization header value is invalid", async () => {
introspectTokenMock.mockResolvedValueOnce(undefined);

await authenticationMiddleware(
mockRequest as Request,
mockResponse as Response,
nextFunction,
mockResponse,
mockNext,
);

expect(mockResponse.sendStatus).toHaveBeenCalledWith(401);
expect(mockNext).not.toHaveBeenCalled();
expect(mockResponse.sendStatus).toHaveBeenCalledWith(403);
});

it("with headers and an invalid bearer token", async () => {
mockRequest = {
headers: {
authorization: "Bearer abc",
},
params: {
formId: "def",
},
};

const introspectTokenSpy = vi.spyOn(introspectToken, "introspectToken");
introspectTokenSpy.mockReturnValueOnce(Promise.resolve(undefined));
it("reject request when the form identifier passed in the URL is different than the one associated to the token", async () => {
introspectTokenMock.mockResolvedValueOnce({ username: "invalid", exp: 0 });

await authenticationMiddleware(
mockRequest as Request,
mockResponse as Response,
nextFunction,
mockResponse,
mockNext,
);

expect(introspectTokenSpy).toHaveBeenCalledTimes(1);
expect(mockNext).not.toHaveBeenCalled();
expect(mockResponse.sendStatus).toHaveBeenCalledWith(403);
});

it("reject request when the token is expired", async () => {
introspectTokenMock.mockResolvedValueOnce({
username: "clzsn6tao000611j50dexeob0",
exp: Date.now() / 1000 - 100000,
});

await authenticationMiddleware(
mockRequest as Request,
mockResponse,
mockNext,
);

expect(mockNext).not.toHaveBeenCalled();
expect(mockResponse.status).toHaveBeenCalledWith(401);
expect(mockResponse.json).toHaveBeenCalledWith({
message: "Token expired",
});
});

it("accept request when the token is valid, not expired and associated to the form identifier passed in the URL", async () => {
introspectTokenMock.mockResolvedValueOnce({
username: "clzsn6tao000611j50dexeob0",
exp: Date.now() / 1000 + 100000,
});

await authenticationMiddleware(
mockRequest as Request,
mockResponse,
mockNext,
);

expect(mockNext).toHaveBeenCalled();
});
});
17 changes: 17 additions & 0 deletions vitest-setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { vi } from "vitest";

vi.mock("./src/config", async () => ({
AWS_REGION: "ca-central-1",
SERVER_PORT: 3001,
LOCALSTACK_ENDPOINT: undefined,
ZITADEL_APPLICATION_KEY: JSON.stringify({
keyId: "test",
clientId: "test",
key: "test",
}),
ZITADEL_DOMAIN: "test",
}));

vi.mock("node:crypto", () => ({
createPrivateKey: vi.fn().mockReturnThis(),
}));
1 change: 1 addition & 0 deletions vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ export default defineConfig({
plugins: [tsconfigPaths()],
test: {
include: ["test/**"],
setupFiles: "vitest-setup.ts",
},
});

0 comments on commit 18540bf

Please sign in to comment.