Files
LambdaMail/services/auth/src/accounts.ts
T
Lucas RayanandClaude Sonnet 5.5 0efc8f096a 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>
2026-10-22 14:42:59 -03:00

57 lines
2.4 KiB
TypeScript

/**
* 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;
}