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:
1 parent
54203d596e
commit
026f24a64a
2 files changed
+139
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user