diff --git a/services/auth/src/router.test.ts b/services/auth/src/router.test.ts index 1b5febd..42b51c5 100644 --- a/services/auth/src/router.test.ts +++ b/services/auth/src/router.test.ts @@ -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 = {}; + + 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, + }; + } + + 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 diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index c8a466a..6a92ec2 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -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