Skip to content

Commit

Permalink
Add better error handling, specification, & docs
Browse files Browse the repository at this point in the history
The trifecta
  • Loading branch information
Timothy-Gonzalez committed Oct 23, 2024
1 parent 084db0c commit d5e661c
Show file tree
Hide file tree
Showing 13 changed files with 266 additions and 254 deletions.
38 changes: 38 additions & 0 deletions DOCS_HEADER.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
HackIllinois' backend API

## Authentication

[Attendee Authentication](https://adonix.hackillinois.org/auth/login/github?device=dev)

[Staff Authentication](https://adonix.hackillinois.org/auth/login/google?device=dev)

You can get a JWT to test with using the two links above - you'll have to do it programmatically in the apps, though.

Authentication is required for most endpoints, and most endpoints have a role requirement as well (staff-only, for example).
You can see these requirements on each endpoint's description.

## Errors
Errors are formatted like:
```json
{
"error": "NotFound",
"message": "Couldn't find that",
}
```
Where `error` is a error type that can be compared in code, and `message` is a user-facing message for that error.

This way, handling errors is straight-forward.

## Shared Errors
These error types are **not listed** under endpoints specifically, since each endpoint shares them.

Authentication:
- `NoToken` - you haven't specified a token on an endpoint that requires one
- `InvalidToken` - the token specified is invalid, try re-logging
- `TokenExpired` - the token has expired, you'll need to get a new one. To prevent this, use `GET /auth/token/refresh/`.
- `Forbidden` - you don't have the role required to use this endpoint

Specification has one key type as well - `BadRequest`.
If you ever get this error code, you're sending a request in the wrong format.
This error will also include information about what information is wrong,
but you should always consult the docs if you get this error to verify.
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ app.use("/version/", versionRouter);
app.use("/user/", database, userRouter);

// Docs
app.use("/docs/json", (_req, res) => res.json(getOpenAPISpec()));
app.use("/docs/json", async (_req, res) => res.json(await getOpenAPISpec()));
app.use("/docs", swaggerUi.serveFiles(undefined, SWAGGER_UI_OPTIONS), swaggerUi.setup(undefined, SWAGGER_UI_OPTIONS));

// Ensure that API is running
Expand Down
121 changes: 53 additions & 68 deletions src/common/auth.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import ms from "ms";
import jsonwebtoken, { SignOptions } from "jsonwebtoken";
import jsonwebtoken, { SignOptions, TokenExpiredError } from "jsonwebtoken";
import { RequestHandler } from "express-serve-static-core";
import passport, { AuthenticateOptions, Profile } from "passport";

Expand All @@ -10,11 +10,45 @@ import { Role, JwtPayload, Provider, ProfileData, RoleOperation } from "../servi
import Models from "./models";
import { AuthInfo } from "../services/auth/auth-schemas";
import { UpdateQuery } from "mongoose";
import { IncomingMessage } from "http";
import StatusCode from "status-code-enum";
import { APIError } from "./schemas";

type AuthenticateFunction = (strategies: string | string[], options: AuthenticateOptions) => RequestHandler;
type VerifyCallback = (err: Error | null, user?: Profile | false, info?: object) => void;
type VerifyFunction = (accessToken: string, refreshToken: string, profile: Profile, done: VerifyCallback) => void;

export enum AuthenticationErrorType {
TOKEN_EXPIRED,
NO_TOKEN,
TOKEN_INVALID,
}

const TYPE_TO_DETAILS = {
[AuthenticationErrorType.TOKEN_EXPIRED]: {
error: "TokenExpired",
message: "Your session has expired, please log in again",
status: StatusCode.ClientErrorForbidden,
},
[AuthenticationErrorType.NO_TOKEN]: {
error: "NoToken",
message: "A authorization token must be sent for this request",
status: StatusCode.ClientErrorUnauthorized,
},
[AuthenticationErrorType.TOKEN_INVALID]: {
error: "TokenInvalid",
message: "Your session is invalid, please log in again",
status: StatusCode.ClientErrorUnauthorized,
},
};

export class AuthenticationError extends APIError<string, string> {
constructor(type: AuthenticationErrorType) {
const { error, message, status } = TYPE_TO_DETAILS[type];
super(error, message, status);
}
}

/**
* Perform authentication step. Use this information to redirect to provider, perform auth, and then redirect user back to main website if successful or unsuccessful.
* In the case of a failure, throw an error.
Expand Down Expand Up @@ -140,13 +174,7 @@ export function generateJwtToken(payload?: JwtPayload, shouldNotExpire?: boolean
*/
export function decodeJwtToken(token?: string): JwtPayload {
if (!token) {
throw new Error("NoToken");
}

// Ensure that we have a secret to parse token
const secret: string | undefined = Config.JWT_SECRET;
if (!secret) {
throw new Error("NoSecret");
throw new AuthenticationError(AuthenticationErrorType.NO_TOKEN);
}

// Remove Bearer if included
Expand All @@ -155,16 +183,29 @@ export function decodeJwtToken(token?: string): JwtPayload {
}

// Verify already ensures that the token isn't expired. If it is, it returns an error
return jsonwebtoken.verify(token, secret) as JwtPayload;
try {
return jsonwebtoken.verify(token, Config.JWT_SECRET) as JwtPayload;
} catch (e) {
// Handle errors by casting them to our type
if (e instanceof TokenExpiredError) {
throw new AuthenticationError(AuthenticationErrorType.TOKEN_EXPIRED);
} else {
throw new AuthenticationError(AuthenticationErrorType.TOKEN_INVALID);
}
}
}

/**
* Gets the authenticated user from a request, errors if fails
* @param req The request
* @returns User payload
*/
export function getAuthenticatedUser(req: IncomingMessage): JwtPayload {
return decodeJwtToken(req.headers.authorization);
export function getAuthenticatedUser(req: IncomingMessage & { authorizationPayload?: JwtPayload }): JwtPayload {
// Basic caching if we request the parsed jwt on the same request multiple times
if (!req.authorizationPayload) {
req.authorizationPayload = decodeJwtToken(req.headers.authorization);
}
return req.authorizationPayload;
}

/**
Expand All @@ -174,7 +215,7 @@ export function getAuthenticatedUser(req: IncomingMessage): JwtPayload {
*/
export function tryGetAuthenticatedUser(req: IncomingMessage): JwtPayload | null {
try {
return decodeJwtToken(req.headers.authorization);
return getAuthenticatedUser(req);
} catch {
return null;
}
Expand Down Expand Up @@ -292,62 +333,6 @@ export async function updateRoles(userId: string, role: Role, operation: RoleOpe
}
}

/**
* Catch-all function to check if a user should have permissions to perform operations on attendees
* @param payload Payload of user performing the actual request
* @returns True if the user is an ADMIN or a STAFF, else false
*/
export function hasElevatedPerms(payload: JwtPayload): boolean {
return hasStaffPerms(payload) || hasAdminPerms(payload);
}

/**
* Check if a user has permissions to perform staff operations
* @param payload Payload of user performing the actual request
* @returns True if the user is a STAFF, else false
*/

export function hasStaffPerms(payload?: JwtPayload): boolean {
if (!payload) {
return false;
}

return payload.roles.includes(Role.STAFF);
}

/**
* Check if a user has permissions to perform admin operations
* @param payload Payload of user performing the actual request
* @returns True if the user is an ADMIN, else false
*/
export function hasAdminPerms(payload?: JwtPayload): boolean {
if (!payload) {
return false;
}

return payload.roles.includes(Role.ADMIN);
}

/**
* Check if a user has PRO permissions
* @param payload Payload of user performing the actual request
* @returns True if the user has PRO, else false
*/
export function isPro(payload?: JwtPayload): boolean {
if (!payload) {
return false;
}

return payload.roles.includes(Role.PRO);
}

export function isAttendee(payload?: JwtPayload): boolean {
if (!payload) {
return false;
}

return payload.roles.includes(Role.ATTENDEE);
}
/**
* Get all id of users that have a particular role within the database.
* @param role role that we want to filter for
Expand Down
26 changes: 19 additions & 7 deletions src/common/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { ExampleObject, InfoObject, OpenAPIObject, SecurityRequirementObjec
import Config from "./config";
import { ResponsesObject, Specification } from "../middleware/specification";
import { SwaggerUiOptions } from "swagger-ui-express";
import { readFileSync } from "fs";

let openAPISpec: OpenAPIObject | undefined = undefined;
export const Registry = new OpenAPIRegistry();
Expand All @@ -15,6 +16,20 @@ export const SWAGGER_UI_OPTIONS: SwaggerUiOptions = {
swaggerOptions: {
persistAuthorization: true,
},
customCss: `
code {
padding: 2px 4px !important;
}
pre > code {
padding: 5px 7px !important;
}
html {
line-height: 1.15;
}
li {
margin: 10px 0;
}
`,
};

// Basic metadata
Expand All @@ -23,10 +38,6 @@ const openapi = "3.1.0";
const info: InfoObject = {
title: "adonix",
version: "1.0.0",
description:
"HackIllinois' backend API\n\n" +
`[Attendee Authentication](${Config.ROOT_URL}/auth/login/github?device=dev)\n\n` +
`[Staff Authentication](${Config.ROOT_URL}/auth/login/google?device=dev)`,
};

const servers: ServerObject[] = [
Expand All @@ -43,8 +54,9 @@ const authentication = Registry.registerComponent("securitySchemes", "Authentica
bearerFormat: "jwt",
});

function generateOpenAPISpec(): OpenAPIObject {
async function generateOpenAPISpec(): Promise<OpenAPIObject> {
const generator = new OpenApiGeneratorV31(Registry.definitions);
info.description = (await readFileSync("DOCS_HEADER.md")).toString();
const document = generator.generateDocument({
info,
openapi,
Expand Down Expand Up @@ -78,9 +90,9 @@ function generateOpenAPISpec(): OpenAPIObject {
return document;
}

export function getOpenAPISpec(): OpenAPIObject {
export async function getOpenAPISpec(): Promise<OpenAPIObject> {
if (!openAPISpec) {
openAPISpec = generateOpenAPISpec();
openAPISpec = await generateOpenAPISpec();
}

return openAPISpec;
Expand Down
70 changes: 43 additions & 27 deletions src/common/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,29 +11,29 @@ export const UserIdSchema = z.string().openapi("UserId", {

export const EventIdSchema = z.string().openapi("EventId", { example: "event1" });

/**
* Creates a error object and schema zod given a error type and message
* @param params the error type and message
* @returns an array of the error object and schema - [ErrorObject, ZodSchema]
* @example
* const [SomeError, SomeErrorSchema] = CreateErrorAndSchema({
* error: "SomeError",
* message: "Some detailed description of some error"
* })
*/
export function CreateErrorAndSchema<TError extends string, TMessage extends string>(params: {
export class APIError<TError, TMessage> {
constructor(
public error: TError,
public message: TMessage,
public status?: number,
public context?: unknown,
) {}
}

interface ErrorParams<TError, TMessage> {
error: TError;
message: TMessage;
}): [
{ readonly error: TError; readonly message: TMessage },
z.ZodObject<{
error: z.ZodLiteral<TError>;
message: z.ZodLiteral<TMessage>;
}>,
] {
const { error, message } = params;
}

export function CreateErrorSchema<TError extends string, TMessage extends string>(
params: ErrorParams<TError, TMessage>,
): z.ZodObject<{
error: z.ZodLiteral<TError>;
message: z.ZodLiteral<TMessage>;
}> {
// Zod schema definition, keeping the literal types for error and message
const schema = z
const { error, message } = params;
return z
.object({
error: z.literal(error),
message: z.literal(message),
Expand All @@ -46,12 +46,28 @@ export function CreateErrorAndSchema<TError extends string, TMessage extends str
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
});
}

// Error object, again preserving the literal types
const errorObj = {
error,
message,
} as const; // Ensure the object literal types are retained

return [errorObj, schema] as const; // Return the tuple with literal types preserved
/**
* Creates a error object and schema zod given a error type and message
* @param params the error type and message
* @returns an array of the error object and schema - [ErrorObject, ZodSchema]
* @example
* const [SomeError, SomeErrorSchema] = CreateErrorAndSchema({
* error: "SomeError",
* message: "Some detailed description of some error"
* })
*/
export function CreateErrorAndSchema<TError extends string, TMessage extends string>(
params: ErrorParams<TError, TMessage>,
): [
APIError<TError, TMessage>,
z.ZodObject<{
error: z.ZodLiteral<TError>;
message: z.ZodLiteral<TMessage>;
}>,
] {
const { error, message } = params;
// Return the tuple with literal types preserved
return [new APIError(error, message), CreateErrorSchema(params)] as const;
}
Loading

0 comments on commit d5e661c

Please sign in to comment.