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:
bunker-admin 2026-03-27 09:20:26 -06:00
parent 82a66a97d0
commit b215cda018
25 changed files with 201 additions and 105 deletions

View File

@ -1,5 +1,5 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Result, Button } from 'antd'; import { Result, Button, Skeleton } from 'antd';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { SettingOutlined } from '@ant-design/icons'; import { SettingOutlined } from '@ant-design/icons';
import { useSettingsStore } from '@/stores/settings.store'; import { useSettingsStore } from '@/stores/settings.store';
@ -36,8 +36,8 @@ export default function FeatureGate({ feature, children }: FeatureGateProps) {
const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']); const isSuperAdmin = hasAnyRole(user, ['SUPER_ADMIN']);
const featureName = FEATURE_LABELS[feature] || feature; const featureName = FEATURE_LABELS[feature] || feature;
// While loading or if settings haven't arrived yet, render children (optimistic) // Show skeleton while settings are loading to prevent briefly showing disabled features
if (loading || !settings) return <>{children}</>; if (loading || !settings) return <Skeleton active style={{ padding: 24 }} />;
if (settings[feature] === false) { if (settings[feature] === false) {
return ( return (

View File

@ -5,8 +5,8 @@ import type { PhotoAlbum } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */ /** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string { function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks(); const { getAccessToken } = getAuthCallbacks();
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (!accessToken) return url; if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?'; const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`; return `${url}${separator}token=${accessToken}`;

View File

@ -12,8 +12,8 @@ import type { PhotoAlbum, PhotoAlbumItem } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */ /** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string { function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks(); const { getAccessToken } = getAuthCallbacks();
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (!accessToken) return url; if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?'; const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`; return `${url}${separator}token=${accessToken}`;

View File

@ -13,8 +13,8 @@ import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */ /** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string { function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks(); const { getAccessToken } = getAuthCallbacks();
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (!accessToken) return url; if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?'; const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`; return `${url}${separator}token=${accessToken}`;

View File

@ -5,8 +5,8 @@ import type { Photo } from '@/types/media';
/** Append JWT access token as query param for <img> src URLs */ /** Append JWT access token as query param for <img> src URLs */
function getAuthenticatedUrl(url: string): string { function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks(); const { getAccessToken } = getAuthCallbacks();
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (!accessToken) return url; if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?'; const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`; return `${url}${separator}token=${accessToken}`;

View File

@ -8,8 +8,8 @@ import ScheduleBadge from './ScheduleBadge';
/** Append JWT access token as query param for <img>/<video> src URLs */ /** Append JWT access token as query param for <img>/<video> src URLs */
function getAuthenticatedUrl(url: string): string { function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks(); const { getAccessToken } = getAuthCallbacks();
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (!accessToken) return url; if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?'; const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`; return `${url}${separator}token=${accessToken}`;

View File

@ -124,8 +124,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
const appendToken = (url: string): string => { const appendToken = (url: string): string => {
if (!isAdmin) return url; if (!isAdmin) return url;
const { getTokens } = getAuthCallbacks(); const { getAccessToken } = getAuthCallbacks();
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (!accessToken) return url; if (!accessToken) return url;
const sep = url.includes('?') ? '&' : '?'; const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}token=${accessToken}`; return `${url}${sep}token=${accessToken}`;
@ -139,8 +139,8 @@ export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(({
// Use relative URL to go through nginx proxy // Use relative URL to go through nginx proxy
const headers: Record<string, string> = {}; const headers: Record<string, string> = {};
if (isAdmin) { if (isAdmin) {
const { getTokens } = getAuthCallbacks(); const { getAccessToken } = getAuthCallbacks();
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (accessToken) { if (accessToken) {
headers['Authorization'] = `Bearer ${accessToken}`; headers['Authorization'] = `Bearer ${accessToken}`;
} }

View File

@ -6,8 +6,8 @@ import { getAuthCallbacks } from '@/lib/api';
/** Append JWT access token as query param for <video> src URLs */ /** Append JWT access token as query param for <video> src URLs */
function getAuthenticatedUrl(url: string): string { function getAuthenticatedUrl(url: string): string {
const { getTokens } = getAuthCallbacks(); const { getAccessToken } = getAuthCallbacks();
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (!accessToken) return url; if (!accessToken) return url;
const separator = url.includes('?') ? '&' : '?'; const separator = url.includes('?') ? '&' : '?';
return `${url}${separator}token=${accessToken}`; return `${url}${separator}token=${accessToken}`;

View File

@ -3,32 +3,32 @@ import type { AuthResponse } from '@/types/api';
export const api = axios.create({ export const api = axios.create({
baseURL: '/api', baseURL: '/api',
withCredentials: true, // Send httpOnly cookies with all requests
}); });
// Token accessor — set by auth store on init to break circular dependency // Token accessor — set by auth store on init to break circular dependency
let getTokens: () => { accessToken: string | null; refreshToken: string | null } = let getAccessToken: () => string | null = () => null;
() => ({ accessToken: null, refreshToken: null }); let onTokenRefresh: (accessToken: string) => void = () => {};
let onTokenRefresh: (accessToken: string, refreshToken: string) => void = () => {};
let onAuthFailure: () => void = () => {}; let onAuthFailure: () => void = () => {};
export function registerAuthCallbacks(callbacks: { export function registerAuthCallbacks(callbacks: {
getTokens: typeof getTokens; getAccessToken: typeof getAccessToken;
onTokenRefresh: typeof onTokenRefresh; onTokenRefresh: typeof onTokenRefresh;
onAuthFailure: typeof onAuthFailure; onAuthFailure: typeof onAuthFailure;
}) { }) {
getTokens = callbacks.getTokens; getAccessToken = callbacks.getAccessToken;
onTokenRefresh = callbacks.onTokenRefresh; onTokenRefresh = callbacks.onTokenRefresh;
onAuthFailure = callbacks.onAuthFailure; onAuthFailure = callbacks.onAuthFailure;
} }
// Helper to get current callbacks (for use in other API clients) // Helper to get current callbacks (for use in other API clients)
export function getAuthCallbacks() { export function getAuthCallbacks() {
return { getTokens, onTokenRefresh, onAuthFailure }; return { getAccessToken, onTokenRefresh, onAuthFailure };
} }
// Request interceptor: attach access token // Request interceptor: attach access token
api.interceptors.request.use((config) => { api.interceptors.request.use((config) => {
const { accessToken } = getTokens(); const accessToken = getAccessToken();
if (accessToken) { if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`; config.headers.Authorization = `Bearer ${accessToken}`;
} }
@ -51,16 +51,11 @@ api.interceptors.response.use(
) { ) {
originalRequest._retry = true; originalRequest._retry = true;
const { refreshToken } = getTokens();
if (!refreshToken) {
onAuthFailure();
return Promise.reject(error);
}
try { try {
if (!refreshPromise) { if (!refreshPromise) {
// Refresh token sent automatically as httpOnly cookie
refreshPromise = api refreshPromise = api
.post<AuthResponse>('/auth/refresh', { refreshToken }) .post<AuthResponse>('/auth/refresh')
.then((res) => res.data) .then((res) => res.data)
.finally(() => { .finally(() => {
refreshPromise = null; refreshPromise = null;
@ -68,7 +63,7 @@ api.interceptors.response.use(
} }
const data = await refreshPromise; const data = await refreshPromise;
onTokenRefresh(data.accessToken!, data.refreshToken!); onTokenRefresh(data.accessToken!);
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`; originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
return api(originalRequest); return api(originalRequest);

View File

@ -8,13 +8,14 @@ export const mediaApi = axios.create({
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
withCredentials: true, // Send httpOnly cookies with requests
}); });
// Request interceptor: attach Bearer token from auth store // Request interceptor: attach Bearer token from auth store
mediaApi.interceptors.request.use( mediaApi.interceptors.request.use(
(config) => { (config) => {
const callbacks = getAuthCallbacks(); const callbacks = getAuthCallbacks();
const { accessToken } = callbacks.getTokens(); const accessToken = callbacks.getAccessToken();
if (accessToken) { if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`; config.headers.Authorization = `Bearer ${accessToken}`;
} }
@ -41,22 +42,15 @@ mediaApi.interceptors.response.use(
originalRequest._retry = true; originalRequest._retry = true;
const callbacks = getAuthCallbacks(); const callbacks = getAuthCallbacks();
const { refreshToken } = callbacks.getTokens();
// No refresh token available - fail immediately
if (!refreshToken) {
callbacks.onAuthFailure();
return Promise.reject(error);
}
try { try {
// Use shared refresh promise to prevent concurrent refreshes // Use shared refresh promise to prevent concurrent refreshes
if (!mediaRefreshPromise) { if (!mediaRefreshPromise) {
// Import main API client dynamically to avoid circular dependency // Refresh token sent automatically as httpOnly cookie
const { api } = await import('./api'); const { api } = await import('./api');
mediaRefreshPromise = api mediaRefreshPromise = api
.post<AuthResponse>('/auth/refresh', { refreshToken }) .post<AuthResponse>('/auth/refresh')
.then((res) => res.data) .then((res) => res.data)
.finally(() => { .finally(() => {
mediaRefreshPromise = null; mediaRefreshPromise = null;
@ -65,8 +59,8 @@ mediaApi.interceptors.response.use(
const data = await mediaRefreshPromise; const data = await mediaRefreshPromise;
// Update tokens in auth store via callback // Update access token in auth store via callback
callbacks.onTokenRefresh(data.accessToken!, data.refreshToken!); callbacks.onTokenRefresh(data.accessToken!);
// Retry original request with new token // Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`; originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;

View File

@ -41,7 +41,7 @@ mediaPublicApi.interceptors.request.use(
// Optionally attach Bearer token if user is logged in // Optionally attach Bearer token if user is logged in
const callbacks = getAuthCallbacks(); const callbacks = getAuthCallbacks();
const { accessToken } = callbacks.getTokens(); const accessToken = callbacks.getAccessToken();
if (accessToken) { if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`; config.headers.Authorization = `Bearer ${accessToken}`;
} }

View File

@ -13,7 +13,7 @@ const { Title, Text } = Typography;
export default function QuickJoinPage() { export default function QuickJoinPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { setTokens, fetchMe } = useAuthStore(); const { setAccessToken, fetchMe } = useAuthStore();
const { settings } = useSettingsStore(); const { settings } = useSettingsStore();
const screens = Grid.useBreakpoint(); const screens = Grid.useBreakpoint();
const isMobile = !screens.md; const isMobile = !screens.md;
@ -35,8 +35,8 @@ export default function QuickJoinPage() {
phone: values.phone || undefined, phone: values.phone || undefined,
}); });
// Store tokens and fetch user profile // Store access token and fetch user profile (refresh token set as httpOnly cookie by server)
setTokens(data.accessToken, data.refreshToken); setAccessToken(data.accessToken);
await fetchMe(); await fetchMe();
// Redirect to volunteer map with cut/shift context // Redirect to volunteer map with cut/shift context

View File

@ -6,7 +6,6 @@ import type { User, AuthResponse } from '@/types/api';
interface AuthState { interface AuthState {
user: User | null; user: User | null;
accessToken: string | null; accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@ -21,7 +20,7 @@ interface AuthActions {
refresh: () => Promise<void>; refresh: () => Promise<void>;
fetchMe: () => Promise<void>; fetchMe: () => Promise<void>;
hydrate: () => Promise<void>; hydrate: () => Promise<void>;
setTokens: (accessToken: string, refreshToken: string) => void; setAccessToken: (accessToken: string) => void;
clearAuth: () => void; clearAuth: () => void;
clearError: () => void; clearError: () => void;
} }
@ -31,7 +30,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
(set, get) => ({ (set, get) => ({
user: null, user: null,
accessToken: null, accessToken: null,
refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: true, isLoading: true,
error: null, error: null,
@ -41,6 +39,7 @@ export const useAuthStore = create<AuthState & AuthActions>()(
login: async (email: string, password: string) => { login: async (email: string, password: string) => {
set({ error: null, errorCode: null, isLoading: true, registrationMessage: null }); set({ error: null, errorCode: null, isLoading: true, registrationMessage: null });
try { try {
// Refresh token is set as httpOnly cookie by the server (not in response body)
const { data } = await api.post<AuthResponse>('/auth/login', { const { data } = await api.post<AuthResponse>('/auth/login', {
email, email,
password, password,
@ -48,7 +47,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
set({ set({
user: data.user, user: data.user,
accessToken: data.accessToken || null, accessToken: data.accessToken || null,
refreshToken: data.refreshToken || null,
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
}); });
@ -83,7 +81,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
set({ set({
user: data.user, user: data.user,
accessToken: data.accessToken || null, accessToken: data.accessToken || null,
refreshToken: data.refreshToken || null,
isAuthenticated: true, isAuthenticated: true,
isLoading: false, isLoading: false,
}); });
@ -98,11 +95,9 @@ export const useAuthStore = create<AuthState & AuthActions>()(
}, },
logout: async () => { logout: async () => {
const { refreshToken } = get();
try { try {
if (refreshToken) { // Refresh token sent automatically as httpOnly cookie
await api.post('/auth/logout', { refreshToken }); await api.post('/auth/logout');
}
} catch { } catch {
// Ignore logout errors // Ignore logout errors
} }
@ -110,19 +105,12 @@ export const useAuthStore = create<AuthState & AuthActions>()(
}, },
refresh: async () => { refresh: async () => {
const { refreshToken } = get();
if (!refreshToken) {
get().clearAuth();
return;
}
try { try {
const { data } = await api.post<AuthResponse>('/auth/refresh', { // Refresh token sent automatically as httpOnly cookie
refreshToken, const { data } = await api.post<AuthResponse>('/auth/refresh');
});
set({ set({
user: data.user, user: data.user,
accessToken: data.accessToken || null, accessToken: data.accessToken || null,
refreshToken: data.refreshToken || null,
isAuthenticated: true, isAuthenticated: true,
}); });
} catch { } catch {
@ -157,15 +145,14 @@ export const useAuthStore = create<AuthState & AuthActions>()(
} }
}, },
setTokens: (accessToken: string, refreshToken: string) => { setAccessToken: (accessToken: string) => {
set({ accessToken, refreshToken }); set({ accessToken });
}, },
clearAuth: () => { clearAuth: () => {
set({ set({
user: null, user: null,
accessToken: null, accessToken: null,
refreshToken: null,
isAuthenticated: false, isAuthenticated: false,
isLoading: false, isLoading: false,
error: null, error: null,
@ -182,7 +169,6 @@ export const useAuthStore = create<AuthState & AuthActions>()(
name: 'cml-auth', name: 'cml-auth',
partialize: (state) => ({ partialize: (state) => ({
accessToken: state.accessToken, accessToken: state.accessToken,
refreshToken: state.refreshToken,
}), }),
} }
) )
@ -190,15 +176,9 @@ export const useAuthStore = create<AuthState & AuthActions>()(
// Register callbacks for the API interceptor (breaks circular dependency) // Register callbacks for the API interceptor (breaks circular dependency)
registerAuthCallbacks({ registerAuthCallbacks({
getTokens: () => { getAccessToken: () => useAuthStore.getState().accessToken,
const state = useAuthStore.getState(); onTokenRefresh: (accessToken) => {
return { useAuthStore.getState().setAccessToken(accessToken);
accessToken: state.accessToken,
refreshToken: state.refreshToken,
};
},
onTokenRefresh: (accessToken, refreshToken) => {
useAuthStore.getState().setTokens(accessToken, refreshToken);
}, },
onAuthFailure: () => { onAuthFailure: () => {
useAuthStore.getState().clearAuth(); useAuthStore.getState().clearAuth();

28
api/package-lock.json generated
View File

@ -17,6 +17,7 @@
"bcryptjs": "^2.4.3", "bcryptjs": "^2.4.3",
"bullmq": "^5.34.0", "bullmq": "^5.34.0",
"compression": "^1.7.5", "compression": "^1.7.5",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5", "cors": "^2.8.5",
"csv-parse": "^6.1.0", "csv-parse": "^6.1.0",
"csv-stringify": "^6.6.0", "csv-stringify": "^6.6.0",
@ -52,6 +53,7 @@
"devDependencies": { "devDependencies": {
"@types/bcryptjs": "^2.4.6", "@types/bcryptjs": "^2.4.6",
"@types/compression": "^1.7.5", "@types/compression": "^1.7.5",
"@types/cookie-parser": "^1.4.10",
"@types/cors": "^2.8.17", "@types/cors": "^2.8.17",
"@types/express": "^5.0.0", "@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7", "@types/jsonwebtoken": "^9.0.7",
@ -1894,6 +1896,15 @@
"@types/node": "*" "@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": { "node_modules/@types/cors": {
"version": "2.8.19", "version": "2.8.19",
"resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz",
@ -2533,6 +2544,23 @@
"node": ">= 0.6" "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": { "node_modules/cookie-signature": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",

View File

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

View File

@ -4830,6 +4830,7 @@ model TicketTier {
minDonationCAD Int? @map("min_donation_cad") // In cents minDonationCAD Int? @map("min_donation_cad") // In cents
maxQuantity Int? @map("max_quantity") maxQuantity Int? @map("max_quantity")
soldCount Int @default(0) @map("sold_count") 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") maxPerOrder Int @default(10) @map("max_per_order")
salesStartAt DateTime? @map("sales_start_at") salesStartAt DateTime? @map("sales_start_at")
salesEndAt DateTime? @map("sales_end_at") salesEndAt DateTime? @map("sales_end_at")

View File

@ -19,6 +19,30 @@ import { profileService } from '../people/profile.service';
const router = Router(); 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 // POST /api/auth/login
router.post( router.post(
'/login', '/login',
@ -27,7 +51,10 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const result = await authService.login(req.body.email, req.body.password); 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) { } catch (err) {
next(err); next(err);
} }
@ -42,7 +69,14 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const result = await authService.register(req.body); 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) { } catch (err) {
next(err); next(err);
} }
@ -231,30 +265,44 @@ router.post(
); );
// POST /api/auth/refresh // POST /api/auth/refresh
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
router.post( router.post(
'/refresh', '/refresh',
authRateLimit, authRateLimit,
validate(refreshSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const result = await authService.refreshTokens(req.body.refreshToken); const refreshToken = req.cookies?.[REFRESH_COOKIE_NAME] || req.body?.refreshToken;
res.json(result); 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) { } catch (err) {
clearRefreshCookie(res);
next(err); next(err);
} }
} }
); );
// POST /api/auth/logout // POST /api/auth/logout
// Accepts refresh token from httpOnly cookie (preferred) or request body (legacy/backward compat)
router.post( router.post(
'/logout', '/logout',
authRateLimit, authRateLimit,
validate(refreshSchema),
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { 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' }); res.json({ message: 'Logged out' });
} catch (err) { } catch (err) {
clearRefreshCookie(res);
next(err); next(err);
} }
} }

View File

@ -424,16 +424,23 @@ export const webhookService = {
const { ticketsService } = await import('../ticketed-events/tickets.service'); const { ticketsService } = await import('../ticketed-events/tickets.service');
const { ticketEmailService } = await import('../ticketed-events/ticket-email.service'); const { ticketEmailService } = await import('../ticketed-events/ticket-email.service');
const qty = parseInt(quantity, 10);
const tickets = await ticketsService.createTickets({ const tickets = await ticketsService.createTickets({
eventId, eventId,
tierId, tierId,
quantity: parseInt(quantity, 10), quantity: qty,
holderEmail: buyerEmail || order.buyerEmail, holderEmail: buyerEmail || order.buyerEmail,
holderName: buyerName || order.buyerName || undefined, holderName: buyerName || order.buyerName || undefined,
userId: userId || order.userId || undefined, userId: userId || order.userId || undefined,
orderId: order.id, 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 // Fetch event + tier for email
const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } }); const event = await prisma.ticketedEvent.findUnique({ where: { id: eventId } });
const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } }); const tier = await prisma.ticketTier.findUnique({ where: { id: tierId } });
@ -534,6 +541,21 @@ export const webhookService = {
where: { id: order.id }, where: { id: order.id },
data: { status: 'FAILED' }, 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}`); 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'); 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 // Check sales window
const now = new Date(); const now = new Date();
if (tier.salesStartAt && tier.salesStartAt > now) { 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'); throw new AppError(400, 'Ticket sales have ended', 'SALES_ENDED');
} }
const unitAmount = tier.tierType === 'DONATION' // Atomically reserve capacity (soldCount + reservedCount checked together)
? Math.max(tier.priceCAD, tier.minDonationCAD || 0) // This prevents overselling from concurrent Stripe checkout sessions
: tier.priceCAD; 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 stripe = await getStripe();
const session = await stripe.checkout.sessions.create({ const session = await stripe.checkout.sessions.create({

View File

@ -696,7 +696,7 @@ export const ticketedEventsService = {
const now = new Date(); const now = new Date();
const tiers = event.ticketTiers.map(t => { 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) && const onSale = (!t.salesStartAt || t.salesStartAt <= now) &&
(!t.salesEndAt || t.salesEndAt >= now); (!t.salesEndAt || t.salesEndAt >= now);
return { return {

View File

@ -5,6 +5,7 @@ import { validate } from '../../middleware/validate';
import { quickJoinRateLimit } from '../../middleware/rate-limit'; import { quickJoinRateLimit } from '../../middleware/rate-limit';
import { volunteerInviteService } from './volunteer-invite.service'; import { volunteerInviteService } from './volunteer-invite.service';
import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas'; import { generateInviteSchema, redeemInviteSchema } from './volunteer-invite.schemas';
import { env } from '../../config/env';
const router = Router(); const router = Router();
@ -33,9 +34,16 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => { async (req: Request, res: Response, next: NextFunction) => {
try { try {
const result = await volunteerInviteService.redeemInvite(req.body); 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({ res.json({
accessToken: result.tokens.accessToken, accessToken: result.tokens.accessToken,
refreshToken: result.tokens.refreshToken,
cutId: result.cutId, cutId: result.cutId,
shiftId: result.shiftId, shiftId: result.shiftId,
}); });

View File

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

View File

@ -510,7 +510,7 @@ services:
image: {{registryUrl}}/mongo:6.0 image: {{registryUrl}}/mongo:6.0
container_name: {{containerPrefix}}-mongodb container_name: {{containerPrefix}}-mongodb
restart: unless-stopped restart: unless-stopped
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"] entrypoint: ["/bin/bash", "-c", "if [ ! -f /data/replica.key ]; then openssl rand -base64 756 > /data/replica.key; fi && chmod 400 /data/replica.key && chown 999:999 /data/replica.key && exec mongod --replSet rs0 --bind_ip_all --auth --keyFile /data/replica.key"]
environment: environment:
MONGO_INITDB_ROOT_USERNAME: "${MONGO_ROOT_USER:-rocketchat}" MONGO_INITDB_ROOT_USERNAME: "${MONGO_ROOT_USER:-rocketchat}"
MONGO_INITDB_ROOT_PASSWORD: "${MONGO_ROOT_PASSWORD}" MONGO_INITDB_ROOT_PASSWORD: "${MONGO_ROOT_PASSWORD}"

View File

@ -891,7 +891,7 @@ services:
image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/mongo:6.0 image: ${GITEA_REGISTRY:-gitea.bnkops.com/admin}/mongo:6.0
container_name: mongodb-rocketchat container_name: mongodb-rocketchat
restart: unless-stopped restart: unless-stopped
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"] entrypoint: ["/bin/bash", "-c", "if [ ! -f /data/replica.key ]; then openssl rand -base64 756 > /data/replica.key; fi && chmod 400 /data/replica.key && chown 999:999 /data/replica.key && exec mongod --replSet rs0 --bind_ip_all --auth --keyFile /data/replica.key"]
environment: environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat} MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env} MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}

View File

@ -908,7 +908,7 @@ services:
image: mongo:6.0 image: mongo:6.0
container_name: mongodb-rocketchat container_name: mongodb-rocketchat
restart: unless-stopped restart: unless-stopped
command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--auth", "--keyFile", "/data/replica.key"] entrypoint: ["/bin/bash", "-c", "if [ ! -f /data/replica.key ]; then openssl rand -base64 756 > /data/replica.key; fi && chmod 400 /data/replica.key && chown 999:999 /data/replica.key && exec mongod --replSet rs0 --bind_ip_all --auth --keyFile /data/replica.key"]
environment: environment:
MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat} MONGO_INITDB_ROOT_USERNAME: ${MONGO_ROOT_USER:-rocketchat}
MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env} MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD:?MONGO_ROOT_PASSWORD must be set in .env}