Security audit follow-up: httpOnly cookies, ticket reservations, MongoDB keyfile

Deferred findings from the March 27 security audit, plus a bug fix:

MongoDB keyfile (bug fix):
- Generate replica.key on first boot via entrypoint script
- Fixes crash from --auth + --keyFile without an existing keyfile
- Applied to docker-compose.yml, docker-compose.prod.yml, CCP template

I7 — Ticket overselling prevention (reservation pattern):
- Add reservedCount field to TicketTier schema
- Atomically increment reservedCount inside transaction on checkout
- Release reservation on checkout.session.completed (webhook)
- Release reservation on checkout.session.expired (webhook)
- Include reservedCount in availability calculations

I17 — Move refresh token to httpOnly cookie:
- Server sets httpOnly SameSite=Strict cookie on login/register/refresh
- Cookie scoped to /api/auth path, secure in production
- Refresh/logout endpoints read from cookie (with body fallback for compat)
- Frontend no longer stores refreshToken in localStorage
- Auth store simplified: removed refreshToken from state + persistence
- API interceptor uses withCredentials:true for automatic cookie sending
- Updated media-api, media-public-api, QuickJoinPage, volunteer-invite
- Renamed getTokens → getAccessToken across all media components
- Install cookie-parser middleware

L2 — FeatureGate loading state:
- Show Skeleton instead of children while settings are loading
- Prevents briefly exposing disabled feature pages

Bunker Admin
This commit is contained in:
2026-03-27 09:20:26 -06:00
parent 82a66a97d0
commit b215cda018
25 changed files with 201 additions and 105 deletions

28
api/package-lock.json generated
View File

@@ -17,6 +17,7 @@
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.0",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.6.0",
@@ -52,6 +53,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
@@ -1894,6 +1896,15 @@
"@types/node": "*"
}
},
"node_modules/@types/cookie-parser": {
"version": "1.4.10",
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.10.tgz",
"integrity": "sha512-B4xqkqfZ8Wek+rCOeRxsjMS9OgvzebEzzLYw7NHYuvzb7IdxOkI0ZHGgeEBX4PUM7QGVvNSK60T3OvWj3YfBRg==",
"dev": true,
"peerDependencies": {
"@types/express": "*"
}
},
"node_modules/@types/cors": {
"version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@@ -2533,6 +2544,23 @@
"node": ">= 0.6"
}
},
"node_modules/cookie-parser": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
"integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
"dependencies": {
"cookie": "0.7.2",
"cookie-signature": "1.0.6"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/cookie-parser/node_modules/cookie-signature": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
"integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="
},
"node_modules/cookie-signature": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",

View File

@@ -25,6 +25,7 @@
"bcryptjs": "^2.4.3",
"bullmq": "^5.34.0",
"compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"csv-parse": "^6.1.0",
"csv-stringify": "^6.6.0",
@@ -60,6 +61,7 @@
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",

View File

@@ -4830,6 +4830,7 @@ model TicketTier {
minDonationCAD Int? @map("min_donation_cad") // In cents
maxQuantity Int? @map("max_quantity")
soldCount Int @default(0) @map("sold_count")
reservedCount Int @default(0) @map("reserved_count") // Pending checkout sessions
maxPerOrder Int @default(10) @map("max_per_order")
salesStartAt DateTime? @map("sales_start_at")
salesEndAt DateTime? @map("sales_end_at")

View File

@@ -19,6 +19,30 @@ import { profileService } from '../people/profile.service';
const router = Router();
const REFRESH_COOKIE_NAME = 'cml_refresh';
const REFRESH_COOKIE_MAX_AGE = 7 * 24 * 60 * 60 * 1000; // 7 days in ms
/** Set the refresh token as an httpOnly cookie */
function setRefreshCookie(res: Response, token: string) {
res.cookie(REFRESH_COOKIE_NAME, token, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: REFRESH_COOKIE_MAX_AGE,
path: '/api/auth',
});
}
/** Clear the refresh token cookie */
function clearRefreshCookie(res: Response) {
res.clearCookie(REFRESH_COOKIE_NAME, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
path: '/api/auth',
});
}
// POST /api/auth/login
router.post(
'/login',
@@ -27,7 +51,10 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.login(req.body.email, req.body.password);
res.json(result);
// Set refresh token as httpOnly cookie (not in response body)
setRefreshCookie(res, result.refreshToken);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh);
} catch (err) {
next(err);
}
@@ -42,7 +69,14 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.register(req.body);
res.status(201).json(result);
// Set refresh token as httpOnly cookie if tokens were issued (non-verification path)
if ('refreshToken' in result && result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.status(201).json(responseWithoutRefresh);
} else {
res.status(201).json(result);
}
} catch (err) {
next(err);
}
@@ -231,30 +265,44 @@ router.post(
);
// POST /api/auth/refresh
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
router.post(
'/refresh',
authRateLimit,
validate(refreshSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.refreshTokens(req.body.refreshToken);
res.json(result);
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
if (!refreshToken) {
res.status(401).json({ error: { message: 'No refresh token', code: 'INVALID_REFRESH_TOKEN' } });
return;
}
const result = await authService.refreshTokens(refreshToken);
// Set new refresh token as httpOnly cookie
setRefreshCookie(res, result.refreshToken);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh);
} catch (err) {
clearRefreshCookie(res);
next(err);
}
}
);
// POST /api/auth/logout
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
router.post(
'/logout',
authRateLimit,
validate(refreshSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
await authService.logout(req.body.refreshToken);
const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
if (refreshToken) {
await authService.logout(refreshToken);
}
clearRefreshCookie(res);
res.json({ message: 'Logged out' });
} catch (err) {
clearRefreshCookie(res);
next(err);
}
}

