diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 9e81e92..a320bea 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -20,6 +20,29 @@ import { sendSms, smsConfigured } from '../lib/sms.js'; const SESSION_COOKIE = 'bmm_session'; const OAUTH_STATE_COOKIE = 'bmm_oauth_state'; +/** + * Single source of truth for the session cookie's flags. setCookie AND + * clearCookie MUST agree on (path, sameSite, secure, httpOnly) — when they + * drift, Chrome treats the clear directive as a brand-new cookie with + * different security attributes and silently leaves the original one in + * place. That's what bit us until now: logout looked successful (200 OK + * with a Set-Cookie clear) but the real session cookie persisted because + * the clear omitted Secure+HttpOnly. + */ +function sessionCookieOpts(): { + httpOnly: true; + sameSite: 'lax'; + path: '/'; + secure: boolean; +} { + return { + httpOnly: true, + sameSite: 'lax', + path: '/', + secure: config.NODE_ENV === 'production', + }; +} + const GoogleClaims = z.object({ iss: z.string(), aud: z.string(), @@ -229,7 +252,7 @@ export async function authRoutes(app: FastifyInstance): Promise { const token = req.cookies[SESSION_COOKIE]; const session = token ? await getSession(token) : null; if (token) await destroySession(token); - reply.clearCookie(SESSION_COOKIE, { path: '/' }); + reply.clearCookie(SESSION_COOKIE, sessionCookieOpts()); if (session) { await audit({ orgId: session.orgId,