The counts were computed inline in JSX where nothing could test them. The
sidebar matched a folder by a special-use role folders do not always carry, its
badges were fetched once and then froze - new mail did not raise them and
reading a message did not lower it - and the list header printed the size of
the loaded page, capped at 50, as the size of the folder. The rules now live in
mailCounts.ts behind tests, useFolders keeps them current from the server's
push and from this tab's own changes, and each filter chip carries its number.
Inline images never left the server: RenderMessage dropped every part a body
references as cid:, so mail written by any normal client rendered with broken
icons. They now come back as bounded data URIs, refusing image/svg+xml, and the
reader resolves them, sizes its frame from the message instead of a fixed
500px, and renders on the light surface mail assumes.
Reading mail no longer asks for a second factor - the mailbox is behind the
account's own password - while the console still requires one unconditionally,
now collected by a step-up that does not ask again for the password the session
in hand just proved. An admin sign-in issues the webmail session too, so
leaving the console is a link rather than another login.
Two defects found while testing the above:
- The admin sign-in checked the password and the second factor and never the
role, so any account that had enrolled a factor from its own settings could
obtain an admin-audience session.
- Session tokens carried nothing that varied below one second, so signing in
twice within the same second minted the identical token and collided with
the unique index on web_sessions.refresh_token_hash. A correct password
answered 500, and a double-clicked sign-in was enough to do it. Tokens now
carry a jti.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
62 lines
2.4 KiB
TypeScript
62 lines
2.4 KiB
TypeScript
import { verifyJwt } from "./session.js";
|
|
|
|
/**
|
|
* Which surface a login opens, and what crossing between them costs.
|
|
*
|
|
* These rules were spread through the login handler, where two of them were
|
|
* wrong in a way no test could reach:
|
|
*
|
|
* - Reading your own mail demanded a second factor. The mailbox is already
|
|
* behind the account's password; it is the console that needs more.
|
|
* - A session issued at the admin sign-in carried the admin audience alone, so
|
|
* leaving the console landed on the webmail's login screen - asking for a
|
|
* password from somebody who had just proven a password *and* a second
|
|
* factor. Proving more must never grant less.
|
|
*/
|
|
|
|
export type Surface = "user" | "admin";
|
|
|
|
/**
|
|
* Whether the surface may only open once a second factor has been proven.
|
|
*
|
|
* The console can change any mailbox, any domain and the DNS records the mail
|
|
* flow depends on, which is why PLAN.md section 14.5 makes it unconditional
|
|
* there. The webmail is one account's own mail, so the password that owns it is
|
|
* the bar. An account may still enrol a factor and be asked for it on the way
|
|
* into the console.
|
|
*/
|
|
export function secondFactorRequired(surface: Surface): boolean {
|
|
return surface === "admin";
|
|
}
|
|
|
|
/**
|
|
* The surfaces a sign-in on `surface` should hand out sessions for.
|
|
*
|
|
* An admin sign-in also yields a webmail session because it satisfied strictly
|
|
* more than the webmail asks for. Issuing both at once is what makes leaving
|
|
* the console a plain link instead of another sign-in.
|
|
*/
|
|
export function surfacesGrantedBy(surface: Surface): Surface[] {
|
|
return surface === "admin" ? ["admin", "user"] : ["user"];
|
|
}
|
|
|
|
/** Cookies are per surface, so an admin session cannot displace a webmail one. */
|
|
export function cookieNameFor(surface: Surface): string {
|
|
return surface === "admin" ? "lm_admin_session" : "lm_user_session";
|
|
}
|
|
|
|
/**
|
|
* Whether this token is a webmail session that may be stepped up to the
|
|
* console by presenting a second factor and nothing else.
|
|
*
|
|
* A challenge or enrolment token is refused: neither proves more than that a
|
|
* password was typed, and accepting one here would turn the step-up into a
|
|
* second way of skipping the factor it exists to collect.
|
|
*/
|
|
export function stepUpNeedsSecondFactor(token: string | null | undefined): boolean {
|
|
if (!token) return false;
|
|
const payload = verifyJwt(token);
|
|
if (!payload) return false;
|
|
return payload.purpose === "session" && payload.aud === "lambdamail:user";
|
|
}
|