View File

@@ -424,16 +424,23 @@ export const webhookService = {
const { ticketsService } = await import('../ticketed-events/tickets.service');
const { ticketEmailService } = await import('../ticketed-events/ticket-email.service');
const qty = parseInt(quantity, 10);
const tickets = await ticketsService.createTickets({
eventId,
tierId,
quantity: parseInt(quantity, 10),
quantity: qty,
holderEmail: buyerEmail || order.buyerEmail,
holderName: buyerName || order.buyerName || undefined,
userId: userId || order.userId || undefined,
orderId: order.id,
});
// Release reservation (soldCount was incremented by createTickets)
await prisma.ticketTier.update({
where: { id: tierId },
data: { reservedCount: { decrement: qty } },
});
// Fetch event + tier for email
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
@@ -534,6 +541,21 @@ export const webhookService = {
where: { id: order.id },
data: { status: 'FAILED' },
});
// Release ticket reservations if this was an event ticket checkout
const { tierId, quantity } = session.metadata || {};
if (tierId && quantity) {
const qty = parseInt(quantity, 10);
if (qty > 0) {
await prisma.ticketTier.update({
where: { id: tierId },
data: { reservedCount: { decrement: qty } },
}).catch((err) => {
logger.warn(`Failed to release reservation for tier ${tierId}:`, err);
});
}
}
logger.info(`Checkout expired, order marked failed: ${order.id}`);
}
},

View File

@@ -119,14 +119,6 @@ router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (re
throw new AppError(400, 'Use /register for free tickets', 'USE_REGISTER');
}
// Check availability
if (tier.maxQuantity && tier.soldCount + quantity > tier.maxQuantity) {
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
}
if (event.maxAttendees && event.currentAttendees + quantity > event.maxAttendees) {
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
}
// Check sales window
const now = new Date();
if (tier.salesStartAt && tier.salesStartAt > now) {
@@ -136,9 +128,33 @@ router.post('/:slug/checkout', optionalAuth, validate(checkoutSchema), async (re
throw new AppError(400, 'Ticket sales have ended', 'SALES_ENDED');
}
const unitAmount = tier.tierType === 'DONATION'
? Math.max(tier.priceCAD, tier.minDonationCAD || 0)
: tier.priceCAD;
// Atomically reserve capacity (soldCount + reservedCount checked together)
// This prevents overselling from concurrent Stripe checkout sessions
const reserved = await prisma.$transaction(async (tx) => {
const currentTier = await tx.ticketTier.findUnique({ where: { id: tierId } });
if (!currentTier) throw new AppError(400, 'Tier not found', 'NOT_FOUND');
const effectiveSold = currentTier.soldCount + currentTier.reservedCount;
if (currentTier.maxQuantity && effectiveSold + quantity > currentTier.maxQuantity) {
throw new AppError(400, 'Not enough tickets available', 'SOLD_OUT');
}
const currentEvent = await tx.ticketedEvent.findUnique({ where: { id: event.id } });
if (currentEvent?.maxAttendees && currentEvent.currentAttendees + currentTier.reservedCount + quantity > currentEvent.maxAttendees) {
throw new AppError(400, 'Event is at full capacity', 'SOLD_OUT');
}
await tx.ticketTier.update({
where: { id: tierId },
data: { reservedCount: { increment: quantity } },
});
return currentTier;
});
const unitAmount = reserved.tierType === 'DONATION'
? Math.max(reserved.priceCAD, reserved.minDonationCAD || 0)
: reserved.priceCAD;
const stripe = await getStripe();
const session = await stripe.checkout.sessions.create({

View File

@@ -696,7 +696,7 @@ export const ticketedEventsService = {
const now = new Date();
const tiers = event.ticketTiers.map(t => {
const available = t.maxQuantity ? t.maxQuantity - t.soldCount : null;
const available = t.maxQuantity ? t.maxQuantity - t.soldCount - (t.reservedCount || 0) : null;
const onSale = (!t.salesStartAt || t.salesStartAt <= now) &&
(!t.salesEndAt || t.salesEndAt >= now);
return {

View File

@@ -5,6 +5,7 @@ import { validate } from '../../middleware/validate';
import { quickJoinRateLimit } from '../../middleware/rate-limit';
import { volunteerInviteService } from './volunteer-invite.service';
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
import { env } from '../../config/env';
const router = Router();
@@ -33,9 +34,16 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await volunteerInviteService.redeemInvite(req.body);
// Set refresh token as httpOnly cookie
res.cookie('cml_refresh', result.tokens.refreshToken, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth',
});
res.json({
accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
cutId: result.cutId,
shiftId: result.shiftId,
});

View File

@@ -125,6 +125,7 @@ import { actionItemsRouter } from './modules/meetings/action-items.routes';
import { WebSocketServer } from 'ws';
import { docsCollabService } from './modules/docs/docs-collab.service';
import { correlationId } from './middleware/correlation-id';
import cookieParser from 'cookie-parser';
const app = express();
@@ -133,6 +134,7 @@ app.set('trust proxy', 1);
// --- Middleware Stack ---
app.use(correlationId);
app.use(cookieParser());
app.use(helmet({
contentSecurityPolicy: env.CSP_ENABLED === 'true'