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 { createServer, Server } from "node:http";
|
||||||
import crypto from "node:crypto";
|
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);
|
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.
|
// 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);
|
}, 120000);
|
||||||
|
|
||||||
// Proving a password and a second factor is strictly more than the webmail
|
// 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()),
|
code: generateHotp(base32Decode(secret), getCurrentStep()),
|
||||||
});
|
});
|
||||||
expect(verified.status).toBe(200);
|
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
|
// 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.
|
// 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(second.status).toBe(200);
|
||||||
expect(first.body.token).not.toBe(second.body.token);
|
expect(first.body.token).not.toBe(second.body.token);
|
||||||
}, 60000);
|
}, 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) {
|
if (!hasDatabase) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.warn("TEST_DATABASE_URL not set - auth API integration tests skipped");
|
console.warn("TEST_DATABASE_URL not set - auth API integration tests skipped");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateTotpSecret is exercised through the enrollment flow above; this
|
// generateTotpSecret is exercised through the enrollment flow above; this
|
||||||
|
|||||||
@@ -567,7 +567,7 @@ async function issueSession(
|
|||||||
mfaSatisfied: factorProven,
|
mfaSatisfied: factorProven,
|
||||||
...(factorProven ? { mfaSatisfiedAt: Date.now() } : {}),
|
...(factorProven ? { mfaSatisfiedAt: Date.now() } : {}),
|
||||||
purpose: "session",
|
purpose: "session",
|
||||||
});
|
}, sessionTtlFor(granted));
|
||||||
if (!primaryToken) primaryToken = token;
|
if (!primaryToken) primaryToken = token;
|
||||||
|
|
||||||
// Each token is recorded separately, or "sign out this device" would revoke
|
// Each token is recorded separately, or "sign out this device" would revoke
|
||||||
|
|||||||
Reference in new issue
Block a user