batchCooking/apps/api/src/modules/auth/auth.routes.ts
Nicolas 266f540d88 fix(api): make the session cookie's Secure flag overridable
Found on http://batch.dev.kyuno.fr/: login/signup succeeded (200/201,
profile in the body) but every subsequent request 401'd. Cause: the
session cookie is `secure: NODE_ENV === "production"`, and
docker-compose.yml sets NODE_ENV=production regardless of whether the
deployment actually has TLS in front of it. A Secure cookie is silently
never sent back by the browser over plain HTTP — no error, just a cookie
that never round-trips.

Adds COOKIE_SECURE, independent from NODE_ENV, to override the flag per
deployment. Unset (default) keeps prior behavior — secure in production.
Set COOKIE_SECURE=false only for a deployment reachable over plain HTTP
(no TLS yet), like this dev instance.

Verified locally: docker compose up with COOKIE_SECURE=false persists
and round-trips the cookie (signup -> /auth/me 200); without it, the
cookie still gets Secure as before. Full pnpm --filter api test / test:bdd
suites still pass (66 + 25 scenarios).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-17 23:49:05 +02:00

82 lines
3.4 KiB
TypeScript

import { wrapAsyncHandler } from "@batch-cooking/express-tools";
import { deleteAccountSchema, loginSchema, signupSchema } from "@batch-cooking/shared";
import { Router } from "express";
import type { CookieOptions, Response } from "express";
import { env } from "../../config/env.js";
import { type AuthLocals, requireAuth } from "../../middlewares/require-auth.js";
import { deleteAccount, login, signup } from "./auth.service.js";
/** Router mounted at `/auth` in app.ts — signup, login, logout, current-profile. */
export const authRouter = Router();
// Deliberately independent from JWT_EXPIRES_IN: the JWT's own expiry is
// what's actually enforced by requireAuth (a request with an expired JWT
// is rejected regardless of the cookie still being present) — this only
// bounds how long the browser keeps *sending* the cookie at all.
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
/** Cookie options shared by every route that sets the session cookie. */
const cookieOptions: CookieOptions = {
httpOnly: true,
// Defaults to requiring HTTPS in production, but overridable via
// COOKIE_SECURE — see its doc comment in config/env.ts for why this
// can't just be `NODE_ENV === "production"`.
secure: env.COOKIE_SECURE ?? env.NODE_ENV === "production",
sameSite: "lax",
maxAge: SEVEN_DAYS_MS,
};
// `res.clearCookie` sets its own expiry to clear the cookie — passing
// `maxAge` alongside is deprecated (and pointless) as of Express 4.20, so
// every clearing route reuses `cookieOptions` minus that one field rather
// than duplicating the rest by hand.
const { maxAge: _maxAge, ...clearCookieOptions } = cookieOptions;
/**
* Creates a profile (+ its household) and logs the new user in
* immediately. `wrapAsyncHandler` forwards a thrown/rejected error to
* Express's error middleware automatically — no manual try/catch needed.
*/
authRouter.post(
"/signup",
wrapAsyncHandler(async (req, res) => {
const input = signupSchema.parse(req.body);
const { profile, token } = await signup(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(201).json(profile);
}),
);
/** Verifies credentials and starts a new session. */
authRouter.post(
"/login",
wrapAsyncHandler(async (req, res) => {
const input = loginSchema.parse(req.body);
const { profile, token } = await login(input);
res.cookie(env.AUTH_COOKIE_NAME, token, cookieOptions);
res.status(200).json(profile);
}),
);
/** Ends the current session by clearing the cookie. Stateless JWT, so there's nothing to revoke server-side (yet — see tokenVersion). */
authRouter.post("/logout", (_req, res) => {
res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions);
res.status(204).end();
});
/** Returns the currently authenticated profile. Behind requireAuth — 401s if there's no valid session. */
authRouter.get("/me", requireAuth, (_req, res: Response<unknown, AuthLocals>) => {
res.status(200).json(res.locals.userProfile);
});
/** Permanently deletes the current profile (re-verifying its password first) and clears the session cookie. */
authRouter.delete(
"/me",
requireAuth,
wrapAsyncHandler<unknown, AuthLocals>(async (req, res) => {
const input = deleteAccountSchema.parse(req.body);
await deleteAccount(res.locals.userProfile.id, input.password);
res.clearCookie(env.AUTH_COOKIE_NAME, clearCookieOptions);
res.status(204).end();
}),
);