feat(auth): 30-day sliding webmail sessions and several accounts signed in at once

The webmail session now lasts 30 days and is swapped for a fresh token once a
day while in use; the console keeps its fixed 8 hours. Each sign-in is also
filed under lm_acct_<mailbox id>, with new endpoints to list and switch
accounts, and sign-out now leaves the other accounts signed in.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 14:42:59 -03:00
1 parent aea4c1ee5e
commit 0efc8f096a
4 files changed
+275 -10

No files matched your search

+42
View File
@@ -0,0 +1,42 @@
import { describe, it, expect } from "vitest";
import {
accountCookieName,
accountTokens,
parseCookies,
shouldRenew,
RENEW_AFTER_SECONDS,
} from "./accounts.js";
const ID_A = "22222222-2222-2222-2222-222222222222";
const ID_B = "33333333-3333-3333-3333-333333333333";
describe("account cookies", () => {
it("names the cookie after the mailbox", () => {
expect(accountCookieName(ID_A)).toBe(`lm_acct_${ID_A}`);
});
it("keeps a value that contains '='", () => {
expect(parseCookies("a=b=c; d=e")).toEqual({ a: "b=c", d: "e" });
});
it("collects every signed-in account and nothing else", () => {
const header = `lm_user_session=x; lm_acct_${ID_A}=tokA; lm_acct_${ID_B}=tokB; lm_acct_notauuid=evil; other=1`;
expect(accountTokens(header)).toEqual([
{ id: ID_A, token: "tokA" },
{ id: ID_B, token: "tokB" },
]);
});
it("returns an empty list when there is no cookie header", () => {
expect(accountTokens(undefined)).toEqual([]);
});
});
describe("shouldRenew", () => {
it("leaves a fresh token alone", () => {
expect(shouldRenew(1000, 1000 + RENEW_AFTER_SECONDS - 1)).toBe(false);
});
it("replaces a token a day old", () => {
expect(shouldRenew(1000, 1000 + RENEW_AFTER_SECONDS)).toBe(true);
});
});
+56
View File
@@ -0,0 +1,56 @@
/**
* Several webmail accounts signed in at once, and a session that stays alive
* for as long as it is used.
*
* Each signed-in account keeps its own cookie, lm_acct_<mailbox id>, next to
* lm_user_session. lm_user_session stays the *active* account: every existing
* route, the Next middleware and the protocols service keep reading that one
* cookie and need no change. Switching accounts is copying one of the
* lm_acct_ tokens into lm_user_session.
*/
/** A webmail session lasts this long without use, and is renewed while in use. */
export const USER_SESSION_TTL_SECONDS = 30 * 24 * 3600;
/** The console is the sensitive surface: it keeps the short, fixed lifetime. */
export const ADMIN_SESSION_TTL_SECONDS = 8 * 3600;
/** A token is replaced once it is older than this, so a busy day costs one write. */
export const RENEW_AFTER_SECONDS = 24 * 3600;
/** How long a replaced token keeps working, for requests already in flight. */
export const REPLACED_GRACE_SECONDS = 120;
const ACCOUNT_COOKIE_PREFIX = "lm_acct_";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** More than this and the Cookie header risks the proxies' 8 KB limit. */
export const MAX_ACCOUNTS = 6;
export function accountCookieName(mailboxId: string): string {
return `${ACCOUNT_COOKIE_PREFIX}${mailboxId}`;
}
export function parseCookies(header: string | undefined): Record<string, string> {
const out: Record<string, string> = {};
if (!header) return out;
for (const part of header.split(";")) {
const trimmed = part.trim();
const eq = trimmed.indexOf("=");
// sliced rather than split so a value containing "=" survives intact.
if (eq > 0) out[trimmed.slice(0, eq)] = trimmed.slice(eq + 1);
}
return out;
}
/** The account cookies a browser sent, as {mailbox id, token}. Names that are not a UUID are ignored. */
export function accountTokens(header: string | undefined): { id: string; token: string }[] {
const found: { id: string; token: string }[] = [];
for (const [name, token] of Object.entries(parseCookies(header))) {
if (!name.startsWith(ACCOUNT_COOKIE_PREFIX) || !token) continue;
const id = name.slice(ACCOUNT_COOKIE_PREFIX.length);
if (UUID.test(id)) found.push({ id: id.toLowerCase(), token });
}
return found;
}
/** Whether a token has aged enough to be replaced with a fresh one. */
export function shouldRenew(iat: number, nowSeconds: number): boolean {
return nowSeconds - iat >= RENEW_AFTER_SECONDS;
}
+15 -1
View File
@@ -662,9 +662,23 @@ export async function revokeOtherSessions(mailboxId: string, currentTokenHash: s
return rows.length;
}
/**
* Ends a session after `graceSeconds`. Renewal uses a short grace so requests
* already in flight with the old token do not fail; sign-out uses none.
*/
export async function retireSession(tokenHash: string, graceSeconds: number): Promise<void> {
await query(
`UPDATE web_sessions SET revoked_at = NOW() + ($2 || ' seconds')::interval
WHERE refresh_token_hash = $1 AND revoked_at IS NULL`,
[tokenHash, String(graceSeconds)],
);
}
export async function isSessionRevoked(tokenHash: string): Promise<boolean> {
const row = await queryOne<{ revoked: boolean }>(
`SELECT (revoked_at IS NOT NULL) AS revoked FROM web_sessions WHERE refresh_token_hash = $1`,
// A revoked_at in the future is a replaced token still inside its grace
// period (see retireSession), so it only counts once that moment passes.
`SELECT (revoked_at IS NOT NULL AND revoked_at <= NOW()) AS revoked FROM web_sessions WHERE refresh_token_hash = $1`,
[tokenHash],
);
// An unknown hash is treated as live: tokens issued before this table was
+162 -9
View File
@@ -14,12 +14,28 @@ import * as repo from "./repository.js";
import { parsePageParams, paged } from "./pagination.js";
import { checkAvatar, MAX_AVATAR_BYTES } from "./imageValidation.js";
import { checkUser, checkServer, overallStatus } from "./diagnostics.js";
import {
USER_SESSION_TTL_SECONDS,
ADMIN_SESSION_TTL_SECONDS,
REPLACED_GRACE_SECONDS,
accountCookieName,
accountTokens,
parseCookies,
shouldRenew,
} from "./accounts.js";
const CHALLENGE_TTL_SECONDS = 300;
// Long enough to scan a code and type one back, short enough that a token
// granting enrolment is not left lying around.
const ENROLMENT_TTL_SECONDS = 900;
const SESSION_TTL_SECONDS = 28800;
function sessionTtlFor(surface: Surface): number {
return surface === "admin" ? ADMIN_SESSION_TTL_SECONDS : USER_SESSION_TTL_SECONDS;
}
function sha256(token: string): string {
return crypto.createHash("sha256").update(token).digest("hex");
}
/** Cookie scoped to the whole origin: the API lives under /api, so a cookie
* pinned to /user or /admin would never be sent with the requests that need
@@ -132,6 +148,10 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
}
}
if (session && url.startsWith("/api/v1/user/")) {
await renewIfAged(req, res, session, token);
}
if (url === "/api/v1/auth/user/login" && method === "POST") return login(req, res, "user");
if (url === "/api/v1/auth/admin/login" && method === "POST") return login(req, res, "admin");
if (url === "/api/v1/auth/mfa/verify" && method === "POST") return mfaVerify(req, res);
@@ -139,7 +159,9 @@ async function route(req: IncomingMessage, res: ServerResponse, url: string): Pr
// session yet - obtaining one is the whole point - so the admin guard above
// must not stand in front of it.
if (url === "/api/v1/auth/admin/step-up" && method === "POST") return adminStepUp(req, res);
if (url === "/api/v1/auth/logout" && method === "POST") return logout(res);
if (url === "/api/v1/auth/logout" && method === "POST") return logout(req, res);
if (url === "/api/v1/auth/accounts" && method === "GET") return listAccounts(req, res);
if (url === "/api/v1/auth/switch" && method === "POST") return switchAccount(req, res);
if (url === "/api/v1/user/me" && method === "GET") return me(res, session!);
if (url === "/api/v1/user/locale" && method === "PUT") return updateLocale(req, res, session!);
@@ -554,12 +576,17 @@ async function issueSession(
mailbox.id,
granted,
crypto.createHash("sha256").update(token).digest("hex"),
new Date(Date.now() + SESSION_TTL_SECONDS * 1000),
new Date(Date.now() + sessionTtlFor(granted) * 1000),
clientIp(req),
req.headers["user-agent"] ?? null,
);
cookies.push(sessionCookie(cookieNameFor(granted), token, SESSION_TTL_SECONDS, isSecureRequest()));
cookies.push(sessionCookie(cookieNameFor(granted), token, sessionTtlFor(granted), isSecureRequest()));
// The webmail session is also filed under the account, so this sign-in
// joins the others instead of replacing them.
if (granted === "user") {
cookies.push(sessionCookie(accountCookieName(mailbox.id), token, sessionTtlFor(granted), isSecureRequest()));
}
}
res.setHeader("Set-Cookie", cookies);
@@ -609,12 +636,138 @@ async function mfaVerify(req: IncomingMessage, res: ServerResponse): Promise<voi
await issueSession(req, res, mailbox, payload.surface, false, true);
}
function logout(res: ServerResponse): void {
/**
* The accounts signed in on this browser whose sessions are still good, with
* the active one first-class so the switcher can mark it.
*/
async function liveAccounts(req: IncomingMessage): Promise<{ id: string; email: string; token: string }[]> {
const live: { id: string; email: string; token: string }[] = [];
for (const { id, token } of accountTokens(req.headers.cookie)) {
const payload = verifyJwt(token);
if (!payload || !isSurfaceAuthorized(payload, "user") || payload.sub.toLowerCase() !== id) continue;
if (await repo.isSessionRevoked(sha256(token))) continue;
live.push({ id, email: payload.email, token });
}
return live.sort((a, b) => a.email.localeCompare(b.email));
}
function activeUserToken(req: IncomingMessage): string {
return parseCookies(req.headers.cookie)["lm_user_session"] ?? "";
}
async function listAccounts(req: IncomingMessage, res: ServerResponse): Promise<void> {
const active = verifyJwt(activeUserToken(req));
const accounts = (await liveAccounts(req)).map(({ id, email }) => ({
id,
email,
active: active?.sub.toLowerCase() === id,
}));
sendJson(res, 200, { accounts });
}
async function switchAccount(req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await parseJsonBody(req);
const id = typeof body?.id === "string" ? body.id.toLowerCase() : "";
const target = (await liveAccounts(req)).find((account) => account.id === id);
if (!target) {
return sendJson(res, 404, { error: "NOT_FOUND", message: "That account is not signed in on this browser" });
}
res.setHeader("Set-Cookie", [
sessionCookie("lm_user_session", "", 0, isSecureRequest()),
sessionCookie("lm_admin_session", "", 0, isSecureRequest()),
] as unknown as string);
sendJson(res, 200, { ok: true });
sessionCookie("lm_user_session", target.token, USER_SESSION_TTL_SECONDS, isSecureRequest()),
]);
sendJson(res, 200, { ok: true, email: target.email });
}
/**
* Signs out the active account. The others stay signed in, and one of them
* becomes active, so leaving an account does not mean typing every password
* again. { "all": true } signs out everything on this browser.
*/
async function logout(req: IncomingMessage, res: ServerResponse): Promise<void> {
const body = await parseJsonBody(req);
const everything = body?.all === true;
const secure = isSecureRequest();
const clear = (name: string) => sessionCookie(name, "", 0, secure);
const cookies: string[] = [clear("lm_admin_session")];
const activeToken = activeUserToken(req);
const active = activeToken ? verifyJwt(activeToken) : null;
const signedIn = accountTokens(req.headers.cookie);
const toEnd = everything ? signedIn.map((a) => a.token) : [];
if (activeToken) toEnd.push(activeToken);
for (const token of toEnd) await repo.retireSession(sha256(token), 0);
if (everything) {
for (const { id } of signedIn) cookies.push(clear(accountCookieName(id)));
} else if (active) {
cookies.push(clear(accountCookieName(active.sub)));
}
const others = everything
? []
: (await liveAccounts(req)).filter((account) => account.id !== active?.sub.toLowerCase());
// One Set-Cookie per name: the next account takes over lm_user_session, or
// the cookie is cleared when nobody is left.
cookies.push(
others.length > 0
? sessionCookie("lm_user_session", others[0].token, USER_SESSION_TTL_SECONDS, secure)
: clear("lm_user_session"),
);
const remaining = others.map(({ id, email }) => ({ id, email }));
res.setHeader("Set-Cookie", cookies);
sendJson(res, 200, { ok: true, remaining });
}
/**
* Keeps a webmail session alive while it is being used.
*
* A token carries its own expiry and cannot be extended, so once it has aged
* a day it is swapped for a fresh one. The old one is retired after a short
* grace rather than at once, so a request already on its way with it does not
* come back 401. The console is never renewed: it stays at its fixed lifetime.
*/
async function renewIfAged(
req: IncomingMessage,
res: ServerResponse,
session: SessionTokenPayload,
token: string,
): Promise<void> {
if (session.purpose !== "session" || session.surface !== "user") return;
// Only the cookie is renewed; a bearer token has nowhere to receive a new one.
if (activeUserToken(req) !== token) return;
if (!shouldRenew(session.iat, Math.floor(Date.now() / 1000))) return;
const fresh = createJwt(
{
sub: session.sub,
email: session.email,
role: session.role,
domainId: session.domainId,
surface: "user",
aud: "lambdamail:user",
mfaSatisfied: session.mfaSatisfied,
...(session.mfaSatisfiedAt !== undefined ? { mfaSatisfiedAt: session.mfaSatisfiedAt } : {}),
purpose: "session",
},
USER_SESSION_TTL_SECONDS,
);
await repo.recordSession(
session.sub,
"user",
sha256(fresh),
new Date(Date.now() + USER_SESSION_TTL_SECONDS * 1000),
clientIp(req),
req.headers["user-agent"] ?? null,
);
await repo.retireSession(sha256(token), REPLACED_GRACE_SECONDS);
const secure = isSecureRequest();
res.setHeader("Set-Cookie", [
sessionCookie("lm_user_session", fresh, USER_SESSION_TTL_SECONDS, secure),
sessionCookie(accountCookieName(session.sub), fresh, USER_SESSION_TTL_SECONDS, secure),
]);
}
// -------------------------------------------------------------- user area