Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions lib/index.ts
Original file line number Diff line number Diff line change
@@ -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<JSONValue>;

interface MustacheBashState extends DefaultState {
user: User;
user: {
id: string;
role: string;
};
accessToken: string;
requestId: string;
log: Logger;
responseTime: number;
}

interface MustacheBashContext extends Context {
Expand All @@ -25,10 +28,6 @@ interface MustacheBashContext extends Context {
body: JSONValue;
}

// type AppState = {
// responseTime: number;
// };

// Create the Koa instance
const app = new Koa<MustacheBashState, MustacheBashContext>(),
npmPackageVersion: string = process.env.npm_package_version as string;
Expand All @@ -46,6 +45,27 @@ const appRouter = new Router<AppContext['state'], AppContext>();
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
*/
Expand Down Expand Up @@ -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) => {
Expand Down
9 changes: 5 additions & 4 deletions lib/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -15,7 +17,6 @@ export async function authorizeUser(ctx, next) {

ctx.state.user = {
id: sub,
username: sub,
role
};
} catch (e) {
Expand All @@ -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);

Expand Down
3 changes: 2 additions & 1 deletion lib/routes/customers.ts
Original file line number Diff line number Diff line change
@@ -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<AppContext['state'], AppContext>({
prefix: '/customers'
});

Expand Down
56 changes: 32 additions & 24 deletions lib/routes/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<AppContext['state'], AppContext>();

apiRouter.use(customersRouter.routes());
apiRouter.use(ordersRouter.routes());
Expand All @@ -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 = {
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion lib/routes/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
47 changes: 39 additions & 8 deletions lib/services/auth.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -13,6 +12,7 @@ const googleAuthClient = new OAuth2Client();

export type User = {
id: string;
username: string;
displayName: string;
role: string;
subClaim: string;
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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}) {
Expand All @@ -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');
Expand Down Expand Up @@ -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',
Expand Down
5 changes: 4 additions & 1 deletion lib/services/customers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
Loading
Loading