Skip to content

Commit

Permalink
User JWTs (#61)
Browse files Browse the repository at this point in the history
* add user auth route

* tests

* add secrets to actions

* bump version

* add more test coverage
  • Loading branch information
owens1127 authored Aug 7, 2024
1 parent 3773a16 commit 3db348b
Show file tree
Hide file tree
Showing 20 changed files with 395 additions and 32 deletions.
1 change: 1 addition & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ jobs:
- name: Run Tests
env:
CLIENT_SECRET: "secret-token"
ADMIN_CLIENT_SECRET: "another-secret-token"
JWT_SECRET: "jwt-secret"
POSTGRES_USER: readonly
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_READONLY_PASSWORD }}
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ jobs:
- name: Run Tests
env:
CLIENT_SECRET: "secret-token"
ADMIN_CLIENT_SECRET: "another-secret-token"
JWT_SECRET: "jwt-secret"
POSTGRES_USER: readonly
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_READONLY_PASSWORD }}
Expand Down
2 changes: 1 addition & 1 deletion open-api/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const doc = new OpenApiGeneratorV3(registry.definitions).generateDocument({
info: {
title: "RaidHub API",
description: "The Semi-public API for RaidHub",
version: "1.0.0",
version: "1.0.1",
contact: {
name: "RaidHub Admin",
email: "admin@raidhub.io"
Expand Down
109 changes: 104 additions & 5 deletions open-api/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"info": {
"title": "RaidHub API",
"description": "The Semi-public API for RaidHub",
"version": "1.0.0",
"version": "1.0.1",
"contact": {
"name": "RaidHub Admin",
"email": "admin@raidhub.io"
Expand Down Expand Up @@ -2358,6 +2358,19 @@
}
},
"required": ["value", "expires"]
},
"AuthorizeUserResponse": {
"type": "object",
"properties": {
"value": {
"type": "string"
},
"expires": {
"type": "string",
"format": "date-time"
}
},
"required": ["value", "expires"]
}
},
"parameters": {},
Expand Down Expand Up @@ -3841,11 +3854,11 @@
"type": "string",
"pattern": "^\\d+n?$"
},
"clientSecret": {
"adminClientSecret": {
"type": "string"
}
},
"required": ["bungieMembershipId", "clientSecret"]
"required": ["bungieMembershipId", "adminClientSecret"]
}
}
}
Expand Down Expand Up @@ -3896,17 +3909,103 @@
},
"403": {
"description": "InvalidClientSecretError",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {}
}
}
}
}
}
}
},
"/authorize/user": {
"post": {
"summary": "/authorize/user",
"description": "Authenticate a user. Grants permission to access restricted resources.",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"bungieMembershipId": {
"type": "string",
"pattern": "^\\d+n?$"
},
"destinyMembershipIds": {
"type": "array",
"items": {
"type": "string",
"pattern": "^\\d+n?$"
}
},
"clientSecret": {
"type": "string"
}
},
"required": [
"bungieMembershipId",
"destinyMembershipIds",
"clientSecret"
]
}
}
}
},
"responses": {
"200": {
"description": "Success",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"unauthorized": {
"minted": {
"type": "string"
},
"success": {
"type": "boolean",
"enum": [true]
},
"response": {
"$ref": "#/components/schemas/AuthorizeUserResponse"
}
},
"required": ["unauthorized"]
"required": ["minted", "success", "response"]
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BodyValidationError"
}
}
}
},
"401": {
"description": "Unauthorized",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiKeyError"
}
}
}
},
"403": {
"description": "InvalidClientSecretError",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {}
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
},
"scripts": {
"prepare": "husky",
"dev": "bun run ./src/index.ts",
"dev": "bun --watch ./src/index.ts",
"test": "bun --env-file=.env jest --maxWorkers=50%",
"types": "tsc --noEmit",
"lint": "eslint . --ext .ts,.json",
Expand Down
12 changes: 10 additions & 2 deletions src/RaidHubRoute.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { RequestHandler, Router } from "express"
import { IncomingHttpHeaders } from "http"
import { ZodObject, ZodType, ZodTypeAny, ZodUnknown, z } from "zod"
import { RaidHubRouter } from "./RaidHubRouter"
import { IRaidHubRoute, RaidHubHandler, RaidHubHandlerReturn } from "./RaidHubRouterTypes"
Expand Down Expand Up @@ -269,6 +270,7 @@ export class RaidHubRoute<
const responseTimeInMs = Date.now() - start
const path = this.getFullPath()
const code = res.statusCode.toString()
/* istanbul ignore next */
if (!process.env.PROD && !process.env.TS_JEST) {
console.log(`Request to ${path} took ${responseTimeInMs}ms`)
}
Expand Down Expand Up @@ -375,11 +377,17 @@ export class RaidHubRoute<

/* istanbul ignore next */
// Used for testing to mock a request by passing the data directly to the handler
async $mock(req: { params?: unknown; query?: unknown; body?: unknown }) {
async $mock(req: {
params?: unknown
query?: unknown
body?: unknown
headers?: IncomingHttpHeaders
}) {
const res = await this.handler({
params: this.paramsSchema?.parse(req.params) ?? {},
query: this.querySchema?.parse(req.query) ?? {},
body: this.bodySchema?.parse(req.body) ?? {}
body: this.bodySchema?.parse(req.body) ?? {},
headers: req.headers ?? {}
}).then(this.buildResponse)

// We essentially can use this type to narrow down the type of res in our unit tests
Expand Down
2 changes: 2 additions & 0 deletions src/RaidHubRouterTypes.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { RouteConfig } from "@asteasolutions/zod-to-openapi"
import { Router } from "express"
import { IncomingHttpHeaders } from "http"
import { ZodType, z } from "zod"
import { RaidHubRouter } from "./RaidHubRouter"
import { ErrorCode } from "./schema/errors/ErrorCode"
Expand All @@ -26,4 +27,5 @@ export type RaidHubHandler<
params: z.infer<Params>
query: z.infer<Query>
body: z.infer<Body>
headers: IncomingHttpHeaders
}) => Promise<RaidHubHandlerReturn<T, E, C>>
1 change: 1 addition & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ declare module "bun" {
POSTGRES_PASSWORD: string
API_KEYS_PATH: string
ADMIN_CLIENT_SECRET: string
CLIENT_SECRET: string
JWT_SECRET: string
PROD?: boolean
PORT?: number
Expand Down
10 changes: 8 additions & 2 deletions src/middlewares/admin.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import express from "express"
import request from "supertest"
import { generateJWT } from "../routes/authorize/admin"
import { generateJWT } from "../util/auth"
import { adminProtected } from "./admin"

const app = express()
Expand All @@ -27,7 +27,13 @@ describe("admin protected", () => {
})

test("should return 200 if valid authorization is provided", async () => {
const token = generateJWT("234671294")
const token = generateJWT({
isAdmin: true,
bungieMembershipId: "123",
destinyMembershipIds: [],
durationSeconds: 600
})

const res = await request(app)
.get("/admin")
.set("Authorization", "Bearer " + token)
Expand Down
4 changes: 2 additions & 2 deletions src/middlewares/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ export const adminProtected: RequestHandler = (req, res, next) => {
jwt.verify(token, process.env.JWT_SECRET!, (err, _) => {
if (err) {
res.status(403).json(error())
} else {
next()
}

next()
})
}
14 changes: 12 additions & 2 deletions src/routes/authorize/admin.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,34 @@
import jwt from "jsonwebtoken"
import { expectErr, expectOk } from "../testUtil"
import { adminAuthorizationRoute } from "./admin"

describe("authorize 200", () => {
test("admin", async () => {
const result = await adminAuthorizationRoute.$mock({
body: {
clientSecret: process.env.CLIENT_SECRET,
adminClientSecret: process.env.ADMIN_CLIENT_SECRET,
bungieMembershipId: "1234567890"
}
})

expectOk(result)

jwt.verify(result.parsed.value as string, process.env.JWT_SECRET!, (err, result) => {
expect(err).toBeNull()
expect(result).toMatchObject({
isAdmin: true,
bungieMembershipId: "1234567890",
destinyMembershipIds: []
})
})
})
})

describe("authorize 403", () => {
test("bad key", async () => {
const result = await adminAuthorizationRoute.$mock({
body: {
clientSecret: "35djfnsadf2933451241",
adminClientSecret: "35djfnsadf2933451241",
bungieMembershipId: "1234567890"
}
})
Expand Down
26 changes: 11 additions & 15 deletions src/routes/authorize/admin.ts
Original file line number Diff line number Diff line change
@@ -1,22 +1,17 @@
import jwt from "jsonwebtoken"
import { z } from "zod"
import { RaidHubRoute } from "../../RaidHubRoute"
import { ErrorCode } from "../../schema/errors/ErrorCode"
import { zDigitString, zISODateString } from "../../schema/util"
import { generateJWT } from "../../util/auth"

const TOKEN_EXPIRY = 3600

export const generateJWT = (bungieMembershipId: string) =>
jwt.sign({ admin: true, bungieMembershipId }, process.env.JWT_SECRET!, {
expiresIn: TOKEN_EXPIRY
})

export const adminAuthorizationRoute = new RaidHubRoute({
method: "post",
description: "Authorize an admin user. Requires the client secret.",
body: z.object({
bungieMembershipId: zDigitString(),
clientSecret: z.string()
adminClientSecret: z.string()
}),
response: {
success: {
Expand All @@ -30,22 +25,23 @@ export const adminAuthorizationRoute = new RaidHubRoute({
{
statusCode: 403,
code: ErrorCode.InvalidClientSecretError,
schema: z.object({
unauthorized: z.literal(true)
})
schema: z.object({})
}
]
},
async handler({ body }) {
if (body.clientSecret === process.env.CLIENT_SECRET) {
if (body.adminClientSecret === process.env.ADMIN_CLIENT_SECRET) {
return RaidHubRoute.ok({
value: generateJWT(body.bungieMembershipId),
value: generateJWT({
bungieMembershipId: body.bungieMembershipId,
isAdmin: true,
destinyMembershipIds: [],
durationSeconds: TOKEN_EXPIRY
}),
expires: new Date(Date.now() + TOKEN_EXPIRY * 1000)
})
} else {
return RaidHubRoute.fail(ErrorCode.InvalidClientSecretError, {
unauthorized: true
})
return RaidHubRoute.fail(ErrorCode.InvalidClientSecretError, {})
}
}
})
Loading

0 comments on commit 3db348b

Please sign in to comment.