From 0efc8f096a23afaf83f9f36a9ab338f93045eb23 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 14:42:59 -0300 Subject: [PATCH 1/3] 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_, 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 --- services/auth/src/accounts.test.ts | 42 +++++++ services/auth/src/accounts.ts | 56 ++++++++++ services/auth/src/repository.ts | 16 ++- services/auth/src/router.ts | 171 +++++++++++++++++++++++++++-- 4 files changed, 275 insertions(+), 10 deletions(-) create mode 100644 services/auth/src/accounts.test.ts create mode 100644 services/auth/src/accounts.ts diff --git a/services/auth/src/accounts.test.ts b/services/auth/src/accounts.test.ts new file mode 100644 index 0000000..edd9aa6 --- /dev/null +++ b/services/auth/src/accounts.test.ts @@ -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); + }); +}); diff --git a/services/auth/src/accounts.ts b/services/auth/src/accounts.ts new file mode 100644 index 0000000..f3f78f2 --- /dev/null +++ b/services/auth/src/accounts.ts @@ -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_, 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 { + const out: Record = {}; + 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; +} diff --git a/services/auth/src/repository.ts b/services/auth/src/repository.ts index 0560da4..b43267a 100644 --- a/services/auth/src/repository.ts +++ b/services/auth/src/repository.ts @@ -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 { + 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 { 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 diff --git a/services/auth/src/router.ts b/services/auth/src/router.ts index 1dd4450..c8a466a 100644 --- a/services/auth/src/router.ts +++ b/services/auth/src/router.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 From 96e624dd12cd98c6b4b957380ddd74106c8a2aa6 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 14:44:50 -0300 Subject: [PATCH 2/3] feat(webmail): account switcher for the several signed-in accounts Co-Authored-By: Claude Sonnet 5.5 --- services/webmail/messages/en.json | 8 ++ services/webmail/messages/es.json | 8 ++ services/webmail/messages/pt-BR.json | 8 ++ .../webmail/src/app/(user)/user/layout.tsx | 33 +---- .../src/components/AccountSwitcher.tsx | 134 ++++++++++++++++++ 5 files changed, 160 insertions(+), 31 deletions(-) create mode 100644 services/webmail/src/components/AccountSwitcher.tsx diff --git a/services/webmail/messages/en.json b/services/webmail/messages/en.json index 39083f4..bc7f22e 100644 --- a/services/webmail/messages/en.json +++ b/services/webmail/messages/en.json @@ -533,5 +533,13 @@ "zoom": "Zoom", "dragToPosition": "Drag to position", "notAnImage": "That file could not be read as an image." + }, + "accounts": { + "switchAccount": "Switch account", + "addAccount": "Add another account", + "signOutOne": "Sign out of {email}", + "signOutAll": "Sign out of all accounts", + "active": "Active", + "settings": "Account settings" } } diff --git a/services/webmail/messages/es.json b/services/webmail/messages/es.json index a68caa3..5156653 100644 --- a/services/webmail/messages/es.json +++ b/services/webmail/messages/es.json @@ -533,5 +533,13 @@ "zoom": "Zoom", "dragToPosition": "Arrastre para posicionar", "notAnImage": "No se pudo leer ese archivo como imagen." + }, + "accounts": { + "switchAccount": "Cambiar de cuenta", + "addAccount": "Añadir otra cuenta", + "signOutOne": "Cerrar sesión de {email}", + "signOutAll": "Cerrar sesión en todas las cuentas", + "active": "Activa", + "settings": "Ajustes de la cuenta" } } diff --git a/services/webmail/messages/pt-BR.json b/services/webmail/messages/pt-BR.json index 3930a8c..3ae4f05 100644 --- a/services/webmail/messages/pt-BR.json +++ b/services/webmail/messages/pt-BR.json @@ -533,5 +533,13 @@ "zoom": "Zoom", "dragToPosition": "Arraste para posicionar", "notAnImage": "Não foi possível ler esse arquivo como imagem." + }, + "accounts": { + "switchAccount": "Trocar de conta", + "addAccount": "Adicionar outra conta", + "signOutOne": "Sair de {email}", + "signOutAll": "Sair de todas as contas", + "active": "Ativa", + "settings": "Configurações da conta" } } diff --git a/services/webmail/src/app/(user)/user/layout.tsx b/services/webmail/src/app/(user)/user/layout.tsx index 87e8cad..8e59258 100644 --- a/services/webmail/src/app/(user)/user/layout.tsx +++ b/services/webmail/src/app/(user)/user/layout.tsx @@ -27,7 +27,7 @@ import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher"; import { useAccount, isAdminRole } from "../../../lib/useAccount"; import { useFolders } from "../../../lib/useFolders"; import { folderBadge, customFolders } from "../../../lib/mailCounts"; -import { Avatar } from "../../../components/Avatar"; +import { AccountSwitcher } from "../../../components/AccountSwitcher"; export default function UserWebmailLayout({ children }: { children: React.ReactNode }) { const t = useTranslations(); @@ -274,36 +274,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN )} - setMobileOpen(false)} - className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge transition-colors hover:bg-dark-card" - > - - - - {account?.email ?? t("common.loading")} - - {/* Reports what is actually configured. The old fixed "2FA - active" reassured accounts that had no second factor at all. */} - - {account?.mfa_enrolled ? ( - <> - - {t("settings.mfaEnabled")} - - ) : ( - <> - - {t("settings.mfaDisabled")} - - )} - - - + setMobileOpen(false)} />
diff --git a/services/webmail/src/components/AccountSwitcher.tsx b/services/webmail/src/components/AccountSwitcher.tsx new file mode 100644 index 0000000..fc32fcd --- /dev/null +++ b/services/webmail/src/components/AccountSwitcher.tsx @@ -0,0 +1,134 @@ +"use client"; + +import React, { useCallback, useEffect, useRef, useState } from "react"; +import Link from "next/link"; +import { Check, ChevronsUpDown, LogOut, Plus, Settings } from "lucide-react"; +import { useTranslations } from "../i18n/provider"; +import { Avatar } from "./Avatar"; + +interface SignedInAccount { + id: string; + email: string; + active: boolean; +} + +/** + * The webmail accounts signed in on this browser, and the way to move between + * them without signing out. The auth service keeps one session per account; + * switching only changes which one is active. + */ +export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) { + const t = useTranslations(); + const [accounts, setAccounts] = useState([]); + const [open, setOpen] = useState(false); + const root = useRef(null); + + const load = useCallback(() => { + fetch("/api/v1/auth/accounts") + .then((res) => (res.ok ? res.json() : { accounts: [] })) + .then((data) => setAccounts(data.accounts ?? [])) + .catch(() => undefined); + }, []); + + useEffect(load, [load]); + + useEffect(() => { + if (!open) return; + const close = (event: MouseEvent | KeyboardEvent) => { + if (event instanceof KeyboardEvent) { + if (event.key === "Escape") setOpen(false); + } else if (root.current && !root.current.contains(event.target as Node)) { + setOpen(false); + } + }; + document.addEventListener("mousedown", close); + document.addEventListener("keydown", close); + return () => { + document.removeEventListener("mousedown", close); + document.removeEventListener("keydown", close); + }; + }, [open]); + + const active = accounts.find((a) => a.active); + + // A reload rather than a client-side update: every screen holds the previous + // account's mail in state, and none of it should survive the switch. + const switchTo = async (id: string) => { + const res = await fetch("/api/v1/auth/switch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ id }), + }); + if (res.ok) window.location.href = "/user/mail/inbox"; + }; + + const signOut = async (all: boolean) => { + const res = await fetch("/api/v1/auth/logout", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ all }), + }).catch(() => null); + const data = res?.ok ? await res.json().catch(() => ({})) : {}; + window.location.href = data.remaining?.length ? "/user/mail/inbox" : "/user/login"; + }; + + const item = + "flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors hover:bg-white/[0.07]"; + + return ( +
+ + + {open && ( +
+ {accounts.map((account) => ( + + ))} + + + {t("accounts.addAccount")} + +
+ + + {t("accounts.settings")} + + + {accounts.length > 1 && ( + + )} +
+ )} +
+ ); +} From 641ab4bbe8d122f0e80b5f2579f7639efd3be935 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Thu, 22 Oct 2026 14:45:34 -0300 Subject: [PATCH 3/3] feat(webmail): keep a message's own stylesheet so designed mail renders as sent Co-Authored-By: Claude Sonnet 5.5 --- services/webmail/src/lib/emailBody.ts | 9 +++--- services/webmail/src/lib/sanitizer.ts | 42 +++++++++++++++++++++++--- services/webmail/src/sanitizer.test.ts | 8 +++-- 3 files changed, 49 insertions(+), 10 deletions(-) diff --git a/services/webmail/src/lib/emailBody.ts b/services/webmail/src/lib/emailBody.ts index 70d73b2..7a4f19c 100644 --- a/services/webmail/src/lib/emailBody.ts +++ b/services/webmail/src/lib/emailBody.ts @@ -55,9 +55,9 @@ export function buildReaderDocument(bodyHtml: string): string {

x

`)).not.toContain("@import url(https://evil.test/x.css);.a{color:red}p{behavior:url(x)}

x

`); + expect(out).toContain("

x

`)).not.toContain("