Fix cookie Secure flag for HTTP dev, un-track generated nginx confs

- Cookie Secure flag now uses req.secure (respects trust proxy +
  X-Forwarded-Proto) instead of NODE_ENV. Works correctly over both
  HTTP (local dev) and HTTPS (production tunnel).
- SameSite=Strict over HTTPS, SameSite=Lax over HTTP (browsers reject
  Strict cookies over plain HTTP).
- Un-track generated nginx/conf.d/api.conf and services.conf (gitignored,
  regenerated from templates at startup).
- Update CLAUDE.md: ENCRYPTION_KEY now required in all environments.

Bunker Admin
This commit is contained in:
2026-03-27 10:06:38 -06:00
parent 776aa6fbac
commit 0fc9ea80bf
5 changed files with 19 additions and 791 deletions

View File

@@ -22,23 +22,25 @@ 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) {
/** Set the refresh token as an httpOnly cookie.
* Uses req.secure (respects trust proxy + X-Forwarded-Proto) to determine
* the Secure flag, so it works correctly over both HTTP (dev) and HTTPS (tunnel). */
function setRefreshCookie(req: Request, res: Response, token: string) {
res.cookie(REFRESH_COOKIE_NAME, token, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
secure: req.secure,
sameSite: req.secure ? 'strict' : 'lax',
maxAge: REFRESH_COOKIE_MAX_AGE,
path: '/api/auth',
});
}
/** Clear the refresh token cookie */
function clearRefreshCookie(res: Response) {
function clearRefreshCookie(req: Request, res: Response) {
res.clearCookie(REFRESH_COOKIE_NAME, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
secure: req.secure,
sameSite: req.secure ? 'strict' : 'lax',
path: '/api/auth',
});
}
@@ -52,7 +54,7 @@ router.post(
try {
const result = await authService.login(req.body.email, req.body.password);
// Set refresh token as httpOnly cookie (not in response body)
setRefreshCookie(res, result.refreshToken);
setRefreshCookie(req, res, result.refreshToken);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh);
} catch (err) {
@@ -71,7 +73,7 @@ router.post(
const result = await authService.register(req.body);
// Set refresh token as httpOnly cookie if tokens were issued (non-verification path)
if ('refreshToken' in result && result.refreshToken) {
setRefreshCookie(res, result.refreshToken);
setRefreshCookie(req, res, result.refreshToken);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.status(201).json(responseWithoutRefresh);
} else {
@@ -278,11 +280,11 @@ router.post(
}
const result = await authService.refreshTokens(refreshToken);
// Set new refresh token as httpOnly cookie
setRefreshCookie(res, result.refreshToken);
setRefreshCookie(req, res, result.refreshToken);
const { refreshToken: _, ...responseWithoutRefresh } = result;
res.json(responseWithoutRefresh);
} catch (err) {
clearRefreshCookie(res);
clearRefreshCookie(req, res);
next(err);
}
}
@@ -299,10 +301,10 @@ router.post(
if (refreshToken) {
await authService.logout(refreshToken);
}
clearRefreshCookie(res);
clearRefreshCookie(req, res);
res.json({ message: 'Logged out' });
} catch (err) {
clearRefreshCookie(res);
clearRefreshCookie(req, res);
next(err);
}
}

View File

@@ -34,11 +34,11 @@ router.post(
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await volunteerInviteService.redeemInvite(req.body);
// Set refresh token as httpOnly cookie
// Set refresh token as httpOnly cookie (Secure flag based on actual protocol)
res.cookie('cml_refresh', result.tokens.refreshToken, {
httpOnly: true,
secure: env.NODE_ENV === 'production',
sameSite: 'strict',
secure: req.secure,
sameSite: req.secure ? 'strict' : 'lax',
maxAge: 7 * 24 * 60 * 60 * 1000,
path: '/api/auth',
});