fix(auth): sign the webmail token for 30 days, not only its cookie

issueSession raised the cookie and the session row to 30 days but still let
createJwt default to 8 hours, so the session would have died at 8 hours with
a cookie that outlived it. Also covers account switching, sign-out and
renewal against a real database.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 15:04:24 -03:00
1 parent 54203d596e
commit 026f24a64a
2 files changed
+139 -4

No files matched your search

+138 -3
View File
@@ -1,4 +1,4 @@
import { describe, it, expect, beforeAll, afterAll } from "vitest";
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
import { createServer, Server } from "node:http";
import crypto from "node:crypto";
@@ -1155,7 +1155,7 @@ describeDb("moving between the surfaces", () => {
expect((await get("/api/v1/admin/dashboard", stepUp.body.token)).status).toBe(200);
// Both sessions come back, which is what makes leaving the console free.
expect(cookieNames(stepUp.cookies)).toEqual(["lm_admin_session", "lm_user_session"]);
expect(cookieNames(stepUp.cookies)).toEqual(["lm_admin_session", `lm_acct_${stepUpId}`, "lm_user_session"].sort());
}, 120000);
// Proving a password and a second factor is strictly more than the webmail
@@ -1172,7 +1172,7 @@ describeDb("moving between the surfaces", () => {
code: generateHotp(base32Decode(secret), getCurrentStep()),
});
expect(verified.status).toBe(200);
expect(cookieNames(verified.cookies)).toEqual(["lm_admin_session", "lm_user_session"]);
expect(cookieNames(verified.cookies)).toEqual(["lm_admin_session", `lm_acct_${stepUpId}`, "lm_user_session"].sort());
// The webmail cookie has to be a webmail token, not the admin one copied
// across: an admin-audience token is refused by every /user route.
@@ -1193,11 +1193,146 @@ describeDb("moving between the surfaces", () => {
expect(second.status).toBe(200);
expect(first.body.token).not.toBe(second.body.token);
}, 60000);
// Several webmail accounts at once, and a session that renews itself.
describe("several accounts and sliding sessions", () => {
const emailOf = (local: string) => `${local}@authtest-${DOMAIN_ID}.invalid`;
const ids: Record<string, string> = {};
async function signIn(local: string): Promise<{ id: string; token: string }> {
const res = await fetch(`${base}/api/v1/auth/user/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: emailOf(local), password: PASSWORD }),
});
expect(res.status).toBe(200);
const token = ((await res.json()) as { token: string }).token;
return { id: ids[local], token };
}
const cookieHeader = (active: string, accounts: { id: string; token: string }[]) =>
[`lm_user_session=${active}`, ...accounts.map((a) => `lm_acct_${a.id}=${a.token}`)].join("; ");
async function call(path: string, cookie: string, method = "GET", body?: unknown) {
const res = await fetch(`${base}${path}`, {
method,
headers: { Cookie: cookie, "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
return {
status: res.status,
cookies: res.headers.getSetCookie(),
body: (await res.json().catch(() => ({}))) as Record<string, any>,
};
}
const valueOf = (cookies: string[], name: string) =>
cookies.find((c) => c.startsWith(`${name}=`))?.split(";")[0].slice(name.length + 1);
beforeAll(async () => {
const hash = await hashPassword(PASSWORD);
for (const local of ["multi1", "multi2"]) {
const row = await query<{ id: string }>(
`INSERT INTO mailboxes (domain_id, local_part, email_address, password_hash, role)
VALUES ($1, $2, $3, $4, 'USER') RETURNING id`,
[DOMAIN_ID, local, emailOf(local), hash],
);
ids[local] = row[0].id;
}
}, 60000);
it("lists the accounts signed in and switches between them", async () => {
const a = await signIn("multi1");
const b = await signIn("multi2");
const jar = cookieHeader(b.token, [a, b]);
const listed = await call("/api/v1/auth/accounts", jar);
expect(listed.body.accounts.map((x: { email: string; active: boolean }) => [x.email, x.active])).toEqual([
[emailOf("multi1"), false],
[emailOf("multi2"), true],
]);
const switched = await call("/api/v1/auth/switch", jar, "POST", { id: a.id });
expect(switched.status).toBe(200);
expect(valueOf(switched.cookies, "lm_user_session")).toBe(a.token);
const unknown = await call("/api/v1/auth/switch", jar, "POST", { id: "44444444-4444-4444-4444-444444444444" });
expect(unknown.status).toBe(404);
}, 60000);
it("will not switch to a token that belongs to another account's cookie", async () => {
const a = await signIn("multi1");
const b = await signIn("multi2");
// b's token filed under a's id must not make a switch land on b.
const jar = cookieHeader(a.token, [{ id: a.id, token: b.token }]);
const listed = await call("/api/v1/auth/accounts", jar);
expect(listed.body.accounts).toEqual([]);
}, 60000);
it("signing out leaves the other account signed in and ends the one left", async () => {
const a = await signIn("multi1");
const b = await signIn("multi2");
const jar = cookieHeader(b.token, [a, b]);
const out = await call("/api/v1/auth/logout", jar, "POST", {});
expect(out.body.remaining.map((x: { email: string }) => x.email)).toEqual([emailOf("multi1")]);
expect(valueOf(out.cookies, "lm_user_session")).toBe(a.token);
expect(out.cookies.find((c) => c.startsWith(`lm_acct_${b.id}=`))).toContain("Max-Age=0");
// The session that was left is dead server-side, not only forgotten by the browser.
const reuse = await call("/api/v1/user/me", `lm_user_session=${b.token}`);
expect(reuse.status).toBe(401);
const still = await call("/api/v1/user/me", `lm_user_session=${a.token}`);
expect(still.status).toBe(200);
}, 60000);
it("signing out of everything clears every cookie", async () => {
const a = await signIn("multi1");
const b = await signIn("multi2");
const out = await call("/api/v1/auth/logout", cookieHeader(b.token, [a, b]), "POST", { all: true });
expect(out.body.remaining).toEqual([]);
expect(valueOf(out.cookies, "lm_user_session")).toBe("");
expect((await call("/api/v1/user/me", `lm_user_session=${a.token}`)).status).toBe(401);
}, 60000);
it("signs the token itself for 30 days, not only the cookie", async () => {
const a = await signIn("multi1");
const claims = (token: string) => JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
const user = claims(a.token);
expect(user.exp - user.iat).toBe(30 * 24 * 3600);
}, 60000);
it("renews a session once it is a day old and keeps the old one for a moment", async () => {
const a = await signIn("multi1");
const jar = cookieHeader(a.token, [a]);
const fresh = await call("/api/v1/user/me", jar);
expect(fresh.status).toBe(200);
expect(valueOf(fresh.cookies, "lm_user_session")).toBeUndefined();
vi.useFakeTimers({ toFake: ["Date"], now: Date.now() + 25 * 3600 * 1000 });
try {
const aged = await call("/api/v1/user/me", jar);
expect(aged.status).toBe(200);
const renewed = valueOf(aged.cookies, "lm_user_session");
expect(renewed).toBeTruthy();
expect(renewed).not.toBe(a.token);
expect(valueOf(aged.cookies, `lm_acct_${a.id}`)).toBe(renewed);
expect((await call("/api/v1/user/me", `lm_user_session=${renewed}`)).status).toBe(200);
// A request already in flight with the old token still succeeds.
expect((await call("/api/v1/user/me", `lm_user_session=${a.token}`)).status).toBe(200);
} finally {
vi.useRealTimers();
}
}, 60000);
});
});
if (!hasDatabase) {
// eslint-disable-next-line no-console
console.warn("TEST_DATABASE_URL not set - auth API integration tests skipped");
}
// generateTotpSecret is exercised through the enrollment flow above; this
+1 -1
View File
@@ -567,7 +567,7 @@ async function issueSession(
mfaSatisfied: factorProven,
...(factorProven ? { mfaSatisfiedAt: Date.now() } : {}),
purpose: "session",
});
}, sessionTtlFor(granted));
if (!primaryToken) primaryToken = token;
// Each token is recorded separately, or "sign out this device" would revoke