fix(web): make session cookie secure flag conditional on production

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) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 18:19:59 +08:00
parent 7ab51335a4
commit ebbbdbdfb8
3 changed files with 45 additions and 18 deletions

View File

@ -79,21 +79,44 @@ function fd(fields: Record<string, string>): FormData {
describe("loginAction", () => { describe("loginAction", () => {
it("issues a session cookie when credentials are correct", async () => { it("issues a session cookie when credentials are correct", async () => {
findUserMock.mockResolvedValue(ADMIN_ROW); findUserMock.mockResolvedValue(ADMIN_ROW);
const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch( const prevEnv = process.env.NODE_ENV;
(e) => e, // @ts-expect-error - test override
); process.env.NODE_ENV = "production";
// Successful login redirects, so the redirect mock throws. try {
expect((r as Error).message).toBe("redirect"); const r = await loginAction(fd({ username: "admin", password: "correct-horse" })).catch(
expect(cookiesSetMock).toHaveBeenCalledTimes(1); (e) => e,
const [name, , attrs] = cookiesSetMock.mock.calls[0]!; );
expect(name).toBe("session"); // Successful login redirects, so the redirect mock throws.
expect(attrs).toMatchObject({ expect((r as Error).message).toBe("redirect");
httpOnly: true, expect(cookiesSetMock).toHaveBeenCalledTimes(1);
secure: true, const [name, , attrs] = cookiesSetMock.mock.calls[0]!;
sameSite: "lax", expect(name).toBe("session");
path: "/", expect(attrs).toMatchObject({
maxAge: 30 * 86400, 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 () => { it("returns ok:false on wrong password and does NOT set a cookie", async () => {

View File

@ -98,9 +98,14 @@ export async function loginAction(formData: FormData): Promise<LoginResult> {
secret, secret,
); );
const jar = await cookies(); 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, { jar.set(COOKIE_NAME, cookie, {
httpOnly: true, httpOnly: true,
secure: true, secure: process.env.NODE_ENV === "production",
sameSite: "lax", sameSite: "lax",
path: "/", path: "/",
maxAge: DEFAULT_TTL_SECONDS, maxAge: DEFAULT_TTL_SECONDS,

View File

@ -59,8 +59,7 @@ export function LoginFormClient({ next }: { next: string }) {
Sign in Sign in
</Button> </Button>
<p className="text-xs text-muted-foreground text-center"> <p className="text-xs text-muted-foreground text-center">
First time? Run <code>./scripts/set-password.sh &lt;username&gt;</code>{" "} Forget Password? Contact IT
in your tools container.
</p> </p>
</form> </form>
); );