From 79c697a93553c9da66868a1d5d302a1ccf3d8e13 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 01:55:15 +0100
Subject: [PATCH 1/9] feat: let an automatic sign-out save open and minimised
drafts first
---
.../composer-sign-out-flush.test.tsx | 265 ++++++++++++++++++
components/email/email-composer.tsx | 23 +-
components/mail/mail-app.tsx | 17 ++
lib/__tests__/compose-flush-registry.test.ts | 69 +++++
lib/compose-flush-registry.ts | 82 ++++++
5 files changed, 455 insertions(+), 1 deletion(-)
create mode 100644 components/email/__tests__/composer-sign-out-flush.test.tsx
create mode 100644 lib/__tests__/compose-flush-registry.test.ts
create mode 100644 lib/compose-flush-registry.ts
diff --git a/components/email/__tests__/composer-sign-out-flush.test.tsx b/components/email/__tests__/composer-sign-out-flush.test.tsx
new file mode 100644
index 000000000..50c4a1e17
--- /dev/null
+++ b/components/email/__tests__/composer-sign-out-flush.test.tsx
@@ -0,0 +1,265 @@
+import { render, screen, fireEvent, act } from '@testing-library/react';
+import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
+import React from 'react';
+import { EmailComposer, type ComposerDraftData } from '../email-composer';
+import { useAuthStore } from '@/stores/auth-store';
+import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
+
+// ─── Heavy component mocks (mirrors reply-addressing.test.tsx) ────────────────
+
+vi.mock('@/components/email/rich-text-editor', () => ({
+ RichTextEditor: () => React.createElement('div', { 'data-testid': 'rich-text-editor' }),
+}));
+
+vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
+vi.mock('@/components/identity/sub-address-helper', () => ({ SubAddressHelper: () => null }));
+vi.mock('@/components/templates/template-picker', () => ({ TemplatePicker: () => null }));
+vi.mock('@/components/templates/template-form', () => ({ TemplateForm: () => null }));
+vi.mock('@/components/files/file-preview-modal', () => ({ FilePreviewModal: () => null }));
+vi.mock('@/hooks/use-focus-trap', () => ({
+ useFocusTrap: () => ({ current: null }),
+}));
+vi.mock('@/hooks/use-pro-multi-account-identities', () => ({
+ useProMultiAccountIdentities: () => ({ enabled: false, groups: [], allIdentities: [] }),
+ stripCrossAccountIdentityPrefix: (id: string) => ({ localAccountId: null, rawId: id }),
+}));
+
+// ─── Store mocks ──────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/auth-store', () => {
+ const state = {
+ client: null,
+ identities: [],
+ primaryIdentity: null,
+ isAuthenticated: false,
+ isDemoMode: false,
+ activeAccountId: null,
+ connectionLost: false,
+ getClientForAccount: () => undefined,
+ getAllConnectedClients: () => new Map(),
+ syncIdentities: () => {},
+ refreshIdentities: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAuthStore: hook };
+});
+
+vi.mock('@/stores/identity-store', () => {
+ const state = {
+ identities: [{ id: 'id-me', email: 'me@example.com', name: 'Me' }],
+ defaultIdentityId: 'id-me',
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useIdentityStore: hook };
+});
+
+vi.mock('@/stores/account-store', () => {
+ const state = { accounts: [], getAccountById: () => undefined };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useAccountStore: hook };
+});
+
+vi.mock('@/stores/email-store', () => {
+ const state = {
+ draftSaveEnabled: false,
+ sendRawEmail: async () => ({ sent: true }),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useEmailStore: hook };
+});
+
+const updateSetting = vi.fn();
+
+vi.mock('@/stores/settings-store', () => {
+ const state = {
+ timeFormat: '24h',
+ plainTextMode: false,
+ subAddressDelimiter: '+',
+ autoSelectReplyIdentity: true,
+ attachmentReminderEnabled: false,
+ attachmentReminderKeywords: [],
+ emptySubjectWarningEnabled: true,
+ sendDelaySeconds: 0,
+ signaturePosition: 'above_quote',
+ signatureSeparatorEnabled: false,
+ requestReadReceiptDefault: false,
+ addTrustedSender: () => {},
+ trustedSendersAddressBook: null,
+ updateSetting: (...args: unknown[]) => updateSetting(...args),
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useSettingsStore: hook };
+});
+
+vi.mock('@/stores/contact-store', () => {
+ const state = {
+ contacts: [],
+ getAutocomplete: async () => [],
+ addToTrustedSendersBook: async () => {},
+ };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useContactStore: hook };
+});
+
+vi.mock('@/stores/template-store', () => {
+ const state = { templates: [], addTemplate: async () => {} };
+ const hook = (sel?: (s: typeof state) => unknown) =>
+ typeof sel === 'function' ? sel(state) : state;
+ hook.getState = () => state;
+ hook.setState = (p: Partial) => Object.assign(state, p);
+ return { useTemplateStore: hook };
+});
+
+// ─── Misc dependency mocks ────────────────────────────────────────────────────
+
+vi.mock('@/stores/toast-store', () => ({
+ toast: { info: () => {}, error: () => {}, success: () => {} },
+}));
+
+vi.mock('@/lib/plugin-hooks', () => ({
+ identityHooks: {
+ onSignatureRender: { size: 0, transform: async (value: unknown) => value },
+ onIdentitySelect: { size: 0, emit: async () => {} },
+ },
+ templateHooks: {
+ onTemplateRender: { size: 0, transform: async (value: unknown) => value },
+ onTemplateApply: { size: 0, emit: async () => {} },
+ },
+ emailHooks: {
+ onComposerOpen: { call: async () => [] },
+ onRecipientChange: { call: async () => [] },
+ getRecipientSuggestions: { call: async () => [] },
+ onRecipientChipsChange: { transform: async (chips: unknown) => chips },
+ onDraftChange: { emit: () => {} },
+ onBeforeDraftAutoSave: { transform: async (draft: unknown) => draft },
+ onBeforeEmailSend: { intercept: async () => true, interceptOutcome: async () => 'allowed' },
+ onComposeSend: { takeOver: async () => 'default' },
+ onTransformOutgoingEmail: { transform: async (email: unknown) => email },
+ },
+ contactHooks: {
+ search: { call: async () => [] },
+ onProvideRecipientSuggestions: { transform: async (initial: unknown) => initial },
+ },
+}));
+
+vi.mock('@/lib/email-sanitization', () => ({
+ sanitizeSignatureHtml: (v: string) => v,
+ sanitizeEmailHtml: (v: string) => v,
+ parseHtmlSafely: (html: string) => new DOMParser().parseFromString(html, 'text/html'),
+}));
+
+vi.mock('@/lib/email-threading', () => ({
+ computeReplyThreadingHeaders: () => ({ inReplyTo: [], references: [] }),
+}));
+vi.mock('@/lib/signature-utils', () => ({
+ appendPlainTextSignature: (body: string) => body,
+ getPlainTextSignature: () => '',
+}));
+vi.mock('@/lib/sub-addressing', () => ({ generateSubAddress: () => '' }));
+vi.mock('@/lib/debug', () => ({ debug: { log: () => {}, warn: () => {}, error: () => {} } }));
+vi.mock('@/components/email/quoted-html', () => ({
+ buildQuotedHtmlBlock: () => '',
+ serializeEditorContent: () => '',
+}));
+vi.mock('@/lib/template-utils', () => ({ substitutePlaceholders: (s: string) => s }));
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+/**
+ * Before an automatic sign-out (organisation idle timeout or maximum session
+ * length) every composer saves what the server doesn't have yet, through the
+ * compose flush registry.
+ */
+
+const DRAFT: ComposerDraftData = {
+ to: 'bob@example.com',
+ cc: '',
+ bcc: '',
+ subject: 'Half-written',
+ body: 'Still typing
',
+ showCc: false,
+ showBcc: false,
+ selectedIdentityId: 'id-me',
+ subAddressTag: '',
+ mode: 'compose',
+ draftId: null,
+};
+
+let createDraft: ReturnType;
+
+beforeEach(() => {
+ resetComposeFlushRegistry();
+ createDraft = vi.fn().mockResolvedValue('draft-1');
+ useAuthStore.setState({
+ client: { createDraft, getEmail: vi.fn(), hasDelayedSend: () => false, getMaxDelayedSend: () => 0 } as never,
+ });
+});
+
+afterEach(() => {
+ useAuthStore.setState({ client: null });
+});
+
+describe('composer flush before an automatic sign-out', () => {
+ it('saves edits the autosave has not stored yet', async () => {
+ render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+
+ let result: Awaited> | undefined;
+ await act(async () => { result = await flushComposers(5000); });
+
+ expect(result).toEqual({ total: 1, settled: 1, timedOut: false });
+ expect(createDraft).toHaveBeenCalledTimes(1);
+ expect(createDraft.mock.calls[0][1]).toBe('Half-written more');
+ });
+
+ it('leaves a composer without changes alone', async () => {
+ render( );
+
+ await act(async () => { await flushComposers(5000); });
+
+ expect(createDraft).not.toHaveBeenCalled();
+ });
+
+ it('saves a minimised composer opened again with its stashed edits', async () => {
+ render( );
+
+ await act(async () => { await flushComposers(5000); });
+
+ expect(createDraft).toHaveBeenCalledTimes(1);
+ expect(createDraft.mock.calls[0][1]).toBe('Half-written');
+ });
+
+ it('marks the state it hands to the host on unmount as unsaved', () => {
+ const onSaveState = vi.fn();
+ const view = render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+ view.unmount();
+
+ expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: true }));
+ });
+
+ it('unregisters when it unmounts', async () => {
+ const view = render( );
+ view.unmount();
+
+ await expect(flushComposers(1000)).resolves.toMatchObject({ total: 0 });
+ });
+});
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 01ac2feb7..11f303fd3 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -133,6 +133,7 @@ import type { Editor } from "@tiptap/react";
import { htmlToPlainText as htmlToPlainTextShared } from "@/lib/html-to-text";
import { fileStorage } from "@/lib/plugin-storage";
import { usePolicyStore } from "@/stores/policy-store";
+import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { toUnicodeEmail } from "@/lib/idn";
/**
@@ -231,6 +232,12 @@ export interface ComposerDraftData {
references?: string[];
/** Priority of a re-opened draft, read back from its headers (#970). */
priority?: MessagePriority;
+ /**
+ * Handed to the host when the composer went away with edits the server may
+ * not have: the composer opened again with it counts them as unsaved, so
+ * an automatic sign-out saves them (lib/compose-flush-registry.ts).
+ */
+ unsaved?: boolean;
}
interface EmailComposerProps {
@@ -1574,7 +1581,8 @@ export function EmailComposer({
// Bumped on every edit and compared with the edit the last successful save
// covered. Closing a message autosave already stored asked about "unsaved
// changes" that were not there.
- const changeVersionRef = useRef(0);
+ // Stashed edits handed back by the host start out unsaved.
+ const changeVersionRef = useRef(initialData?.unsaved ? 1 : 0);
const savedVersionRef = useRef(0);
const firstChangeRunRef = useRef(true);
useEffect(() => {
@@ -1648,6 +1656,7 @@ export function EmailComposer({
mode,
replyTo,
priority: priorityRef.current,
+ unsaved: true,
});
}
};
@@ -2699,6 +2708,18 @@ export function EmailComposer({
// Keep saveDraftRef pointing to latest saveDraft
saveDraftRef.current = saveDraft;
+ // An automatic sign-out saves what the server doesn't have yet, with the
+ // same save the close and send paths use.
+ useEffect(() => registerComposeFlush(async () => {
+ const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current;
+ if (!unsaved) return;
+ if (saveTimeoutRef.current) {
+ clearTimeout(saveTimeoutRef.current);
+ saveTimeoutRef.current = null;
+ }
+ await saveDraftRef.current();
+ }), []);
+
// The draft as plugins see it: onDraftChange, the frames above and below
// the body, and clicks on plugin entries in the Send menu.
const draftSnapshot = () => ({
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index e5bfaf3c9..49581ae72 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -159,6 +159,7 @@ import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-lin
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import type { QuoteHeader } from "@/lib/plugin-types";
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
+import { registerComposeFlush, waitForComposeFlush } from "@/lib/compose-flush-registry";
export interface MailAppProps {
/**
@@ -198,6 +199,9 @@ function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise = {};
@@ -2266,6 +2270,19 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
setPendingDraft(null);
}, []);
+ // A minimised composer's edits live only in pendingDraft. Before an
+ // automatic sign-out it is opened again and saves them itself.
+ const minimisedUnsaved = !showComposer && pendingDraft?.unsaved === true;
+ useEffect(() => {
+ if (!minimisedUnsaved) return;
+ return registerComposeFlush(async () => {
+ const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS);
+ setShowComposer(true);
+ const flush = await reopened;
+ await flush?.();
+ });
+ }, [minimisedUnsaved]);
+
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
const formerSelected = { ...selectedEmail };
diff --git a/lib/__tests__/compose-flush-registry.test.ts b/lib/__tests__/compose-flush-registry.test.ts
new file mode 100644
index 000000000..3858bb358
--- /dev/null
+++ b/lib/__tests__/compose-flush-registry.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ flushComposers,
+ registerComposeFlush,
+ resetComposeFlushRegistry,
+ waitForComposeFlush,
+} from '../compose-flush-registry';
+
+describe('compose flush registry', () => {
+ beforeEach(() => {
+ resetComposeFlushRegistry();
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('runs every registered flush and waits for them', async () => {
+ const order: string[] = [];
+ registerComposeFlush(async () => {
+ await new Promise((r) => setTimeout(r, 100));
+ order.push('a');
+ });
+ registerComposeFlush(async () => { order.push('b'); });
+
+ const done = flushComposers(5000);
+ await vi.advanceTimersByTimeAsync(100);
+ await expect(done).resolves.toEqual({ total: 2, settled: 2, timedOut: false });
+ expect(order.sort()).toEqual(['a', 'b']);
+ });
+
+ it('stops waiting for a hanging flush at the timeout', async () => {
+ const quick = vi.fn(async () => {});
+ registerComposeFlush(() => new Promise(() => {}));
+ registerComposeFlush(quick);
+
+ let result: Awaited> | null = null;
+ void flushComposers(3000).then((r) => { result = r; });
+ await vi.advanceTimersByTimeAsync(2999);
+ expect(result).toBeNull();
+ await vi.advanceTimersByTimeAsync(1);
+ expect(result).toEqual({ total: 2, settled: 1, timedOut: true });
+ expect(quick).toHaveBeenCalledTimes(1);
+ });
+
+ it('goes on when a flush fails', async () => {
+ registerComposeFlush(async () => { throw new Error('save refused'); });
+ await expect(flushComposers(1000)).resolves.toEqual({ total: 1, settled: 1, timedOut: false });
+ });
+
+ it('forgets a composer that unregistered', async () => {
+ const flush = vi.fn(async () => {});
+ const unregister = registerComposeFlush(flush);
+ unregister();
+ await expect(flushComposers(1000)).resolves.toEqual({ total: 0, settled: 0, timedOut: false });
+ expect(flush).not.toHaveBeenCalled();
+ });
+
+ it('hands the next registration to a waiting host, or null after the timeout', async () => {
+ const waiting = waitForComposeFlush(500);
+ const flush = vi.fn(async () => {});
+ registerComposeFlush(flush);
+ await expect(waiting).resolves.toBe(flush);
+
+ const nobody = waitForComposeFlush(500);
+ await vi.advanceTimersByTimeAsync(500);
+ await expect(nobody).resolves.toBeNull();
+ });
+});
diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts
new file mode 100644
index 000000000..8af821e9e
--- /dev/null
+++ b/lib/compose-flush-registry.ts
@@ -0,0 +1,82 @@
+/**
+ * Composers that hold text the server may not have yet. An automatic
+ * sign-out (the organisation's idle timeout or maximum session length) saves
+ * them as drafts before the credentials go: each open composer registers a
+ * `flush` that runs its own draft save, and the host of a minimised composer
+ * (its state lives only in the host's memory) registers one that reopens it
+ * so the composer can save itself.
+ *
+ * A flush that hangs must not keep the user signed in, so flushComposers
+ * waits at most `timeoutMs` for all of them together.
+ */
+
+export type ComposeFlush = () => Promise;
+
+let nextId = 1;
+const flushes = new Map();
+const waiters = new Set<(flush: ComposeFlush) => void>();
+
+/** Register a composer's flush; returns the unregister function. */
+export function registerComposeFlush(flush: ComposeFlush): () => void {
+ const id = nextId++;
+ flushes.set(id, flush);
+ for (const waiter of [...waiters]) waiter(flush);
+ return () => {
+ flushes.delete(id);
+ };
+}
+
+/**
+ * The flush of the next composer to register, or null after `timeoutMs`.
+ * A minimised composer's host reopens it and waits for it here.
+ */
+export function waitForComposeFlush(timeoutMs: number): Promise {
+ return new Promise((resolve) => {
+ const waiter = (flush: ComposeFlush) => {
+ clearTimeout(timer);
+ waiters.delete(waiter);
+ resolve(flush);
+ };
+ const timer = setTimeout(() => {
+ waiters.delete(waiter);
+ resolve(null);
+ }, timeoutMs);
+ waiters.add(waiter);
+ });
+}
+
+export interface FlushResult {
+ /** Flushes that were registered when the sign-out started. */
+ total: number;
+ /** Flushes that finished (or failed) in time. */
+ settled: number;
+ timedOut: boolean;
+}
+
+/** Run every registered flush and wait for them, but no longer than `timeoutMs`. */
+export async function flushComposers(timeoutMs: number): Promise {
+ const pending = [...flushes.values()];
+ if (pending.length === 0) return { total: 0, settled: 0, timedOut: false };
+ let settled = 0;
+ const all = Promise.all(pending.map(async (flush) => {
+ try {
+ await flush();
+ } catch {
+ // A failed save is the composer's to report; the sign-out goes on.
+ }
+ settled += 1;
+ }));
+ let timer: ReturnType | undefined;
+ const timedOut = await Promise.race([
+ all.then(() => false),
+ new Promise((resolve) => { timer = setTimeout(() => resolve(true), timeoutMs); }),
+ ]);
+ clearTimeout(timer);
+ return { total: pending.length, settled, timedOut };
+}
+
+/** Test helper: forget every registration. */
+export function resetComposeFlushRegistry(): void {
+ flushes.clear();
+ waiters.clear();
+}
From 45cc6988e051c46d37481008bc23e084191e75a6 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 02:18:07 +0100
Subject: [PATCH 2/9] feat: sign out after an admin-set idle time or session
length, with a warning first
---
app/(main)/[locale]/layout.tsx | 2 +
app/(main)/[locale]/login/page.tsx | 17 +-
.../admin/org/sections/sessions-section.tsx | 87 ++++++-
.../__tests__/session-timeout-guard.test.tsx | 232 ++++++++++++++++++
components/session/session-timeout-dialog.tsx | 85 +++++++
components/session/session-timeout-guard.tsx | 221 +++++++++++++++++
.../host-api-app-session-timeout.test.ts | 43 ++++
lib/plugin-sandbox/host-api-app.ts | 8 +
.../__tests__/activity-channel.test.ts | 69 ++++++
lib/session-policy/__tests__/limits.test.ts | 34 +++
.../__tests__/session-timer.test.ts | 143 +++++++++++
lib/session-policy/activity-channel.ts | 72 ++++++
lib/session-policy/limits.ts | 60 +++++
lib/session-policy/session-timer.ts | 139 +++++++++++
lib/session-policy/sign-out-reason.ts | 33 +++
locales/de/common.json | 12 +
locales/en/common.json | 12 +
.../auth-store-provider-logout.test.ts | 16 ++
stores/account-store.ts | 6 +
stores/auth-store.ts | 15 +-
20 files changed, 1294 insertions(+), 12 deletions(-)
create mode 100644 components/session/__tests__/session-timeout-guard.test.tsx
create mode 100644 components/session/session-timeout-dialog.tsx
create mode 100644 components/session/session-timeout-guard.tsx
create mode 100644 lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts
create mode 100644 lib/session-policy/__tests__/activity-channel.test.ts
create mode 100644 lib/session-policy/__tests__/limits.test.ts
create mode 100644 lib/session-policy/__tests__/session-timer.test.ts
create mode 100644 lib/session-policy/activity-channel.ts
create mode 100644 lib/session-policy/limits.ts
create mode 100644 lib/session-policy/session-timer.ts
create mode 100644 lib/session-policy/sign-out-reason.ts
diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx
index 76f9396ad..31c983c1d 100644
--- a/app/(main)/[locale]/layout.tsx
+++ b/app/(main)/[locale]/layout.tsx
@@ -19,6 +19,7 @@ import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
+import { SessionTimeoutGuard } from "@/components/session/session-timeout-guard";
import { IS_LITE } from "@/lib/lite";
import { setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n/routing";
@@ -61,6 +62,7 @@ export default async function LocaleLayout({
+
{children}
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index efc06c706..84a214c2c 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -34,6 +34,7 @@ import { toAsciiDomain } from "@/lib/idn";
import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view";
import { buildMobileHandoffUrl, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff";
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
+import { consumeSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
/** The domain of a complete address (`user@example.com`), or '' while it is still being typed. */
function completeAddressDomain(username: string): string {
@@ -139,6 +140,7 @@ function VersionBadge() {
function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
+ const tSessionLimits = useTranslations("session_limits");
const params = useParams();
const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account";
@@ -207,6 +209,8 @@ function LoginPageContent() {
// those sessions then only last as long as the tab (lib/auth/lite-tokens.ts).
const [liteTokenLoginSupported, setLiteTokenLoginSupported] = useState(null);
const [sessionExpired, setSessionExpired] = useState(false);
+ // Signed out by the organisation's idle timeout or maximum session length.
+ const [sessionEnd, setSessionEnd] = useState(null);
const [showPassword, setShowPassword] = useState(false);
const [shakeError, setShakeError] = useState(false);
const [showThemeMenu, setShowThemeMenu] = useState(false);
@@ -332,6 +336,11 @@ function LoginPageContent() {
sessionStorage.removeItem('session_expired');
}
} catch { /* sessionStorage unavailable */ }
+ const ended = consumeSessionEnd();
+ if (ended) {
+ setSessionEnd(ended);
+ setSessionExpired(true);
+ }
}, []);
useEffect(() => {
@@ -1229,8 +1238,12 @@ function LoginPageContent() {
-
- {t("session_expired")}
+
+ {sessionEnd?.kind === "idle"
+ ? tSessionLimits("reason_idle", { minutes: sessionEnd.minutes })
+ : sessionEnd?.kind === "max"
+ ? tSessionLimits("reason_max", { hours: sessionEnd.hours })
+ : t("session_expired")}
0 ? `${value} ${field.unit}` : field.zero;
+}
+
+/** Organisation > Security > Sessions: the idle timeout and the maximum session length. */
export function SessionsSection({ draft }: { draft: OrgPolicyDraft }) {
- void draft;
return (
-
- Nothing to set here yet.
+
+ {FIELDS.map((field) => {
+ const { value, own, everyone } = draft.orgValue('session', field.key);
+ const inputId = `org-session-${field.key}`;
+ return (
+
+
+
+ 0 ? value : ''}
+ onChange={(e) => {
+ const raw = e.target.value.trim();
+ const parsed = raw === '' ? 0 : Math.round(Number(raw));
+ if (!Number.isFinite(parsed)) return;
+ draft.setOrgValue('session', field.key, Math.min(field.max, Math.max(0, parsed)));
+ }}
+ className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
+ />
+ {field.unit}
+
+
draft.clearOrgValue('session', field.key)}
+ />
+
+
+ );
+ })}
);
}
diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx
new file mode 100644
index 000000000..f693eb685
--- /dev/null
+++ b/components/session/__tests__/session-timeout-guard.test.tsx
@@ -0,0 +1,232 @@
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS } from '../session-timeout-guard';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountStore, type AccountEntry } from '@/stores/account-store';
+import { usePolicyStore } from '@/stores/policy-store';
+import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
+import { consumeSessionEnd } from '@/lib/session-policy/sign-out-reason';
+import { LAST_ACTIVITY_KEY } from '@/lib/session-policy/activity-channel';
+
+/** BroadcastChannel between "tabs" of one test. */
+class FakeBroadcastChannel {
+ static instances: FakeBroadcastChannel[] = [];
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ closed = false;
+ constructor(public name: string) {
+ FakeBroadcastChannel.instances.push(this);
+ }
+ postMessage(data: unknown) {
+ for (const other of FakeBroadcastChannel.instances) {
+ if (other !== this && !other.closed && other.name === this.name) other.onmessage?.({ data } as MessageEvent);
+ }
+ }
+ close() {
+ this.closed = true;
+ }
+}
+
+const START = new Date('2026-10-28T09:00:00Z').getTime();
+const SECOND = 1000;
+
+function fakeClient(email: string) {
+ return {
+ getUsername: () => email,
+ getSharedAccounts: () => [],
+ getAccountInfo: () => undefined,
+ getPrincipalTypes: async () => ({}),
+ };
+}
+
+function account(id: string, email: string, signedInAt = START): AccountEntry {
+ return {
+ id, label: email, serverUrl: 'https://mail.example.org', username: email, authMode: 'basic', cookieSlot: 0,
+ rememberMe: true, displayName: email, email, avatarColor: '#336699', lastLoginAt: START, signedInAt,
+ isConnected: true, hasError: false, isDefault: true,
+ };
+}
+
+function policyWith(session: { idleMinutes: number; maxHours: number; warnSeconds: number }, scopes: SettingsPolicy['scopes'] = []): SettingsPolicy {
+ return { ...DEFAULT_POLICY, organisation: { ...DEFAULT_ORGANISATION_POLICY, session }, scopes };
+}
+
+let logoutAll: ReturnType;
+const realGetAllConnectedClients = useAuthStore.getState().getAllConnectedClients;
+
+async function signIn(policy: SettingsPolicy, email = 'org-alice@example.org') {
+ usePolicyStore.setState({ rawPolicy: policy });
+ useAccountStore.setState({ accounts: [account('alice', email)], activeAccountId: 'alice' });
+ useAuthStore.setState({
+ isAuthenticated: true,
+ isDemoMode: false,
+ activeAccountId: 'alice',
+ client: fakeClient(email) as never,
+ logoutAll: logoutAll as never,
+ });
+ render( );
+ // The limits are worked out asynchronously (the policy subject).
+ await act(async () => { await vi.advanceTimersByTimeAsync(0); });
+}
+
+const advance = (ms: number) => act(async () => { await vi.advanceTimersByTimeAsync(ms); });
+
+describe('session timeout guard', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(START);
+ vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
+ FakeBroadcastChannel.instances = [];
+ localStorage.clear();
+ sessionStorage.clear();
+ resetComposeFlushRegistry();
+ logoutAll = vi.fn(async () => {});
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ useAuthStore.setState({ isAuthenticated: false, client: null, activeAccountId: null, getAllConnectedClients: realGetAllConnectedClients });
+ });
+
+ it('warns at idle minus warnSeconds and goes away on "Stay signed in"', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+
+ await advance(29 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+ await advance(1 * SECOND);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+
+ fireEvent.click(screen.getByText('stay'));
+ expect(screen.queryByText('idle_title')).toBeNull();
+ await advance(29 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+ expect(logoutAll).not.toHaveBeenCalled();
+ });
+
+ it('starts the idle time again on activity in this tab', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+
+ await advance(20 * SECOND);
+ fireEvent.keyDown(window, { key: 'a' });
+ await advance(29 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+ await advance(1 * SECOND);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+ });
+
+ it('starts the idle time again on activity in another tab', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ const otherTab = new FakeBroadcastChannel('bulwark-session-activity');
+
+ await advance(40 * SECOND);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+ await act(async () => { otherTab.postMessage({ at: Date.now() }); });
+ expect(screen.queryByText('idle_title')).toBeNull();
+
+ await advance(59 * SECOND);
+ expect(logoutAll).not.toHaveBeenCalled();
+ });
+
+ it('tells the other tabs about activity here', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ const otherTab = new FakeBroadcastChannel('bulwark-session-activity');
+ const heard = vi.fn();
+ otherTab.onmessage = (event) => heard((event.data as { at: number }).at);
+
+ await advance(10 * SECOND);
+ fireEvent.pointerDown(window);
+ expect(heard).toHaveBeenCalledWith(START + 10 * SECOND);
+ expect(localStorage.getItem(LAST_ACTIVITY_KEY)).toBe(String(START + 10 * SECOND));
+ });
+
+ it('saves the drafts, then signs every account out with the reason, at zero', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ const order: string[] = [];
+ registerComposeFlush(async () => {
+ await new Promise((r) => setTimeout(r, 500));
+ order.push('draft saved');
+ });
+ logoutAll.mockImplementation(async () => { order.push('signed out'); });
+
+ await advance(60 * SECOND);
+ expect(screen.getByText('signing_out')).toBeTruthy();
+ expect(logoutAll).not.toHaveBeenCalled();
+ await advance(500);
+
+ expect(order).toEqual(['draft saved', 'signed out']);
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
+ });
+
+ it('signs out after the flush timeout when a draft save hangs', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ registerComposeFlush(() => new Promise(() => {}));
+
+ await advance(60 * SECOND);
+ await advance(DRAFT_FLUSH_TIMEOUT_MS - 1);
+ expect(logoutAll).not.toHaveBeenCalled();
+ await advance(1);
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ });
+
+ it('uses the strictest of the signed-in accounts', async () => {
+ const policy = policyWith({ idleMinutes: 0, maxHours: 0, warnSeconds: 30 }, [
+ { id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 10 } } } },
+ { id: 'two', label: 'org-two.test', match: { domains: ['org-two.test'] }, policy: { organisation: { session: { idleMinutes: 2 } } } },
+ ]);
+ usePolicyStore.setState({ rawPolicy: policy });
+ useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org'), account('dave', 'org-dave@org-two.test')], activeAccountId: 'alice' });
+ const alice = fakeClient('org-alice@example.org');
+ const dave = fakeClient('org-dave@org-two.test');
+ useAuthStore.setState({
+ isAuthenticated: true,
+ isDemoMode: false,
+ activeAccountId: 'alice',
+ client: alice as never,
+ logoutAll: logoutAll as never,
+ getAllConnectedClients: (() => new Map([['alice', alice], ['dave', dave]])) as never,
+ });
+ render( );
+ await advance(0);
+
+ // Two minutes (dave's domain), not ten (alice's).
+ await advance(2 * 60 * SECOND - 1);
+ expect(logoutAll).not.toHaveBeenCalled();
+ await advance(1);
+ expect(logoutAll).toHaveBeenCalledTimes(1);
+ expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 2 });
+ });
+
+ it('gives an account in a scope without a timeout none', async () => {
+ const policy = policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }, [
+ { id: 'two', label: 'org-two.test', match: { domains: ['org-two.test'] }, policy: { organisation: { session: { idleMinutes: 0 } } } },
+ ]);
+ await signIn(policy, 'org-dave@org-two.test');
+
+ await advance(10 * 60 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+ expect(logoutAll).not.toHaveBeenCalled();
+ });
+
+ it('warns before the maximum session length and signs out at it', async () => {
+ usePolicyStore.setState({ rawPolicy: policyWith({ idleMinutes: 0, maxHours: 1, warnSeconds: 60 }) });
+ useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org', START - 50 * 60 * SECOND)], activeAccountId: 'alice' });
+ useAuthStore.setState({
+ isAuthenticated: true, isDemoMode: false, activeAccountId: 'alice',
+ client: fakeClient('org-alice@example.org') as never, logoutAll: logoutAll as never,
+ getAllConnectedClients: (() => new Map()) as never,
+ });
+ render( );
+ await advance(0);
+
+ await advance(9 * 60 * SECOND);
+ expect(screen.getByText('max_title')).toBeTruthy();
+ fireEvent.click(screen.getByText('dismiss'));
+ expect(screen.queryByText('max_title')).toBeNull();
+ await advance(60 * SECOND);
+ await advance(0);
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 });
+ });
+});
diff --git a/components/session/session-timeout-dialog.tsx b/components/session/session-timeout-dialog.tsx
new file mode 100644
index 000000000..917b77085
--- /dev/null
+++ b/components/session/session-timeout-dialog.tsx
@@ -0,0 +1,85 @@
+"use client";
+
+import { useEffect, useId, useState } from "react";
+import { useTranslations } from "next-intl";
+import { useFocusTrap } from "@/hooks/use-focus-trap";
+import { Button } from "@/components/ui/button";
+import { Clock, Loader2 } from "@/components/icons";
+import type { SessionEndKind } from "@/lib/session-policy/session-timer";
+
+interface SessionTimeoutDialogProps {
+ /** 'ending': the session is over, drafts are being saved. */
+ mode: SessionEndKind | 'ending';
+ deadline: number | null;
+ /** Hours of the maximum session length, for its message. */
+ maxHours?: number;
+ onStay: () => void;
+ onDismiss: () => void;
+ onSignOut: () => void;
+}
+
+function secondsLeft(deadline: number | null): number {
+ return deadline === null ? 0 : Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
+}
+
+/**
+ * The warning before an organisation session limit signs the user out. For
+ * the idle timeout it asks whether to stay; the maximum session length
+ * can't be extended, so that one only says when it ends.
+ */
+export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismiss, onSignOut }: SessionTimeoutDialogProps) {
+ const t = useTranslations("session_limits");
+ const id = useId();
+ const [seconds, setSeconds] = useState(() => secondsLeft(deadline));
+ const dialogRef = useFocusTrap({
+ isActive: true,
+ onEscape: mode === "idle" ? onStay : mode === "max" ? onDismiss : undefined,
+ restoreFocus: true,
+ });
+
+ useEffect(() => {
+ setSeconds(secondsLeft(deadline));
+ if (mode === "ending") return;
+ const interval = setInterval(() => setSeconds(secondsLeft(deadline)), 250);
+ return () => clearInterval(interval);
+ }, [deadline, mode]);
+
+ const title = mode === "max" ? t("max_title") : mode === "idle" ? t("idle_title") : t("signing_out");
+
+ return (
+
+
+
+
+ {mode === "ending" ? : }
+
+
+
{title}
+ {mode !== "ending" && (
+
+ {mode === "max"
+ ? t("max_message", { hours: maxHours ?? 0, seconds })
+ : t("idle_message", { seconds })}
+
+ )}
+
+
+ {mode !== "ending" && (
+
+ {t("sign_out_now")}
+ {mode === "idle"
+ ? {t("stay")}
+ : {t("dismiss")} }
+
+ )}
+
+
+ );
+}
diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx
new file mode 100644
index 000000000..cb43453e6
--- /dev/null
+++ b/components/session/session-timeout-guard.tsx
@@ -0,0 +1,221 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { useAuthStore } from "@/stores/auth-store";
+import { useAccountStore } from "@/stores/account-store";
+import { usePolicyStore } from "@/stores/policy-store";
+import { policySubjectForClient } from "@/lib/admin/policy-subject";
+import type { PolicySubject } from "@/lib/admin/policy-scopes";
+import type { IJMAPClient } from "@/lib/jmap/client-interface";
+import { flushComposers } from "@/lib/compose-flush-registry";
+import { openActivityChannel, readLastActivity, type ActivityChannel } from "@/lib/session-policy/activity-channel";
+import {
+ NO_SESSION_LIMITS,
+ sameLimits,
+ sessionLimits,
+ setCurrentSessionLimits,
+ type AccountSession,
+ type SessionLimits,
+} from "@/lib/session-policy/limits";
+import { createSessionTimer, type SessionEndKind, type SessionTimer, type SessionTimerState } from "@/lib/session-policy/session-timer";
+import { rememberSessionEnd } from "@/lib/session-policy/sign-out-reason";
+import { SessionTimeoutDialog } from "./session-timeout-dialog";
+
+/** How long the sign-out waits for the open composers to save their drafts. */
+export const DRAFT_FLUSH_TIMEOUT_MS = 8000;
+/** Local activity reaches the timer and the other tabs at most this often. */
+const ACTIVITY_THROTTLE_MS = 1000;
+const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel", "scroll", "focus"] as const;
+
+const subjects = new WeakMap>();
+function subjectFor(client: IJMAPClient): Promise {
+ let subject = subjects.get(client);
+ if (!subject) {
+ subject = policySubjectForClient(client).catch(() => null);
+ subjects.set(client, subject);
+ }
+ return subject;
+}
+
+/** The limits of every account signed in here, each with its own domain and group scopes. */
+async function collectLimits(): Promise {
+ const auth = useAuthStore.getState();
+ const clients = new Map(auth.getAllConnectedClients());
+ if (auth.client && auth.activeAccountId && !clients.has(auth.activeAccountId)) clients.set(auth.activeAccountId, auth.client);
+ const accounts: AccountSession[] = await Promise.all([...clients.entries()].map(async ([accountId, client]) => {
+ const subject = await subjectFor(client);
+ const session = usePolicyStore.getState().resolveFor(subject).organisation?.session;
+ return { session, signedInAt: signedInAtFor(accountId) };
+ }));
+ return sessionLimits(accounts);
+}
+
+/**
+ * When the account signed in. An account signed in before this was recorded
+ * counts as signed in now, the first time it is seen.
+ */
+function signedInAtFor(accountId: string): number | undefined {
+ const store = useAccountStore.getState();
+ const account = store.getAccountById(accountId);
+ if (!account) return undefined;
+ if (typeof account.signedInAt === "number") return account.signedInAt;
+ const now = Date.now();
+ store.updateAccount(accountId, { signedInAt: now });
+ return now;
+}
+
+const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: null };
+
+/**
+ * The organisation's idle timeout and maximum session length
+ * (policy.json `organisation.session`). Activity in any Bulwark tab keeps
+ * every tab signed in; a warning counts down first; at zero the open and
+ * minimised composers save their drafts and every account signs out, with
+ * the reason on the login page.
+ */
+export function SessionTimeoutGuard() {
+ const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
+ const isDemoMode = useAuthStore((s) => s.isDemoMode);
+ const client = useAuthStore((s) => s.client);
+ const revision = useAuthStore((s) => s.connectedAccountsRevision);
+ const rawPolicy = usePolicyStore((s) => s.rawPolicy);
+ const accountsKey = useAccountStore((s) => s.accounts.map((a) => `${a.id}|${a.signedInAt ?? ""}|${a.isConnected}`).join(","));
+
+ const [limits, setLimits] = useState(NO_SESSION_LIMITS);
+ const [state, setState] = useState(IDLE_STATE);
+ const [ending, setEnding] = useState(false);
+ const [dismissedMax, setDismissedMax] = useState(null);
+ const timerRef = useRef(null);
+ const channelRef = useRef(null);
+ const limitsRef = useRef(limits);
+ limitsRef.current = limits;
+ const endingRef = useRef(false);
+ const active = isAuthenticated && !isDemoMode;
+
+ useEffect(() => {
+ if (!active) {
+ setLimits(NO_SESSION_LIMITS);
+ return;
+ }
+ let cancelled = false;
+ void collectLimits().then((next) => {
+ if (!cancelled) setLimits((prev) => (sameLimits(prev, next) ? prev : next));
+ });
+ return () => { cancelled = true; };
+ }, [active, client, revision, rawPolicy, accountsKey]);
+
+ useEffect(() => {
+ setCurrentSessionLimits(active ? limits : null);
+ }, [active, limits]);
+
+ const endSession = useCallback(async (kind: SessionEndKind) => {
+ if (endingRef.current) return;
+ endingRef.current = true;
+ setEnding(true);
+ const current = limitsRef.current;
+ rememberSessionEnd(kind === "max"
+ ? { kind: "max", hours: current.max?.hours ?? 0 }
+ : { kind: "idle", minutes: current.idle?.minutes ?? 0 });
+ await flushComposers(DRAFT_FLUSH_TIMEOUT_MS);
+ await useAuthStore.getState().logoutAll({ endProviderSession: false });
+ }, []);
+
+ // The clock, while there is a limit to count.
+ const hasLimits = active && (!!limits.idle || !!limits.max);
+ useEffect(() => {
+ if (!hasLimits) {
+ timerRef.current?.dispose();
+ timerRef.current = null;
+ setState(IDLE_STATE);
+ return;
+ }
+ if (timerRef.current) {
+ timerRef.current.setLimits(limits);
+ return;
+ }
+ // A tab opened or reloaded after a long pause starts from the pause, a
+ // fresh sign-in from the sign-in.
+ const newestSignIn = Math.max(0, ...useAccountStore.getState().accounts.map((a) => a.signedInAt ?? 0));
+ const lastActivity = Math.max(readLastActivity() ?? Date.now(), newestSignIn);
+ timerRef.current = createSessionTimer({
+ limits,
+ lastActivity,
+ onChange: setState,
+ onExpire: (kind) => { void endSession(kind); },
+ });
+ setState(timerRef.current.state());
+ }, [hasLimits, limits, endSession]);
+
+ useEffect(() => () => {
+ timerRef.current?.dispose();
+ timerRef.current = null;
+ }, []);
+
+ useEffect(() => {
+ if (!active) {
+ endingRef.current = false;
+ setEnding(false);
+ }
+ }, [active]);
+
+ // Activity in this tab and the others.
+ useEffect(() => {
+ const channel = openActivityChannel((at) => timerRef.current?.remoteActivity(at));
+ channelRef.current = channel;
+ let lastSeen = 0;
+ const onActivity = () => {
+ if (endingRef.current) return;
+ const at = Date.now();
+ if (at - lastSeen < ACTIVITY_THROTTLE_MS) return;
+ const timer = timerRef.current;
+ if (timer) {
+ timer.activity(at);
+ // During the idle warning only "Stay signed in" counts.
+ const s = timer.state();
+ if (s.phase === "expired" || (s.phase === "warning" && s.kind === "idle")) return;
+ }
+ lastSeen = at;
+ channel.post(at);
+ };
+ const onVisibility = () => {
+ if (document.visibilityState === "visible") timerRef.current?.check();
+ };
+ for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onActivity, { capture: true, passive: true });
+ document.addEventListener("visibilitychange", onVisibility);
+ return () => {
+ for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true });
+ document.removeEventListener("visibilitychange", onVisibility);
+ channel.close();
+ channelRef.current = null;
+ };
+ }, []);
+
+ const stay = useCallback(() => {
+ const timer = timerRef.current;
+ if (!timer) return;
+ timer.stay();
+ channelRef.current?.post(timer.lastActivity());
+ }, []);
+
+ const signOutNow = useCallback(() => {
+ const kind = timerRef.current?.state().kind ?? "idle";
+ void endSession(kind);
+ }, [endSession]);
+
+ if (!active) return null;
+ if (ending) {
+ return ;
+ }
+ if (state.phase !== "warning" || !state.kind) return null;
+ if (state.kind === "max" && dismissedMax === state.deadline) return null;
+ return (
+ setDismissedMax(state.deadline)}
+ onSignOut={signOutNow}
+ />
+ );
+}
diff --git a/lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts b/lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts
new file mode 100644
index 000000000..a371e4192
--- /dev/null
+++ b/lib/plugin-sandbox/__tests__/host-api-app-session-timeout.test.ts
@@ -0,0 +1,43 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { doAppGetSettings } from '../host-api-app';
+import { usePolicyStore } from '@/stores/policy-store';
+import { useSettingsStore } from '@/stores/settings-store';
+import { DEFAULT_POLICY } from '@/lib/admin/types';
+import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
+import { setCurrentSessionLimits } from '@/lib/session-policy/limits';
+
+/**
+ * Plugins read `sessionTimeout` from the settings view. The user setting of
+ * that name never did anything; the view reports the idle timeout the
+ * organisation set instead.
+ */
+describe('plugin settings view: sessionTimeout', () => {
+ beforeEach(() => {
+ useSettingsStore.setState({ sessionTimeout: 45 } as never);
+ usePolicyStore.setState({
+ rawPolicy: DEFAULT_POLICY,
+ policy: {
+ ...DEFAULT_POLICY,
+ organisation: { ...DEFAULT_ORGANISATION_POLICY, session: { idleMinutes: 20, maxHours: 0, warnSeconds: 60 } },
+ },
+ });
+ setCurrentSessionLimits(null);
+ });
+ afterEach(() => {
+ setCurrentSessionLimits(null);
+ });
+
+ it("reports the active account's idle timeout before the guard has looked at every account", () => {
+ expect(doAppGetSettings().sessionTimeout).toBe(20);
+ });
+
+ it('reports the strictest idle timeout of the signed-in accounts', () => {
+ setCurrentSessionLimits({ idle: { minutes: 5, warnSeconds: 60 }, max: null });
+ expect(doAppGetSettings().sessionTimeout).toBe(5);
+ });
+
+ it('reports 0 when no account has an idle timeout, whatever the old user setting says', () => {
+ setCurrentSessionLimits({ idle: null, max: null });
+ expect(doAppGetSettings().sessionTimeout).toBe(0);
+ });
+});
diff --git a/lib/plugin-sandbox/host-api-app.ts b/lib/plugin-sandbox/host-api-app.ts
index 86f7f58ad..b3ddc8968 100644
--- a/lib/plugin-sandbox/host-api-app.ts
+++ b/lib/plugin-sandbox/host-api-app.ts
@@ -17,6 +17,7 @@ import { productName } from '@/lib/product-name';
import {
collectSubResults, getByPath, tabKeywords, tabSearchPaths, withAppName, type SettingsSearchTab, type SubResult,
} from '@/lib/settings-search';
+import { currentSessionLimits } from '@/lib/session-policy/limits';
import type { AppSettingsView, SettingsSearchResult } from '../plugin-types';
type Messages = Record;
@@ -144,6 +145,13 @@ export function doAppGetSettings(): AppSettingsView {
if (typeof value === 'string' || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))) out[key] = value;
else if (Array.isArray(value) && value.every((v) => typeof v === 'number' || typeof v === 'string')) out[key] = value.slice(0, 20) as number[] | string[];
}
+ // Minutes without activity before Bulwark signs out (0 = never). The user
+ // setting of that name was never read; the organisation's idle timeout is
+ // what applies, the strictest of the accounts signed in here.
+ const limits = currentSessionLimits();
+ out.sessionTimeout = limits
+ ? limits.idle?.minutes ?? 0
+ : usePolicyStore.getState().getOrganisation().session.idleMinutes;
const theme = (useThemeStore.getState() as unknown as { theme?: unknown }).theme;
if (typeof theme === 'string') out.theme = theme;
const locale = useLocaleStore.getState().locale;
diff --git a/lib/session-policy/__tests__/activity-channel.test.ts b/lib/session-policy/__tests__/activity-channel.test.ts
new file mode 100644
index 000000000..beabf28e6
--- /dev/null
+++ b/lib/session-policy/__tests__/activity-channel.test.ts
@@ -0,0 +1,69 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { LAST_ACTIVITY_KEY, openActivityChannel, readLastActivity } from '../activity-channel';
+
+/** BroadcastChannel between "tabs" of one test: every instance of a name hears the others. */
+class FakeBroadcastChannel {
+ static instances: FakeBroadcastChannel[] = [];
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ closed = false;
+ constructor(public name: string) {
+ FakeBroadcastChannel.instances.push(this);
+ }
+ postMessage(data: unknown) {
+ for (const other of FakeBroadcastChannel.instances) {
+ if (other !== this && !other.closed && other.name === this.name) other.onmessage?.({ data } as MessageEvent);
+ }
+ }
+ close() {
+ this.closed = true;
+ }
+}
+
+describe('activity shared between tabs', () => {
+ beforeEach(() => {
+ localStorage.clear();
+ FakeBroadcastChannel.instances = [];
+ });
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it('tells the other tabs through a BroadcastChannel and keeps the time', () => {
+ vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel);
+ const heardA = vi.fn();
+ const heardB = vi.fn();
+ const a = openActivityChannel(heardA);
+ const b = openActivityChannel(heardB);
+
+ a.post(1234);
+
+ expect(heardB).toHaveBeenCalledWith(1234);
+ expect(heardA).not.toHaveBeenCalled();
+ expect(readLastActivity()).toBe(1234);
+
+ b.close();
+ a.post(5678);
+ expect(heardB).toHaveBeenCalledTimes(1);
+ a.close();
+ });
+
+ it('falls back to storage events without BroadcastChannel', () => {
+ vi.stubGlobal('BroadcastChannel', undefined);
+ const heard = vi.fn();
+ const channel = openActivityChannel(heard);
+
+ // Another tab's write arrives as a storage event.
+ window.dispatchEvent(new StorageEvent('storage', { key: LAST_ACTIVITY_KEY, newValue: '4321' }));
+ window.dispatchEvent(new StorageEvent('storage', { key: 'something-else', newValue: '1' }));
+ window.dispatchEvent(new StorageEvent('storage', { key: LAST_ACTIVITY_KEY, newValue: 'garbage' }));
+ expect(heard).toHaveBeenCalledTimes(1);
+ expect(heard).toHaveBeenCalledWith(4321);
+
+ channel.post(9999);
+ expect(readLastActivity()).toBe(9999);
+
+ channel.close();
+ window.dispatchEvent(new StorageEvent('storage', { key: LAST_ACTIVITY_KEY, newValue: '5555' }));
+ expect(heard).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/lib/session-policy/__tests__/limits.test.ts b/lib/session-policy/__tests__/limits.test.ts
new file mode 100644
index 000000000..676a30e08
--- /dev/null
+++ b/lib/session-policy/__tests__/limits.test.ts
@@ -0,0 +1,34 @@
+import { describe, expect, it } from 'vitest';
+import { sessionLimits } from '../limits';
+
+const HOUR = 60 * 60 * 1000;
+const session = (idleMinutes: number, maxHours: number, warnSeconds = 60) => ({ idleMinutes, maxHours, warnSeconds });
+
+describe('session limits of the signed-in accounts', () => {
+ it('takes the shortest idle time that is set', () => {
+ const limits = sessionLimits([
+ { session: session(30, 0, 120) },
+ { session: session(0, 0) },
+ { session: session(10, 0, 45) },
+ ]);
+ expect(limits.idle).toEqual({ minutes: 10, warnSeconds: 45 });
+ });
+
+ it('ignores accounts that set nothing', () => {
+ expect(sessionLimits([{ session: session(0, 0) }, { session: undefined }])).toEqual({ idle: null, max: null });
+ expect(sessionLimits([])).toEqual({ idle: null, max: null });
+ });
+
+ it('ends at the first maximum that runs out, counted from each sign-in', () => {
+ const limits = sessionLimits([
+ { session: session(0, 8), signedInAt: 10 * HOUR },
+ { session: session(0, 12), signedInAt: 0 },
+ { session: session(0, 4, 30), signedInAt: 9 * HOUR },
+ ]);
+ expect(limits.max).toEqual({ at: 12 * HOUR, hours: 12, warnSeconds: 60 });
+ });
+
+ it('needs a sign-in time to count a maximum', () => {
+ expect(sessionLimits([{ session: session(0, 8) }]).max).toBeNull();
+ });
+});
diff --git a/lib/session-policy/__tests__/session-timer.test.ts b/lib/session-policy/__tests__/session-timer.test.ts
new file mode 100644
index 000000000..f5fef76d2
--- /dev/null
+++ b/lib/session-policy/__tests__/session-timer.test.ts
@@ -0,0 +1,143 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { createSessionTimer, type SessionTimerState } from '../session-timer';
+import type { SessionLimits } from '../limits';
+
+const START = new Date('2026-10-28T09:00:00Z').getTime();
+const SECOND = 1000;
+const MINUTE = 60 * SECOND;
+
+const idleOnly = (minutes: number, warnSeconds: number): SessionLimits => ({ idle: { minutes, warnSeconds }, max: null });
+
+function setup(limits: SessionLimits) {
+ const states: SessionTimerState[] = [];
+ const onExpire = vi.fn();
+ const timer = createSessionTimer({ limits, lastActivity: Date.now(), onChange: (s) => states.push(s), onExpire });
+ return { timer, states, onExpire };
+}
+
+describe('session timer', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(START);
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('warns warnSeconds before the idle time runs out, then signs out at zero', () => {
+ const { timer, onExpire } = setup(idleOnly(1, 30));
+ expect(timer.state()).toMatchObject({ phase: 'active', kind: 'idle', deadline: START + MINUTE });
+
+ vi.advanceTimersByTime(30 * SECOND - 1);
+ expect(timer.state().phase).toBe('active');
+ vi.advanceTimersByTime(1);
+ expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'idle', deadline: START + MINUTE });
+ expect(onExpire).not.toHaveBeenCalled();
+
+ vi.advanceTimersByTime(30 * SECOND - 1);
+ expect(onExpire).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(onExpire).toHaveBeenCalledWith('idle');
+ expect(timer.state().phase).toBe('expired');
+ timer.dispose();
+ });
+
+ it('starts the idle time again on activity before the warning', () => {
+ const { timer, onExpire } = setup(idleOnly(1, 30));
+ vi.advanceTimersByTime(20 * SECOND);
+ timer.activity();
+ // Without the reset the warning would have started at 30 s.
+ vi.advanceTimersByTime(29 * SECOND);
+ expect(timer.state().phase).toBe('active');
+ vi.advanceTimersByTime(1 * SECOND);
+ expect(timer.state().phase).toBe('warning');
+ expect(timer.state().deadline).toBe(START + 20 * SECOND + MINUTE);
+ vi.advanceTimersByTime(30 * SECOND);
+ expect(onExpire).toHaveBeenCalledTimes(1);
+ timer.dispose();
+ });
+
+ it('keeps the warning up on local activity until the user chooses to stay', () => {
+ const { timer, onExpire } = setup(idleOnly(1, 30));
+ vi.advanceTimersByTime(40 * SECOND);
+ expect(timer.state().phase).toBe('warning');
+
+ timer.activity();
+ expect(timer.state().phase).toBe('warning');
+
+ timer.stay();
+ expect(timer.state()).toMatchObject({ phase: 'active', deadline: START + 40 * SECOND + MINUTE });
+ vi.advanceTimersByTime(MINUTE - 1);
+ expect(onExpire).not.toHaveBeenCalled();
+ vi.advanceTimersByTime(1);
+ expect(onExpire).toHaveBeenCalledWith('idle');
+ timer.dispose();
+ });
+
+ it('ends the warning when another tab reports activity', () => {
+ const { timer, onExpire } = setup(idleOnly(1, 30));
+ vi.advanceTimersByTime(45 * SECOND);
+ expect(timer.state().phase).toBe('warning');
+
+ timer.remoteActivity(Date.now());
+ expect(timer.state().phase).toBe('active');
+ vi.advanceTimersByTime(MINUTE - 1);
+ expect(onExpire).not.toHaveBeenCalled();
+ timer.dispose();
+ });
+
+ it('signs out after a sleep even when the wake-up arrives as activity first', () => {
+ const { timer, onExpire } = setup(idleOnly(15, 60));
+ // The machine slept: the clock jumped, no timer ran.
+ vi.setSystemTime(START + 2 * 60 * MINUTE);
+ timer.activity();
+ expect(onExpire).toHaveBeenCalledWith('idle');
+ expect(timer.state().phase).toBe('expired');
+ timer.remoteActivity(Date.now());
+ timer.stay();
+ expect(onExpire).toHaveBeenCalledTimes(1);
+ timer.dispose();
+ });
+
+ it('runs no clock without limits', () => {
+ const { timer, onExpire } = setup({ idle: null, max: null });
+ expect(timer.state()).toEqual({ phase: 'active', kind: null, deadline: null });
+ vi.advanceTimersByTime(365 * 24 * 60 * MINUTE);
+ expect(onExpire).not.toHaveBeenCalled();
+ timer.dispose();
+ });
+
+ it('follows new limits', () => {
+ const { timer, onExpire } = setup({ idle: null, max: null });
+ vi.advanceTimersByTime(10 * MINUTE);
+ timer.setLimits(idleOnly(5, 60));
+ // The last activity was ten minutes ago: already past five minutes.
+ expect(onExpire).toHaveBeenCalledWith('idle');
+ timer.dispose();
+ });
+
+ it('warns before the maximum session length and does not move it on activity', () => {
+ const at = START + 2 * 60 * MINUTE;
+ const { timer, onExpire } = setup({ idle: { minutes: 30, warnSeconds: 60 }, max: { at, hours: 2, warnSeconds: 60 } });
+
+ for (let i = 0; i < 119; i++) {
+ vi.advanceTimersByTime(MINUTE);
+ timer.activity();
+ }
+ expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'max', deadline: at });
+ timer.stay();
+ expect(timer.state()).toMatchObject({ phase: 'warning', kind: 'max' });
+ vi.advanceTimersByTime(MINUTE);
+ expect(onExpire).toHaveBeenCalledWith('max');
+ timer.dispose();
+ });
+
+ it('counts a maximum far away without running its timer early', () => {
+ const at = START + 365 * 24 * 60 * MINUTE;
+ const { timer, onExpire } = setup({ idle: null, max: { at, hours: 365 * 24, warnSeconds: 60 } });
+ vi.advanceTimersByTime(30 * 24 * 60 * MINUTE);
+ expect(onExpire).not.toHaveBeenCalled();
+ expect(timer.state().phase).toBe('active');
+ timer.dispose();
+ });
+});
diff --git a/lib/session-policy/activity-channel.ts b/lib/session-policy/activity-channel.ts
new file mode 100644
index 000000000..283560c89
--- /dev/null
+++ b/lib/session-policy/activity-channel.ts
@@ -0,0 +1,72 @@
+/**
+ * Activity shared between Bulwark tabs, so working in one tab keeps the
+ * others signed in. Tabs tell each other through a BroadcastChannel; where
+ * there is none, through `storage` events on a localStorage key. The last
+ * activity is also kept in localStorage, so a tab opened (or reloaded) after
+ * a long pause starts from it rather than from the page load.
+ */
+
+const CHANNEL = 'bulwark-session-activity';
+export const LAST_ACTIVITY_KEY = 'bulwark-last-activity';
+
+export interface ActivityChannel {
+ /** Tell the other tabs about activity at `at` (ms). */
+ post(at: number): void;
+ close(): void;
+}
+
+function parse(value: unknown): number | null {
+ const at = typeof value === 'string' ? Number(value) : value;
+ return typeof at === 'number' && Number.isFinite(at) && at > 0 ? at : null;
+}
+
+/** The last activity any tab recorded, or null. */
+export function readLastActivity(): number | null {
+ try {
+ return parse(window.localStorage.getItem(LAST_ACTIVITY_KEY));
+ } catch {
+ return null;
+ }
+}
+
+export function openActivityChannel(onActivity: (at: number) => void): ActivityChannel {
+ let channel: BroadcastChannel | null = null;
+ if (typeof BroadcastChannel !== 'undefined') {
+ try {
+ channel = new BroadcastChannel(CHANNEL);
+ channel.onmessage = (event: MessageEvent) => {
+ const at = parse((event.data as { at?: unknown } | null)?.at);
+ if (at) onActivity(at);
+ };
+ } catch {
+ channel = null;
+ }
+ }
+
+ // Without a channel, the localStorage write below reaches the other tabs as a storage event.
+ const onStorage = (event: StorageEvent) => {
+ if (event.key !== LAST_ACTIVITY_KEY) return;
+ const at = parse(event.newValue);
+ if (at) onActivity(at);
+ };
+ if (!channel) window.addEventListener('storage', onStorage);
+
+ return {
+ post(at) {
+ try {
+ window.localStorage.setItem(LAST_ACTIVITY_KEY, String(at));
+ } catch {
+ /* storage unavailable */
+ }
+ try {
+ channel?.postMessage({ at });
+ } catch {
+ /* channel closed */
+ }
+ },
+ close() {
+ channel?.close();
+ if (!channel) window.removeEventListener('storage', onStorage);
+ },
+ };
+}
diff --git a/lib/session-policy/limits.ts b/lib/session-policy/limits.ts
new file mode 100644
index 000000000..6a939f507
--- /dev/null
+++ b/lib/session-policy/limits.ts
@@ -0,0 +1,60 @@
+/**
+ * The organisation's session limits (policy.json `organisation.session`) as
+ * they apply to this browser. Every account signed in here has its own
+ * effective policy (its domain and group scopes); the browser follows the
+ * strictest: the shortest idle time any of them sets, and the first
+ * maximum session length to run out. 0 is off, so it never wins.
+ */
+
+import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
+
+export type SessionPolicy = OrganisationPolicy['session'];
+
+export interface AccountSession {
+ session: SessionPolicy | undefined;
+ /** When the account signed in (ms); its maximum session length counts from here. */
+ signedInAt?: number;
+}
+
+export interface SessionLimits {
+ idle: { minutes: number; warnSeconds: number } | null;
+ /** `at`: when the first account's maximum session length runs out. */
+ max: { at: number; hours: number; warnSeconds: number } | null;
+}
+
+export const NO_SESSION_LIMITS: SessionLimits = { idle: null, max: null };
+
+const HOUR_MS = 60 * 60 * 1000;
+
+export function sessionLimits(accounts: readonly AccountSession[]): SessionLimits {
+ let idle: SessionLimits['idle'] = null;
+ let max: SessionLimits['max'] = null;
+ for (const account of accounts) {
+ const session = account.session ?? DEFAULT_ORGANISATION_POLICY.session;
+ const warnSeconds = session.warnSeconds;
+ if (session.idleMinutes > 0 && (!idle || session.idleMinutes < idle.minutes)) {
+ idle = { minutes: session.idleMinutes, warnSeconds };
+ }
+ if (session.maxHours > 0 && typeof account.signedInAt === 'number') {
+ const at = account.signedInAt + session.maxHours * HOUR_MS;
+ if (!max || at < max.at) max = { at, hours: session.maxHours, warnSeconds };
+ }
+ }
+ return { idle, max };
+}
+
+export function sameLimits(a: SessionLimits, b: SessionLimits): boolean {
+ return JSON.stringify(a) === JSON.stringify(b);
+}
+
+let current: SessionLimits | null = null;
+
+/** The limits the guard counts with, for readers outside React (the plugin settings view). */
+export function setCurrentSessionLimits(limits: SessionLimits | null): void {
+ current = limits;
+}
+
+/** Null before the guard has looked at the signed-in accounts. */
+export function currentSessionLimits(): SessionLimits | null {
+ return current;
+}
diff --git a/lib/session-policy/session-timer.ts b/lib/session-policy/session-timer.ts
new file mode 100644
index 000000000..b9d54b8e0
--- /dev/null
+++ b/lib/session-policy/session-timer.ts
@@ -0,0 +1,139 @@
+/**
+ * The clock behind the organisation's idle timeout and maximum session
+ * length. It knows the last activity and the limits, and says when the
+ * warning starts and when the session ends; the guard component
+ * (components/session/session-timeout-guard.tsx) feeds it activity and
+ * does the signing out.
+ *
+ * Deadlines are times, not countdowns: a laptop that slept through one
+ * expires on the first check after waking, and activity that arrives after
+ * the deadline (the focus event of the wake-up itself) can't bring the
+ * session back.
+ */
+
+import type { SessionLimits } from './limits';
+
+export type SessionEndKind = 'idle' | 'max';
+
+export interface SessionTimerState {
+ phase: 'active' | 'warning' | 'expired';
+ /** Which limit the next deadline belongs to; null when no limit is set. */
+ kind: SessionEndKind | null;
+ /** When the session ends (ms); null when no limit is set. */
+ deadline: number | null;
+}
+
+export interface SessionTimerOptions {
+ limits: SessionLimits;
+ lastActivity: number;
+ onChange?: (state: SessionTimerState) => void;
+ onExpire: (kind: SessionEndKind) => void;
+ now?: () => number;
+}
+
+export interface SessionTimer {
+ /** Activity in this tab. Ignored while the idle warning shows: the user answers it with "Stay signed in". */
+ activity(at?: number): void;
+ /** Activity in another tab, where the user may have answered the warning already. */
+ remoteActivity(at: number): void;
+ /** "Stay signed in": restarts the idle time, never the maximum. */
+ stay(): void;
+ setLimits(limits: SessionLimits): void;
+ /** Look at the clock now (after a wake-up or a tab becoming visible). */
+ check(): void;
+ lastActivity(): number;
+ state(): SessionTimerState;
+ dispose(): void;
+}
+
+const MINUTE_MS = 60 * 1000;
+const MAX_WAIT_MS = 60 * MINUTE_MS;
+
+export function createSessionTimer(options: SessionTimerOptions): SessionTimer {
+ const now = options.now ?? Date.now;
+ let limits = options.limits;
+ let lastActivity = options.lastActivity;
+ let state: SessionTimerState = { phase: 'active', kind: null, deadline: null };
+ let timer: ReturnType | null = null;
+ let disposed = false;
+
+ const clear = () => {
+ if (timer) clearTimeout(timer);
+ timer = null;
+ };
+
+ const publish = (next: SessionTimerState) => {
+ if (next.phase === state.phase && next.kind === state.kind && next.deadline === state.deadline) return;
+ state = next;
+ options.onChange?.(state);
+ };
+
+ /** The deadline that comes first, with how long its warning lasts. */
+ const target = (): { kind: SessionEndKind; deadline: number; warnMs: number } | null => {
+ const idleMs = limits.idle ? limits.idle.minutes * MINUTE_MS : 0;
+ const idle = limits.idle
+ ? { kind: 'idle' as const, deadline: lastActivity + idleMs, warnMs: Math.min(limits.idle.warnSeconds * 1000, idleMs) }
+ : null;
+ const max = limits.max
+ ? { kind: 'max' as const, deadline: limits.max.at, warnMs: limits.max.warnSeconds * 1000 }
+ : null;
+ if (idle && max) return max.deadline <= idle.deadline ? max : idle;
+ return idle ?? max;
+ };
+
+ const evaluate = () => {
+ if (disposed || state.phase === 'expired') return;
+ clear();
+ const next = target();
+ if (!next) {
+ publish({ phase: 'active', kind: null, deadline: null });
+ return;
+ }
+ const at = now();
+ if (at >= next.deadline) {
+ publish({ phase: 'expired', kind: next.kind, deadline: next.deadline });
+ options.onExpire(next.kind);
+ return;
+ }
+ const warnFrom = next.deadline - next.warnMs;
+ const warning = at >= warnFrom;
+ publish({ phase: warning ? 'warning' : 'active', kind: next.kind, deadline: next.deadline });
+ // Capped: setTimeout overflows past 24.8 days, and a long wait is checked again anyway.
+ timer = setTimeout(evaluate, Math.min((warning ? next.deadline : warnFrom) - at, MAX_WAIT_MS));
+ };
+
+ const record = (at: number) => {
+ // A deadline that already passed ends the session before the activity counts.
+ evaluate();
+ if (state.phase === 'expired') return;
+ if (at > lastActivity) lastActivity = at;
+ evaluate();
+ };
+
+ evaluate();
+
+ return {
+ activity(at = now()) {
+ evaluate();
+ if (state.phase === 'warning' && state.kind === 'idle') return;
+ record(at);
+ },
+ remoteActivity(at) {
+ record(at);
+ },
+ stay() {
+ record(now());
+ },
+ setLimits(next) {
+ limits = next;
+ evaluate();
+ },
+ check: evaluate,
+ lastActivity: () => lastActivity,
+ state: () => state,
+ dispose() {
+ disposed = true;
+ clear();
+ },
+ };
+}
diff --git a/lib/session-policy/sign-out-reason.ts b/lib/session-policy/sign-out-reason.ts
new file mode 100644
index 000000000..b52166520
--- /dev/null
+++ b/lib/session-policy/sign-out-reason.ts
@@ -0,0 +1,33 @@
+/**
+ * Why Bulwark signed the user out on its own, for the login page to say.
+ * Kept in sessionStorage: the tab that signed out shows it once.
+ */
+
+export type SessionEndReason =
+ | { kind: 'idle'; minutes: number }
+ | { kind: 'max'; hours: number };
+
+const KEY = 'session_end_reason';
+
+export function rememberSessionEnd(reason: SessionEndReason): void {
+ try {
+ window.sessionStorage.setItem(KEY, JSON.stringify(reason));
+ } catch {
+ /* storage unavailable */
+ }
+}
+
+/** The reason, once; null when the user wasn't signed out by a session limit. */
+export function consumeSessionEnd(): SessionEndReason | null {
+ try {
+ const raw = window.sessionStorage.getItem(KEY);
+ window.sessionStorage.removeItem(KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw) as Record;
+ if (parsed.kind === 'idle' && typeof parsed.minutes === 'number') return { kind: 'idle', minutes: parsed.minutes };
+ if (parsed.kind === 'max' && typeof parsed.hours === 'number') return { kind: 'max', hours: parsed.hours };
+ return null;
+ } catch {
+ return null;
+ }
+}
diff --git a/locales/de/common.json b/locales/de/common.json
index ffdd16235..5d888afd7 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -76,6 +76,18 @@
"back_to_login": "Zurück zur Anmeldung"
}
},
+ "session_limits": {
+ "idle_title": "Sind Sie noch da?",
+ "idle_message": "Sie werden in {seconds, plural, one {# Sekunde} other {# Sekunden}} abgemeldet, weil es keine Aktivität gab. Geöffnete Nachrichten werden als Entwurf gespeichert.",
+ "stay": "Angemeldet bleiben",
+ "sign_out_now": "Jetzt abmelden",
+ "max_title": "Ihre Sitzung endet",
+ "max_message": "Ihre Organisation beendet Sitzungen {hours, plural, one {# Stunde} other {# Stunden}} nach der Anmeldung. Sie werden in {seconds, plural, one {# Sekunde} other {# Sekunden}} abgemeldet. Geöffnete Nachrichten werden als Entwurf gespeichert.",
+ "dismiss": "OK",
+ "signing_out": "Entwürfe werden gespeichert und Sie werden abgemeldet …",
+ "reason_idle": "Sie wurden nach {minutes, plural, one {# Minute} other {# Minuten}} ohne Aktivität abgemeldet.",
+ "reason_max": "Sie wurden abgemeldet, weil Ihre Sitzung die Höchstdauer von {hours, plural, one {# Stunde} other {# Stunden}} erreicht hat."
+ },
"sidebar": {
"close": "Schließen",
"compose": "Verfassen",
diff --git a/locales/en/common.json b/locales/en/common.json
index 95ec87fa3..f9ca53762 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -76,6 +76,18 @@
"back_to_login": "Back to login"
}
},
+ "session_limits": {
+ "idle_title": "Are you still there?",
+ "idle_message": "You'll be signed out in {seconds, plural, one {# second} other {# seconds}} because there was no activity. Open messages are saved as drafts.",
+ "stay": "Stay signed in",
+ "sign_out_now": "Sign out now",
+ "max_title": "Your session is ending",
+ "max_message": "Your organisation ends sessions {hours, plural, one {# hour} other {# hours}} after sign-in. You'll be signed out in {seconds, plural, one {# second} other {# seconds}}. Open messages are saved as drafts.",
+ "dismiss": "OK",
+ "signing_out": "Saving drafts and signing out…",
+ "reason_idle": "You were signed out after {minutes, plural, one {# minute} other {# minutes}} without activity.",
+ "reason_max": "You were signed out because your session reached its maximum length of {hours, plural, one {# hour} other {# hours}}."
+ },
"sidebar": {
"close": "Close",
"compose": "Compose",
diff --git a/stores/__tests__/auth-store-provider-logout.test.ts b/stores/__tests__/auth-store-provider-logout.test.ts
index 237773b58..7a22f3f79 100644
--- a/stores/__tests__/auth-store-provider-logout.test.ts
+++ b/stores/__tests__/auth-store-provider-logout.test.ts
@@ -381,6 +381,22 @@ describe('signing out of several accounts', () => {
expect(calls).toEqual(['DELETE /api/auth/session?all=true', 'DELETE /api/auth/token?all=true']);
expect(replaceSpy).toHaveBeenCalledWith('/en/login');
});
+
+ it('leaves every provider session alone for a sign-out the session limits made', async () => {
+ signIn([alice, bob, carol], 'carol');
+ const { calls } = mockFetch({
+ 'DELETE /api/auth/session?all=true': () => ok(),
+ 'DELETE /api/auth/token?all=true': () => ok(),
+ });
+
+ await useAuthStore.getState().logoutAll({ endProviderSession: false });
+
+ expect(calls).toEqual(['DELETE /api/auth/session?all=true', 'DELETE /api/auth/token?all=true']);
+ expect(replaceSpy).toHaveBeenCalledWith('/en/login');
+ expect(useAccountStore.getState().accounts).toEqual([]);
+ // The login page shows the form rather than signing straight back in.
+ expect(consumeSignedOut()).toBe(true);
+ });
});
// Leaving for the provider is final for the page, so that state is never
diff --git a/stores/account-store.ts b/stores/account-store.ts
index 2a6a37059..7522cf24d 100644
--- a/stores/account-store.ts
+++ b/stores/account-store.ts
@@ -43,6 +43,12 @@ export interface AccountEntry {
avatarColor: string;
/** Timestamp of last successful login */
lastLoginAt: number;
+ /**
+ * When the user last signed in to it with a password, token or provider
+ * login (not a restore). The organisation's maximum session length counts
+ * from here. `undefined` = signed in before this was recorded.
+ */
+ signedInAt?: number;
/** Whether this account is currently connected */
isConnected: boolean;
/** Whether this account had a connection error */
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index 6a7421f7e..0a414af71 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -108,9 +108,10 @@ interface AuthState {
logout: (options?: LogoutOptions) => Promise;
/**
* Sign out of every account. Ends at most one identity provider session:
- * see {@link pickEndSessionSlot}.
+ * see {@link pickEndSessionSlot}; none with `endProviderSession: false`
+ * (the organisation's session limits, which sign out without asking).
*/
- logoutAll: () => Promise;
+ logoutAll: (options?: LogoutOptions) => Promise;
removeAccount: (accountId: string) => void;
switchAccount: (accountId: string) => Promise;
checkAuth: () => Promise;
@@ -1267,6 +1268,8 @@ export const LOGOUT_HOOK_BUDGET_MS = 2000;
function notifySignedIn(state: { activeAccountId: string | null; username: string | null }): void {
if (!state.activeAccountId) return;
+ // The organisation's maximum session length counts from a sign-in, never from a restore.
+ useAccountStore.getState().updateAccount(state.activeAccountId, { signedInAt: Date.now() });
void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' });
}
@@ -2315,12 +2318,14 @@ export const useAuthStore = create()(
clearPluginStorageForAccount(accountId);
},
- logoutAll: async () => {
+ logoutAll: async (options) => {
await beforeLogoutHooks('all');
const accountStore = useAccountStore.getState();
const allAccounts = [...accountStore.accounts];
- const endSessionSlot = get().isDemoMode ? null : pickEndSessionSlot(allAccounts, get().activeAccountId);
+ // `options` can be a click event when the action is wired straight to onClick.
+ const endProviderSession = options?.endProviderSession !== false;
+ const endSessionSlot = get().isDemoMode || !endProviderSession ? null : pickEndSessionSlot(allAccounts, get().activeAccountId);
// Starts by marking every slot closing, so nothing renews a session
// while the clients are torn down.
@@ -3092,7 +3097,7 @@ export const useAuthStore = create()(
bindClientStatusHandlers(client, set, get, accountId);
accountStore.updateAccount(accountId, {
awaitingSignIn: false, isConnected: true, hasError: false, errorMessage: undefined,
- rememberMe, lastLoginAt: Date.now(),
+ rememberMe, lastLoginAt: Date.now(), signedInAt: Date.now(),
});
void syncAccountDisplayName(accountId, client);
set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 }));
From 6e5d8faec6bf26410f69fd8c884745d8600959a4 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 02:30:32 +0100
Subject: [PATCH 3/9] feat: refuse to restore or renew sessions older than the
admin-set maximum length
---
app/api/auth/session/route.ts | 54 +++-
app/api/auth/sso/complete/route.ts | 3 +
app/api/auth/stalwart-context/route.ts | 17 +-
app/api/auth/token/route.ts | 28 ++
app/api/auth/totp-token-exchange/route.ts | 3 +
lib/__tests__/auth-crypto.test.ts | 1 +
.../auth-login-routes-budget.test.ts | 1 +
.../auth-routes-credential-check.test.ts | 1 +
lib/__tests__/session-max-age-routes.test.ts | 240 ++++++++++++++++++
lib/auth/crypto.ts | 28 +-
lib/auth/session-age.ts | 102 ++++++++
lib/stalwart/auth-context.ts | 19 +-
...auth-store-restore-session-too-old.test.ts | 64 +++++
stores/auth-store.ts | 25 +-
14 files changed, 571 insertions(+), 15 deletions(-)
create mode 100644 lib/__tests__/session-max-age-routes.test.ts
create mode 100644 lib/auth/session-age.ts
create mode 100644 stores/__tests__/auth-store-restore-session-too-old.test.ts
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 3b0bbb5dd..6a0ce0a23 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -23,6 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation';
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
+import { isPastMaxAge, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
function sessionCookieOptions() {
return {
@@ -73,6 +74,42 @@ function getSlot(request: NextRequest): number {
return slot;
}
+/**
+ * The organisation's maximum session length for a remembered session
+ * (lib/auth/session-age.ts). A cookie from before sign-in times were
+ * recorded counts as signed in now and gets the time written into it.
+ * Returns the sign-in time, or null when the session is too old: its
+ * cookies are cleared then.
+ */
+async function checkSessionAge(
+ cookieStore: Awaited>,
+ slot: number,
+ credentials: SessionCredentials,
+): Promise<{ signedInAt: number } | { maxHours: number }> {
+ const cookieName = sessionCookieName(slot);
+ if (credentials.signedInAt === undefined) {
+ const signedInAt = Date.now();
+ const secret = credentials.token !== undefined ? { token: credentials.token } : credentials.password;
+ cookieStore.set(cookieName, encryptSession(credentials.serverUrl, credentials.username, secret, signedInAt), sessionCookieOptions());
+ return { signedInAt };
+ }
+ // An impersonation login is `target%master`; the policy is the target's.
+ const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
+ if (isPastMaxAge(credentials.signedInAt, maxHours)) {
+ cookieStore.delete(cookieName);
+ clearStalwartAuthContextInStore(cookieStore, slot);
+ return { maxHours };
+ }
+ return { signedInAt: credentials.signedInAt };
+}
+
+function sessionTooOld(maxHours: number) {
+ return NextResponse.json(
+ { error: SESSION_TOO_OLD, maxHours },
+ { status: 401, headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
+ );
+}
+
export async function POST(request: NextRequest) {
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
// so a cross-site top-level POST would otherwise reach this handler.
@@ -162,7 +199,9 @@ export async function POST(request: NextRequest) {
throw error;
}
refund();
- const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password);
+ // A remembered sign-in starts the maximum session length.
+ const signedInAt = Date.now();
+ const token = encryptSession(normalizedServerUrl, username, accessToken ? { token: accessToken } : password, signedInAt);
const cookieStore = await cookies();
cookieStore.set(cookieName, token, sessionCookieOptions());
setStalwartAuthContextInStore(cookieStore, slot, {
@@ -170,6 +209,7 @@ export async function POST(request: NextRequest) {
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
+ signedInAt,
});
void recordLogin(username, normalizedServerUrl);
@@ -208,10 +248,14 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
+ const age = await checkSessionAge(cookieStore, slot, credentials);
+ if ('maxHours' in age) return sessionTooOld(age.maxHours);
+
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
+ signedInAt: age.signedInAt,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
@@ -258,13 +302,19 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
+ const age = await checkSessionAge(cookieStore, slot, credentials);
+ if ('maxHours' in age) return sessionTooOld(age.maxHours);
+
setStalwartAuthContextInStore(cookieStore, slot, {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
+ signedInAt: age.signedInAt,
});
- return NextResponse.json(credentials, {
+ // The credentials as they were stored, without the bookkeeping.
+ const { signedInAt: _signedInAt, ...restored } = credentials;
+ return NextResponse.json(restored, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
} catch (error) {
diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts
index de4a8f8de..5ae1dc11c 100644
--- a/app/api/auth/sso/complete/route.ts
+++ b/app/api/auth/sso/complete/route.ts
@@ -14,6 +14,7 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { storeIdToken } from '@/lib/oauth/end-session';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
+import { writeSignInStamp } from '@/lib/auth/session-age';
const SSO_PENDING_COOKIE = 'sso_pending';
const SSO_PENDING_MAX_AGE_MS = 5 * 60 * 1000; // 5 minutes
@@ -115,6 +116,8 @@ export async function POST(request: NextRequest) {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
+ // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
+ writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts
index 0d07ec38b..43c884d1e 100644
--- a/app/api/auth/stalwart-context/route.ts
+++ b/app/api/auth/stalwart-context/route.ts
@@ -1,7 +1,9 @@
import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
-import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
+import { cookies } from 'next/headers';
+import { readStalwartAuthContextFromStore, setStalwartAuthContext } from '@/lib/stalwart/auth-context';
+import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
@@ -101,11 +103,24 @@ export async function POST(request: NextRequest) {
// Just signed in: an older refusal of this credential no longer applies.
recordStoredCredentialStatus(authHeader, 200);
+ // The context is written again on every renewed token and restore, so
+ // the sign-in time is carried over: from this slot's context for the
+ // same account, else from the token sign-in's stamp, else it is now.
+ const cookieStore = await cookies();
+ const previous = readStalwartAuthContextFromStore(cookieStore, slot);
+ const stamp = readSignInStamp(cookieStore, slot);
+ const ownStamp = stamp && (!stamp.account || stamp.account === accountName) ? stamp : null;
+ const samePrevious = previous && previous.serverUrl === normalizedServerUrl && previous.username === username ? previous : null;
+ const signedInAt = samePrevious?.signedInAt ?? ownStamp?.signedInAt ?? Date.now();
+ // The token routes know no account; the first verified one is the stamp's.
+ if (ownStamp && !ownStamp.account) writeSignInStamp(cookieStore, slot, { ...ownStamp, account: accountName });
+
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
+ signedInAt,
});
void recordLogin(username, normalizedServerUrl);
diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts
index 05e7ec29c..942514fde 100644
--- a/app/api/auth/token/route.ts
+++ b/app/api/auth/token/route.ts
@@ -30,6 +30,15 @@ import {
} from '@/lib/oauth/end-session';
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
+import {
+ clearSignInStamp,
+ isPastMaxAge,
+ maxSessionHoursFor,
+ readSignInStamp,
+ SESSION_TOO_OLD,
+ writeSignInStamp,
+} from '@/lib/auth/session-age';
+import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
function parseSlot(raw: string | null): number | null {
if (raw === null) return null;
@@ -152,6 +161,8 @@ export async function POST(request: NextRequest) {
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
+ // A sign-in: the maximum session length counts from here.
+ writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
}
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600, [tokens.refresh_token, serverId]);
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]);
@@ -189,6 +200,22 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
}
+ // The organisation's maximum session length (lib/auth/session-age.ts).
+ // A refresh token from before sign-in times were recorded counts as
+ // signed in now.
+ const stamp = readSignInStamp(cookieStore, slot);
+ if (!stamp) {
+ writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
+ } else {
+ const context = readStalwartAuthContextFromStore(cookieStore, slot);
+ const maxHours = await maxSessionHoursFor(stamp.account ?? context?.accountName ?? context?.username ?? null);
+ if (isPastMaxAge(stamp.signedInAt, maxHours)) {
+ await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false);
+ clearStalwartAuthContextInStore(cookieStore, slot);
+ return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 });
+ }
+ }
+
// A session restore calls this to get its token back, not because the
// current one expired. Serving the cached token avoids spending a refresh
// the IdP may legitimately reject: Rauthy stamps refresh tokens with
@@ -307,6 +334,7 @@ async function signOutSlot(
for (const name of [cookieName, refreshTokenServerCookieName(slot), accessTokenCookieName(slot)]) {
if (cookieStore.get(name)) cookieStore.delete(name);
}
+ clearSignInStamp(cookieStore, slot);
if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath);
return endSessionUrl;
}
diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts
index 6172b7283..bdceadbf7 100644
--- a/app/api/auth/totp-token-exchange/route.ts
+++ b/app/api/auth/totp-token-exchange/route.ts
@@ -14,6 +14,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
+import { writeSignInStamp } from '@/lib/auth/session-age';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
@@ -213,6 +214,8 @@ async function storeAndRespond(
// Without "Remember me" the sign-in ends with the browser session.
const { maxAge: _maxAge, ...sessionOptions } = getCookieOptions();
cookieStore.set(cookieName, tokens.refresh_token, rememberMe ? getCookieOptions() : sessionOptions);
+ // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
+ writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (serverId) {
diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts
index 2fa3c854d..e30d869b5 100644
--- a/lib/__tests__/auth-crypto.test.ts
+++ b/lib/__tests__/auth-crypto.test.ts
@@ -31,6 +31,7 @@ describe('encryptSession / decryptSession', () => {
serverUrl: 'https://mail.example.com',
username: 'alice',
password: 's3cret',
+ signedInAt: expect.any(Number),
});
});
diff --git a/lib/__tests__/auth-login-routes-budget.test.ts b/lib/__tests__/auth-login-routes-budget.test.ts
index 793cc52f5..1a228c07a 100644
--- a/lib/__tests__/auth-login-routes-budget.test.ts
+++ b/lib/__tests__/auth-login-routes-budget.test.ts
@@ -48,6 +48,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: vi.fn(),
setStalwartAuthContextInStore: vi.fn(),
clearStalwartAuthContextInStore: vi.fn(),
+ readStalwartAuthContextFromStore: () => null,
}));
vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() }));
diff --git a/lib/__tests__/auth-routes-credential-check.test.ts b/lib/__tests__/auth-routes-credential-check.test.ts
index 3c49cbe32..f286fc472 100644
--- a/lib/__tests__/auth-routes-credential-check.test.ts
+++ b/lib/__tests__/auth-routes-credential-check.test.ts
@@ -56,6 +56,7 @@ vi.mock('@/lib/stalwart/auth-context', () => ({
setStalwartAuthContext: (...args: unknown[]) => setStalwartAuthContext(...args),
setStalwartAuthContextInStore: (...args: unknown[]) => setStalwartAuthContextInStore(...args),
clearStalwartAuthContextInStore: vi.fn(),
+ readStalwartAuthContextFromStore: () => null,
}));
const recordLogin = vi.fn();
diff --git a/lib/__tests__/session-max-age-routes.test.ts b/lib/__tests__/session-max-age-routes.test.ts
new file mode 100644
index 000000000..5b3dd23b8
--- /dev/null
+++ b/lib/__tests__/session-max-age-routes.test.ts
@@ -0,0 +1,240 @@
+// @vitest-environment node
+import { createCipheriv, createHash, randomBytes } from 'node:crypto';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { NextRequest } from 'next/server';
+import type { SettingsPolicy } from '@/lib/admin/types';
+
+/**
+ * The organisation's maximum session length on the server: the routes that
+ * restore a remembered sign-in or renew a provider token refuse a session
+ * older than `maxHours` of the account's domain scopes, and a cookie from
+ * before sign-in times were recorded counts as signed in when first seen.
+ */
+
+const SECRET = 's'.repeat(64);
+vi.mock('@/lib/auth/session-secret', () => ({ getSessionSecret: () => SECRET, hasSessionSecret: () => true }));
+vi.mock('@/lib/logger', () => ({ logger: { warn: () => {}, error: () => {}, info: () => {}, debug: () => {} } }));
+vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: () => {} }));
+vi.mock('@/lib/oauth/cookie-config', () => ({
+ getCookieOptions: () => ({ httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 2592000 }),
+}));
+
+const jar = new Map();
+vi.mock('next/headers', () => ({
+ cookies: async () => ({
+ get: (name: string) => (jar.has(name) ? { name, value: jar.get(name)! } : undefined),
+ set: (name: string, value: string) => { jar.set(name, value); },
+ delete: (arg: string | { name: string }) => { jar.delete(typeof arg === 'string' ? arg : arg.name); },
+ getAll: () => [...jar].map(([name, value]) => ({ name, value })),
+ }),
+}));
+
+let policy: Partial;
+vi.mock('@/lib/admin/config-manager', () => ({
+ configManager: {
+ ensureLoaded: async () => {},
+ get: (key: string, fallback: unknown) => (key === 'jmapServerUrl' ? 'https://mail.example.org' : fallback),
+ getPolicy: () => policy,
+ },
+}));
+
+vi.mock('@/lib/auth/verify-jmap-auth', () => ({
+ JmapAuthVerificationError: class extends Error { status = 401; },
+ resolveJmapIdentity: async (serverUrl: string, _auth: string, username: string) => ({ serverUrl, accountName: username }),
+}));
+
+const tokenFetch = vi.fn();
+vi.mock('@/lib/oauth/token-exchange', () => ({
+ exchangeCodeForTokens: async () => ({ access_token: 'first-access', refresh_token: 'refresh-1', expires_in: 300 }),
+ buildOAuthParams: (params: Record) => new URLSearchParams(params),
+ getMetadata: async () => ({ revocation_endpoint: 'https://id.example.org/revoke' }),
+ getRequiredConfig: () => ({ clientId: 'webmail' }),
+ getTokenEndpoint: async () => 'https://id.example.org/token',
+ defaultClientOptions: async () => ({ fallbackClientId: 'bulwark-webmail' }),
+ DEFAULT_CLIENT_ID: 'bulwark-webmail',
+}));
+
+const { encryptSession, decryptSession, decryptPayload } = await import('@/lib/auth/crypto');
+const { readSignInStamp, writeSignInStamp } = await import('@/lib/auth/session-age');
+const sessionRoute = await import('@/app/api/auth/session/route');
+const tokenRoute = await import('@/app/api/auth/token/route');
+const contextRoute = await import('@/app/api/auth/stalwart-context/route');
+
+const HOUR = 60 * 60 * 1000;
+const NOW = new Date('2026-10-28T12:00:00Z').getTime();
+const SERVER = 'https://mail.example.org';
+const SAME_ORIGIN = { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' };
+
+/** Eight hours for example.org, nothing for everyone else. */
+function policyWithDomainMax(hours: number): Partial {
+ return {
+ features: {} as never, restrictions: {}, defaults: {},
+ scopes: [{ id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { maxHours: hours } } } }],
+ };
+}
+
+/** A remembered-session cookie as Bulwark wrote it before sign-in times were recorded. */
+function legacySessionCookie(username: string, password: string): string {
+ const key = createHash('sha256').update(SECRET).digest();
+ const iv = randomBytes(12);
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify({ v: 1, serverUrl: SERVER, username, password }), 'utf8'), cipher.final()]);
+ return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64');
+}
+
+const restore = () => sessionRoute.PUT(new NextRequest('https://webmail.example/api/auth/session', { method: 'PUT', headers: SAME_ORIGIN }));
+const peek = () => sessionRoute.GET(new NextRequest('https://webmail.example/api/auth/session'));
+const refresh = () => tokenRoute.PUT(new NextRequest('https://webmail.example/api/auth/token?slot=0', { method: 'PUT', headers: SAME_ORIGIN }));
+const context = () => decryptPayload(jar.get('jmap_stalwart_ctx')!, 'session-context');
+
+beforeEach(() => {
+ jar.clear();
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(NOW);
+ policy = policyWithDomainMax(8);
+ tokenFetch.mockReset();
+ tokenFetch.mockImplementation(async (url: string) => {
+ if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', expires_in: 300 });
+ return new Response(null, { status: 200 });
+ });
+ vi.stubGlobal('fetch', tokenFetch);
+});
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+});
+
+describe('remembered sessions (/api/auth/session)', () => {
+ it('records the sign-in time when the session is remembered', async () => {
+ const res = await sessionRoute.POST(new NextRequest('https://webmail.example/api/auth/session?slot=0', {
+ method: 'POST',
+ headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
+ body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' }),
+ }));
+ expect(res.status).toBe(200);
+ expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
+ expect(context()?.signedInAt).toBe(NOW);
+ });
+
+ it('restores a session younger than the maximum, without the sign-in time in the answer', async () => {
+ jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 7 * HOUR));
+ const res = await restore();
+ expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
+ expect(context()?.signedInAt).toBe(NOW - 7 * HOUR);
+ });
+
+ it('refuses a session older than its domain allows and clears its cookies', async () => {
+ jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 8 * HOUR));
+ jar.set('jmap_stalwart_ctx', 'stale');
+
+ const res = await restore();
+ expect(res.status).toBe(401);
+ expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
+ expect(jar.has('jmap_session')).toBe(false);
+ expect(jar.has('jmap_stalwart_ctx')).toBe(false);
+
+ jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 9 * HOUR));
+ expect((await peek()).status).toBe(401);
+ });
+
+ it('leaves an account of another domain alone', async () => {
+ jar.set('jmap_session', encryptSession(SERVER, 'org-dave@org-two.test', 'pw', NOW - 100 * HOUR));
+ expect((await restore()).status).toBe(200);
+ });
+
+ it('applies the maximum of Everyone to every domain', async () => {
+ policy = { ...policyWithDomainMax(8), organisation: { session: { idleMinutes: 0, maxHours: 24, warnSeconds: 60 } } as never };
+ jar.set('jmap_session', encryptSession(SERVER, 'org-dave@org-two.test', 'pw', NOW - 25 * HOUR));
+ expect((await restore()).status).toBe(401);
+ });
+
+ it('counts a cookie without a sign-in time as signed in when first seen', async () => {
+ jar.set('jmap_session', legacySessionCookie('org-alice@example.org', 'pw'));
+
+ const first = await restore();
+ expect(first.status).toBe(200);
+ expect(await first.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
+ expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
+
+ vi.setSystemTime(NOW + 7 * HOUR);
+ expect((await restore()).status).toBe(200);
+ vi.setSystemTime(NOW + 8 * HOUR);
+ expect((await restore()).status).toBe(401);
+ });
+});
+
+describe('provider tokens (/api/auth/token)', () => {
+ it('records the sign-in time when a provider sign-in stores its refresh token', async () => {
+ // A leftover stamp from an earlier sign-in in this slot.
+ const store = (await (await import('next/headers')).cookies()) as never;
+ writeSignInStamp(store, 0, { signedInAt: NOW - 20 * HOUR, account: 'org-bob@example.org' });
+
+ const res = await tokenRoute.POST(new NextRequest('https://webmail.example/api/auth/token?slot=0', {
+ method: 'POST',
+ headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
+ body: JSON.stringify({ code: 'c', code_verifier: 'v', redirect_uri: 'https://webmail.example/auth/callback', slot: 0 }),
+ }));
+ expect(res.status).toBe(200);
+ expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
+ });
+
+ it('refuses to renew a token sign-in older than the maximum and revokes it', async () => {
+ jar.set('jmap_rt', 'refresh-1');
+ const store = (await (await import('next/headers')).cookies()) as never;
+ writeSignInStamp(store, 0, { signedInAt: NOW - 8 * HOUR, account: 'org-alice@example.org' });
+
+ const res = await refresh();
+ expect(res.status).toBe(401);
+ expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
+ expect(jar.has('jmap_rt')).toBe(false);
+ expect(jar.has('jmap_signed_in')).toBe(false);
+ expect(tokenFetch).toHaveBeenCalledWith('https://id.example.org/revoke', expect.anything());
+ expect(tokenFetch).not.toHaveBeenCalledWith('https://id.example.org/token', expect.anything());
+ });
+
+ it('renews a token sign-in younger than the maximum', async () => {
+ jar.set('jmap_rt', 'refresh-1');
+ const store = (await (await import('next/headers')).cookies()) as never;
+ writeSignInStamp(store, 0, { signedInAt: NOW - 7 * HOUR, account: 'org-alice@example.org' });
+
+ const res = await refresh();
+ expect(res.status).toBe(200);
+ expect((await res.json()).access_token).toBe('new-access');
+ });
+
+ it('counts a refresh token without a sign-in time as signed in when first seen', async () => {
+ jar.set('jmap_rt', 'refresh-1');
+
+ expect((await refresh()).status).toBe(200);
+ const store = (await (await import('next/headers')).cookies()) as never;
+ expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
+ });
+});
+
+describe('the auth context (/api/auth/stalwart-context)', () => {
+ const post = () => contextRoute.POST(new NextRequest('https://webmail.example/api/auth/stalwart-context', {
+ method: 'POST',
+ headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
+ body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', authHeader: 'Bearer new', slot: 0 }),
+ }));
+
+ it('keeps the sign-in time when a renewed token is written again', async () => {
+ jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 3 * HOUR));
+ await restore();
+ vi.setSystemTime(NOW + HOUR);
+
+ expect((await post()).status).toBe(200);
+ expect(context()?.signedInAt).toBe(NOW - 3 * HOUR);
+ });
+
+ it("takes a token sign-in's time from its stamp and names the account on it", async () => {
+ const store = (await (await import('next/headers')).cookies()) as never;
+ writeSignInStamp(store, 0, { signedInAt: NOW - 2 * HOUR });
+
+ expect((await post()).status).toBe(200);
+ expect(context()?.signedInAt).toBe(NOW - 2 * HOUR);
+ expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW - 2 * HOUR, account: 'org-alice@example.org' });
+ });
+});
diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts
index 1dfc768ab..89b2545a9 100644
--- a/lib/auth/crypto.ts
+++ b/lib/auth/crypto.ts
@@ -23,10 +23,13 @@ function getKey(): Buffer {
/**
* A remembered sign-in: the account's password (Basic auth), or the access
* token the user signed in with (Bearer auth, e.g. a Fastmail API token).
+ * `signedInAt`: when the user signed in (ms), for the organisation's maximum
+ * session length (lib/auth/session-age.ts); absent in cookies written
+ * before it was recorded.
*/
export type SessionCredentials =
- | { serverUrl: string; username: string; password: string; token?: undefined }
- | { serverUrl: string; username: string; token: string; password?: undefined };
+ | { serverUrl: string; username: string; password: string; token?: undefined; signedInAt?: number }
+ | { serverUrl: string; username: string; token: string; password?: undefined; signedInAt?: number };
/** The Authorization header remembered credentials sign in with. */
export function sessionAuthHeader(credentials: SessionCredentials): string {
@@ -34,13 +37,18 @@ export function sessionAuthHeader(credentials: SessionCredentials): string {
return `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`;
}
-export function encryptSession(serverUrl: string, username: string, secret: string | { token: string }): string {
+export function encryptSession(
+ serverUrl: string,
+ username: string,
+ secret: string | { token: string },
+ signedInAt: number = Date.now(),
+): string {
const key = getKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, key, iv);
const credential = typeof secret === 'string' ? { password: secret } : { token: secret.token };
- const payload = JSON.stringify({ v: 1, serverUrl, username, ...credential });
+ const payload = JSON.stringify({ v: 1, serverUrl, username, ...credential, signedInAt });
const encrypted = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
@@ -64,11 +72,14 @@ export function decryptSession(token: string): SessionCredentials | null {
const parsed = JSON.parse(decrypted.toString('utf8'));
if (parsed.v !== 1 || !parsed.serverUrl || !parsed.username) return null;
+ const signedInAt = typeof parsed.signedInAt === 'number' && Number.isFinite(parsed.signedInAt) && parsed.signedInAt > 0
+ ? { signedInAt: parsed.signedInAt as number }
+ : {};
if (typeof parsed.password === 'string' && parsed.password) {
- return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password };
+ return { serverUrl: parsed.serverUrl, username: parsed.username, password: parsed.password, ...signedInAt };
}
if (typeof parsed.token === 'string' && parsed.token) {
- return { serverUrl: parsed.serverUrl, username: parsed.username, token: parsed.token };
+ return { serverUrl: parsed.serverUrl, username: parsed.username, token: parsed.token, ...signedInAt };
}
return null;
} catch (error) {
@@ -104,7 +115,9 @@ export type PayloadPurpose =
/** The app password an impersonation handoff minted, kept for revocation. */
| 'impersonation-grant'
/** A user's plugin credentials (lib/plugin-secrets/vault.ts). */
- | 'plugin-secrets';
+ | 'plugin-secrets'
+ /** When a token sign-in happened, for the maximum session length (lib/auth/session-age.ts). */
+ | 'session-age';
/**
* Whether a blob minted before purposes were bound may be re-admitted for
@@ -125,6 +138,7 @@ const LEGACY_PAYLOAD_MARKERS: Record p.t === 'wopi',
'impersonation-grant': () => false,
'plugin-secrets': () => false,
+ 'session-age': () => false,
};
export function encryptPayload(payload: Record, purpose: PayloadPurpose): string {
diff --git a/lib/auth/session-age.ts b/lib/auth/session-age.ts
new file mode 100644
index 000000000..e5689e889
--- /dev/null
+++ b/lib/auth/session-age.ts
@@ -0,0 +1,102 @@
+/**
+ * The organisation's maximum session length (policy.json
+ * `organisation.session.maxHours`) on the server side.
+ *
+ * JMAP goes from the browser straight to Stalwart, so the client's timer
+ * signs users out on time (components/session/session-timeout-guard.tsx).
+ * The server's part is to stop a session older than the maximum from coming
+ * back: the routes that restore a remembered sign-in (/api/auth/session) or
+ * renew a provider token (/api/auth/token) refuse it and clear its cookies.
+ *
+ * The sign-in time travels in the remembered-session cookie (`signedInAt`
+ * in lib/auth/crypto.ts), in the auth-context cookie, and for token
+ * sign-ins in a small cookie of its own written when the refresh token is
+ * (the auth-context cookie ends with the browser, the refresh token lasts
+ * 30 days). A cookie written before this existed has no sign-in time: it
+ * counts as signed in when the server first sees it after the upgrade,
+ * which then writes the time into it.
+ *
+ * The server knows an account's address, not its groups, so only Everyone
+ * and the domain scopes count here; a group scope's value is enforced by the
+ * client alone.
+ */
+
+import type { cookies } from 'next/headers';
+import { configManager } from '@/lib/admin/config-manager';
+import { resolvePolicy } from '@/lib/admin/policy-scopes';
+import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
+import { getCookieOptions } from '@/lib/oauth/cookie-config';
+import { logger } from '@/lib/logger';
+
+type CookieStore = Awaited>;
+
+const HOUR_MS = 60 * 60 * 1000;
+
+/** What a route answers when it refuses a session that is too old. */
+export const SESSION_TOO_OLD = 'session_expired';
+
+/** The maximum session length (hours, 0 = none) for an account's address. */
+export async function maxSessionHoursFor(account: string | null | undefined): Promise {
+ try {
+ await configManager.ensureLoaded?.();
+ const email = account?.trim().toLowerCase() ?? '';
+ const policy = resolvePolicy(configManager.getPolicy(), email ? { email, groups: [] } : null).policy;
+ const hours = policy.organisation?.session?.maxHours;
+ return typeof hours === 'number' && hours > 0 ? hours : 0;
+ } catch (error) {
+ logger.warn('Could not read the maximum session length', { error: error instanceof Error ? error.message : 'Unknown error' });
+ return 0;
+ }
+}
+
+/** Whether a session signed in at `signedInAt` has outlived `maxHours`. */
+export function isPastMaxAge(signedInAt: number | undefined, maxHours: number, now = Date.now()): boolean {
+ if (!maxHours || typeof signedInAt !== 'number') return false;
+ return now - signedInAt >= maxHours * HOUR_MS;
+}
+
+export function validSignedInAt(value: unknown): number | undefined {
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
+}
+
+/**
+ * The sign-in time of a token sign-in, kept as long as its refresh token.
+ * `account` is filled in once the auth context names the account.
+ */
+export interface SignInStamp {
+ signedInAt: number;
+ account?: string;
+}
+
+export function signInStampCookieName(slot: number): string {
+ return slot === 0 ? 'jmap_signed_in' : `jmap_signed_in_${slot}`;
+}
+
+export function readSignInStamp(cookieStore: CookieStore, slot: number): SignInStamp | null {
+ const value = cookieStore.get(signInStampCookieName(slot))?.value;
+ if (!value) return null;
+ try {
+ const payload = decryptPayload(value, 'session-age');
+ const signedInAt = validSignedInAt(payload?.signedInAt);
+ if (!signedInAt) return null;
+ return typeof payload?.account === 'string' ? { signedInAt, account: payload.account } : { signedInAt };
+ } catch {
+ return null;
+ }
+}
+
+/** Without a session secret nothing can be sealed; the client's timer still applies. */
+export function writeSignInStamp(cookieStore: CookieStore, slot: number, stamp: SignInStamp): void {
+ try {
+ const payload: Record = { signedInAt: stamp.signedInAt };
+ if (stamp.account) payload.account = stamp.account;
+ cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), getCookieOptions());
+ } catch (error) {
+ logger.warn('Could not record the sign-in time', { error: error instanceof Error ? error.message : 'Unknown error' });
+ }
+}
+
+export function clearSignInStamp(cookieStore: CookieStore, slot: number): void {
+ const name = signInStampCookieName(slot);
+ if (cookieStore.get(name)) cookieStore.delete(name);
+}
diff --git a/lib/stalwart/auth-context.ts b/lib/stalwart/auth-context.ts
index 7d22b3f63..3a34f1901 100644
--- a/lib/stalwart/auth-context.ts
+++ b/lib/stalwart/auth-context.ts
@@ -14,6 +14,12 @@ export interface StalwartAuthContext {
* data such as synced settings is keyed on this.
*/
accountName?: string;
+ /**
+ * When the user signed in (ms), for the organisation's maximum session
+ * length (lib/auth/session-age.ts). Carried over when the context is
+ * written again for the same account (a renewed token, a restore).
+ */
+ signedInAt?: number;
}
type CookieStore = Awaited>;
@@ -39,7 +45,8 @@ function isValidContext(payload: unknown): payload is StalwartAuthContext {
return typeof candidate.serverUrl === 'string'
&& typeof candidate.username === 'string'
&& typeof candidate.authHeader === 'string'
- && (candidate.accountName === undefined || typeof candidate.accountName === 'string');
+ && (candidate.accountName === undefined || typeof candidate.accountName === 'string')
+ && (candidate.signedInAt === undefined || typeof candidate.signedInAt === 'number');
}
function getSessionCookieOptions() {
@@ -59,8 +66,14 @@ export function readStalwartAuthContextFromStore(
// Hand back exactly the declared shape: the envelope's own bookkeeping
// stays in the envelope rather than riding along into the credentials.
- const { serverUrl, username, authHeader, accountName } = payload;
- return accountName ? { serverUrl, username, authHeader, accountName } : { serverUrl, username, authHeader };
+ const { serverUrl, username, authHeader, accountName, signedInAt } = payload;
+ return {
+ serverUrl,
+ username,
+ authHeader,
+ ...(accountName ? { accountName } : {}),
+ ...(typeof signedInAt === 'number' ? { signedInAt } : {}),
+ };
}
export function setStalwartAuthContextInStore(
diff --git a/stores/__tests__/auth-store-restore-session-too-old.test.ts b/stores/__tests__/auth-store-restore-session-too-old.test.ts
new file mode 100644
index 000000000..333775cf5
--- /dev/null
+++ b/stores/__tests__/auth-store-restore-session-too-old.test.ts
@@ -0,0 +1,64 @@
+import { beforeEach, expect, it, vi } from 'vitest';
+import { createConfig } from '@/lib/__tests__/fixtures/config';
+
+// The server refuses to restore a session older than the organisation's
+// maximum session length (lib/auth/session-age.ts). The login page then says
+// why the user has to sign in again.
+vi.mock('@/lib/jmap/client', () => {
+ class JMAPClient {
+ constructor(public serverUrl: string, public username: string, public password: string) {}
+ static withBearer(serverUrl: string, _token: string, username: string) { return new JMAPClient(serverUrl, username, ''); }
+ async connect() {}
+ getAuthHeader() { return 'Basic x'; }
+ onConnectionChange() {}
+ onRateLimit() {}
+ getRateLimitRemainingMs() { return 0; }
+ disconnect() {}
+ }
+ class RateLimitError extends Error {}
+ class RequestTimeoutError extends Error {}
+ return { JMAPClient, RateLimitError, RequestTimeoutError };
+});
+
+import * as nav from '@/lib/browser-navigation';
+import { useAuthStore } from '@/stores/auth-store';
+import { useAccountStore } from '@/stores/account-store';
+import { consumeSessionEnd } from '@/lib/session-policy/sign-out-reason';
+
+const account = (authMode: 'basic' | 'oauth') => ({
+ id: 'alice@example.org@mail.example.org', label: 'Alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org', authMode,
+ rememberMe: true, providerSession: authMode === 'oauth', cookieSlot: 0, displayName: 'Alice', email: 'alice@example.org', isConnected: false,
+ hasError: false, lastLoginAt: 1, avatarColor: '#000000', isDefault: true,
+});
+
+const tooOld = () => new Response(JSON.stringify({ error: 'session_expired', maxHours: 8 }), { status: 401 });
+
+beforeEach(() => {
+ sessionStorage.clear();
+ vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
+ const url = String(input);
+ if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
+ if (url.includes('/api/auth/session') && init?.method === 'PUT') return tooOld();
+ if (url.includes('/api/auth/token') && init?.method === 'PUT') return tooOld();
+ return new Response('{}', { status: 200 });
+ }));
+ vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(() => {});
+});
+
+it('says on the login page that a remembered session reached its maximum length', async () => {
+ useAccountStore.setState({ accounts: [account('basic')] as never, activeAccountId: account('basic').id, defaultAccountId: account('basic').id });
+
+ await useAuthStore.getState().checkAuth();
+ await vi.waitFor(() => expect(useAccountStore.getState().accounts).toHaveLength(0));
+
+ expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
+});
+
+it('says so for a provider sign-in whose renewal the server refused', async () => {
+ useAccountStore.setState({ accounts: [account('oauth')] as never, activeAccountId: account('oauth').id, defaultAccountId: account('oauth').id });
+
+ await useAuthStore.getState().checkAuth();
+ await vi.waitFor(() => expect(useAccountStore.getState().accounts).toHaveLength(0));
+
+ expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
+});
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index 0a414af71..b8b084d5d 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -24,6 +24,7 @@ import { useSearchHistoryStore } from './search-history-store';
import { useCalendarNotificationStore } from './calendar-notification-store';
import { usePolicyStore } from './policy-store';
import { policySubjectForClient } from '@/lib/admin/policy-subject';
+import { rememberSessionEnd } from '@/lib/session-policy/sign-out-reason';
import type { Identity } from '@/lib/jmap/types';
import { authHooks } from '@/lib/plugin-hooks';
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
@@ -536,12 +537,31 @@ async function persistSession(slot: number, session: RememberedSession): Promise
}
}
+/**
+ * A restore or renewal the server refused because the session reached the
+ * organisation's maximum length (lib/auth/session-age.ts): the login page
+ * says so.
+ */
+async function noteSessionTooOld(res: Response): Promise {
+ if (res.status !== 401 || typeof res.clone !== 'function') return res;
+ try {
+ const data = await res.clone().json() as { error?: unknown; maxHours?: unknown };
+ if (data?.error === 'session_expired' && typeof data.maxHours === 'number') {
+ rememberSessionEnd({ kind: 'max', hours: data.maxHours });
+ }
+ } catch {
+ /* not JSON */
+ }
+ return res;
+}
+
/** PUT /api/auth/token?slot=N - renew (or, unless `force`, reuse) the slot's access token. */
async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}): Promise {
// Renewing would write the tokens of a slot being signed out back.
if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401);
if (!IS_LITE) {
- return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal }));
+ return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal }))
+ .then(noteSessionTooOld);
}
// Like the route's cookie cache: a restore resumes with the token it had,
// since a renewal before the refresh token's `nbf` is refused (#552).
@@ -563,7 +583,8 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}
/** PUT /api/auth/session[?slot=N] - the slot's remembered password or access token. */
async function fetchSlotSession(slot?: number): Promise {
if (!IS_LITE) {
- return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' });
+ return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' })
+ .then(noteSessionTooOld);
}
const stored = readLiteBasicSession(slot ?? 0);
return stored ? jsonResponse(stored) : jsonResponse({ error: 'no_session' }, 401);
From af47856cded5132812b3f35ec4b881bef1bfc6e5 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 02:48:19 +0100
Subject: [PATCH 4/9] test: cover the idle timeout per domain against the test
servers
---
integration/tests/19-org-sessions.spec.ts | 209 ++++++++++++++++++++++
1 file changed, 209 insertions(+)
create mode 100644 integration/tests/19-org-sessions.spec.ts
diff --git a/integration/tests/19-org-sessions.spec.ts b/integration/tests/19-org-sessions.spec.ts
new file mode 100644
index 000000000..58b5e3203
--- /dev/null
+++ b/integration/tests/19-org-sessions.spec.ts
@@ -0,0 +1,209 @@
+import { test, expect, type Page } from '@playwright/test';
+import {
+ ORG,
+ ORG_PASSWORD,
+ adminContext,
+ getPolicy,
+ putPolicy,
+ requireOrgEnv,
+ shot,
+ userPage,
+} from './helpers/org';
+import { JmapClient } from './helpers/jmap';
+import { emailItem, openComposer, setSubject, waitDraftSaved } from './helpers/app';
+
+/**
+ * Session idle timeout (organisation feature 4), through the real UI on a
+ * VM 106 test server. The admin sets one minute with a 30-second warning for
+ * example.org on Organisation > Security, Off for org-two.test, and two
+ * minutes for everyone else. org-alice gets the warning, stays, then lets it
+ * run out: Bulwark saves her minimised draft and signs her out with the
+ * reason on the login page. org-dave's domain has no timeout. The admin's
+ * policy is put back afterwards.
+ */
+
+test.describe.configure({ mode: 'serial' });
+
+const RUN = Date.now().toString(36);
+const INBOX_SUBJECT = `Session limits inbox ${RUN}`;
+const DRAFT_SUBJECT = `Session limits draft ${RUN}`;
+
+let original: Record;
+let alice: JmapClient;
+
+async function draftSubjects(client: JmapClient): Promise {
+ const drafts = await client.mailboxByRole('drafts');
+ if (!drafts) throw new Error('No Drafts mailbox');
+ const r = await client.request([
+ ['Email/query', { accountId: client.accountId, filter: { inMailbox: drafts.id }, sort: [{ property: 'receivedAt', isAscending: false }], limit: 50 }, '0'],
+ ['Email/get', { accountId: client.accountId, '#ids': { resultOf: '0', name: 'Email/query', path: '/ids' }, properties: ['subject'] }, '1'],
+ ]);
+ return (r.methodResponses[1][1].list as Array<{ subject: string }>).map((e) => e.subject);
+}
+
+async function removeTestMail(client: JmapClient): Promise {
+ const r = await client.request([
+ ['Email/query', { accountId: client.accountId, filter: { text: RUN }, limit: 50 }, '0'],
+ ]);
+ const ids = r.methodResponses[0][1].ids as string[];
+ if (ids.length) await client.request([['Email/set', { accountId: client.accountId, destroy: ids }, '0']]);
+}
+
+const warning = (page: Page) => page.getByTestId('session-timeout-dialog');
+
+test.beforeAll(async ({ browser }) => {
+ requireOrgEnv();
+ const admin = await adminContext(browser);
+ original = await getPolicy(admin);
+ await putPolicy(admin, {
+ ...original,
+ restrictions: {},
+ features: {},
+ organisation: { ...(original.organisation as object), session: { idleMinutes: 0, maxHours: 0, warnSeconds: 60 } },
+ scopes: [
+ { id: 'sessions-example', label: 'Example', match: { domains: ['example.org'] }, policy: {} },
+ { id: 'sessions-two', label: 'Org two', match: { domains: ['org-two.test'] }, policy: {} },
+ ],
+ });
+ await admin.close();
+
+ // A message for org-alice to open while composing, which minimises the composer.
+ alice = await JmapClient.connect(ORG.alice, ORG_PASSWORD);
+ const inbox = await alice.mailboxByRole('inbox');
+ const r = await alice.request([
+ ['Email/set', {
+ accountId: alice.accountId,
+ create: {
+ m: {
+ mailboxIds: { [inbox!.id]: true },
+ keywords: { $seen: true },
+ from: [{ name: 'Org Bob', email: ORG.bob }],
+ to: [{ email: ORG.alice }],
+ subject: INBOX_SUBJECT,
+ receivedAt: new Date().toISOString(),
+ bodyValues: { b: { value: 'Open me while writing.' } },
+ textBody: [{ partId: 'b', type: 'text/plain' }],
+ },
+ },
+ }, '0'],
+ ]);
+ expect(r.methodResponses[0][1].created?.m, JSON.stringify(r.methodResponses[0][1])).toBeTruthy();
+});
+
+test.afterAll(async ({ browser }) => {
+ if (original) {
+ const admin = await adminContext(browser);
+ await putPolicy(admin, original);
+ await admin.close();
+ }
+ if (alice) await removeTestMail(alice);
+});
+
+test('the admin sets the idle timeout per domain on Organisation > Security', async ({ browser }) => {
+ const admin = await adminContext(browser);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ const idle = page.getByLabel('Sign out after inactivity (minutes)');
+ const warn = page.getByLabel('Warning before signing out (seconds)');
+ await expect(idle).toBeVisible();
+
+ // Everyone: two minutes, so org-dave would be warned within the test if his scope didn't apply.
+ await idle.fill('2');
+ // example.org: one minute, warned 30 seconds before.
+ await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Example', exact: true }).click();
+ await expect(idle).toHaveValue('2');
+ await expect(page.getByText('From Everyone: 2 minutes')).toBeVisible();
+ await idle.fill('1');
+ await warn.fill('30');
+ // org-two.test: off.
+ await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Org two', exact: true }).click();
+ await idle.fill('');
+ await expect(idle).toHaveAttribute('placeholder', 'Off');
+ await page.getByRole('button', { name: 'Save', exact: true }).click();
+ await expect(page.getByText('Saved.')).toBeVisible();
+ await page.getByTestId('org-scope-bar').getByRole('button', { name: 'Example', exact: true }).click();
+ await page.locator('[data-settings-section="sessions"]').scrollIntoViewIfNeeded();
+ await shot(page, 'sessions-admin-page');
+
+ const policy = await getPolicy(admin);
+ expect((policy.organisation as { session: unknown }).session).toEqual({ idleMinutes: 2, maxHours: 0, warnSeconds: 60 });
+ const scopes = policy.scopes as Array<{ id: string; policy: { organisation?: { session?: unknown } } }>;
+ expect(scopes.find((s) => s.id === 'sessions-example')?.policy.organisation?.session).toEqual({ idleMinutes: 1, warnSeconds: 30 });
+ expect(scopes.find((s) => s.id === 'sessions-two')?.policy.organisation?.session).toEqual({ idleMinutes: 0 });
+ await admin.close();
+});
+
+test('the admin page works at phone width', async ({ browser }) => {
+ const admin = await adminContext(browser, true);
+ const page = await admin.newPage();
+ await page.goto('/admin/org-security');
+ const idle = page.getByLabel('Sign out after inactivity (minutes)');
+ await idle.scrollIntoViewIfNeeded();
+ await expect(idle).toBeVisible();
+ const box = await idle.boundingBox();
+ expect(box!.x + box!.width).toBeLessThanOrEqual(390);
+ await shot(page, 'sessions-admin-page-phone');
+ await admin.close();
+});
+
+test('org-alice is warned, stays, then is signed out with her minimised draft saved; org-dave is not', async ({ browser }) => {
+ test.setTimeout(5 * 60 * 1000);
+ const [page, dave] = await Promise.all([userPage(browser, ORG.alice), userPage(browser, ORG.dave)]);
+
+ // Write, let the autosave store a first version, write more, then open a
+ // message: the composer is minimised with the newer text only in memory.
+ await expect(emailItem(page, INBOX_SUBJECT).first()).toBeVisible({ timeout: 30000 });
+ await openComposer(page);
+ await setSubject(page, DRAFT_SUBJECT);
+ await waitDraftSaved(page);
+ await setSubject(page, `${DRAFT_SUBJECT} final`);
+ await emailItem(page, INBOX_SUBJECT).first().click();
+ await expect(page.getByText('Continue draft')).toBeVisible();
+ let lastActivity = Date.now();
+ expect(await draftSubjects(alice)).toContain(DRAFT_SUBJECT);
+ expect(await draftSubjects(alice)).not.toContain(`${DRAFT_SUBJECT} final`);
+
+ // The warning comes 30 seconds before the minute is up.
+ await expect(warning(page)).toBeVisible({ timeout: 45000 });
+ const warnedAfter = Date.now() - lastActivity;
+ expect(warnedAfter).toBeGreaterThan(25000);
+ expect(warnedAfter).toBeLessThan(40000);
+ await expect(warning(page)).toContainText('Are you still there?');
+ await shot(page, 'sessions-idle-warning');
+
+ await page.getByRole('button', { name: 'Stay signed in' }).click();
+ lastActivity = Date.now();
+ await expect(warning(page)).toBeHidden();
+
+ // Left alone: warned again, then signed out a minute after the last activity.
+ await expect(warning(page)).toBeVisible({ timeout: 45000 });
+ await expect(page.locator('#username')).toBeVisible({ timeout: 60000 });
+ const signedOutAfter = Date.now() - lastActivity;
+ expect(signedOutAfter).toBeGreaterThan(55000);
+ await expect(page.getByTestId('session-expired-reason')).toHaveText('You were signed out after 1 minute without activity.');
+ await shot(page, 'sessions-login-reason');
+
+ // The minimised composer saved its newer text before the sign-out.
+ await expect.poll(() => draftSubjects(alice), { timeout: 15000 }).toContain(`${DRAFT_SUBJECT} final`);
+
+ // org-two.test turns the timeout off: org-dave, idle just as long, is still in.
+ await expect(warning(dave)).toHaveCount(0);
+ await expect(dave.locator('[data-testid="account-switcher"]').first()).toBeVisible();
+ await page.context().close();
+ await dave.context().close();
+});
+
+test('the warning fits a phone and "Stay signed in" works there', async ({ browser }) => {
+ test.setTimeout(3 * 60 * 1000);
+ const page = await userPage(browser, ORG.alice, true);
+ await expect(warning(page)).toBeVisible({ timeout: 45000 });
+ const dialog = page.getByRole('alertdialog');
+ const box = await dialog.boundingBox();
+ expect(box!.x).toBeGreaterThanOrEqual(0);
+ expect(box!.x + box!.width).toBeLessThanOrEqual(390);
+ await shot(page, 'sessions-idle-warning-phone');
+ await page.getByRole('button', { name: 'Stay signed in' }).tap();
+ await expect(warning(page)).toBeHidden();
+ await expect(page.locator('[data-testid="account-switcher"]').first()).toBeVisible();
+ await page.context().close();
+});
From 4821227415ba9d09c13d1e4ecfa9cd1cf0443b6e Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:06:28 +0100
Subject: [PATCH 5/9] fix: start the session warning's focus on the button that
keeps users signed in
---
.../session/__tests__/session-timeout-guard.test.tsx | 2 ++
components/session/session-timeout-dialog.tsx | 10 ++++++----
2 files changed, 8 insertions(+), 4 deletions(-)
diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx
index f693eb685..4b1baccb1 100644
--- a/components/session/__tests__/session-timeout-guard.test.tsx
+++ b/components/session/__tests__/session-timeout-guard.test.tsx
@@ -96,6 +96,8 @@ describe('session timeout guard', () => {
expect(screen.queryByText('idle_title')).toBeNull();
await advance(1 * SECOND);
expect(screen.getByText('idle_title')).toBeTruthy();
+ // Enter on the warning keeps the user signed in.
+ expect(document.activeElement?.textContent).toBe('stay');
fireEvent.click(screen.getByText('stay'));
expect(screen.queryByText('idle_title')).toBeNull();
diff --git a/components/session/session-timeout-dialog.tsx b/components/session/session-timeout-dialog.tsx
index 917b77085..f41b65613 100644
--- a/components/session/session-timeout-dialog.tsx
+++ b/components/session/session-timeout-dialog.tsx
@@ -72,11 +72,13 @@ export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismi
{mode !== "ending" && (
-
-
{t("sign_out_now")}
+ // The safe answer comes first, so the focus trap starts on it and
+ // Enter keeps the user signed in; row-reverse shows it on the right.
+
{mode === "idle"
- ? {t("stay")}
- : {t("dismiss")} }
+ ? {t("stay")}
+ : {t("dismiss")} }
+ {t("sign_out_now")}
)}
From defe4e93f3ee99eb13c2008fca8f763654243582 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:42:19 +0100
Subject: [PATCH 6/9] fix: bind the sign-in time to the refresh token and count
unstamped sessions from a fixed time
---
app/api/auth/session/route.ts | 38 ++--
app/api/auth/sso/complete/route.ts | 4 +-
app/api/auth/stalwart-context/route.ts | 22 +--
app/api/auth/token/route.ts | 38 ++--
app/api/auth/totp-token-exchange/route.ts | 9 +-
lib/__tests__/auth-crypto.test.ts | 4 +-
lib/__tests__/impersonation-session.test.ts | 2 +-
lib/__tests__/session-max-age-routes.test.ts | 191 ++++++++++++-------
lib/auth/session-age.ts | 159 ++++++++++++---
lib/session-policy/constants.ts | 6 +
lib/stalwart/auth-context.ts | 19 +-
11 files changed, 329 insertions(+), 163 deletions(-)
create mode 100644 lib/session-policy/constants.ts
diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts
index 6a0ce0a23..e68645a30 100644
--- a/app/api/auth/session/route.ts
+++ b/app/api/auth/session/route.ts
@@ -23,7 +23,7 @@ import { revokeWopiTokens } from '@/lib/wopi/revocation';
import { clearPairReauthInStore } from '@/lib/auth/pair-reauth';
import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
-import { isPastMaxAge, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
+import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
function sessionCookieOptions() {
return {
@@ -77,30 +77,25 @@ function getSlot(request: NextRequest): number {
/**
* The organisation's maximum session length for a remembered session
* (lib/auth/session-age.ts). A cookie from before sign-in times were
- * recorded counts as signed in now and gets the time written into it.
- * Returns the sign-in time, or null when the session is too old: its
- * cookies are cleared then.
+ * recorded counts as signed in when the server first enforced the limits,
+ * a fixed time, so a kept copy of it runs out like any other. Returns the
+ * sign-in time, or the limit when the session is too old: its cookies are
+ * cleared then.
*/
async function checkSessionAge(
cookieStore: Awaited>,
slot: number,
credentials: SessionCredentials,
): Promise<{ signedInAt: number } | { maxHours: number }> {
- const cookieName = sessionCookieName(slot);
- if (credentials.signedInAt === undefined) {
- const signedInAt = Date.now();
- const secret = credentials.token !== undefined ? { token: credentials.token } : credentials.password;
- cookieStore.set(cookieName, encryptSession(credentials.serverUrl, credentials.username, secret, signedInAt), sessionCookieOptions());
- return { signedInAt };
- }
+ const signedInAt = credentials.signedInAt ?? await limitsEnforcedSince();
// An impersonation login is `target%master`; the policy is the target's.
const maxHours = await maxSessionHoursFor(credentials.username.split('%', 1)[0]);
- if (isPastMaxAge(credentials.signedInAt, maxHours)) {
- cookieStore.delete(cookieName);
+ if (isPastMaxAge(signedInAt, maxHours)) {
+ cookieStore.delete(sessionCookieName(slot));
clearStalwartAuthContextInStore(cookieStore, slot);
return { maxHours };
}
- return { signedInAt: credentials.signedInAt };
+ return { signedInAt };
}
function sessionTooOld(maxHours: number) {
@@ -209,14 +204,14 @@ export async function POST(request: NextRequest) {
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
- signedInAt,
});
void recordLogin(username, normalizedServerUrl);
const warning = insecureCookieHint(request);
if (warning) logger.warn(`session: ${warning}`);
- return NextResponse.json(warning ? { ok: true, warning } : { ok: true });
+ // The browser counts the maximum session length from the server's time.
+ return NextResponse.json(warning ? { ok: true, warning, signedInAt } : { ok: true, signedInAt });
} catch (error) {
if (error instanceof JmapAuthVerificationError) {
return NextResponse.json(
@@ -242,7 +237,7 @@ export async function GET(request: NextRequest) {
}
const credentials = decryptSession(token);
- if (!credentials) {
+ if (!credentials || holdsMasterPassword(credentials)) {
cookieStore.delete(cookieName);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
@@ -255,13 +250,12 @@ export async function GET(request: NextRequest) {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
- signedInAt: age.signedInAt,
});
// Only return non-sensitive fields. Use PUT to retrieve full credentials.
const { serverUrl, username } = credentials;
return NextResponse.json(
- { serverUrl, username },
+ { serverUrl, username, signedInAt: age.signedInAt },
{ headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' } },
);
} catch (error) {
@@ -309,12 +303,10 @@ export async function PUT(request: NextRequest) {
serverUrl: credentials.serverUrl,
username: credentials.username,
authHeader: sessionAuthHeader(credentials),
- signedInAt: age.signedInAt,
});
- // The credentials as they were stored, without the bookkeeping.
- const { signedInAt: _signedInAt, ...restored } = credentials;
- return NextResponse.json(restored, {
+ // The browser counts the maximum session length from the server's time.
+ return NextResponse.json({ ...credentials, signedInAt: age.signedInAt }, {
headers: { 'Cache-Control': 'no-store, no-cache, must-revalidate' },
});
} catch (error) {
diff --git a/app/api/auth/sso/complete/route.ts b/app/api/auth/sso/complete/route.ts
index 5ae1dc11c..f79fdb6b9 100644
--- a/app/api/auth/sso/complete/route.ts
+++ b/app/api/auth/sso/complete/route.ts
@@ -106,6 +106,7 @@ export async function POST(request: NextRequest) {
// Exchange code for tokens
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
+ const signedInAt = Date.now();
// For the mobile handoff flow the tokens are handed back to the app
// verbatim - we deliberately don't write any cookies on the webmail
@@ -117,7 +118,7 @@ export async function POST(request: NextRequest) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
- writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
+ writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (pendingServerId) {
@@ -169,6 +170,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
+ signed_in_at: signedInAt,
});
} catch (error) {
// Clean up pending cookie on any error
diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts
index 43c884d1e..fe25532d3 100644
--- a/app/api/auth/stalwart-context/route.ts
+++ b/app/api/auth/stalwart-context/route.ts
@@ -2,8 +2,9 @@ import { NextRequest, NextResponse } from 'next/server';
import { logger } from '@/lib/logger';
import { JmapAuthVerificationError, resolveJmapIdentity } from '@/lib/auth/verify-jmap-auth';
import { cookies } from 'next/headers';
-import { readStalwartAuthContextFromStore, setStalwartAuthContext } from '@/lib/stalwart/auth-context';
+import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
import { readSignInStamp, writeSignInStamp } from '@/lib/auth/session-age';
+import { refreshTokenCookieName } from '@/lib/oauth/tokens';
import { configManager } from '@/lib/admin/config-manager';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { recordLogin } from '@/lib/telemetry/login-tracker';
@@ -103,24 +104,21 @@ export async function POST(request: NextRequest) {
// Just signed in: an older refusal of this credential no longer applies.
recordStoredCredentialStatus(authHeader, 200);
- // The context is written again on every renewed token and restore, so
- // the sign-in time is carried over: from this slot's context for the
- // same account, else from the token sign-in's stamp, else it is now.
+ // A token sign-in's stamp (lib/auth/session-age.ts) knows no account:
+ // the token routes never see one. The first verified account names it,
+ // so the maximum session length of its domain applies on renewal.
const cookieStore = await cookies();
- const previous = readStalwartAuthContextFromStore(cookieStore, slot);
- const stamp = readSignInStamp(cookieStore, slot);
- const ownStamp = stamp && (!stamp.account || stamp.account === accountName) ? stamp : null;
- const samePrevious = previous && previous.serverUrl === normalizedServerUrl && previous.username === username ? previous : null;
- const signedInAt = samePrevious?.signedInAt ?? ownStamp?.signedInAt ?? Date.now();
- // The token routes know no account; the first verified one is the stamp's.
- if (ownStamp && !ownStamp.account) writeSignInStamp(cookieStore, slot, { ...ownStamp, account: accountName });
+ const refreshToken = cookieStore.get(refreshTokenCookieName(slot))?.value;
+ const stamp = refreshToken ? readSignInStamp(cookieStore, slot, refreshToken) : null;
+ if (refreshToken && stamp && !stamp.account) {
+ writeSignInStamp(cookieStore, slot, { signedInAt: stamp.signedInAt, refreshToken, account: accountName });
+ }
await setStalwartAuthContext(slot, {
serverUrl: normalizedServerUrl,
username,
authHeader,
...(accountName !== username ? { accountName } : {}),
- signedInAt,
});
void recordLogin(username, normalizedServerUrl);
diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts
index 942514fde..b2ea53c3e 100644
--- a/app/api/auth/token/route.ts
+++ b/app/api/auth/token/route.ts
@@ -34,8 +34,8 @@ import {
clearSignInStamp,
isPastMaxAge,
maxSessionHoursFor,
- readSignInStamp,
SESSION_TOO_OLD,
+ tokenSignedInAt,
writeSignInStamp,
} from '@/lib/auth/session-age';
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
@@ -151,18 +151,21 @@ export async function POST(request: NextRequest) {
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
+ // A sign-in: the maximum session length counts from here, on the server
+ // and, from this answer, in the browser.
+ const signedInAt = Date.now();
const response = NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in,
+ signed_in_at: signedInAt,
});
const cookieStore = await cookies();
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
- // A sign-in: the maximum session length counts from here.
- writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
+ writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token });
}
const cachedAccessToken = cacheAccessToken(cookieStore, slot, tokens.access_token, tokens.expires_in || 3600, [tokens.refresh_token, serverId]);
storeIdToken(cookieStore, slot, tokens.id_token, request.nextUrl.basePath, [tokens.refresh_token, cachedAccessToken, serverId]);
@@ -201,20 +204,23 @@ export async function PUT(request: NextRequest) {
}
// The organisation's maximum session length (lib/auth/session-age.ts).
- // A refresh token from before sign-in times were recorded counts as
- // signed in now.
- const stamp = readSignInStamp(cookieStore, slot);
- if (!stamp) {
- writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
- } else {
+ // A refresh token without a matching stamp (from before stamps, or one
+ // whose stamp was deleted) counts from the fixed time the server first
+ // enforced the limits, never from now.
+ const age = await tokenSignedInAt(cookieStore, slot, refreshToken);
+ if (age) {
const context = readStalwartAuthContextFromStore(cookieStore, slot);
- const maxHours = await maxSessionHoursFor(stamp.account ?? context?.accountName ?? context?.username ?? null);
- if (isPastMaxAge(stamp.signedInAt, maxHours)) {
+ const account = age.stamp?.account ?? context?.accountName ?? context?.username ?? null;
+ const maxHours = await maxSessionHoursFor(account);
+ if (isPastMaxAge(age.signedInAt, maxHours)) {
await signOutSlot(cookieStore, slot, request.nextUrl.basePath, false);
clearStalwartAuthContextInStore(cookieStore, slot);
return NextResponse.json({ error: SESSION_TOO_OLD, maxHours }, { status: 401 });
}
+ // Bind the time to this token from now on.
+ if (!age.stamp) writeSignInStamp(cookieStore, slot, { signedInAt: age.signedInAt, refreshToken, ...(account ? { account } : {}) });
}
+ const signedIn = age ? { signed_in_at: age.signedInAt } : {};
// A session restore calls this to get its token back, not because the
// current one expired. Serving the cached token avoids spending a refresh
@@ -231,6 +237,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
access_token: cached.accessToken,
expires_in: cached.expiresIn,
+ ...signedIn,
});
}
}
@@ -279,6 +286,14 @@ export async function PUT(request: NextRequest) {
if (tokens.refresh_token) {
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
+ // The stamp follows the rotated token, with the same sign-in time and lifetime.
+ if (age) {
+ writeSignInStamp(cookieStore, slot, {
+ signedInAt: age.signedInAt,
+ refreshToken: tokens.refresh_token,
+ ...(age.stamp?.account ? { account: age.stamp.account } : {}),
+ });
+ }
}
const expiresIn = tokens.expires_in || 3600;
@@ -292,6 +307,7 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({
access_token: tokens.access_token,
expires_in: expiresIn,
+ ...signedIn,
});
} catch (error) {
logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' });
diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts
index bdceadbf7..9b61b428f 100644
--- a/app/api/auth/totp-token-exchange/route.ts
+++ b/app/api/auth/totp-token-exchange/route.ts
@@ -209,13 +209,15 @@ async function storeAndRespond(
rememberMe: boolean,
): Promise {
const cookieStore = await cookies();
+ // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
+ const signedInAt = Date.now();
if (tokens.refresh_token) {
const cookieName = refreshTokenCookieName(slot);
// Without "Remember me" the sign-in ends with the browser session.
const { maxAge: _maxAge, ...sessionOptions } = getCookieOptions();
- cookieStore.set(cookieName, tokens.refresh_token, rememberMe ? getCookieOptions() : sessionOptions);
- // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
- writeSignInStamp(cookieStore, slot, { signedInAt: Date.now() });
+ const options = rememberMe ? getCookieOptions() : sessionOptions;
+ cookieStore.set(cookieName, tokens.refresh_token, options);
+ writeSignInStamp(cookieStore, slot, { signedInAt, refreshToken: tokens.refresh_token }, options);
}
const serverCookieName = refreshTokenServerCookieName(slot);
if (serverId) {
@@ -228,5 +230,6 @@ async function storeAndRespond(
access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600,
has_refresh_token: !!tokens.refresh_token,
+ signed_in_at: signedInAt,
});
}
diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts
index e30d869b5..5d296c0e1 100644
--- a/lib/__tests__/auth-crypto.test.ts
+++ b/lib/__tests__/auth-crypto.test.ts
@@ -36,8 +36,8 @@ describe('encryptSession / decryptSession', () => {
});
it('produces a base64 token with a random IV (two encrypts differ, both decrypt equal)', () => {
- const a = encryptSession('https://x', 'u', 'p');
- const b = encryptSession('https://x', 'u', 'p');
+ const a = encryptSession('https://x', 'u', 'p', 1000);
+ const b = encryptSession('https://x', 'u', 'p', 1000);
expect(a).not.toBe(b);
expect(Buffer.from(a, 'base64').toString('base64')).toBe(a); // valid base64
expect(decryptSession(a)).toEqual(decryptSession(b));
diff --git a/lib/__tests__/impersonation-session.test.ts b/lib/__tests__/impersonation-session.test.ts
index b70abd176..aa44a2171 100644
--- a/lib/__tests__/impersonation-session.test.ts
+++ b/lib/__tests__/impersonation-session.test.ts
@@ -110,7 +110,7 @@ describe('impersonation handoff', () => {
const put = await readSession();
expect(put.status).toBe(200);
const body = await put.json();
- expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET });
+ expect(body).toEqual({ serverUrl: 'https://mail.example.org', username: 'alice@example.org', password: APP_SECRET, signedInAt: expect.any(Number) });
expect(JSON.stringify(body)).not.toContain(MASTER_PASSWORD);
expect([...jar.values()].join()).not.toContain(MASTER_PASSWORD);
});
diff --git a/lib/__tests__/session-max-age-routes.test.ts b/lib/__tests__/session-max-age-routes.test.ts
index 5b3dd23b8..8048e2a28 100644
--- a/lib/__tests__/session-max-age-routes.test.ts
+++ b/lib/__tests__/session-max-age-routes.test.ts
@@ -1,14 +1,19 @@
// @vitest-environment node
import { createCipheriv, createHash, randomBytes } from 'node:crypto';
-import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import type { SettingsPolicy } from '@/lib/admin/types';
/**
* The organisation's maximum session length on the server: the routes that
* restore a remembered sign-in or renew a provider token refuse a session
- * older than `maxHours` of the account's domain scopes, and a cookie from
- * before sign-in times were recorded counts as signed in when first seen.
+ * older than `maxHours` of the account's domain scopes. A cookie or token
+ * without a sign-in time counts from one fixed time, when the server first
+ * enforced the limits, so deleting the stamp or keeping an old cookie never
+ * makes a session younger.
*/
const SECRET = 's'.repeat(64);
@@ -43,7 +48,6 @@ vi.mock('@/lib/auth/verify-jmap-auth', () => ({
resolveJmapIdentity: async (serverUrl: string, _auth: string, username: string) => ({ serverUrl, accountName: username }),
}));
-const tokenFetch = vi.fn();
vi.mock('@/lib/oauth/token-exchange', () => ({
exchangeCodeForTokens: async () => ({ access_token: 'first-access', refresh_token: 'refresh-1', expires_in: 300 }),
buildOAuthParams: (params: Record) => new URLSearchParams(params),
@@ -54,8 +58,12 @@ vi.mock('@/lib/oauth/token-exchange', () => ({
DEFAULT_CLIENT_ID: 'bulwark-webmail',
}));
-const { encryptSession, decryptSession, decryptPayload } = await import('@/lib/auth/crypto');
-const { readSignInStamp, writeSignInStamp } = await import('@/lib/auth/session-age');
+const stateDir = mkdtempSync(path.join(tmpdir(), 'bw-session-age-'));
+process.env.ADMIN_STATE_DIR = stateDir;
+afterAll(() => rmSync(stateDir, { recursive: true, force: true }));
+
+const { encryptSession, decryptSession } = await import('@/lib/auth/crypto');
+const { readSignInStamp, writeSignInStamp, limitsEnforcedSince, resetLimitsEnforcedSince } = await import('@/lib/auth/session-age');
const sessionRoute = await import('@/app/api/auth/session/route');
const tokenRoute = await import('@/app/api/auth/token/route');
const contextRoute = await import('@/app/api/auth/stalwart-context/route');
@@ -82,19 +90,33 @@ function legacySessionCookie(username: string, password: string): string {
return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString('base64');
}
+const store = async () => (await (await import('next/headers')).cookies()) as never;
const restore = () => sessionRoute.PUT(new NextRequest('https://webmail.example/api/auth/session', { method: 'PUT', headers: SAME_ORIGIN }));
const peek = () => sessionRoute.GET(new NextRequest('https://webmail.example/api/auth/session'));
-const refresh = () => tokenRoute.PUT(new NextRequest('https://webmail.example/api/auth/token?slot=0', { method: 'PUT', headers: SAME_ORIGIN }));
-const context = () => decryptPayload(jar.get('jmap_stalwart_ctx')!, 'session-context');
+const refresh = () => tokenRoute.PUT(new NextRequest('https://webmail.example/api/auth/token?slot=0&force=true', { method: 'PUT', headers: SAME_ORIGIN }));
+const signInWithProvider = () => tokenRoute.POST(new NextRequest('https://webmail.example/api/auth/token?slot=0', {
+ method: 'POST',
+ headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
+ body: JSON.stringify({ code: 'c', code_verifier: 'v', redirect_uri: 'https://webmail.example/auth/callback', slot: 0 }),
+}));
-beforeEach(() => {
+let issued = 0;
+const tokenFetch = vi.fn();
+
+beforeEach(async () => {
jar.clear();
+ rmSync(path.join(stateDir, 'session-limits-since'), { force: true });
+ resetLimitsEnforcedSince();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(NOW);
+ // The server enforces the limits from NOW on.
+ expect(await limitsEnforcedSince()).toBe(NOW);
policy = policyWithDomainMax(8);
+ issued = 0;
tokenFetch.mockReset();
tokenFetch.mockImplementation(async (url: string) => {
- if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', expires_in: 300 });
+ // The provider rotates the refresh token on every renewal.
+ if (url.endsWith('/token')) return Response.json({ access_token: 'new-access', refresh_token: `rotated-${++issued}`, expires_in: 300 });
return new Response(null, { status: 200 });
});
vi.stubGlobal('fetch', tokenFetch);
@@ -105,24 +127,31 @@ afterEach(() => {
vi.unstubAllGlobals();
});
+describe('the time the limits were first enforced', () => {
+ it('is recorded once and kept across restarts', async () => {
+ vi.setSystemTime(NOW + 5 * HOUR);
+ resetLimitsEnforcedSince();
+ expect(await limitsEnforcedSince()).toBe(NOW);
+ });
+});
+
describe('remembered sessions (/api/auth/session)', () => {
- it('records the sign-in time when the session is remembered', async () => {
+ it('records the sign-in time when the session is remembered, and answers with it', async () => {
const res = await sessionRoute.POST(new NextRequest('https://webmail.example/api/auth/session?slot=0', {
method: 'POST',
headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' }),
}));
expect(res.status).toBe(200);
+ expect(await res.json()).toEqual({ ok: true, signedInAt: NOW });
expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
- expect(context()?.signedInAt).toBe(NOW);
});
- it('restores a session younger than the maximum, without the sign-in time in the answer', async () => {
+ it('restores a session younger than the maximum and says when it signed in', async () => {
jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 7 * HOUR));
const res = await restore();
expect(res.status).toBe(200);
- expect(await res.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
- expect(context()?.signedInAt).toBe(NOW - 7 * HOUR);
+ expect(await res.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw', signedInAt: NOW - 7 * HOUR });
});
it('refuses a session older than its domain allows and clears its cookies', async () => {
@@ -150,41 +179,46 @@ describe('remembered sessions (/api/auth/session)', () => {
expect((await restore()).status).toBe(401);
});
- it('counts a cookie without a sign-in time as signed in when first seen', async () => {
- jar.set('jmap_session', legacySessionCookie('org-alice@example.org', 'pw'));
-
- const first = await restore();
- expect(first.status).toBe(200);
- expect(await first.json()).toEqual({ serverUrl: SERVER, username: 'org-alice@example.org', password: 'pw' });
- expect(decryptSession(jar.get('jmap_session')!)?.signedInAt).toBe(NOW);
+ it('counts a cookie without a sign-in time from the fixed time, without rewriting it', async () => {
+ const legacy = legacySessionCookie('org-alice@example.org', 'pw');
+ jar.set('jmap_session', legacy);
vi.setSystemTime(NOW + 7 * HOUR);
- expect((await restore()).status).toBe(200);
+ const first = await restore();
+ expect(first.status).toBe(200);
+ expect((await first.json()).signedInAt).toBe(NOW);
+ expect(jar.get('jmap_session')).toBe(legacy);
+
vi.setSystemTime(NOW + 8 * HOUR);
expect((await restore()).status).toBe(401);
});
+
+ it('refuses a kept copy of an old cookie replayed later', async () => {
+ const legacy = legacySessionCookie('org-alice@example.org', 'pw');
+ // First seen only now, nine hours after the server started enforcing.
+ vi.setSystemTime(NOW + 9 * HOUR);
+ jar.set('jmap_session', legacy);
+ expect((await restore()).status).toBe(401);
+ jar.set('jmap_session', legacy);
+ expect((await peek()).status).toBe(401);
+ });
});
describe('provider tokens (/api/auth/token)', () => {
- it('records the sign-in time when a provider sign-in stores its refresh token', async () => {
- // A leftover stamp from an earlier sign-in in this slot.
- const store = (await (await import('next/headers')).cookies()) as never;
- writeSignInStamp(store, 0, { signedInAt: NOW - 20 * HOUR, account: 'org-bob@example.org' });
-
- const res = await tokenRoute.POST(new NextRequest('https://webmail.example/api/auth/token?slot=0', {
- method: 'POST',
- headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
- body: JSON.stringify({ code: 'c', code_verifier: 'v', redirect_uri: 'https://webmail.example/auth/callback', slot: 0 }),
- }));
+ it('records the sign-in time with the refresh token when a provider sign-in stores it', async () => {
+ const res = await signInWithProvider();
expect(res.status).toBe(200);
- expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
+ expect((await res.json()).signed_in_at).toBe(NOW);
+ expect(readSignInStamp(await store(), 0, 'refresh-1')).toMatchObject({ signedInAt: NOW });
+ // The stamp belongs to that token only.
+ expect(readSignInStamp(await store(), 0, 'another-token')).toBeNull();
});
it('refuses to renew a token sign-in older than the maximum and revokes it', async () => {
jar.set('jmap_rt', 'refresh-1');
- const store = (await (await import('next/headers')).cookies()) as never;
- writeSignInStamp(store, 0, { signedInAt: NOW - 8 * HOUR, account: 'org-alice@example.org' });
+ writeSignInStamp(await store(), 0, { signedInAt: NOW + HOUR, refreshToken: 'refresh-1', account: 'org-alice@example.org' });
+ vi.setSystemTime(NOW + 9 * HOUR);
const res = await refresh();
expect(res.status).toBe(401);
expect(await res.json()).toEqual({ error: 'session_expired', maxHours: 8 });
@@ -194,47 +228,70 @@ describe('provider tokens (/api/auth/token)', () => {
expect(tokenFetch).not.toHaveBeenCalledWith('https://id.example.org/token', expect.anything());
});
- it('renews a token sign-in younger than the maximum', async () => {
- jar.set('jmap_rt', 'refresh-1');
- const store = (await (await import('next/headers')).cookies()) as never;
- writeSignInStamp(store, 0, { signedInAt: NOW - 7 * HOUR, account: 'org-alice@example.org' });
+ it('renews a younger one, and the stamp follows the rotated token with the same time', async () => {
+ await signInWithProvider();
+ vi.setSystemTime(NOW + 3 * HOUR);
const res = await refresh();
expect(res.status).toBe(200);
- expect((await res.json()).access_token).toBe('new-access');
+ expect(await res.json()).toMatchObject({ access_token: 'new-access', signed_in_at: NOW });
+ expect(jar.get('jmap_rt')).toBe('rotated-1');
+ expect(readSignInStamp(await store(), 0, 'rotated-1')).toMatchObject({ signedInAt: NOW });
+
+ vi.setSystemTime(NOW + 7 * HOUR);
+ expect((await refresh()).status).toBe(200);
+ vi.setSystemTime(NOW + 8 * HOUR);
+ expect((await refresh()).status).toBe(401);
});
- it('counts a refresh token without a sign-in time as signed in when first seen', async () => {
- jar.set('jmap_rt', 'refresh-1');
+ it('counts a refresh token without a stamp from the fixed time and binds that time to it', async () => {
+ jar.set('jmap_rt', 'refresh-legacy');
+ vi.setSystemTime(NOW + HOUR);
+ const res = await refresh();
+ expect(res.status).toBe(200);
+ expect((await res.json()).signed_in_at).toBe(NOW);
+ expect(readSignInStamp(await store(), 0, 'rotated-1')).toMatchObject({ signedInAt: NOW });
+
+ vi.setSystemTime(NOW + 8 * HOUR);
+ expect((await refresh()).status).toBe(401);
+ });
+
+ it('does not make a session younger when its stamp is deleted', async () => {
+ vi.setSystemTime(NOW + 5 * HOUR);
+ await signInWithProvider();
+ jar.delete('jmap_signed_in');
+
+ vi.setSystemTime(NOW + 8 * HOUR);
+ const res = await refresh();
+ expect(res.status).toBe(401);
+ });
+
+ it('applies the domain of the account the stamp names', async () => {
+ jar.set('jmap_rt', 'refresh-1');
+ writeSignInStamp(await store(), 0, { signedInAt: NOW, refreshToken: 'refresh-1', account: 'org-dave@org-two.test' });
+ vi.setSystemTime(NOW + 100 * HOUR);
expect((await refresh()).status).toBe(200);
- const store = (await (await import('next/headers')).cookies()) as never;
- expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW });
+ });
+
+ it('does not take a stamp written for another refresh token', async () => {
+ jar.set('jmap_rt', 'refresh-2');
+ writeSignInStamp(await store(), 0, { signedInAt: NOW + 7 * HOUR, refreshToken: 'refresh-1' });
+
+ vi.setSystemTime(NOW + 8 * HOUR);
+ expect((await refresh()).status).toBe(401);
});
});
describe('the auth context (/api/auth/stalwart-context)', () => {
- const post = () => contextRoute.POST(new NextRequest('https://webmail.example/api/auth/stalwart-context', {
- method: 'POST',
- headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
- body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', authHeader: 'Bearer new', slot: 0 }),
- }));
-
- it('keeps the sign-in time when a renewed token is written again', async () => {
- jar.set('jmap_session', encryptSession(SERVER, 'org-alice@example.org', 'pw', NOW - 3 * HOUR));
- await restore();
- vi.setSystemTime(NOW + HOUR);
-
- expect((await post()).status).toBe(200);
- expect(context()?.signedInAt).toBe(NOW - 3 * HOUR);
- });
-
- it("takes a token sign-in's time from its stamp and names the account on it", async () => {
- const store = (await (await import('next/headers')).cookies()) as never;
- writeSignInStamp(store, 0, { signedInAt: NOW - 2 * HOUR });
-
- expect((await post()).status).toBe(200);
- expect(context()?.signedInAt).toBe(NOW - 2 * HOUR);
- expect(readSignInStamp(store, 0)).toEqual({ signedInAt: NOW - 2 * HOUR, account: 'org-alice@example.org' });
+ it("names the account on a token sign-in's stamp", async () => {
+ await signInWithProvider();
+ const res = await contextRoute.POST(new NextRequest('https://webmail.example/api/auth/stalwart-context', {
+ method: 'POST',
+ headers: { ...SAME_ORIGIN, 'content-type': 'application/json' },
+ body: JSON.stringify({ serverUrl: SERVER, username: 'org-alice@example.org', authHeader: 'Bearer new', slot: 0 }),
+ }));
+ expect(res.status).toBe(200);
+ expect(readSignInStamp(await store(), 0, 'refresh-1')).toMatchObject({ signedInAt: NOW, account: 'org-alice@example.org' });
});
});
diff --git a/lib/auth/session-age.ts b/lib/auth/session-age.ts
index e5689e889..ba7f97e8a 100644
--- a/lib/auth/session-age.ts
+++ b/lib/auth/session-age.ts
@@ -8,41 +8,58 @@
* back: the routes that restore a remembered sign-in (/api/auth/session) or
* renew a provider token (/api/auth/token) refuse it and clear its cookies.
*
- * The sign-in time travels in the remembered-session cookie (`signedInAt`
- * in lib/auth/crypto.ts), in the auth-context cookie, and for token
- * sign-ins in a small cookie of its own written when the refresh token is
- * (the auth-context cookie ends with the browser, the refresh token lasts
- * 30 days). A cookie written before this existed has no sign-in time: it
- * counts as signed in when the server first sees it after the upgrade,
- * which then writes the time into it.
+ * The sign-in time travels in the remembered-session cookie (`signedInAt` in
+ * lib/auth/crypto.ts) and, for token sign-ins, in a stamp cookie of its own
+ * that is bound to the refresh token by a hash and rewritten with it on
+ * every rotation. A cookie or token without a sign-in time (one written
+ * before this existed, or a stamp that was deleted or doesn't match the
+ * token) counts as signed in at one fixed time: when the server first
+ * enforced the limits (limitsEnforcedSince). Deleting the stamp therefore
+ * never makes a session younger.
*
* The server knows an account's address, not its groups, so only Everyone
* and the domain scopes count here; a group scope's value is enforced by the
* client alone.
*/
+import { createHash } from 'node:crypto';
+import { readFile, writeFile } from 'node:fs/promises';
import type { cookies } from 'next/headers';
import { configManager } from '@/lib/admin/config-manager';
+import { ensureStateDir, getStatePath } from '@/lib/admin/paths';
import { resolvePolicy } from '@/lib/admin/policy-scopes';
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
+import { hasSessionSecret } from '@/lib/auth/session-secret';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { logger } from '@/lib/logger';
+export { SESSION_TOO_OLD } from '@/lib/session-policy/constants';
+
type CookieStore = Awaited>;
const HOUR_MS = 60 * 60 * 1000;
-/** What a route answers when it refuses a session that is too old. */
-export const SESSION_TOO_OLD = 'session_expired';
+const positiveHours = (value: unknown): number => (typeof value === 'number' && value > 0 ? value : 0);
-/** The maximum session length (hours, 0 = none) for an account's address. */
+/**
+ * The maximum session length (hours, 0 = none) for an account's address.
+ * Without an address (a token sign-in whose stamp names no account, which
+ * includes one whose stamp was deleted) the strictest value of Everyone and
+ * every domain entry applies, so losing the account never loosens the limit.
+ */
export async function maxSessionHoursFor(account: string | null | undefined): Promise {
try {
await configManager.ensureLoaded?.();
+ const raw = configManager.getPolicy();
const email = account?.trim().toLowerCase() ?? '';
- const policy = resolvePolicy(configManager.getPolicy(), email ? { email, groups: [] } : null).policy;
- const hours = policy.organisation?.session?.maxHours;
- return typeof hours === 'number' && hours > 0 ? hours : 0;
+ if (email) return positiveHours(resolvePolicy(raw, { email, groups: [] }).policy.organisation?.session?.maxHours);
+ const values = [
+ positiveHours(resolvePolicy(raw, null).policy.organisation?.session?.maxHours),
+ ...(raw.scopes ?? [])
+ .filter((scope) => (scope.match?.domains?.length ?? 0) > 0)
+ .map((scope) => positiveHours(scope.policy?.organisation?.session?.maxHours)),
+ ].filter((hours) => hours > 0);
+ return values.length > 0 ? Math.min(...values) : 0;
} catch (error) {
logger.warn('Could not read the maximum session length', { error: error instanceof Error ? error.message : 'Unknown error' });
return 0;
@@ -50,47 +67,120 @@ export async function maxSessionHoursFor(account: string | null | undefined): Pr
}
/** Whether a session signed in at `signedInAt` has outlived `maxHours`. */
-export function isPastMaxAge(signedInAt: number | undefined, maxHours: number, now = Date.now()): boolean {
- if (!maxHours || typeof signedInAt !== 'number') return false;
+export function isPastMaxAge(signedInAt: number, maxHours: number, now = Date.now()): boolean {
+ if (!maxHours) return false;
return now - signedInAt >= maxHours * HOUR_MS;
}
-export function validSignedInAt(value: unknown): number | undefined {
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
+// ── The fixed time for sessions without a sign-in time ─────────────────────
+
+const ENFORCED_SINCE_FILE = 'session-limits-since';
+const PROCESS_START = Date.now() - Math.round(process.uptime() * 1000);
+let enforcedSince: Promise | null = null;
+
+function parseTime(text: string): number | null {
+ const value = Number(text.trim());
+ return Number.isFinite(value) && value > 0 ? value : null;
+}
+
+async function readRecorded(path: string): Promise {
+ try {
+ return parseTime(await readFile(path, 'utf8'));
+ } catch {
+ return null;
+ }
}
/**
- * The sign-in time of a token sign-in, kept as long as its refresh token.
- * `account` is filled in once the auth context names the account.
+ * When this server first enforced the session limits, recorded once in the
+ * admin state dir. Falls back to the process start when the file can't be
+ * written (a read-only state dir), which restarts the count on a restart.
+ */
+export function limitsEnforcedSince(): Promise {
+ enforcedSince ??= (async () => {
+ const path = getStatePath(ENFORCED_SINCE_FILE);
+ const recorded = await readRecorded(path);
+ if (recorded) return recorded;
+ const now = Date.now();
+ try {
+ await ensureStateDir();
+ // 'wx': a second process starting at the same moment keeps the first one's time.
+ await writeFile(path, String(now), { flag: 'wx' });
+ return now;
+ } catch {
+ const other = await readRecorded(path);
+ if (other) return other;
+ logger.warn('Could not record when the session limits were first enforced; counting from the process start');
+ return PROCESS_START;
+ }
+ })();
+ return enforcedSince;
+}
+
+/** Test helper. */
+export function resetLimitsEnforcedSince(): void {
+ enforcedSince = null;
+}
+
+// ── The sign-in stamp of a token sign-in ───────────────────────────────────
+
+/**
+ * The sign-in time of a token sign-in, bound to the refresh token it was
+ * written with. `account` is filled in once the auth context names it.
*/
export interface SignInStamp {
signedInAt: number;
+ tokenHash: string;
account?: string;
}
+export function refreshTokenHash(refreshToken: string): string {
+ return createHash('sha256').update(refreshToken).digest('base64url');
+}
+
export function signInStampCookieName(slot: number): string {
return slot === 0 ? 'jmap_signed_in' : `jmap_signed_in_${slot}`;
}
-export function readSignInStamp(cookieStore: CookieStore, slot: number): SignInStamp | null {
+/** The slot's stamp, or null when it is missing, unreadable, or written for another refresh token. */
+export function readSignInStamp(cookieStore: CookieStore, slot: number, refreshToken: string): SignInStamp | null {
const value = cookieStore.get(signInStampCookieName(slot))?.value;
if (!value) return null;
try {
const payload = decryptPayload(value, 'session-age');
- const signedInAt = validSignedInAt(payload?.signedInAt);
- if (!signedInAt) return null;
- return typeof payload?.account === 'string' ? { signedInAt, account: payload.account } : { signedInAt };
+ const signedInAt = payload?.signedInAt;
+ if (typeof signedInAt !== 'number' || !Number.isFinite(signedInAt)) return null;
+ if (payload?.tokenHash !== refreshTokenHash(refreshToken)) return null;
+ const account = typeof payload.account === 'string' ? payload.account : undefined;
+ return { signedInAt, tokenHash: payload.tokenHash, ...(account ? { account } : {}) };
} catch {
return null;
}
}
-/** Without a session secret nothing can be sealed; the client's timer still applies. */
-export function writeSignInStamp(cookieStore: CookieStore, slot: number, stamp: SignInStamp): void {
+let missingSecretLogged = false;
+
+/**
+ * Written whenever the refresh token cookie is, with that cookie's options.
+ * Without a session secret nothing can be sealed; the client's timer still applies.
+ */
+export function writeSignInStamp(
+ cookieStore: CookieStore,
+ slot: number,
+ stamp: { signedInAt: number; refreshToken: string; account?: string },
+ options: Record = getCookieOptions(),
+): void {
+ if (!hasSessionSecret()) {
+ if (!missingSecretLogged) {
+ missingSecretLogged = true;
+ logger.warn('No session secret: the maximum session length is enforced by the browser only');
+ }
+ return;
+ }
try {
- const payload: Record = { signedInAt: stamp.signedInAt };
+ const payload: Record = { signedInAt: stamp.signedInAt, tokenHash: refreshTokenHash(stamp.refreshToken) };
if (stamp.account) payload.account = stamp.account;
- cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), getCookieOptions());
+ cookieStore.set(signInStampCookieName(slot), encryptPayload(payload, 'session-age'), options);
} catch (error) {
logger.warn('Could not record the sign-in time', { error: error instanceof Error ? error.message : 'Unknown error' });
}
@@ -100,3 +190,18 @@ export function clearSignInStamp(cookieStore: CookieStore, slot: number): void {
const name = signInStampCookieName(slot);
if (cookieStore.get(name)) cookieStore.delete(name);
}
+
+/**
+ * When a token sign-in happened: its stamp's time, or the fixed time when
+ * there is no stamp for this refresh token. Null without a session secret,
+ * when the server can't keep sign-in times at all.
+ */
+export async function tokenSignedInAt(
+ cookieStore: CookieStore,
+ slot: number,
+ refreshToken: string,
+): Promise<{ signedInAt: number; stamp: SignInStamp | null } | null> {
+ if (!hasSessionSecret()) return null;
+ const stamp = readSignInStamp(cookieStore, slot, refreshToken);
+ return { signedInAt: stamp?.signedInAt ?? await limitsEnforcedSince(), stamp };
+}
diff --git a/lib/session-policy/constants.ts b/lib/session-policy/constants.ts
new file mode 100644
index 000000000..dab0c03a7
--- /dev/null
+++ b/lib/session-policy/constants.ts
@@ -0,0 +1,6 @@
+/**
+ * What the sign-in routes answer (`error`) when they refuse a session that
+ * reached the organisation's maximum length. The server (lib/auth/session-age.ts)
+ * and the browser (stores/auth-store.ts) both read it from here.
+ */
+export const SESSION_TOO_OLD = 'session_expired';
diff --git a/lib/stalwart/auth-context.ts b/lib/stalwart/auth-context.ts
index 3a34f1901..7d22b3f63 100644
--- a/lib/stalwart/auth-context.ts
+++ b/lib/stalwart/auth-context.ts
@@ -14,12 +14,6 @@ export interface StalwartAuthContext {
* data such as synced settings is keyed on this.
*/
accountName?: string;
- /**
- * When the user signed in (ms), for the organisation's maximum session
- * length (lib/auth/session-age.ts). Carried over when the context is
- * written again for the same account (a renewed token, a restore).
- */
- signedInAt?: number;
}
type CookieStore = Awaited>;
@@ -45,8 +39,7 @@ function isValidContext(payload: unknown): payload is StalwartAuthContext {
return typeof candidate.serverUrl === 'string'
&& typeof candidate.username === 'string'
&& typeof candidate.authHeader === 'string'
- && (candidate.accountName === undefined || typeof candidate.accountName === 'string')
- && (candidate.signedInAt === undefined || typeof candidate.signedInAt === 'number');
+ && (candidate.accountName === undefined || typeof candidate.accountName === 'string');
}
function getSessionCookieOptions() {
@@ -66,14 +59,8 @@ export function readStalwartAuthContextFromStore(
// Hand back exactly the declared shape: the envelope's own bookkeeping
// stays in the envelope rather than riding along into the credentials.
- const { serverUrl, username, authHeader, accountName, signedInAt } = payload;
- return {
- serverUrl,
- username,
- authHeader,
- ...(accountName ? { accountName } : {}),
- ...(typeof signedInAt === 'number' ? { signedInAt } : {}),
- };
+ const { serverUrl, username, authHeader, accountName } = payload;
+ return accountName ? { serverUrl, username, authHeader, accountName } : { serverUrl, username, authHeader };
}
export function setStalwartAuthContextInStore(
From 65e83e4b0dd3f1c5848f47fafc5e6ae52d0b8f35 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 03:53:56 +0100
Subject: [PATCH 7/9] fix: show the session warning above every overlay and
count the server's sign-in time
---
app/(main)/[locale]/login/page.tsx | 3 +
.../composer-sign-out-flush.test.tsx | 2 +-
.../__tests__/session-timeout-dialog.test.tsx | 48 ++++++++
.../__tests__/session-timeout-guard.test.tsx | 96 +++++++++++++++-
components/session/session-timeout-dialog.tsx | 28 ++++-
components/session/session-timeout-guard.tsx | 103 ++++++++++++++----
lib/__tests__/compose-flush-registry.test.ts | 8 +-
lib/compose-flush-registry.ts | 10 +-
lib/session-policy/__tests__/limits.test.ts | 7 +-
lib/session-policy/limits.ts | 11 +-
lib/session-policy/sign-out-reason.ts | 18 ++-
locales/de/common.json | 4 +-
locales/en/common.json | 4 +-
...auth-store-restore-session-too-old.test.ts | 31 ++++++
stores/auth-store.ts | 68 +++++++++---
15 files changed, 373 insertions(+), 68 deletions(-)
create mode 100644 components/session/__tests__/session-timeout-dialog.test.tsx
diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx
index 84a214c2c..7da1f499b 100644
--- a/app/(main)/[locale]/login/page.tsx
+++ b/app/(main)/[locale]/login/page.tsx
@@ -1244,6 +1244,9 @@ function LoginPageContent() {
: sessionEnd?.kind === "max"
? tSessionLimits("reason_max", { hours: sessionEnd.hours })
: t("session_expired")}
+ {sessionEnd?.draftsUnsaved && (
+ {tSessionLimits("drafts_not_saved")}
+ )}
{
let result: Awaited> | undefined;
await act(async () => { result = await flushComposers(5000); });
- expect(result).toEqual({ total: 1, settled: 1, timedOut: false });
+ expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false });
expect(createDraft).toHaveBeenCalledTimes(1);
expect(createDraft.mock.calls[0][1]).toBe('Half-written more');
});
diff --git a/components/session/__tests__/session-timeout-dialog.test.tsx b/components/session/__tests__/session-timeout-dialog.test.tsx
new file mode 100644
index 000000000..ad4603516
--- /dev/null
+++ b/components/session/__tests__/session-timeout-dialog.test.tsx
@@ -0,0 +1,48 @@
+import { act, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { SessionTimeoutDialog } from '../session-timeout-dialog';
+
+const noop = () => {};
+
+describe('session timeout dialog', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date('2026-10-28T09:00:00Z'));
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('sits at the end of the body above plugin dialogs and the office editor', () => {
+ const { container } = render(
+
+
+
,
+ );
+ const overlay = screen.getByTestId('session-timeout-dialog');
+ expect(container.contains(overlay)).toBe(false);
+ expect(overlay.parentElement).toBe(document.body);
+ const z = Number(/z-\[(\d+)\]/.exec(overlay.className)?.[1]);
+ expect(z).toBeGreaterThan(100000);
+ });
+
+ it('does not read out every second, only the 30 and 10 second marks', () => {
+ render( );
+ const message = screen.getByText('idle_message');
+ expect(message.closest('[aria-live]')).toBeNull();
+ const live = document.querySelector('[aria-live]')!;
+ expect(live.textContent).toBe('');
+ act(() => { vi.advanceTimersByTime(5_000); });
+ expect(live.textContent).toBe('seconds_left');
+ });
+
+ it('points aria-describedby only at a message that is there', () => {
+ const { rerender } = render( );
+ const dialog = screen.getByRole('alertdialog');
+ const described = dialog.getAttribute('aria-describedby');
+ expect(described && document.getElementById(described)).toBeTruthy();
+
+ rerender( );
+ expect(screen.getByRole('alertdialog').hasAttribute('aria-describedby')).toBe(false);
+ });
+});
diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx
index 4b1baccb1..6016df443 100644
--- a/components/session/__tests__/session-timeout-guard.test.tsx
+++ b/components/session/__tests__/session-timeout-guard.test.tsx
@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS } from '../session-timeout-guard';
+import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS, FRAME_FOCUS_CHECK_MS } from '../session-timeout-guard';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { usePolicyStore } from '@/stores/policy-store';
@@ -222,7 +222,10 @@ describe('session timeout guard', () => {
render( );
await advance(0);
- await advance(9 * 60 * SECOND);
+ // Ten minutes left, ended a minute early for clock skew, warned a minute before that.
+ await advance(8 * 60 * SECOND - 1);
+ expect(screen.queryByText('max_title')).toBeNull();
+ await advance(1);
expect(screen.getByText('max_title')).toBeTruthy();
fireEvent.click(screen.getByText('dismiss'));
expect(screen.queryByText('max_title')).toBeNull();
@@ -231,4 +234,93 @@ describe('session timeout guard', () => {
expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 1 });
});
+
+ it('signs out right away after a reload that follows a pause longer than the limit', async () => {
+ // Recorded by a tab before the reload, five minutes ago; signed in long before.
+ localStorage.setItem(LAST_ACTIVITY_KEY, String(START - 5 * 60 * SECOND));
+ usePolicyStore.setState({ rawPolicy: policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }) });
+ useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org', START - 60 * 60 * SECOND)], activeAccountId: 'alice' });
+ useAuthStore.setState({
+ isAuthenticated: true, isDemoMode: false, activeAccountId: 'alice',
+ client: fakeClient('org-alice@example.org') as never, logoutAll: logoutAll as never,
+ });
+ render( );
+ // Moving the mouse while the page loads doesn't bring the session back.
+ fireEvent.pointerDown(window);
+ await advance(0);
+
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
+ expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1 });
+ });
+
+ it('says on the login page when a draft could not be saved', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ registerComposeFlush(async () => { throw new Error('save refused'); });
+
+ await advance(60 * SECOND);
+ await advance(0);
+ expect(logoutAll).toHaveBeenCalled();
+ expect(consumeSessionEnd()).toEqual({ kind: 'idle', minutes: 1, draftsUnsaved: true });
+ });
+
+ it('does not count scrolling as activity', async () => {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ await advance(20 * SECOND);
+ fireEvent.scroll(window);
+ await advance(10 * SECOND);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+ });
+
+ it('counts the focus on a cross-origin frame as activity, but not on a same-origin one', async () => {
+ const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true);
+ try {
+ await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
+ const editor = document.createElement('iframe');
+ Object.defineProperty(editor, 'contentDocument', { get: () => null });
+ document.body.appendChild(editor);
+ editor.focus();
+ expect(document.activeElement).toBe(editor);
+
+ await advance(45 * SECOND);
+ expect(screen.queryByText('idle_title')).toBeNull();
+
+ // A message body (same origin) clicked into and left doesn't keep the session.
+ const body = document.createElement('iframe');
+ document.body.appendChild(body);
+ body.focus();
+ await advance(30 * SECOND + FRAME_FOCUS_CHECK_MS);
+ expect(screen.getByText('idle_title')).toBeTruthy();
+ editor.remove();
+ body.remove();
+ } finally {
+ hasFocus.mockRestore();
+ }
+ });
+
+ it('asks again for the policy subject of a client that had none', async () => {
+ const policy = policyWith({ idleMinutes: 0, maxHours: 0, warnSeconds: 30 }, [
+ { id: 'example', label: 'example.org', match: { domains: ['example.org'] }, policy: { organisation: { session: { idleMinutes: 1 } } } },
+ ]);
+ let username = '';
+ const client = { ...fakeClient(''), getUsername: () => username };
+ usePolicyStore.setState({ rawPolicy: policy });
+ useAccountStore.setState({ accounts: [account('alice', 'org-alice@example.org')], activeAccountId: 'alice' });
+ useAuthStore.setState({
+ isAuthenticated: true, isDemoMode: false, activeAccountId: 'alice', connectedAccountsRevision: 1,
+ client: client as never, logoutAll: logoutAll as never,
+ });
+ render( );
+ await advance(0);
+ await advance(2 * 60 * SECOND);
+ expect(logoutAll).not.toHaveBeenCalled();
+
+ // The session is complete now: the subject is known and the domain's minute applies.
+ username = 'org-alice@example.org';
+ await act(async () => { useAuthStore.setState({ connectedAccountsRevision: 2 }); });
+ await advance(0);
+ fireEvent.keyDown(window, { key: 'a' });
+ await advance(60 * SECOND);
+ await advance(0);
+ expect(logoutAll).toHaveBeenCalled();
+ });
});
diff --git a/components/session/session-timeout-dialog.tsx b/components/session/session-timeout-dialog.tsx
index f41b65613..edcd06e6c 100644
--- a/components/session/session-timeout-dialog.tsx
+++ b/components/session/session-timeout-dialog.tsx
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useId, useState } from "react";
+import { createPortal } from "react-dom";
import { useTranslations } from "next-intl";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { Button } from "@/components/ui/button";
@@ -22,6 +23,9 @@ function secondsLeft(deadline: number | null): number {
return deadline === null ? 0 : Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
}
+/** Screen readers hear the dialog when it opens, then only at these marks, not every second. */
+const ANNOUNCE_AT = [30, 10];
+
/**
* The warning before an organisation session limit signs the user out. For
* the idle timeout it asks whether to stay; the maximum session length
@@ -31,6 +35,7 @@ export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismi
const t = useTranslations("session_limits");
const id = useId();
const [seconds, setSeconds] = useState(() => secondsLeft(deadline));
+ const [announced, setAnnounced] = useState(null);
const dialogRef = useFocusTrap({
isActive: true,
onEscape: mode === "idle" ? onStay : mode === "max" ? onDismiss : undefined,
@@ -44,16 +49,25 @@ export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismi
return () => clearInterval(interval);
}, [deadline, mode]);
+ useEffect(() => {
+ const mark = ANNOUNCE_AT.find((at) => seconds === at);
+ if (mark !== undefined) setAnnounced(mark);
+ }, [seconds]);
+
+ if (typeof document === "undefined") return null;
+
const title = mode === "max" ? t("max_title") : mode === "idle" ? t("idle_title") : t("signing_out");
- return (
-
+ // Portalled to the end of the body above everything else, the office
+ // editor (z-100) and plugin dialogs (z-100000) included.
+ return createPortal(
+
@@ -63,12 +77,15 @@ export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismi
{title}
{mode !== "ending" && (
-
+
{mode === "max"
? t("max_message", { hours: maxHours ?? 0, seconds })
: t("idle_message", { seconds })}
)}
+
+ {mode !== "ending" && announced !== null ? t("seconds_left", { seconds: announced }) : ""}
+
{mode !== "ending" && (
@@ -82,6 +99,7 @@ export function SessionTimeoutDialog({ mode, deadline, maxHours, onStay, onDismi
)}
-
+ ,
+ document.body,
);
}
diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx
index cb43453e6..2a805f02e 100644
--- a/components/session/session-timeout-guard.tsx
+++ b/components/session/session-timeout-guard.tsx
@@ -18,32 +18,58 @@ import {
type SessionLimits,
} from "@/lib/session-policy/limits";
import { createSessionTimer, type SessionEndKind, type SessionTimer, type SessionTimerState } from "@/lib/session-policy/session-timer";
-import { rememberSessionEnd } from "@/lib/session-policy/sign-out-reason";
+import { rememberSessionEnd, type SessionEndReason } from "@/lib/session-policy/sign-out-reason";
import { SessionTimeoutDialog } from "./session-timeout-dialog";
-/** How long the sign-out waits for the open composers to save their drafts. */
+/** How long the sign-out waits for the open composers to save their drafts (and for sends in flight). */
export const DRAFT_FLUSH_TIMEOUT_MS = 8000;
/** Local activity reaches the timer and the other tabs at most this often. */
const ACTIVITY_THROTTLE_MS = 1000;
-const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel", "scroll", "focus"] as const;
+/**
+ * Bubbling input events, caught on the way down. Scrolling and focus moves
+ * inside the page don't count: the page scrolls and refocuses on its own.
+ */
+const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"] as const;
+/**
+ * Input inside a cross-origin frame (the office editor, plugin panels)
+ * never reaches this window. While such a frame has the focus, that counts
+ * as activity, checked this often.
+ */
+export const FRAME_FOCUS_CHECK_MS = 5000;
-const subjects = new WeakMap>();
-function subjectFor(client: IJMAPClient): Promise {
- let subject = subjects.get(client);
- if (!subject) {
- subject = policySubjectForClient(client).catch(() => null);
- subjects.set(client, subject);
+function crossOriginFrameHasFocus(): boolean {
+ if (typeof document === "undefined" || !document.hasFocus()) return false;
+ const element = document.activeElement;
+ if (!(element instanceof HTMLIFrameElement)) return false;
+ try {
+ // Same-origin frames (the message body) are readable and don't count:
+ // a message clicked into and left would keep the session alive.
+ return element.contentDocument === null;
+ } catch {
+ return true;
}
+}
+
+/** Policy subjects per client and connection round; a failed or empty answer is asked again. */
+const subjects = new WeakMap }>();
+function subjectFor(client: IJMAPClient, revision: number): Promise {
+ const cached = subjects.get(client);
+ if (cached && cached.revision === revision) return cached.subject;
+ const subject = policySubjectForClient(client).catch(() => null);
+ subjects.set(client, { revision, subject });
+ void subject.then((value) => {
+ if (value === null && subjects.get(client)?.subject === subject) subjects.delete(client);
+ });
return subject;
}
/** The limits of every account signed in here, each with its own domain and group scopes. */
-async function collectLimits(): Promise {
+async function collectLimits(revision: number): Promise {
const auth = useAuthStore.getState();
const clients = new Map(auth.getAllConnectedClients());
if (auth.client && auth.activeAccountId && !clients.has(auth.activeAccountId)) clients.set(auth.activeAccountId, auth.client);
const accounts: AccountSession[] = await Promise.all([...clients.entries()].map(async ([accountId, client]) => {
- const subject = await subjectFor(client);
+ const subject = await subjectFor(client, revision);
const session = usePolicyStore.getState().resolveFor(subject).organisation?.session;
return { session, signedInAt: signedInAtFor(accountId) };
}));
@@ -64,6 +90,13 @@ function signedInAtFor(accountId: string): number | undefined {
return now;
}
+function endReason(kind: SessionEndKind, limits: SessionLimits, draftsUnsaved: boolean): SessionEndReason {
+ const drafts = draftsUnsaved ? { draftsUnsaved: true } : {};
+ return kind === "max"
+ ? { kind: "max", hours: limits.max?.hours ?? 0, ...drafts }
+ : { kind: "idle", minutes: limits.idle?.minutes ?? 0, ...drafts };
+}
+
const IDLE_STATE: SessionTimerState = { phase: "active", kind: null, deadline: null };
/**
@@ -81,25 +114,36 @@ export function SessionTimeoutGuard() {
const rawPolicy = usePolicyStore((s) => s.rawPolicy);
const accountsKey = useAccountStore((s) => s.accounts.map((a) => `${a.id}|${a.signedInAt ?? ""}|${a.isConnected}`).join(","));
+ // The last activity any tab recorded, read once before this tab's own
+ // listeners can write it: a reload after a pause longer than the limit
+ // signs out right away, whatever the user does while the page loads.
+ const [mount] = useState(() => ({ at: Date.now(), lastActivity: readLastActivity() }));
const [limits, setLimits] = useState(NO_SESSION_LIMITS);
+ const [limitsReady, setLimitsReady] = useState(false);
const [state, setState] = useState(IDLE_STATE);
const [ending, setEnding] = useState(false);
const [dismissedMax, setDismissedMax] = useState(null);
const timerRef = useRef(null);
+ const timersStartedRef = useRef(0);
const channelRef = useRef(null);
const limitsRef = useRef(limits);
limitsRef.current = limits;
+ const limitsReadyRef = useRef(limitsReady);
+ limitsReadyRef.current = limitsReady;
const endingRef = useRef(false);
const active = isAuthenticated && !isDemoMode;
useEffect(() => {
if (!active) {
setLimits(NO_SESSION_LIMITS);
+ setLimitsReady(false);
return;
}
let cancelled = false;
- void collectLimits().then((next) => {
- if (!cancelled) setLimits((prev) => (sameLimits(prev, next) ? prev : next));
+ void collectLimits(revision).then((next) => {
+ if (cancelled) return;
+ setLimits((prev) => (sameLimits(prev, next) ? prev : next));
+ setLimitsReady(true);
});
return () => { cancelled = true; };
}, [active, client, revision, rawPolicy, accountsKey]);
@@ -113,15 +157,15 @@ export function SessionTimeoutGuard() {
endingRef.current = true;
setEnding(true);
const current = limitsRef.current;
- rememberSessionEnd(kind === "max"
- ? { kind: "max", hours: current.max?.hours ?? 0 }
- : { kind: "idle", minutes: current.idle?.minutes ?? 0 });
- await flushComposers(DRAFT_FLUSH_TIMEOUT_MS);
+ // Said first, in case another tab's sign-out takes this one to the login page meanwhile.
+ rememberSessionEnd(endReason(kind, current, false));
+ const flushed = await flushComposers(DRAFT_FLUSH_TIMEOUT_MS);
+ if (flushed.failed > 0 || flushed.timedOut) rememberSessionEnd(endReason(kind, current, true));
await useAuthStore.getState().logoutAll({ endProviderSession: false });
}, []);
// The clock, while there is a limit to count.
- const hasLimits = active && (!!limits.idle || !!limits.max);
+ const hasLimits = active && limitsReady && (!!limits.idle || !!limits.max);
useEffect(() => {
if (!hasLimits) {
timerRef.current?.dispose();
@@ -133,18 +177,20 @@ export function SessionTimeoutGuard() {
timerRef.current.setLimits(limits);
return;
}
- // A tab opened or reloaded after a long pause starts from the pause, a
- // fresh sign-in from the sign-in.
+ // The first clock of this page starts from the activity recorded before
+ // the page loaded, a fresh sign-in from the sign-in. A clock started
+ // later (limits turned on meanwhile) from what the tabs recorded since.
+ const stored = timersStartedRef.current === 0 ? mount.lastActivity ?? mount.at : readLastActivity() ?? Date.now();
const newestSignIn = Math.max(0, ...useAccountStore.getState().accounts.map((a) => a.signedInAt ?? 0));
- const lastActivity = Math.max(readLastActivity() ?? Date.now(), newestSignIn);
+ timersStartedRef.current += 1;
timerRef.current = createSessionTimer({
limits,
- lastActivity,
+ lastActivity: Math.max(stored, newestSignIn),
onChange: setState,
onExpire: (kind) => { void endSession(kind); },
});
setState(timerRef.current.state());
- }, [hasLimits, limits, endSession]);
+ }, [hasLimits, limits, endSession, mount]);
useEffect(() => () => {
timerRef.current?.dispose();
@@ -164,7 +210,9 @@ export function SessionTimeoutGuard() {
channelRef.current = channel;
let lastSeen = 0;
const onActivity = () => {
- if (endingRef.current) return;
+ // Nothing counts until the limits of the signed-in accounts are known:
+ // the clock starts from what was recorded before.
+ if (endingRef.current || !limitsReadyRef.current) return;
const at = Date.now();
if (at - lastSeen < ACTIVITY_THROTTLE_MS) return;
const timer = timerRef.current;
@@ -180,11 +228,18 @@ export function SessionTimeoutGuard() {
const onVisibility = () => {
if (document.visibilityState === "visible") timerRef.current?.check();
};
+ const frameCheck = setInterval(() => {
+ if (crossOriginFrameHasFocus()) onActivity();
+ }, FRAME_FOCUS_CHECK_MS);
for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onActivity, { capture: true, passive: true });
+ // The window coming back into focus, not focus moving between elements.
+ window.addEventListener("focus", onActivity);
document.addEventListener("visibilitychange", onVisibility);
return () => {
for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true });
+ window.removeEventListener("focus", onActivity);
document.removeEventListener("visibilitychange", onVisibility);
+ clearInterval(frameCheck);
channel.close();
channelRef.current = null;
};
diff --git a/lib/__tests__/compose-flush-registry.test.ts b/lib/__tests__/compose-flush-registry.test.ts
index 3858bb358..c87cbad79 100644
--- a/lib/__tests__/compose-flush-registry.test.ts
+++ b/lib/__tests__/compose-flush-registry.test.ts
@@ -25,7 +25,7 @@ describe('compose flush registry', () => {
const done = flushComposers(5000);
await vi.advanceTimersByTimeAsync(100);
- await expect(done).resolves.toEqual({ total: 2, settled: 2, timedOut: false });
+ await expect(done).resolves.toEqual({ total: 2, settled: 2, failed: 0, timedOut: false });
expect(order.sort()).toEqual(['a', 'b']);
});
@@ -39,20 +39,20 @@ describe('compose flush registry', () => {
await vi.advanceTimersByTimeAsync(2999);
expect(result).toBeNull();
await vi.advanceTimersByTimeAsync(1);
- expect(result).toEqual({ total: 2, settled: 1, timedOut: true });
+ expect(result).toEqual({ total: 2, settled: 1, failed: 0, timedOut: true });
expect(quick).toHaveBeenCalledTimes(1);
});
it('goes on when a flush fails', async () => {
registerComposeFlush(async () => { throw new Error('save refused'); });
- await expect(flushComposers(1000)).resolves.toEqual({ total: 1, settled: 1, timedOut: false });
+ await expect(flushComposers(1000)).resolves.toEqual({ total: 1, settled: 1, failed: 1, timedOut: false });
});
it('forgets a composer that unregistered', async () => {
const flush = vi.fn(async () => {});
const unregister = registerComposeFlush(flush);
unregister();
- await expect(flushComposers(1000)).resolves.toEqual({ total: 0, settled: 0, timedOut: false });
+ await expect(flushComposers(1000)).resolves.toEqual({ total: 0, settled: 0, failed: 0, timedOut: false });
expect(flush).not.toHaveBeenCalled();
});
diff --git a/lib/compose-flush-registry.ts b/lib/compose-flush-registry.ts
index 8af821e9e..d9ea445f8 100644
--- a/lib/compose-flush-registry.ts
+++ b/lib/compose-flush-registry.ts
@@ -50,19 +50,23 @@ export interface FlushResult {
total: number;
/** Flushes that finished (or failed) in time. */
settled: number;
+ /** Flushes that reported a save the server didn't take (threw). */
+ failed: number;
timedOut: boolean;
}
/** Run every registered flush and wait for them, but no longer than `timeoutMs`. */
export async function flushComposers(timeoutMs: number): Promise {
const pending = [...flushes.values()];
- if (pending.length === 0) return { total: 0, settled: 0, timedOut: false };
+ if (pending.length === 0) return { total: 0, settled: 0, failed: 0, timedOut: false };
let settled = 0;
+ let failed = 0;
const all = Promise.all(pending.map(async (flush) => {
try {
await flush();
} catch {
- // A failed save is the composer's to report; the sign-out goes on.
+ // The sign-out goes on; the login page says a draft wasn't saved.
+ failed += 1;
}
settled += 1;
}));
@@ -72,7 +76,7 @@ export async function flushComposers(timeoutMs: number): Promise {
new Promise((resolve) => { timer = setTimeout(() => resolve(true), timeoutMs); }),
]);
clearTimeout(timer);
- return { total: pending.length, settled, timedOut };
+ return { total: pending.length, settled, failed, timedOut };
}
/** Test helper: forget every registration. */
diff --git a/lib/session-policy/__tests__/limits.test.ts b/lib/session-policy/__tests__/limits.test.ts
index 676a30e08..341d3d31c 100644
--- a/lib/session-policy/__tests__/limits.test.ts
+++ b/lib/session-policy/__tests__/limits.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { sessionLimits } from '../limits';
+import { MAX_SKEW_MARGIN_MS, sessionLimits } from '../limits';
const HOUR = 60 * 60 * 1000;
const session = (idleMinutes: number, maxHours: number, warnSeconds = 60) => ({ idleMinutes, maxHours, warnSeconds });
@@ -19,13 +19,14 @@ describe('session limits of the signed-in accounts', () => {
expect(sessionLimits([])).toEqual({ idle: null, max: null });
});
- it('ends at the first maximum that runs out, counted from each sign-in', () => {
+ it('ends at the first maximum that runs out, counted from each sign-in, a minute early', () => {
const limits = sessionLimits([
{ session: session(0, 8), signedInAt: 10 * HOUR },
{ session: session(0, 12), signedInAt: 0 },
{ session: session(0, 4, 30), signedInAt: 9 * HOUR },
]);
- expect(limits.max).toEqual({ at: 12 * HOUR, hours: 12, warnSeconds: 60 });
+ expect(limits.max).toEqual({ at: 12 * HOUR - 60 * 1000, hours: 12, warnSeconds: 60 });
+ expect(MAX_SKEW_MARGIN_MS).toBe(60 * 1000);
});
it('needs a sign-in time to count a maximum', () => {
diff --git a/lib/session-policy/limits.ts b/lib/session-policy/limits.ts
index 6a939f507..d748d6088 100644
--- a/lib/session-policy/limits.ts
+++ b/lib/session-policy/limits.ts
@@ -18,7 +18,7 @@ export interface AccountSession {
export interface SessionLimits {
idle: { minutes: number; warnSeconds: number } | null;
- /** `at`: when the first account's maximum session length runs out. */
+ /** `at`: when the first account's maximum session length runs out, a minute early (MAX_SKEW_MARGIN_MS). */
max: { at: number; hours: number; warnSeconds: number } | null;
}
@@ -26,6 +26,13 @@ export const NO_SESSION_LIMITS: SessionLimits = { idle: null, max: null };
const HOUR_MS = 60 * 60 * 1000;
+/**
+ * The browser ends a maximum session length this much before the server
+ * would refuse it, so a clock that runs a little behind the server's still
+ * warns first.
+ */
+export const MAX_SKEW_MARGIN_MS = 60 * 1000;
+
export function sessionLimits(accounts: readonly AccountSession[]): SessionLimits {
let idle: SessionLimits['idle'] = null;
let max: SessionLimits['max'] = null;
@@ -36,7 +43,7 @@ export function sessionLimits(accounts: readonly AccountSession[]): SessionLimit
idle = { minutes: session.idleMinutes, warnSeconds };
}
if (session.maxHours > 0 && typeof account.signedInAt === 'number') {
- const at = account.signedInAt + session.maxHours * HOUR_MS;
+ const at = account.signedInAt + session.maxHours * HOUR_MS - MAX_SKEW_MARGIN_MS;
if (!max || at < max.at) max = { at, hours: session.maxHours, warnSeconds };
}
}
diff --git a/lib/session-policy/sign-out-reason.ts b/lib/session-policy/sign-out-reason.ts
index b52166520..370b3ebb1 100644
--- a/lib/session-policy/sign-out-reason.ts
+++ b/lib/session-policy/sign-out-reason.ts
@@ -4,8 +4,8 @@
*/
export type SessionEndReason =
- | { kind: 'idle'; minutes: number }
- | { kind: 'max'; hours: number };
+ | { kind: 'idle'; minutes: number; draftsUnsaved?: boolean }
+ | { kind: 'max'; hours: number; draftsUnsaved?: boolean };
const KEY = 'session_end_reason';
@@ -17,6 +17,15 @@ export function rememberSessionEnd(reason: SessionEndReason): void {
}
}
+/** Takes a reason back: an account is signed in after all. */
+export function clearSessionEnd(): void {
+ try {
+ window.sessionStorage.removeItem(KEY);
+ } catch {
+ /* storage unavailable */
+ }
+}
+
/** The reason, once; null when the user wasn't signed out by a session limit. */
export function consumeSessionEnd(): SessionEndReason | null {
try {
@@ -24,8 +33,9 @@ export function consumeSessionEnd(): SessionEndReason | null {
window.sessionStorage.removeItem(KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Record;
- if (parsed.kind === 'idle' && typeof parsed.minutes === 'number') return { kind: 'idle', minutes: parsed.minutes };
- if (parsed.kind === 'max' && typeof parsed.hours === 'number') return { kind: 'max', hours: parsed.hours };
+ const drafts = parsed.draftsUnsaved === true ? { draftsUnsaved: true } : {};
+ if (parsed.kind === 'idle' && typeof parsed.minutes === 'number') return { kind: 'idle', minutes: parsed.minutes, ...drafts };
+ if (parsed.kind === 'max' && typeof parsed.hours === 'number') return { kind: 'max', hours: parsed.hours, ...drafts };
return null;
} catch {
return null;
diff --git a/locales/de/common.json b/locales/de/common.json
index 5d888afd7..6d6b42629 100644
--- a/locales/de/common.json
+++ b/locales/de/common.json
@@ -86,7 +86,9 @@
"dismiss": "OK",
"signing_out": "Entwürfe werden gespeichert und Sie werden abgemeldet …",
"reason_idle": "Sie wurden nach {minutes, plural, one {# Minute} other {# Minuten}} ohne Aktivität abgemeldet.",
- "reason_max": "Sie wurden abgemeldet, weil Ihre Sitzung die Höchstdauer von {hours, plural, one {# Stunde} other {# Stunden}} erreicht hat."
+ "reason_max": "Sie wurden abgemeldet, weil Ihre Sitzung die Höchstdauer von {hours, plural, one {# Stunde} other {# Stunden}} erreicht hat.",
+ "drafts_not_saved": "Mindestens eine geöffnete Nachricht konnte nicht als Entwurf gespeichert werden.",
+ "seconds_left": "Noch {seconds, plural, one {# Sekunde} other {# Sekunden}}"
},
"sidebar": {
"close": "Schließen",
diff --git a/locales/en/common.json b/locales/en/common.json
index f9ca53762..81fc096fe 100644
--- a/locales/en/common.json
+++ b/locales/en/common.json
@@ -86,7 +86,9 @@
"dismiss": "OK",
"signing_out": "Saving drafts and signing out…",
"reason_idle": "You were signed out after {minutes, plural, one {# minute} other {# minutes}} without activity.",
- "reason_max": "You were signed out because your session reached its maximum length of {hours, plural, one {# hour} other {# hours}}."
+ "reason_max": "You were signed out because your session reached its maximum length of {hours, plural, one {# hour} other {# hours}}.",
+ "drafts_not_saved": "One or more open messages could not be saved as drafts.",
+ "seconds_left": "{seconds, plural, one {# second} other {# seconds}} left"
},
"sidebar": {
"close": "Close",
diff --git a/stores/__tests__/auth-store-restore-session-too-old.test.ts b/stores/__tests__/auth-store-restore-session-too-old.test.ts
index 333775cf5..b2ff21a38 100644
--- a/stores/__tests__/auth-store-restore-session-too-old.test.ts
+++ b/stores/__tests__/auth-store-restore-session-too-old.test.ts
@@ -14,6 +14,15 @@ vi.mock('@/lib/jmap/client', () => {
onRateLimit() {}
getRateLimitRemainingMs() { return 0; }
disconnect() {}
+ getUsername() { return this.username; }
+ async getIdentities() { return []; }
+ supportsContacts() { return false; }
+ supportsPrincipals() { return false; }
+ supportsVacationResponse() { return false; }
+ supportsCalendars() { return false; }
+ supportsSieve() { return false; }
+ getSharedAccounts() { return []; }
+ getAccountInfo() { return undefined; }
}
class RateLimitError extends Error {}
class RequestTimeoutError extends Error {}
@@ -62,3 +71,25 @@ it('says so for a provider sign-in whose renewal the server refused', async () =
expect(consumeSessionEnd()).toEqual({ kind: 'max', hours: 8 });
});
+
+it('says nothing when another account comes back, and takes the server sign-in time', async () => {
+ const bob = {
+ ...account('basic'), id: 'bob@example.org@mail.example.org', username: 'bob@example.org', email: 'bob@example.org', cookieSlot: 1, isDefault: false,
+ };
+ vi.stubGlobal('fetch', vi.fn(async (input: unknown, init?: { method?: string }) => {
+ const url = String(input);
+ if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 });
+ if (url.includes('/api/auth/session?slot=1') && init?.method === 'PUT') {
+ return new Response(JSON.stringify({ serverUrl: 'https://mail.example.org', username: 'bob@example.org', password: 'pw', signedInAt: 1234 }), { status: 200 });
+ }
+ if (url.includes('/api/auth/session') && init?.method === 'PUT') return tooOld();
+ return new Response('{}', { status: 200 });
+ }));
+ useAccountStore.setState({ accounts: [account('basic'), bob] as never, activeAccountId: account('basic').id, defaultAccountId: account('basic').id });
+
+ await useAuthStore.getState().checkAuth();
+ await vi.waitFor(() => expect(useAccountStore.getState().accounts.map((a) => a.id)).toEqual([bob.id]));
+ await vi.waitFor(() => expect(useAccountStore.getState().accounts[0].signedInAt).toBe(1234));
+
+ expect(consumeSessionEnd()).toBeNull();
+});
diff --git a/stores/auth-store.ts b/stores/auth-store.ts
index b8b084d5d..03e20a93c 100644
--- a/stores/auth-store.ts
+++ b/stores/auth-store.ts
@@ -24,7 +24,8 @@ import { useSearchHistoryStore } from './search-history-store';
import { useCalendarNotificationStore } from './calendar-notification-store';
import { usePolicyStore } from './policy-store';
import { policySubjectForClient } from '@/lib/admin/policy-subject';
-import { rememberSessionEnd } from '@/lib/session-policy/sign-out-reason';
+import { clearSessionEnd, rememberSessionEnd } from '@/lib/session-policy/sign-out-reason';
+import { SESSION_TOO_OLD } from '@/lib/session-policy/constants';
import type { Identity } from '@/lib/jmap/types';
import { authHooks } from '@/lib/plugin-hooks';
import { IS_LITE, IS_LITE_STALWART } from '@/lib/lite';
@@ -420,7 +421,7 @@ async function exchangePasswordForTokens(params: {
// server_id isn't passed - the route looks up the server entry by
// serverUrl, so per-server OAuth still applies for password+TOTP.
body: JSON.stringify({ serverUrl, username, password, totp, slot, redirectUri, rememberMe }),
- });
+ }).then((res) => noteSessionAnswer(res, slot));
}
try {
const tokens = await liteTokenLogin({ serverUrl, username, password, totp, redirectUri });
@@ -460,7 +461,7 @@ async function exchangeOAuthCode(params: {
slot,
...(serverId ? { server_id: serverId } : {}),
}),
- });
+ }).then((res) => noteSessionAnswer(res, slot));
}
// The login page parked where and as whom to redeem the code
// (lib/auth/lite-oauth.ts); without it this is not a flow we started. A
@@ -530,7 +531,7 @@ async function persistSession(slot: number, session: RememberedSession): Promise
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...session, slot }),
signal,
- }));
+ })).then((answer) => noteSessionAnswer(answer, slot));
if (!res.ok) debug.error('Failed to store session: server returned', res.status);
} catch (err) {
debug.error('Failed to store session:', err);
@@ -538,16 +539,45 @@ async function persistSession(slot: number, session: RememberedSession): Promise
}
/**
- * A restore or renewal the server refused because the session reached the
- * organisation's maximum length (lib/auth/session-age.ts): the login page
- * says so.
+ * When the server says each cookie slot signed in, from the sign-in and
+ * restore answers. The organisation's maximum session length counts from
+ * the server's time in the browser too, so the server never refuses a
+ * session before the browser warned (lib/auth/session-age.ts).
*/
-async function noteSessionTooOld(res: Response): Promise {
- if (res.status !== 401 || typeof res.clone !== 'function') return res;
+const serverSignedInAt = new Map();
+
+/**
+ * The server's sign-in time for a sign-in to `slot` that just happened, or
+ * the browser's clock. An older answer belongs to an earlier sign-in.
+ */
+export function signedInAtForSlot(slot: number | undefined): number {
+ const now = Date.now();
+ const server = slot !== undefined ? serverSignedInAt.get(slot) : undefined;
+ return server !== undefined && Math.abs(now - server) < 10 * 60 * 1000 ? server : now;
+}
+
+/**
+ * Reads what a sign-in, restore or renewal answer says about the session:
+ * the server's sign-in time, or that it refused the session because it
+ * reached the organisation's maximum length. The login page says so only
+ * when no other account stays signed in here; a later successful restore
+ * takes the reason back.
+ */
+async function noteSessionAnswer(res: Response, slot: number | undefined): Promise {
+ if (typeof res.clone !== 'function' || (!res.ok && res.status !== 401)) return res;
try {
- const data = await res.clone().json() as { error?: unknown; maxHours?: unknown };
- if (data?.error === 'session_expired' && typeof data.maxHours === 'number') {
- rememberSessionEnd({ kind: 'max', hours: data.maxHours });
+ const data = await res.clone().json() as { error?: unknown; maxHours?: unknown; signedInAt?: unknown; signed_in_at?: unknown };
+ if (res.ok) {
+ clearSessionEnd();
+ const at = typeof data?.signedInAt === 'number' ? data.signedInAt : data?.signed_in_at;
+ if (typeof at === 'number' && Number.isFinite(at) && slot !== undefined) {
+ serverSignedInAt.set(slot, at);
+ const account = useAccountStore.getState().accounts.find((a) => a.cookieSlot === slot);
+ if (account && account.signedInAt !== at) useAccountStore.getState().updateAccount(account.id, { signedInAt: at });
+ }
+ } else if (data?.error === SESSION_TOO_OLD && typeof data.maxHours === 'number') {
+ const othersSignedIn = useAccountStore.getState().accounts.some((a) => a.cookieSlot !== slot && a.isConnected);
+ if (!othersSignedIn) rememberSessionEnd({ kind: 'max', hours: data.maxHours });
}
} catch {
/* not JSON */
@@ -561,7 +591,7 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}
if (closingSlots.has(slot)) return jsonResponse({ error: 'signed_out' }, 401);
if (!IS_LITE) {
return trackSessionWrite(slot, (signal) => apiFetch(`/api/auth/token?slot=${slot}${opts.force ? '&force=true' : ''}`, { method: 'PUT', signal }))
- .then(noteSessionTooOld);
+ .then((res) => noteSessionAnswer(res, slot));
}
// Like the route's cookie cache: a restore resumes with the token it had,
// since a renewal before the refresh token's `nbf` is refused (#552).
@@ -584,7 +614,7 @@ async function fetchSlotAccessToken(slot: number, opts: { force?: boolean } = {}
async function fetchSlotSession(slot?: number): Promise {
if (!IS_LITE) {
return apiFetch(slot === undefined ? '/api/auth/session' : `/api/auth/session?slot=${slot}`, { method: 'PUT' })
- .then(noteSessionTooOld);
+ .then((res) => noteSessionAnswer(res, slot ?? 0));
}
const stored = readLiteBasicSession(slot ?? 0);
return stored ? jsonResponse(stored) : jsonResponse({ error: 'no_session' }, 401);
@@ -1289,8 +1319,10 @@ export const LOGOUT_HOOK_BUDGET_MS = 2000;
function notifySignedIn(state: { activeAccountId: string | null; username: string | null }): void {
if (!state.activeAccountId) return;
- // The organisation's maximum session length counts from a sign-in, never from a restore.
- useAccountStore.getState().updateAccount(state.activeAccountId, { signedInAt: Date.now() });
+ // The organisation's maximum session length counts from a sign-in, never
+ // from a restore, and from the server's time when it said.
+ const slot = useAccountStore.getState().getAccountById(state.activeAccountId)?.cookieSlot;
+ useAccountStore.getState().updateAccount(state.activeAccountId, { signedInAt: signedInAtForSlot(slot) });
void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' });
}
@@ -1939,7 +1971,7 @@ export const useAuthStore = create()(
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, state, slot }),
- }),
+ }).then((res) => noteSessionAnswer(res, slot)),
fetchConfig(),
]);
@@ -3118,7 +3150,7 @@ export const useAuthStore = create()(
bindClientStatusHandlers(client, set, get, accountId);
accountStore.updateAccount(accountId, {
awaitingSignIn: false, isConnected: true, hasError: false, errorMessage: undefined,
- rememberMe, lastLoginAt: Date.now(), signedInAt: Date.now(),
+ rememberMe, lastLoginAt: Date.now(), signedInAt: signedInAtForSlot(cookieSlot),
});
void syncAccountDisplayName(accountId, client);
set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 }));
From ecbbe137c4f802234ceeb1523d0fa33d2cd32e17 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:06:05 +0100
Subject: [PATCH 8/9] fix: wait for sends and keep quick replies as drafts
before an automatic sign-out
---
.../composer-sign-out-flush.test.tsx | 47 +++++++++++-
.../__tests__/viewer-send-failures.test.tsx | 16 ++++
components/email/email-composer.tsx | 15 +++-
components/email/email-viewer.tsx | 17 +++++
components/mail/mail-app.tsx | 67 ++++-------------
components/pro/pro-email-tab-body.tsx | 8 ++
.../use-minimised-composer-flush.test.tsx | 53 +++++++++++++
hooks/use-minimised-composer-flush.ts | 26 +++++++
.../__tests__/quick-reply-envelope.test.ts | 46 ++++++++++++
lib/outgoing/quick-reply-envelope.ts | 74 +++++++++++++++++++
10 files changed, 312 insertions(+), 57 deletions(-)
create mode 100644 hooks/__tests__/use-minimised-composer-flush.test.tsx
create mode 100644 hooks/use-minimised-composer-flush.ts
create mode 100644 lib/outgoing/__tests__/quick-reply-envelope.test.ts
create mode 100644 lib/outgoing/quick-reply-envelope.ts
diff --git a/components/email/__tests__/composer-sign-out-flush.test.tsx b/components/email/__tests__/composer-sign-out-flush.test.tsx
index 2f55cdf10..be454dee1 100644
--- a/components/email/__tests__/composer-sign-out-flush.test.tsx
+++ b/components/email/__tests__/composer-sign-out-flush.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen, fireEvent, act } from '@testing-library/react';
+import { render, screen, fireEvent, act, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest';
import React from 'react';
import { EmailComposer, type ComposerDraftData } from '../email-composer';
@@ -70,7 +70,6 @@ vi.mock('@/stores/account-store', () => {
vi.mock('@/stores/email-store', () => {
const state = {
- draftSaveEnabled: false,
sendRawEmail: async () => ({ sent: true }),
};
const hook = (sel?: (s: typeof state) => unknown) =>
@@ -247,6 +246,50 @@ describe('composer flush before an automatic sign-out', () => {
expect(createDraft.mock.calls[0][1]).toBe('Half-written');
});
+ it('waits for a send in flight and saves no draft of the sent message', async () => {
+ let finishSend!: () => void;
+ const onSend = vi.fn(() => new Promise((resolve) => { finishSend = resolve; }));
+ render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+ fireEvent.click(screen.getAllByTestId('composer-send')[0]);
+ await waitFor(() => expect(onSend).toHaveBeenCalled());
+ const savesBeforeSignOut = createDraft.mock.calls.length;
+
+ let done = false;
+ const flushed = flushComposers(5000).then((r) => { done = true; return r; });
+ await new Promise((r) => setTimeout(r, 300));
+ expect(done).toBe(false);
+
+ await act(async () => { finishSend(); });
+ await expect(flushed).resolves.toMatchObject({ total: 1, failed: 0, timedOut: false });
+ expect(createDraft).toHaveBeenCalledTimes(savesBeforeSignOut);
+ });
+
+ it('reports a save the server refused, so the login page can say so', async () => {
+ createDraft.mockRejectedValue(new Error('refused'));
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ try {
+ render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+
+ let result: Awaited> | undefined;
+ await act(async () => { result = await flushComposers(5000); });
+ expect(result).toMatchObject({ total: 1, failed: 1 });
+ } finally {
+ consoleError.mockRestore();
+ }
+ });
+
+ it('hands an already-saved state back as saved', async () => {
+ const onSaveState = vi.fn();
+ const view = render( );
+ fireEvent.change(screen.getByDisplayValue('Half-written'), { target: { value: 'Half-written more' } });
+ await act(async () => { await flushComposers(5000); });
+ view.unmount();
+
+ expect(onSaveState).toHaveBeenCalledWith(expect.objectContaining({ subject: 'Half-written more', unsaved: false }));
+ });
+
it('marks the state it hands to the host on unmount as unsaved', () => {
const onSaveState = vi.fn();
const view = render( );
diff --git a/components/email/__tests__/viewer-send-failures.test.tsx b/components/email/__tests__/viewer-send-failures.test.tsx
index 27250067a..fb791f74a 100644
--- a/components/email/__tests__/viewer-send-failures.test.tsx
+++ b/components/email/__tests__/viewer-send-failures.test.tsx
@@ -6,6 +6,7 @@ import { toast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useSettingsStore } from '@/stores/settings-store';
import { useEmailStore } from '@/stores/email-store';
+import { flushComposers, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
// Sending from the viewer - quick reply, "Send now" / "Reschedule" of a
// scheduled message, and read receipts - used to fail silently or as an
@@ -110,6 +111,21 @@ describe('viewer send failures', () => {
expect((box as HTMLTextAreaElement).value).toBe('Thanks!');
});
+ it('keeps typed quick-reply text as a draft before an automatic sign-out', async () => {
+ resetComposeFlushRegistry();
+ const onSaveQuickReplyDraft = vi.fn().mockResolvedValue(undefined);
+ render(
+ ,
+ );
+ await act(async () => { await flushComposers(1000); });
+ expect(onSaveQuickReplyDraft).not.toHaveBeenCalled();
+
+ fireEvent.change(screen.getByPlaceholderText('quick_reply_placeholder'), { target: { value: 'On it.' } });
+ await act(async () => { await flushComposers(1000); });
+ expect(onSaveQuickReplyDraft).toHaveBeenCalledWith('On it.');
+ });
+
const scheduled = () => makeEmail({
isScheduled: true,
scheduledUndoStatus: 'pending',
diff --git a/components/email/email-composer.tsx b/components/email/email-composer.tsx
index 11f303fd3..6da50a5b9 100644
--- a/components/email/email-composer.tsx
+++ b/components/email/email-composer.tsx
@@ -142,6 +142,9 @@ import { toUnicodeEmail } from "@/lib/idn";
* blocks are separated by a blank line, matching their visual rendering (#421).
* Quotes get "> " and list items their markers, like any plain-text mail.
*/
+/** How often an automatic sign-out looks whether a send in flight has finished. */
+const SEND_WAIT_POLL_MS = 100;
+
function htmlToPlainText(html: string): string {
return htmlToPlainTextShared(html, { paragraphSpacing: true, mailMarkup: true });
}
@@ -1656,7 +1659,9 @@ export function EmailComposer({
mode,
replyTo,
priority: priorityRef.current,
- unsaved: true,
+ // Edits the last save didn't cover; a minimised composer with
+ // none needs no save before an automatic sign-out.
+ unsaved: changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current,
});
}
};
@@ -2709,8 +2714,13 @@ export function EmailComposer({
saveDraftRef.current = saveDraft;
// An automatic sign-out saves what the server doesn't have yet, with the
- // same save the close and send paths use.
+ // same save the close and send paths use. A send in flight goes first:
+ // the sign-out waits for it (within its own time limit), and a message
+ // that went out needs no draft. A save the server refused throws, so the
+ // login page can say a draft wasn't saved.
useEffect(() => registerComposeFlush(async () => {
+ while (isSendingRef.current) await new Promise((resolve) => setTimeout(resolve, SEND_WAIT_POLL_MS));
+ if (sentRef.current) return;
const unsaved = changeVersionRef.current !== savedVersionRef.current || saveFailedRef.current;
if (!unsaved) return;
if (saveTimeoutRef.current) {
@@ -2718,6 +2728,7 @@ export function EmailComposer({
saveTimeoutRef.current = null;
}
await saveDraftRef.current();
+ if (saveFailedRef.current) throw new Error('The draft was not saved');
}), []);
// The draft as plugins see it: onDraftChange, the frames above and below
diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx
index 54a8409d6..7ab8f5dac 100644
--- a/components/email/email-viewer.tsx
+++ b/components/email/email-viewer.tsx
@@ -102,6 +102,7 @@ import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
+import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { useUIStore } from "@/stores/ui-store";
import { useContactStore, getContactDisplayName, getContactPrimaryEmail } from "@/stores/contact-store";
import { toast } from "@/stores/toast-store";
@@ -175,6 +176,8 @@ interface EmailViewerProps {
onSetTag?: (emailId: string, tagId: string | null) => void;
onDownloadAttachment?: (blobId: string, name: string, type?: string, forceDownload?: boolean, source?: EmailBlobContext) => void;
onQuickReply?: (body: string) => Promise;
+ /** Keeps the typed quick reply as a draft before an automatic sign-out (lib/compose-flush-registry.ts). */
+ onSaveQuickReplyDraft?: (body: string) => Promise;
onMarkAsSpam?: () => void;
onUndoSpam?: () => void;
onMoveToMailbox?: (mailboxId: string) => void;
@@ -1044,6 +1047,7 @@ export function EmailViewer({
onSetTag,
onDownloadAttachment,
onQuickReply,
+ onSaveQuickReplyDraft,
onMarkAsSpam,
onUndoSpam,
onMoveToMailbox,
@@ -1369,6 +1373,19 @@ export function EmailViewer({
const [quickReplyText, setQuickReplyText] = useState("");
const [isQuickReplyFocused, setIsQuickReplyFocused] = useState(false);
const [isSendingQuickReply, setIsSendingQuickReply] = useState(false);
+ // An automatic sign-out keeps typed quick-reply text as a draft; text on
+ // its way out as a reply needs none.
+ const quickReplyDraftRef = useRef({ text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft });
+ quickReplyDraftRef.current = { text: quickReplyText, sending: isSendingQuickReply, save: onSaveQuickReplyDraft };
+ const hasQuickReplyDraft = quickReplyText.trim().length > 0 && !!onSaveQuickReplyDraft;
+ useEffect(() => {
+ if (!hasQuickReplyDraft) return;
+ return registerComposeFlush(async () => {
+ const { text, sending, save } = quickReplyDraftRef.current;
+ if (sending || !text.trim() || !save) return;
+ await save(text);
+ });
+ }, [hasQuickReplyDraft]);
const handleSendQuickReply = async () => {
if (!quickReplyText.trim() || !onQuickReply || isSendingQuickReply) return;
setIsSendingQuickReply(true);
diff --git a/components/mail/mail-app.tsx b/components/mail/mail-app.tsx
index 49581ae72..30bdb9dd7 100644
--- a/components/mail/mail-app.tsx
+++ b/components/mail/mail-app.tsx
@@ -115,8 +115,7 @@ import { isFilePreviewable } from "@/lib/file-preview";
import { prepareQuickReply } from "@/lib/outgoing/quick-reply";
import { computeReplyThreadingHeaders } from "@/lib/email-threading";
import { EML_IMPORT_ACCEPT, expandImportableEmails } from "@/lib/eml-import";
-import { findDraftIdentityId, findReplyIdentityId, resolveComposeAccountEmail } from "@/lib/reply-identity";
-import { buildReplyRecipients, isSelfSent } from "@/lib/reply-recipients";
+import { resolveComposeAccountEmail } from "@/lib/reply-identity";
import { useProMultiAccountIdentities } from "@/hooks/use-pro-multi-account-identities";
import { Filter, ChevronDown, X, Paperclip, Star, Mail, MailOpen, RotateCcw, PenSquare, PenLine, CheckSquare, Square, AlertTriangle, ArrowLeft } from "@/components/icons";
import { ResizeHandle } from "@/components/layout/resize-handle";
@@ -159,7 +158,8 @@ import { consumePendingDeepLink, subscribePendingDeepLink } from "@/lib/deep-lin
import { useProInterfaceActive } from "@/components/pro/pro-interface-redirect";
import type { QuoteHeader } from "@/lib/plugin-types";
import { initialLinkSearch, useLiteLinkSegments } from "@/hooks/use-lite-link-segments";
-import { registerComposeFlush, waitForComposeFlush } from "@/lib/compose-flush-registry";
+import { useMinimisedComposerFlush } from "@/hooks/use-minimised-composer-flush";
+import { quickReplyEnvelope, saveQuickReplyDraft } from "@/lib/outgoing/quick-reply-envelope";
export interface MailAppProps {
/**
@@ -199,9 +199,6 @@ function freshMailboxes(previous: Mailbox[], timeoutMs = 10_000): Promise = {};
@@ -2272,16 +2269,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
// A minimised composer's edits live only in pendingDraft. Before an
// automatic sign-out it is opened again and saves them itself.
- const minimisedUnsaved = !showComposer && pendingDraft?.unsaved === true;
- useEffect(() => {
- if (!minimisedUnsaved) return;
- return registerComposeFlush(async () => {
- const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS);
- setShowComposer(true);
- const flush = await reopened;
- await flush?.();
- });
- }, [minimisedUnsaved]);
+ useMinimisedComposerFlush(!showComposer && pendingDraft?.unsaved === true, () => setShowComposer(true));
const handleReply = async (draftText?: string) => {
if (selectedEmail) {
@@ -3864,49 +3852,21 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
};
}, [previewAttachment]);
+ // Before an automatic sign-out: the typed quick reply is kept as a draft.
+ const handleSaveQuickReplyDraft = async (body: string) => {
+ if (!client || !selectedEmail) return;
+ await saveQuickReplyDraft({ client, email: selectedEmail, identities, body, replyPrefix: t('email_composer.prefix.reply') });
+ };
+
const handleQuickReply = async (body: string) => {
if (!client || !selectedEmail) return;
- // Quick reply follows the same addressing rules as the composer: Reply-To
- // over From, and for our own messages in a thread the original recipients
- // instead of ourselves (#703).
- const ownIdentityEmails = identities.map(i => i.email).filter(Boolean);
- const replySource = {
- from: selectedEmail.from,
- replyToAddresses: selectedEmail.replyTo,
- to: selectedEmail.to,
- cc: selectedEmail.cc,
- };
- const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
- .map(r => r.email)
- .filter((email): email is string => Boolean(email));
+ // Quick reply follows the composer's addressing rules and sends from the
+ // address the message was delivered to (lib/outgoing/quick-reply-envelope.ts).
+ const { recipients, sendingIdentity, primaryIdentity } = quickReplyEnvelope(selectedEmail, identities);
if (recipients.length === 0) {
throw new Error("No sender email found");
}
-
- const primaryIdentity = identities[0];
-
- // Send from the address the message was delivered to, so a reply out of a
- // shared or aliased mailbox does not go out as the account owner. Our own
- // message keeps the identity it was sent from - the recipients are the
- // other party, so resolving from them would send as their address.
- //
- // Quick reply resolves the user's OWN identities only. It deliberately does
- // NOT take the domain catch-all `From:` rewrite that the full composer
- // offers: this surface is a bare text box with no From row, so a rewritten
- // From would be applied with nothing on screen to show it - or to correct
- // it. A catch-all delivery quick-replies as the matching own identity, and
- // the user can open the full composer when they need the rewrite.
- const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
- ? findDraftIdentityId(identities, selectedEmail.from?.[0])
- : null;
- const sendingIdentityId = selfSentIdentityId ?? findReplyIdentityId(identities, {
- to: selectedEmail.to,
- cc: selectedEmail.cc,
- bcc: selectedEmail.bcc,
- });
- const sendingIdentity =
- (sendingIdentityId ? identities.find((i) => i.id === sendingIdentityId) : undefined) || primaryIdentity;
const headerFromEmail = sendingIdentity?.email;
const headerFromName = sendingIdentity?.name || undefined;
@@ -5465,6 +5425,7 @@ export function MailApp({ linkSegments: routeSegments }: MailAppProps = {}) {
}}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
+ onSaveQuickReplyDraft={handleSaveQuickReplyDraft}
onBack={handleMobileBack}
onNavigateNext={handleNavigateNext}
onNavigatePrev={handleNavigatePrev}
diff --git a/components/pro/pro-email-tab-body.tsx b/components/pro/pro-email-tab-body.tsx
index 255f9fa39..8c8288b87 100644
--- a/components/pro/pro-email-tab-body.tsx
+++ b/components/pro/pro-email-tab-body.tsx
@@ -24,6 +24,7 @@ import { cn } from "@/lib/utils";
import { getDraftTextBody } from '@/lib/email-body-selection';
import { messagePriorityFromHeaders } from '@/lib/message-priority';
import { useArchiveActionEnabled } from '@/lib/archive-gate';
+import { saveQuickReplyDraft } from '@/lib/outgoing/quick-reply-envelope';
interface ProEmailTabBodyProps {
tabId: string;
@@ -399,6 +400,12 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
handleReply(body);
}, [handleReply]);
+ // Before an automatic sign-out: the typed quick reply is kept as a draft.
+ const handleSaveQuickReplyDraft = useCallback(async (body: string) => {
+ if (!client || !email) return;
+ await saveQuickReplyDraft({ client, email, identities, body, replyPrefix: t('email_composer.prefix.reply') });
+ }, [client, email, identities, t]);
+
const handleEditDraft = useCallback(() => {
if (!email) return;
@@ -473,6 +480,7 @@ export function ProEmailView({ emailId, client: clientOverride, clientAccountId,
onSetTag={handleSetTag}
onDownloadAttachment={handleDownloadAttachment}
onQuickReply={handleQuickReply}
+ onSaveQuickReplyDraft={handleSaveQuickReplyDraft}
onEditDraft={handleEditDraft}
onMoveToMailbox={handleMoveToMailbox}
currentUserEmail={client?.getUsername()}
diff --git a/hooks/__tests__/use-minimised-composer-flush.test.tsx b/hooks/__tests__/use-minimised-composer-flush.test.tsx
new file mode 100644
index 000000000..61031d1ae
--- /dev/null
+++ b/hooks/__tests__/use-minimised-composer-flush.test.tsx
@@ -0,0 +1,53 @@
+import { renderHook } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { MINIMISED_COMPOSER_REOPEN_MS, useMinimisedComposerFlush } from '../use-minimised-composer-flush';
+import { flushComposers, registerComposeFlush, resetComposeFlushRegistry } from '@/lib/compose-flush-registry';
+
+/**
+ * mail-app keeps a minimised composer's edits in memory only. Before an
+ * automatic sign-out the hook opens the composer again and lets it save.
+ */
+describe('minimised composer flush', () => {
+ beforeEach(() => {
+ resetComposeFlushRegistry();
+ vi.useFakeTimers();
+ });
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('opens the minimised composer again and runs its own save', async () => {
+ const save = vi.fn(async () => {});
+ // Opening the composer mounts it, and it registers its flush.
+ const reopen = vi.fn(() => { registerComposeFlush(save); });
+ renderHook(() => useMinimisedComposerFlush(true, reopen));
+
+ const result = await flushComposers(8000);
+
+ expect(reopen).toHaveBeenCalledTimes(1);
+ expect(save).toHaveBeenCalledTimes(1);
+ expect(result).toEqual({ total: 1, settled: 1, failed: 0, timedOut: false });
+ });
+
+ it('registers nothing without unsaved minimised edits', async () => {
+ const reopen = vi.fn();
+ renderHook(() => useMinimisedComposerFlush(false, reopen));
+
+ await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 });
+ expect(reopen).not.toHaveBeenCalled();
+ });
+
+ it('counts a composer that does not come back as a draft not saved', async () => {
+ renderHook(() => useMinimisedComposerFlush(true, () => {}));
+
+ const done = flushComposers(8000);
+ await vi.advanceTimersByTimeAsync(MINIMISED_COMPOSER_REOPEN_MS);
+ await expect(done).resolves.toEqual({ total: 1, settled: 1, failed: 1, timedOut: false });
+ });
+
+ it('stops registering once the composer is open again', async () => {
+ const { rerender } = renderHook(({ minimised }) => useMinimisedComposerFlush(minimised, () => {}), { initialProps: { minimised: true } });
+ rerender({ minimised: false });
+ await expect(flushComposers(8000)).resolves.toMatchObject({ total: 0 });
+ });
+});
diff --git a/hooks/use-minimised-composer-flush.ts b/hooks/use-minimised-composer-flush.ts
new file mode 100644
index 000000000..28b1ccccd
--- /dev/null
+++ b/hooks/use-minimised-composer-flush.ts
@@ -0,0 +1,26 @@
+import { useEffect, useRef } from 'react';
+import { registerComposeFlush, waitForComposeFlush } from '@/lib/compose-flush-registry';
+
+/** How long an automatic sign-out waits for a minimised composer to open again and register. */
+export const MINIMISED_COMPOSER_REOPEN_MS = 3000;
+
+/**
+ * A minimised composer's edits live only in its host's memory. While there
+ * are unsaved ones, an automatic sign-out opens the composer again (`reopen`)
+ * and lets it save itself (lib/compose-flush-registry.ts). A composer that
+ * doesn't come back in time counts as a draft that wasn't saved.
+ */
+export function useMinimisedComposerFlush(minimisedUnsaved: boolean, reopen: () => void): void {
+ const reopenRef = useRef(reopen);
+ reopenRef.current = reopen;
+ useEffect(() => {
+ if (!minimisedUnsaved) return;
+ return registerComposeFlush(async () => {
+ const reopened = waitForComposeFlush(MINIMISED_COMPOSER_REOPEN_MS);
+ reopenRef.current();
+ const flush = await reopened;
+ if (!flush) throw new Error('The minimised message did not open again');
+ await flush();
+ });
+ }, [minimisedUnsaved]);
+}
diff --git a/lib/outgoing/__tests__/quick-reply-envelope.test.ts b/lib/outgoing/__tests__/quick-reply-envelope.test.ts
new file mode 100644
index 000000000..cf6d32a44
--- /dev/null
+++ b/lib/outgoing/__tests__/quick-reply-envelope.test.ts
@@ -0,0 +1,46 @@
+import { describe, expect, it, vi } from 'vitest';
+import { quickReplyEnvelope, saveQuickReplyDraft } from '../quick-reply-envelope';
+import type { Email, Identity } from '@/lib/jmap/types';
+
+const me: Identity = { id: 'id-me', name: 'Org Alice', email: 'org-alice@example.org', mayDelete: false };
+const team: Identity = { id: 'id-team', name: 'Org Team', email: 'org-team@example.org', mayDelete: false };
+
+const message = {
+ id: 'm1',
+ subject: 'Quarterly numbers',
+ from: [{ name: 'Bob', email: 'org-bob@example.org' }],
+ replyTo: [{ name: 'Bob (desk)', email: 'desk@example.org' }],
+ to: [{ name: 'Team', email: 'org-team@example.org' }],
+ cc: [],
+ messageId: ['abc@example.org'],
+ references: ['root@example.org'],
+} as unknown as Email;
+
+describe('quick reply addressing', () => {
+ it('answers Reply-To from the identity the message was delivered to', () => {
+ const envelope = quickReplyEnvelope(message, [me, team]);
+ expect(envelope.recipients).toEqual(['desk@example.org']);
+ expect(envelope.sendingIdentity?.id).toBe('id-team');
+ expect(envelope.primaryIdentity?.id).toBe('id-me');
+ });
+});
+
+describe('saving a quick reply as a draft before an automatic sign-out', () => {
+ it('saves the typed text as a threaded reply draft', async () => {
+ const createDraft = vi.fn().mockResolvedValue('d1');
+ await saveQuickReplyDraft({ client: { createDraft } as never, email: message, identities: [me, team], body: 'On it.', replyPrefix: 'Re:' });
+
+ expect(createDraft).toHaveBeenCalledTimes(1);
+ const [to, subject, body, , , identityId, fromEmail, draftId, , fromName, htmlBody, inReplyTo, references] = createDraft.mock.calls[0];
+ expect(to).toEqual(['desk@example.org']);
+ expect(subject).toBe('Re: Quarterly numbers');
+ expect(body).toBe('On it.');
+ expect(identityId).toBe('id-team');
+ expect(fromEmail).toBe('org-team@example.org');
+ expect(fromName).toBe('Org Team');
+ expect(draftId).toBeUndefined();
+ expect(htmlBody).toBeUndefined();
+ expect(inReplyTo).toEqual(['abc@example.org']);
+ expect(references).toEqual(['root@example.org', 'abc@example.org']);
+ });
+});
diff --git a/lib/outgoing/quick-reply-envelope.ts b/lib/outgoing/quick-reply-envelope.ts
new file mode 100644
index 000000000..9b863865c
--- /dev/null
+++ b/lib/outgoing/quick-reply-envelope.ts
@@ -0,0 +1,74 @@
+import type { IJMAPClient } from '@/lib/jmap/client-interface';
+import type { Email, Identity } from '@/lib/jmap/types';
+import { computeReplyThreadingHeaders } from '@/lib/email-threading';
+import { findDraftIdentityId, findReplyIdentityId } from '@/lib/reply-identity';
+import { buildReplyRecipients, isSelfSent } from '@/lib/reply-recipients';
+import { buildReplySubject } from '@/lib/subject-prefix';
+
+type ReplySource = Pick;
+
+export interface QuickReplyEnvelope {
+ recipients: string[];
+ /** The identity the reply goes out as, the primary one when none matches. */
+ sendingIdentity: Identity | undefined;
+ primaryIdentity: Identity | undefined;
+}
+
+/**
+ * Who a quick reply goes to and from whom: the composer's addressing rules
+ * (Reply-To over From; for our own messages in a thread, the original
+ * recipients instead of ourselves, #703), from the address the message was
+ * delivered to, or for our own message the identity it was sent from.
+ *
+ * Quick reply resolves the user's OWN identities only. It deliberately does
+ * NOT take the domain catch-all `From:` rewrite that the full composer
+ * offers: this surface is a bare text box with no From row, so a rewritten
+ * From would be applied with nothing on screen to show it - or to correct
+ * it.
+ */
+export function quickReplyEnvelope(email: ReplySource, identities: Identity[]): QuickReplyEnvelope {
+ const ownIdentityEmails = identities.map((i) => i.email).filter(Boolean);
+ const replySource = { from: email.from, replyToAddresses: email.replyTo, to: email.to, cc: email.cc };
+ const recipients = buildReplyRecipients(replySource, 'reply', ownIdentityEmails).to
+ .map((r) => r.email)
+ .filter((address): address is string => Boolean(address));
+ const primaryIdentity = identities[0];
+ const selfSentIdentityId = isSelfSent(replySource, ownIdentityEmails)
+ ? findDraftIdentityId(identities, email.from?.[0])
+ : null;
+ const sendingIdentityId = selfSentIdentityId ?? findReplyIdentityId(identities, { to: email.to, cc: email.cc, bcc: email.bcc });
+ const sendingIdentity = (sendingIdentityId ? identities.find((i) => i.id === sendingIdentityId) : undefined) || primaryIdentity;
+ return { recipients, sendingIdentity, primaryIdentity };
+}
+
+/**
+ * Saves the text typed into a quick reply as a draft reply, before an
+ * automatic sign-out (lib/compose-flush-registry.ts). Plain text, threaded
+ * like the reply would be; the signature is added when it is sent.
+ */
+export async function saveQuickReplyDraft(input: {
+ client: IJMAPClient;
+ email: Email;
+ identities: Identity[];
+ body: string;
+ replyPrefix: string;
+}): Promise {
+ const { client, email, identities, body, replyPrefix } = input;
+ const { recipients, sendingIdentity } = quickReplyEnvelope(email, identities);
+ const threading = computeReplyThreadingHeaders({ messageId: email.messageId, references: email.references });
+ await client.createDraft(
+ recipients,
+ buildReplySubject(email.subject || '', replyPrefix),
+ body,
+ undefined,
+ undefined,
+ sendingIdentity?.id,
+ sendingIdentity?.email,
+ undefined,
+ undefined,
+ sendingIdentity?.name || undefined,
+ undefined,
+ threading?.inReplyTo,
+ threading?.references,
+ );
+}
From 629e96b6bdcae032c9710bf6864d81819cc7dc31 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 04:18:24 +0100
Subject: [PATCH 9/9] fix: stop counting focus in the office editor or a plugin
frame as activity
---
.../__tests__/session-timeout-guard.test.tsx | 34 ++++++++++---------
components/session/session-timeout-guard.tsx | 27 +++------------
2 files changed, 22 insertions(+), 39 deletions(-)
diff --git a/components/session/__tests__/session-timeout-guard.test.tsx b/components/session/__tests__/session-timeout-guard.test.tsx
index 6016df443..90b040cd3 100644
--- a/components/session/__tests__/session-timeout-guard.test.tsx
+++ b/components/session/__tests__/session-timeout-guard.test.tsx
@@ -1,6 +1,6 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
-import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS, FRAME_FOCUS_CHECK_MS } from '../session-timeout-guard';
+import { SessionTimeoutGuard, DRAFT_FLUSH_TIMEOUT_MS } from '../session-timeout-guard';
import { useAuthStore } from '@/stores/auth-store';
import { useAccountStore, type AccountEntry } from '@/stores/account-store';
import { usePolicyStore } from '@/stores/policy-store';
@@ -271,28 +271,30 @@ describe('session timeout guard', () => {
expect(screen.getByText('idle_title')).toBeTruthy();
});
- it('counts the focus on a cross-origin frame as activity, but not on a same-origin one', async () => {
+ it('does not count a focused cross-origin frame as activity, and warns above its overlay', async () => {
const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(true);
+ // The office editor: a cross-origin frame in a z-[100000] overlay.
+ const overlay = document.createElement('div');
+ overlay.className = 'fixed inset-0 z-[100000]';
+ const editor = document.createElement('iframe');
+ Object.defineProperty(editor, 'contentDocument', { get: () => null });
+ overlay.appendChild(editor);
+ document.body.appendChild(overlay);
try {
await signIn(policyWith({ idleMinutes: 1, maxHours: 0, warnSeconds: 30 }));
- const editor = document.createElement('iframe');
- Object.defineProperty(editor, 'contentDocument', { get: () => null });
- document.body.appendChild(editor);
editor.focus();
expect(document.activeElement).toBe(editor);
- await advance(45 * SECOND);
- expect(screen.queryByText('idle_title')).toBeNull();
-
- // A message body (same origin) clicked into and left doesn't keep the session.
- const body = document.createElement('iframe');
- document.body.appendChild(body);
- body.focus();
- await advance(30 * SECOND + FRAME_FOCUS_CHECK_MS);
- expect(screen.getByText('idle_title')).toBeTruthy();
- editor.remove();
- body.remove();
+ // Left focused, the editor keeps nothing alive: warned at 30 s, signed out at 60 s.
+ await advance(30 * SECOND);
+ const warning = screen.getByTestId('session-timeout-dialog');
+ expect(Number(/z-\[(\d+)\]/.exec(warning.className)?.[1])).toBeGreaterThan(100000);
+ // Later in the body than the overlay, so it paints on top at a higher z-index too.
+ expect(overlay.compareDocumentPosition(warning) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
+ await advance(30 * SECOND);
+ expect(logoutAll).toHaveBeenCalledWith({ endProviderSession: false });
} finally {
+ overlay.remove();
hasFocus.mockRestore();
}
});
diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx
index 2a805f02e..f8a5e6006 100644
--- a/components/session/session-timeout-guard.tsx
+++ b/components/session/session-timeout-guard.tsx
@@ -28,27 +28,12 @@ const ACTIVITY_THROTTLE_MS = 1000;
/**
* Bubbling input events, caught on the way down. Scrolling and focus moves
* inside the page don't count: the page scrolls and refocuses on its own.
+ * Input inside a frame (the office editor, plugin panels) never reaches this
+ * window and doesn't count either: a focused frame left alone would keep the
+ * session open forever. Users working there see the warning, which sits
+ * above every overlay, and choose to stay.
*/
const ACTIVITY_EVENTS = ["pointerdown", "pointermove", "keydown", "touchstart", "wheel"] as const;
-/**
- * Input inside a cross-origin frame (the office editor, plugin panels)
- * never reaches this window. While such a frame has the focus, that counts
- * as activity, checked this often.
- */
-export const FRAME_FOCUS_CHECK_MS = 5000;
-
-function crossOriginFrameHasFocus(): boolean {
- if (typeof document === "undefined" || !document.hasFocus()) return false;
- const element = document.activeElement;
- if (!(element instanceof HTMLIFrameElement)) return false;
- try {
- // Same-origin frames (the message body) are readable and don't count:
- // a message clicked into and left would keep the session alive.
- return element.contentDocument === null;
- } catch {
- return true;
- }
-}
/** Policy subjects per client and connection round; a failed or empty answer is asked again. */
const subjects = new WeakMap }>();
@@ -228,9 +213,6 @@ export function SessionTimeoutGuard() {
const onVisibility = () => {
if (document.visibilityState === "visible") timerRef.current?.check();
};
- const frameCheck = setInterval(() => {
- if (crossOriginFrameHasFocus()) onActivity();
- }, FRAME_FOCUS_CHECK_MS);
for (const event of ACTIVITY_EVENTS) window.addEventListener(event, onActivity, { capture: true, passive: true });
// The window coming back into focus, not focus moving between elements.
window.addEventListener("focus", onActivity);
@@ -239,7 +221,6 @@ export function SessionTimeoutGuard() {
for (const event of ACTIVITY_EVENTS) window.removeEventListener(event, onActivity, { capture: true });
window.removeEventListener("focus", onActivity);
document.removeEventListener("visibilitychange", onVisibility);
- clearInterval(frameCheck);
channel.close();
channelRef.current = null;
};