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:
1 parent
aea4c1ee5e
commit
0efc8f096a
4 files changed
+275
-10
No files matched your search
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user