From ebbbdbdfb8a2683e2ad6d19e25562e9b75017bad Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 18:19:59 +0800 Subject: [PATCH] fix(web): make session cookie secure flag conditional on production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setting Secure on http://localhost cookies works in Chrome (localhost exception) but Firefox/Safari silently drop them, so dev users hit 'redirect to /login on every click' after a 'successful' login. Switch to secure: NODE_ENV === 'production'. Public deploy still gets Secure-only. Also swap the login footer copy from a CLI hint to 'Forget Password? Contact IT' — operator-friendly, doesn't leak the bootstrap mechanism on the public sign-in screen. Test updated to assert secure=true under prod NODE_ENV and a new test locks in secure=false in dev. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/actions/auth.test.ts | 53 ++++++++++++++------ apps/web/src/actions/auth.ts | 7 ++- apps/web/src/app/login/login-form-client.tsx | 3 +- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/apps/web/src/actions/auth.test.ts b/apps/web/src/actions/auth.test.ts index 56b5d36..2307795 100644 --- a/apps/web/src/actions/auth.test.ts +++ b/apps/web/src/actions/auth.test.ts @@ -79,21 +79,44 @@ function fd(fields: Record): FormData { describe("loginAction", () => { it("issues a session cookie when credentials are correct", async () => { findUserMock.mockResolvedValue(ADMIN_ROW); - const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch( - (e) => e, - ); - // Successful login redirects, so the redirect mock throws. - expect((r as Error).message).toBe("redirect"); - expect(cookiesSetMock).toHaveBeenCalledTimes(1); - const [name, , attrs] = cookiesSetMock.mock.calls[0]!; - expect(name).toBe("session"); - expect(attrs).toMatchObject({ - httpOnly: true, - secure: true, - sameSite: "lax", - path: "/", - maxAge: 30 * 86400, - }); + const prevEnv = process.env.NODE_ENV; + // @ts-expect-error - test override + process.env.NODE_ENV = "production"; + try { + const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch( + (e) => e, + ); + // Successful login redirects, so the redirect mock throws. + expect((r as Error).message).toBe("redirect"); + expect(cookiesSetMock).toHaveBeenCalledTimes(1); + const [name, , attrs] = cookiesSetMock.mock.calls[0]!; + expect(name).toBe("session"); + expect(attrs).toMatchObject({ + httpOnly: true, + secure: true, + sameSite: "lax", + path: "/", + maxAge: 30 * 86400, + }); + } finally { + // @ts-expect-error - test restore + process.env.NODE_ENV = prevEnv; + } + }); + + it("sets secure=false on the cookie when NODE_ENV !== production", async () => { + findUserMock.mockResolvedValue(ADMIN_ROW); + const prevEnv = process.env.NODE_ENV; + // @ts-expect-error - test override + process.env.NODE_ENV = "development"; + try { + await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(() => {}); + const [, , attrs] = cookiesSetMock.mock.calls[0]!; + expect(attrs).toMatchObject({ secure: false }); + } finally { + // @ts-expect-error - test restore + process.env.NODE_ENV = prevEnv; + } }); it("returns ok:false on wrong password and does NOT set a cookie", async () => { diff --git a/apps/web/src/actions/auth.ts b/apps/web/src/actions/auth.ts index 93a84ae..fbf0773 100644 --- a/apps/web/src/actions/auth.ts +++ b/apps/web/src/actions/auth.ts @@ -98,9 +98,14 @@ export async function loginAction(formData: FormData): Promise { secret, ); const jar = await cookies(); + // Secure: only require https in production. In dev we hit + // http://localhost:9000 directly, and Firefox/Safari silently drop + // Set-Cookie when Secure is set on http origins (Chrome has a + // localhost exception, others don't), which manifested as the + // session cookie never being persisted across requests. jar.set(COOKIE_NAME, cookie, { httpOnly: true, - secure: true, + secure: process.env.NODE_ENV === "production", sameSite: "lax", path: "/", maxAge: DEFAULT_TTL_SECONDS, diff --git a/apps/web/src/app/login/login-form-client.tsx b/apps/web/src/app/login/login-form-client.tsx index 190cd25..3914b6a 100644 --- a/apps/web/src/app/login/login-form-client.tsx +++ b/apps/web/src/app/login/login-form-client.tsx @@ -59,8 +59,7 @@ export function LoginFormClient({ next }: { next: string }) { Sign in

- First time? Run ./scripts/set-password.sh <username>{" "} - in your tools container. + Forget Password? Contact IT

);