Skip to content

Commit

Permalink
Create JWT auth middleware
Browse files Browse the repository at this point in the history
In order to verify a JWT and authorize access based on user roles. This
assumes user roles will be stateless and included in the `aud` claim of
the JWT
  • Loading branch information
Pauline Vos committed Nov 15, 2024
1 parent 6620b04 commit 8aaa335
Show file tree
Hide file tree
Showing 4 changed files with 141 additions and 4 deletions.
117 changes: 117 additions & 0 deletions src/auth/jwt.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import {
ErrorType,
BadgeHubApiError,
NotAuthorizedError,
NotAuthenticatedError,
} from "../error";
import { isAdmin, isContributor, UserRole } from "./role";
import { JWTPayload, jwtVerify } from "jose";
import { NextFunction, Request, Response } from "express";

// Middleware for routes that require a contributor role
const ensureAdminRouteMiddleware = async (
req: Request,
res: Response,
next: NextFunction
) => {
await handleMiddlewareCheck(req, res, ensureAdmin);

next();
};

// Middleware for routes that require a contributor role
const ensureContributorRouteMiddleware = (
req: Request,
res: Response,
next: NextFunction
) => {
handleMiddlewareCheck(req, res, ensureContributor).then(next);
};

const handleMiddlewareCheck = async (
req: Request,
res: Response,
assertion: Function
) => {
try {
const token = await decodeJwtFromRequest(req);
const roles: UserRole[] = rolesFromJwtPayload(token);

console.debug("Roles:", roles);
if (roles.length == 0) {
throw NotAuthorizedError();
}
assertion(roles);
} catch (e: any) {
handleError(e, res);
}
};

const handleError = (err: Error, res: Response) => {
if (err.name == ErrorType.NotAuthenticated) {
res.status(403).json({ error: err.message });
}
if (err.name == ErrorType.NotAuthorized) {
res.status(401).json({ error: err.message });
}

console.debug(err);
res.status(500).json({ error: "Internal server error" });

Check failure on line 59 in src/auth/jwt.ts

View workflow job for this annotation

GitHub Actions / build

Unhandled error

Error: Cannot set headers after they are sent to the client ❯ ServerResponse.setHeader node:_http_outgoing:699:11 ❯ ServerResponse.header node_modules/express/lib/response.js:794:10 ❯ ServerResponse.send node_modules/express/lib/response.js:174:12 ❯ ServerResponse.json node_modules/express/lib/response.js:278:15 ❯ handleError src/auth/jwt.ts:59:19 ❯ handleMiddlewareCheck src/auth/jwt.ts:46:5 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_HTTP_HEADERS_SENT' } This error originated in "src/controllers/public-rest.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running. The latest test that might've caused the error is "GET /vitest". It might mean one of the following: - The error was thrown, while Vitest was running this test. - If the error occurred after the test had been completed, this was the last documented test before it was thrown.
};

const ensureAdmin = (roles: UserRole[]) => {
roles.forEach((role) => {
if (!isAdmin(role)) {
throw NotAuthorizedError();
}
});
};

const ensureContributor = (roles: UserRole[]) => {
roles.forEach((role) => {
if (!isContributor(role)) {
throw NotAuthorizedError();
}
});
};

const rolesFromJwtPayload = (payload: JWTPayload): UserRole[] => {
const aud = payload.aud;

if (!aud) {
return [];
}

if (typeof aud == "string") {
return [UserRole[aud as UserRole]];
}

return (aud as string[]).map((i: string) => {
return UserRole[i as UserRole];
});
};

const decodeJwtFromRequest = async (req: Request): Promise<JWTPayload> => {
const token = req.headers.authorization;

if (!token) {
throw NotAuthenticatedError("missing API token");
}

try {
const result = await jwtVerify(
token,
new TextEncoder().encode(process.env.JWT_SIGNING_KEY)
);
const payload = result.payload;
if (!Object.hasOwn(payload, "aud")) {
throw NotAuthenticatedError("API token invalid");
}

return payload;
} catch (err) {
throw NotAuthenticatedError("API token invalid");
}
};

export { ensureAdminRouteMiddleware, ensureContributorRouteMiddleware };
15 changes: 15 additions & 0 deletions src/auth/role.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
enum UserRole {
Admin = "Admin",
Contributor = "Contributor",
ReadOnlyUser = "ReadOnlyUser",
}

const isAdmin = (role: UserRole): boolean => {
return role == UserRole.Admin;
};

const isContributor = (role: UserRole): boolean => {
return role == UserRole.Contributor || isAdmin(role);
};

export { UserRole, isAdmin, isContributor };
7 changes: 6 additions & 1 deletion src/controllers/public-rest.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import pg, { Pool } from "pg";
import { Get, Path, Query, Res, Route, Tags } from "tsoa";
import { Get, Middlewares, Path, Query, Res, Route, Tags } from "tsoa";
import type { TsoaResponse } from "tsoa";
import { getPool } from "../db/connectionPool";
import { App, AppDetails, Category, Device } from "../db/models";
import {
ensureAdminRouteMiddleware,
ensureContributorRouteMiddleware,
} from "../auth/jwt";

/**
* The code is annotated so that OpenAPI documentation can be generated with tsoa
Expand All @@ -28,6 +32,7 @@ export class PublicRestController {
* Get list of devices (badges)
*/
@Get("/devices")
@Middlewares(ensureContributorRouteMiddleware)
public async getDevices(): Promise<Device[]> {
const result = await this.pool.query<Device>(
`select name, slug from badges`
Expand Down
6 changes: 3 additions & 3 deletions src/error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ enum ErrorType {
NotAuthenticated = "ERROR_NOT_AUTHENTICATED",
}
class BadgeHubApiError extends Error {
name: ErrorType;
message: string;
cause: any;
override name: ErrorType;
override message: string;
override cause: any;

constructor({
name,
Expand Down

0 comments on commit 8aaa335

Please sign in to comment.