Files
webmail/lib/auth/token-lock.ts
T
Linus Rath b79781cc94 fix: never lose a draft moving between windows and keep credentials off the window channel
Review of the pop-out work found ways to lose text, a wrong sign-out and
changes reaching ordinary browser tabs.

- Drafts: a closing window hands text over only to a main window holding
  its login, and asks instead when the last save failed, the browser is
  offline, a save is running or plugins could still have a say. A failed
  rescue goes to "Continue draft". The opening window lets go only once
  the composer is on screen, also for Edit draft in a message window.
  Send options travel with the draft. Closing during a send asks first.
- Sessions: answers are encrypted for the window that asked, and only a
  window holding the login answers. A renewal queued on the lock can be
  called off by a sign-out, signs nobody out once its account is gone, and
  takes the cached token after waiting (Rauthy `nbf`, #552).
- Ordinary tabs run none of the host. No lock is held for life.
- A message window hides after a refused session and waits for its
  folders; a composer stays on screen with its text. Another tab dropping a
  login it couldn't restore no longer closes pop-outs.
- Plain-text mail stays selectable in the desktop app. Folder save renames
  reserved and shell names and skips one refused name. All display-mode
  checks know the overlay. The attachment bar keeps to one row.

The earlier test that accepted text being gone after a failed rescue now
asserts that it reaches the "Continue draft" slot.
2026-11-03 08:37:10 +01:00

79 lines
2.9 KiB
TypeScript

/**
* One token renewal at a time across the windows of this browser.
*
* Refresh tokens rotate, and the token route reads the current one from a
* cookie. Two windows renewing at once send the same token twice; on Stalwart
* 1.0 a superseded token reused past its retry window counts as a replay and
* revokes the whole grant. Held across the request, so the next window's
* request carries the cookie the first one's answer wrote.
*
* Without Web Locks, or after waiting too long for a window that hangs, the
* work runs anyway: never worse than having no lock.
*/
interface LockManagerLike {
request<T>(name: string, options: { signal?: AbortSignal; ifAvailable?: boolean }, callback: (lock: unknown) => T | Promise<T>): Promise<T>;
}
/** How long a renewal waits for another window's before going ahead. Above the route's own timeouts. */
export const TOKEN_LOCK_WAIT_MS = 30_000;
const NOT_HELD = Symbol('not held');
export interface TokenLockTurn {
/**
* Another window held the lock first, so it may have renewed already. A
* renewal that was going to force one should take the cached token instead:
* an identity provider can refuse a second one this soon (Rauthy `nbf`, #552).
*/
contended: boolean;
}
function lockManager(): LockManagerLike | null {
const locks = typeof navigator !== 'undefined' ? (navigator as { locks?: LockManagerLike }).locks : undefined;
return locks && typeof locks.request === 'function' ? locks : null;
}
export function tokenLockName(slot: number): string {
return `bulwark-token-refresh:${slot}`;
}
export async function withTokenLock<T>(
slot: number,
work: (turn: TokenLockTurn) => Promise<T>,
options: { waitMs?: number; signal?: AbortSignal } = {},
): Promise<T> {
const locks = lockManager();
if (!locks) return work({ contended: false });
const name = tokenLockName(slot);
let started = false;
const run = (contended: boolean) => {
started = true;
return work({ contended });
};
const gaveUp = new AbortController();
const forwardAbort = () => gaveUp.abort();
options.signal?.addEventListener('abort', forwardAbort);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const first = await locks.request<T | typeof NOT_HELD>(name, { ifAvailable: true }, (lock) => (lock ? run(false) : NOT_HELD));
if (first !== NOT_HELD) return first as T;
timer = setTimeout(() => gaveUp.abort(), options.waitMs ?? TOKEN_LOCK_WAIT_MS);
return await locks.request<T>(name, { signal: gaveUp.signal }, () => {
clearTimeout(timer);
return run(true);
});
} catch (error) {
// An error from the work goes to the caller, who decides what it means. So
// does a caller calling it off (a sign-out). Only the lock itself failing,
// or the wait running out, runs the work.
if (started || options.signal?.aborted) throw error;
return run(true);
} finally {
clearTimeout(timer);
options.signal?.removeEventListener('abort', forwardAbort);
}
}