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..7da1f499b 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,15 @@ 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")} + {sessionEnd?.draftsUnsaved && ( + {tSessionLimits("drafts_not_saved")} + )}

+ : } + +
+ )} + + , + document.body, + ); +} diff --git a/components/session/session-timeout-guard.tsx b/components/session/session-timeout-guard.tsx new file mode 100644 index 000000000..f8a5e6006 --- /dev/null +++ b/components/session/session-timeout-guard.tsx @@ -0,0 +1,257 @@ +"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, 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 (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; +/** + * 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; + +/** 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(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, revision); + 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; +} + +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 }; + +/** + * 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(",")); + + // 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(revision).then((next) => { + if (cancelled) return; + setLimits((prev) => (sameLimits(prev, next) ? prev : next)); + setLimitsReady(true); + }); + 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; + // 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 && limitsReady && (!!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; + } + // 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)); + timersStartedRef.current += 1; + timerRef.current = createSessionTimer({ + limits, + lastActivity: Math.max(stored, newestSignIn), + onChange: setState, + onExpire: (kind) => { void endSession(kind); }, + }); + setState(timerRef.current.state()); + }, [hasLimits, limits, endSession, mount]); + + 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 = () => { + // 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; + 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 }); + // 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); + 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/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/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(); +}); diff --git a/lib/__tests__/auth-crypto.test.ts b/lib/__tests__/auth-crypto.test.ts index 2fa3c854d..5d296c0e1 100644 --- a/lib/__tests__/auth-crypto.test.ts +++ b/lib/__tests__/auth-crypto.test.ts @@ -31,12 +31,13 @@ describe('encryptSession / decryptSession', () => { serverUrl: 'https://mail.example.com', username: 'alice', password: 's3cret', + signedInAt: expect.any(Number), }); }); 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__/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__/compose-flush-registry.test.ts b/lib/__tests__/compose-flush-registry.test.ts new file mode 100644 index 000000000..c87cbad79 --- /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, failed: 0, 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, 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, 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, failed: 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/__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 new file mode 100644 index 000000000..8048e2a28 --- /dev/null +++ b/lib/__tests__/session-max-age-routes.test.ts @@ -0,0 +1,297 @@ +// @vitest-environment node +import { createCipheriv, createHash, randomBytes } from 'node:crypto'; +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. 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); +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 }), +})); + +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 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'); + +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 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&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 }), +})); + +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) => { + // 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); +}); + +afterEach(() => { + vi.useRealTimers(); + 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, 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); + }); + + 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', signedInAt: 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 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); + 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 with the refresh token when a provider sign-in stores it', async () => { + const res = await signInWithProvider(); + expect(res.status).toBe(200); + 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'); + 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 }); + 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 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()).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 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); + }); + + 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)', () => { + 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/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..ba7f97e8a --- /dev/null +++ b/lib/auth/session-age.ts @@ -0,0 +1,207 @@ +/** + * 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) 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; + +const positiveHours = (value: unknown): number => (typeof value === 'number' && value > 0 ? value : 0); + +/** + * 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() ?? ''; + 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; + } +} + +/** Whether a session signed in at `signedInAt` has outlived `maxHours`. */ +export function isPastMaxAge(signedInAt: number, maxHours: number, now = Date.now()): boolean { + if (!maxHours) return false; + return now - signedInAt >= maxHours * HOUR_MS; +} + +// ── 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; + } +} + +/** + * 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}`; +} + +/** 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 = 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; + } +} + +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, tokenHash: refreshTokenHash(stamp.refreshToken) }; + if (stamp.account) payload.account = stamp.account; + 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' }); + } +} + +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/compose-flush-registry.ts b/lib/compose-flush-registry.ts new file mode 100644 index 000000000..d9ea445f8 --- /dev/null +++ b/lib/compose-flush-registry.ts @@ -0,0 +1,86 @@ +/** + * 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; + /** 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, failed: 0, timedOut: false }; + let settled = 0; + let failed = 0; + const all = Promise.all(pending.map(async (flush) => { + try { + await flush(); + } catch { + // The sign-out goes on; the login page says a draft wasn't saved. + failed += 1; + } + 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, failed, timedOut }; +} + +/** Test helper: forget every registration. */ +export function resetComposeFlushRegistry(): void { + flushes.clear(); + waiters.clear(); +} 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, + ); +} 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..341d3d31c --- /dev/null +++ b/lib/session-policy/__tests__/limits.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_SKEW_MARGIN_MS, 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, 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 - 60 * 1000, hours: 12, warnSeconds: 60 }); + expect(MAX_SKEW_MARGIN_MS).toBe(60 * 1000); + }); + + 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/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/session-policy/limits.ts b/lib/session-policy/limits.ts new file mode 100644 index 000000000..d748d6088 --- /dev/null +++ b/lib/session-policy/limits.ts @@ -0,0 +1,67 @@ +/** + * 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, a minute early (MAX_SKEW_MARGIN_MS). */ + max: { at: number; hours: number; warnSeconds: number } | null; +} + +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; + 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 - MAX_SKEW_MARGIN_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..370b3ebb1 --- /dev/null +++ b/lib/session-policy/sign-out-reason.ts @@ -0,0 +1,43 @@ +/** + * 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; draftsUnsaved?: boolean } + | { kind: 'max'; hours: number; draftsUnsaved?: boolean }; + +const KEY = 'session_end_reason'; + +export function rememberSessionEnd(reason: SessionEndReason): void { + try { + window.sessionStorage.setItem(KEY, JSON.stringify(reason)); + } catch { + /* storage unavailable */ + } +} + +/** 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 { + const raw = window.sessionStorage.getItem(KEY); + window.sessionStorage.removeItem(KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Record; + 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 ffdd16235..6d6b42629 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -76,6 +76,20 @@ "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.", + "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", "compose": "Verfassen", diff --git a/locales/en/common.json b/locales/en/common.json index 95ec87fa3..81fc096fe 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -76,6 +76,20 @@ "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}}.", + "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", "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/__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..b2ff21a38 --- /dev/null +++ b/stores/__tests__/auth-store-restore-session-too-old.test.ts @@ -0,0 +1,95 @@ +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() {} + 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 {} + 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 }); +}); + +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/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..03e20a93c 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -24,6 +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 { 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'; @@ -108,9 +110,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; @@ -418,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 }); @@ -458,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 @@ -528,19 +531,67 @@ 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); } } +/** + * 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). + */ +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; 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 */ + } + 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((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). @@ -562,7 +613,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((res) => noteSessionAnswer(res, slot ?? 0)); } const stored = readLiteBasicSession(slot ?? 0); return stored ? jsonResponse(stored) : jsonResponse({ error: 'no_session' }, 401); @@ -1267,6 +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, 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 ?? '' }); } @@ -1915,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(), ]); @@ -2315,12 +2371,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 +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(), + rememberMe, lastLoginAt: Date.now(), signedInAt: signedInAtForSlot(cookieSlot), }); void syncAccountDisplayName(accountId, client); set((state) => ({ connectedAccountsRevision: state.connectedAccountsRevision + 1 }));