diff --git a/lib/index.ts b/lib/index.ts index 5bb6919..5e9ea96 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -1,20 +1,23 @@ -import Koa from 'koa'; +import Koa, { HttpError } from 'koa'; import type { DefaultState, ParameterizedContext, Middleware, Context, Next } from 'koa'; import Router from '@koa/router'; import bodyParser from 'koa-bodyparser'; import cors from '@koa/cors'; import { v4 as uuidV4 } from 'uuid'; import log, { Logger } from './utils/log.js'; -import type { User } from './services/auth.js'; import apiRouter from './routes/index.js'; type JSONValue = string | number | boolean | null | { [x: string]: JSONValue | unknown } | Array; interface MustacheBashState extends DefaultState { - user: User; + user: { + id: string; + role: string; + }; accessToken: string; requestId: string; log: Logger; + responseTime: number; } interface MustacheBashContext extends Context { @@ -25,10 +28,6 @@ interface MustacheBashContext extends Context { body: JSONValue; } -// type AppState = { -// responseTime: number; -// }; - // Create the Koa instance const app = new Koa(), npmPackageVersion: string = process.env.npm_package_version as string; @@ -46,6 +45,27 @@ const appRouter = new Router(); app.proxy = true; app.proxyIpHeader = 'X-Real-IP'; +/** + * Global error handler + * Errors should be thrown directly from middleware and controllers to be handled here + */ +app.use(async (ctx, next) => { + try { + await next(); + } catch(e) { + if(e instanceof HttpError) { + ctx.status = e.status + + ctx.body = e.expose ? e.message : 'Internal Server Error'; + } else if(e instanceof Error) { + ctx.status = 500; + ctx.body = 'Internal Server Error'; + } + + ctx.app.emit('error', e, ctx); + } +}); + /** * Attach API Version Header */ @@ -181,7 +201,7 @@ app.use(appRouter.routes()); app.use(ctx => ctx.throw(404)); /** - * Global error handler + * Global error logger * Errors should be thrown directly from middleware and controllers to be handled here */ app.on('error', (err, ctx) => { diff --git a/lib/middleware/auth.ts b/lib/middleware/auth.ts index 4d886ca..5577ff5 100644 --- a/lib/middleware/auth.ts +++ b/lib/middleware/auth.ts @@ -1,7 +1,9 @@ +import { Next } from 'koa'; +import { AppContext } from '../index.js'; import { validateAccessToken, checkScope } from '../services/auth.js'; -export async function authorizeUser(ctx, next) { +export async function authorizeUser(ctx: AppContext, next: Next) { const authHeader = ctx.headers.authorization && ctx.headers.authorization.split(' '); if(!authHeader || authHeader.length !== 2 && authHeader[0] !== 'Bearer') throw ctx.throw(403); @@ -15,7 +17,6 @@ export async function authorizeUser(ctx, next) { ctx.state.user = { id: sub, - username: sub, role }; } catch (e) { @@ -25,8 +26,8 @@ export async function authorizeUser(ctx, next) { await next(); } -export function requiresPermission(scopeRequired) { - return async (ctx, next) => { +export function requiresPermission(scopeRequired: string) { + return async (ctx: AppContext, next: Next) => { // Just don't even try if it's not there if(!ctx.state.user || !ctx.state.user.role) throw ctx.throw(403); diff --git a/lib/routes/customers.ts b/lib/routes/customers.ts index be7d9c4..501feaf 100644 --- a/lib/routes/customers.ts +++ b/lib/routes/customers.ts @@ -1,8 +1,9 @@ import Router from '@koa/router'; +import { AppContext } from '../index.js'; import { authorizeUser, requiresPermission } from '../middleware/auth.js'; import { createCustomer, getCustomers, getCustomer, updateCustomer } from '../services/customers.js'; -const customersRouter = new Router({ +const customersRouter = new Router({ prefix: '/customers' }); diff --git a/lib/routes/index.ts b/lib/routes/index.ts index f631268..c11f6d5 100644 --- a/lib/routes/index.ts +++ b/lib/routes/index.ts @@ -1,14 +1,13 @@ /** * API Router handles entity routing and miscellaneous - * @type {Express Router} */ import Router from '@koa/router'; -import { isRecordLike } from '../utils/type-guards.js'; +import { isRecordLike, isServiceError } from '../utils/type-guards.js'; import { authorizeUser, requiresPermission } from '../middleware/auth.js'; import { authenticateGoogleUser, refreshAccessToken } from '../services/auth.js'; import { getEventSettings } from '../services/events.js'; import { generateOrderToken, validateOrderToken } from '../services/orders.js'; -import { checkInWithTicket, getCustomerActiveTicketsByOrderId, inspectTicket, transferTickets } from '../services/tickets.js'; +import { checkInWithTicket, getCustomerActiveTicketsByOrderId, getCustomerActiveAccommodationsByOrderId, inspectTicket, transferTickets, TicketsServiceError } from '../services/tickets.js'; import customersRouter from './customers.js'; import ordersRouter from './orders.js'; import transactionsRouter from './transactions.js'; @@ -18,10 +17,11 @@ import productsRouter from './products.js'; import promosRouter from './promos.js'; import guestsRouter from './guests.js'; import usersRouter from './users.js'; -import { getCustomer } from '../services/customers.js'; +import { getCustomer, CustomerServiceError } from '../services/customers.js'; import { sendTransfereeConfirmation, upsertEmailSubscriber } from '../services/email.js'; +import { AppContext } from '../index.js'; -const apiRouter = new Router(); +const apiRouter = new Router(); apiRouter.use(customersRouter.routes()); apiRouter.use(ordersRouter.routes()); @@ -41,34 +41,45 @@ apiRouter .get('/mytickets', async ctx => { if(!ctx.query.t || typeof ctx.query.t !== 'string') throw ctx.throw(400); - let orderId; + + const { sub: orderId } = validateOrderToken(ctx.query.t); + + // TODO: make this one large query that returns all the public data needed + let tickets; try { - ({ sub: orderId } = validateOrderToken(ctx.query.t)); + tickets = await getCustomerActiveTicketsByOrderId(orderId); } catch(e) { - throw ctx.throw(e); + if(isServiceError(e)) { + if (e.code === 'NOT_FOUND') throw ctx.throw(404); + } + + throw e; } - // TODO: make this one large query that returns all the public data needed - let tickets; + let accommodations; try { - tickets = await getCustomerActiveTicketsByOrderId(orderId); + accommodations = await getCustomerActiveAccommodationsByOrderId(orderId); } catch(e) { - if (e.code === 'NOT_FOUND') throw ctx.throw(404); + if(isServiceError(e)) { + if (e.code === 'NOT_FOUND') throw ctx.throw(404); + } - throw ctx.throw(e); + throw e; } - if(!tickets.length) { + if(!tickets.length && !accommodations.length) { return ctx.status = 204; } let customer; try { - customer = await getCustomer(tickets[0].customerId); + customer = await getCustomer(tickets[0]?.customerId || accommodations[0]?.customerId); } catch(e) { - if (e.code === 'NOT_FOUND') throw ctx.throw(404); + if(isServiceError(e)) { + if (e.code === 'NOT_FOUND') throw ctx.throw(404); + } - throw ctx.throw(e); + throw e; } return ctx.body = { @@ -77,20 +88,17 @@ apiRouter lastName: customer.lastName, email: customer.email }, - tickets + tickets, + accommodations }; }) .post('/mytickets/transfers', async ctx => { - if(!ctx.request.body) throw ctx.throw(400); + if(!isRecordLike(ctx.request.body)) throw ctx.throw(400); if(!ctx.request.body.orderToken || typeof ctx.request.body.orderToken !== 'string') throw ctx.throw(400); // Use the order token as authorization - try { - validateOrderToken(ctx.request.body.orderToken); - } catch(e) { - throw ctx.throw(e); - } + validateOrderToken(ctx.request.body.orderToken); const selectedTickets = ctx.request.body.tickets, transferee = ctx.request.body.transferee, diff --git a/lib/routes/orders.ts b/lib/routes/orders.ts index e7e25b7..1ebcc34 100644 --- a/lib/routes/orders.ts +++ b/lib/routes/orders.ts @@ -7,7 +7,7 @@ import { getTransactions } from '../services/transactions.js'; // TODO: make this configurable at some point const EMAIL_LIST = '90392ecd5e', - EMAIL_TAG = 'Mustache Bash 2025 Attendee'; + EMAIL_TAG = 'Mustache Bash SF 2025 Attendee'; const ordersRouter = new Router({ prefix: '/orders' diff --git a/lib/services/auth.ts b/lib/services/auth.ts index cc94353..8d6583a 100644 --- a/lib/services/auth.ts +++ b/lib/services/auth.ts @@ -1,9 +1,8 @@ /** * Auth service * Handles user authentication and authorization, as well as user management - * @type {object} */ -import jwt, { JwtPayload } from 'jsonwebtoken'; +import jwt from 'jsonwebtoken'; import { v4 as uuidV4 } from 'uuid'; import { OAuth2Client, TokenPayload} from 'google-auth-library'; import { sql } from '../utils/db.js'; @@ -13,6 +12,7 @@ const googleAuthClient = new OAuth2Client(); export type User = { id: string; + username: string; displayName: string; role: string; subClaim: string; @@ -33,6 +33,11 @@ class AuthServiceError extends Error { } } +type AccessToken = { + sub: string; + role: string; + name: string; +}; function generateAccessToken(user: User) { return jwt.sign({ exp: Math.floor(Date.now()/1000) + (60*20), // In seconds, 20m expiration @@ -44,6 +49,23 @@ function generateAccessToken(user: User) { config.jwt.secret); } +export function validateAccessToken(accessToken: string) { + const tokenPayload = jwt.verify(accessToken, config.jwt.secret, {issuer: 'mustachebash'}); + + if( + typeof tokenPayload !== 'object' || + typeof tokenPayload.sub !== 'string' || + !tokenPayload.sub || + typeof tokenPayload.role !== 'string' + ) throw new AuthServiceError('Invalid token', 'UNAUTHORIZED'); + + return tokenPayload as AccessToken; +} + +type RefreshToken = { + jti: string; + sub: string; +}; function generateRefreshToken(user: User, jti: string) { return jwt.sign({ exp: Math.floor(Date.now()/1000) + (60*60*24*30), // In seconds, 30d expiration @@ -56,7 +78,17 @@ function generateRefreshToken(user: User, jti: string) { } function validateRefreshToken(refreshToken: string) { - return jwt.verify(refreshToken, config.jwt.secret, {issuer: 'mustachebash', audience: 'mustachebash-refresh'}); + const tokenPayload = jwt.verify(refreshToken, config.jwt.secret, {issuer: 'mustachebash', audience: 'mustachebash-refresh'}); + + if( + typeof tokenPayload !== 'object' || + typeof tokenPayload.sub !== 'string' || + !tokenPayload.sub || + typeof tokenPayload.jti !== 'string' || + !tokenPayload.jti + ) throw new AuthServiceError('Invalid token', 'UNAUTHORIZED'); + + return tokenPayload as RefreshToken; } export async function authenticateGoogleUser(token: string, {userAgent, ip}: {userAgent: string, ip: string}) { @@ -72,7 +104,10 @@ export async function authenticateGoogleUser(token: string, {userAgent, ip}: {us payload = ticket.getPayload(); googleUserId = ticket.getUserId(); } catch(e) { - throw new AuthServiceError(e.message, 'UNAUTHORIZED'); + if(e instanceof Error) + throw new AuthServiceError(e.message, 'UNAUTHORIZED'); + + throw new AuthServiceError('Failed to verify Google token', 'UNAUTHORIZED'); } if(!payload || !googleUserId) throw new AuthServiceError('Invalid token', 'UNAUTHORIZED'); @@ -205,10 +240,6 @@ export async function refreshAccessToken(refreshToken: string, {userAgent, ip}: return generateAccessToken(user); } -export function validateAccessToken(accessToken: string) { - return jwt.verify(accessToken, config.jwt.secret, {issuer: 'mustachebash'}); -} - export function checkScope(userRole: string, scopeRequired: string) { const roles = [ 'root', diff --git a/lib/services/customers.ts b/lib/services/customers.ts index ebb55e9..affefb9 100644 --- a/lib/services/customers.ts +++ b/lib/services/customers.ts @@ -6,7 +6,10 @@ import { sql } from '../utils/db.js'; import { v4 as uuidV4 } from 'uuid'; -class CustomerServiceError extends Error { +export class CustomerServiceError extends Error { + code: string; + context?: unknown; + constructor(message = 'An unknown error occured', code = 'UNKNOWN', context) { super(message); diff --git a/lib/services/email.ts b/lib/services/email.ts index 8f29dbe..d3867e0 100644 --- a/lib/services/email.ts +++ b/lib/services/email.ts @@ -54,14 +54,14 @@ export function sendReceipt(guestFirstName, guestLastName, guestEmail, confirmat mailgun.messages().send({ from: 'Mustache Bash Tickets ', to: guestFirstName + ' ' + guestLastName + ' <' + guestEmail + '> ', - subject: 'Your Tickets & Confirmation For San Diego Mustache Bash 2025', + subject: 'Your Tickets & Confirmation For San Francisco Mustache Bash 2025', html: ` - The Mustache Bash SD 2025 Confirmation + The Mustache Bash SF 2025 Confirmation