From c907b0ba99803100ed2f624b93c94acd4d791db7 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 24 Oct 2026 23:52:16 -0400 Subject: [PATCH] feat(security): nudge to sign out sessions unused for 3 months (#113) Sessions of this account not seen for 90+ days (never the current one; a session with no last-seen time is left alone) get a nudge in the existing security strip, after the device-security ones and with the same rules ("Not now" snoozes 7 days, gone after 3 dismissals; a snoozed security nudge no longer hides it): "You have 3 sessions you haven't used in 3 months. Signing them out keeps your account safe if an old device is lost." [Review] Review opens Settings -> Devices with those sessions ticked for sign-out (one confirm, the usual re-auth incl. SSO); they are listed first and marked "Inactive for 3+ months". OIDC accounts sign out on the provider's dashboard, so nothing is ticked there. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- e2e/stale-sessions.spec.ts | 98 +++++++++++++++++++ .../security-nudge/SecurityBanner.tsx | 58 +++++++++-- .../settings/devices/OtherDevices.tsx | 41 +++++++- src/app/state/staleSessions.ts | 7 ++ src/app/utils/securityNudge.test.ts | 45 +++++++++ src/app/utils/securityNudge.ts | 55 ++++++++++- 6 files changed, 294 insertions(+), 10 deletions(-) create mode 100644 e2e/stale-sessions.spec.ts create mode 100644 src/app/state/staleSessions.ts diff --git a/e2e/stale-sessions.spec.ts b/e2e/stale-sessions.spec.ts new file mode 100644 index 000000000..ddc3d5a48 --- /dev/null +++ b/e2e/stale-sessions.spec.ts @@ -0,0 +1,98 @@ +import { test, expect, Page } from '@playwright/test'; +import { HS, PASSWORD, api, ensureUser, hsReachable, loginUI, uniq } from './localHs'; + +// [Gitea #113] Sessions unused for 90+ days: a nudge (after the device-security +// one), and Review opens Settings → Devices with them ticked for sign-out. +// Synapse won't backdate last-seen, so the session list is served with one +// real extra session made 120 days old. +const DAY = 86_400_000; + +const strip = (page: Page, text: RegExp) => page.getByRole('status').filter({ hasText: text }); + +async function pastGrace(page: Page) { + await page.evaluate(() => { + const { deviceId } = JSON.parse(localStorage.getItem('cinny_session_v1') ?? '{}'); + localStorage.setItem( + `lotus-security-nudge-${deviceId}`, + JSON.stringify({ firstSeen: Date.now() - 2 * 86_400_000, dismissals: {} }), + ); + }); + await page.reload(); +} + +async function settled(page: Page) { + await page + .getByText('Connecting...') + .first() + .waitFor({ timeout: 30_000 }) + .catch(() => undefined); + await page.getByText('Connecting...').first().waitFor({ state: 'detached', timeout: 90_000 }); +} + +test.describe('stale sessions (#113)', () => { + test.beforeAll(async () => { + test.skip(!(await hsReachable()), 'local homeserver not running'); + }); + + test('nudge after the security one, Review ticks the old session, sign-out works', async ({ + page, + }) => { + test.setTimeout(180_000); + const user = await ensureUser(uniq('ss')); + // Two extra sessions: one we'll make look 4 months old, one recent. + const login = (device: string) => + api<{ device_id: string }>('POST', '/_matrix/client/v3/login', undefined, { + type: 'm.login.password', + identifier: { type: 'm.id.user', user: user.localpart }, + password: PASSWORD, + device_id: device, + initial_device_display_name: device, + }); + await login('OLDLAPTOP'); + await login('RECENTPHONE'); + await page.route(/\/_matrix\/client\/v3\/devices(\?.*)?$/, async (route) => { + const res = await route.fetch(); + const body = await res.json(); + for (const d of body.devices ?? []) { + if (d.device_id === 'OLDLAPTOP') d.last_seen_ts = Date.now() - 120 * DAY; + else d.last_seen_ts = Date.now() - DAY; + } + await route.fulfill({ response: res, json: body }); + }); + + await loginUI(page, user); + await pastGrace(page); + await settled(page); + + // A fresh account has no key backup: that nudge comes first. + const security = strip(page, /encryption keys/); + await expect(security).toBeVisible({ timeout: 30_000 }); + await security.getByRole('button', { name: 'Not now' }).click(); + + const stale = strip(page, /a session you haven't used in 3 months/); + await expect(stale).toBeVisible({ timeout: 30_000 }); + await stale.getByRole('button', { name: 'Review' }).click(); + + const dialog = page.getByRole('dialog').first(); + await expect(dialog.getByText('Inactive for 3+ months')).toHaveCount(1); + await expect(dialog.getByText(/Logout from selected devices\. \(1 selected\)/)).toBeVisible(); + + // The sticky "Logout from selected devices" bar comes last. + await dialog.getByRole('button', { name: 'Logout', exact: true }).last().click(); + await page.locator('input[name="passwordInput"]').fill(PASSWORD); + await page.getByRole('button', { name: 'Continue' }).click(); + await expect(dialog.getByText(/Logout from selected devices/)).toHaveCount(0, { + timeout: 30_000, + }); + + const { devices } = await api<{ devices: { device_id: string }[] }>( + 'GET', + '/_matrix/client/v3/devices', + user.token, + ); + const ids = devices.map((d) => d.device_id); + expect(ids).not.toContain('OLDLAPTOP'); + expect(ids).toContain('RECENTPHONE'); + expect(HS).toBeTruthy(); + }); +}); diff --git a/src/app/features/security-nudge/SecurityBanner.tsx b/src/app/features/security-nudge/SecurityBanner.tsx index 8ad82bb82..171a4e65d 100644 --- a/src/app/features/security-nudge/SecurityBanner.tsx +++ b/src/app/features/security-nudge/SecurityBanner.tsx @@ -23,8 +23,11 @@ import { nudgeFor, NudgeRecord, parseNudgeRecord, - shouldShowNudge, + pickNudge, + staleSessions, + staleSessionsText, } from '../../utils/securityNudge'; +import { preselectSignOutAtom } from '../../state/staleSessions'; import { StatusStrip } from '../server-status/ServerStatusBanner'; /** This device's backup connection: undefined until known, then true/false. */ @@ -40,6 +43,35 @@ const useBackupActive = (crypto: CryptoApi): boolean | undefined => { return active; }; +/** + * [Gitea #113] Ids of this account's sessions unused for 90+ days. Fetched + * once sync has settled, then hourly; [] until known or on error. + */ +const useStaleSessionIds = (enabled: boolean): string[] => { + const mx = useMatrixClient(); + const alive = useAlive(); + const [ids, setIds] = useState([]); + useEffect(() => { + if (!enabled) return undefined; + const load = () => + mx + .getDevices() + .then(({ devices }) => { + if (alive()) + setIds( + staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map( + (d) => d.device_id, + ), + ); + }) + .catch(() => undefined); + load(); + const t = window.setInterval(load, 60 * 60 * 1000); + return () => window.clearInterval(t); + }, [mx, enabled, alive]); + return ids; +}; + const readRecord = (key: string): NudgeRecord => { let raw: string | null = null; try { @@ -84,12 +116,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) { let deviceVerified: boolean | undefined; if (status === VerificationStatus.Verified) deviceVerified = true; else if (status === VerificationStatus.Unverified) deviceVerified = false; - const kind = nudgeFor({ + const securityKind = nudgeFor({ crossSigning, deviceVerified, backupOnServer: backupInfo === undefined ? undefined : backupInfo !== null, backupActive, }); + const staleIds = useStaleSessionIds(settled); + const setPreselect = useSetAtom(preselectSignOutAtom); + const kind = pickNudge(securityKind, staleIds.length, record, Date.now()); // One strip at a time: outages, maintenance and a lost connection win. // [Gitea #307] Pass what the user dismissed: a dismissed announcement isn't @@ -97,10 +132,12 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) { const otherStrip = syncLost || !!pickBanner(serverStatus, { syncLost, where: 'client', dismissed: new Set(statusDismissed) }); - if (!settled || !deviceId || !kind || otherStrip || !shouldShowNudge(kind, record, Date.now())) - return null; + if (!settled || !deviceId || !kind || otherStrip) return null; - const copy = NUDGE_COPY[kind]; + const copy = + kind === 'stale-sessions' + ? { ...NUDGE_COPY[kind], text: staleSessionsText(staleIds.length) } + : NUDGE_COPY[kind]; const dismiss = () => { const next = dismissNudge(kind, record, Date.now()); try { @@ -113,8 +150,15 @@ function SecurityBannerFor({ crypto }: { crypto: CryptoApi }) { return ( requestSettings('devices') }} + action={{ + label: copy.action, + onClick: () => { + // Review: the Devices page opens with these ticked for sign-out. + if (kind === 'stale-sessions') setPreselect(staleIds); + requestSettings('devices'); + }, + }} + icon={kind === 'stale-sessions' ? Icons.Category : Icons.ShieldUser} dismissLabel="Not now" onDismiss={dismiss} /> diff --git a/src/app/features/settings/devices/OtherDevices.tsx b/src/app/features/settings/devices/OtherDevices.tsx index 896aea07c..be468dc7d 100644 --- a/src/app/features/settings/devices/OtherDevices.tsx +++ b/src/app/features/settings/devices/OtherDevices.tsx @@ -1,4 +1,5 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useAtom } from 'jotai'; import { Box, Button, config, Menu, Spinner, Text } from 'folds'; import { AuthDict, IMyDevice, MatrixError } from 'matrix-js-sdk'; import { SequenceCard } from '../../../components/sequence-card'; @@ -15,6 +16,8 @@ import { useAuthMetadata } from '../../../hooks/useAuthMetadata'; import { withSearchParam } from '../../../pages/pathUtils'; import { useAccountManagementActions } from '../../../hooks/useAccountManagement'; import { SettingTile } from '../../../components/setting-tile'; +import { preselectSignOutAtom } from '../../../state/staleSessions'; +import { staleSessions } from '../../../utils/securityNudge'; type OtherDevicesProps = { devices: IMyDevice[]; @@ -29,6 +32,23 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O const [deleted, setDeleted] = useState>(new Set()); + // [Gitea #113] The stale-sessions nudge's Review: tick those sessions for + // sign-out (one confirm below). OIDC accounts sign out on the provider's + // dashboard instead, so nothing is ticked there. + const [preselect, setPreselect] = useAtom(preselectSignOutAtom); + useEffect(() => { + if (preselect.length === 0) return; + if (!authMetadata) { + const present = new Set(devices.map((d) => d.device_id)); + setDeleted(new Set(preselect.filter((id) => present.has(id)))); + } + setPreselect([]); + }, [preselect, setPreselect, devices, authMetadata]); + + const staleIds = new Set( + staleSessions(devices, mx.getDeviceId() ?? undefined, Date.now()).map((d) => d.device_id), + ); + const handleDashboardOIDC = useCallback(() => { const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer; if (!authUrl) return; @@ -132,8 +152,20 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O /> )} - {devices + {staleIds.size > 0 && ( + + {staleIds.size === 1 + ? "1 session hasn't been used in 3 months or more (marked Inactive)." + : `${staleIds.size} sessions haven't been used in 3 months or more (marked Inactive).`}{' '} + Sign out the ones you no longer use. + + )} + {[...devices] + // [Gitea #113] Inactive (90+ days) sessions first, then most recent. .sort((d1, d2) => { + const s1 = staleIds.has(d1.device_id) ? 0 : 1; + const s2 = staleIds.has(d2.device_id) ? 0 : 1; + if (s1 !== s2) return s1 - s2; if (!d1.last_seen_ts || !d2.last_seen_ts) return 0; return d1.last_seen_ts < d2.last_seen_ts ? 1 : -1; }) @@ -145,6 +177,11 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O direction="Column" gap="400" > + {staleIds.has(device.device_id) && ( + + Inactive for 3+ months + + )} ([]); diff --git a/src/app/utils/securityNudge.test.ts b/src/app/utils/securityNudge.test.ts index 38be5a441..aa301a8fa 100644 --- a/src/app/utils/securityNudge.test.ts +++ b/src/app/utils/securityNudge.test.ts @@ -8,6 +8,9 @@ import { parseNudgeRecord, SecurityState, shouldShowNudge, + pickNudge, + staleSessions, + staleSessionsText, } from './securityNudge'; const healthy: SecurityState = { @@ -79,3 +82,45 @@ test('stored record: round trip, and garbage starts a fresh grace period', () => assert.deepEqual(parseNudgeRecord(bad, t0), { firstSeen: t0, dismissals: {} }, bad); } }); + +test('[#113] stale sessions: 90+ days unused, never the current one', () => { + const now = Date.UTC(2026, 9, 25); + const day = 86_400_000; + const devices = [ + { device_id: 'ME', last_seen_ts: now - 400 * day }, + { device_id: 'OLD', last_seen_ts: now - 91 * day }, + { device_id: 'EDGE', last_seen_ts: now - 90 * day }, + { device_id: 'RECENT', last_seen_ts: now - 89 * day }, + { device_id: 'UNKNOWN' }, + { device_id: 'NULL', last_seen_ts: null }, + ]; + assert.deepEqual( + staleSessions(devices, 'ME', now).map((d) => d.device_id), + ['OLD', 'EDGE'], + ); +}); + +test('[#113] wording follows the count', () => { + assert.match( + staleSessionsText(1), + /^You have a session you haven't used in 3 months\. Signing it out/, + ); + assert.match( + staleSessionsText(3), + /^You have 3 sessions you haven't used in 3 months\. Signing them out/, + ); +}); + +test('[#113] pickNudge: security first, stale sessions when that one is snoozed or absent', () => { + const now = 10 * 86_400_000; + const fresh = { firstSeen: 0, dismissals: {} }; + assert.equal(pickNudge('verify-device', 2, fresh, now), 'verify-device'); + assert.equal(pickNudge(undefined, 2, fresh, now), 'stale-sessions'); + assert.equal(pickNudge(undefined, 0, fresh, now), undefined); + const snoozed = dismissNudge('verify-device', fresh, now); + assert.equal(pickNudge('verify-device', 2, snoozed, now), 'stale-sessions'); + const bothSnoozed = dismissNudge('stale-sessions', snoozed, now); + assert.equal(pickNudge('verify-device', 2, bothSnoozed, now), undefined); + // inside the 24 h grace period nothing shows + assert.equal(pickNudge(undefined, 2, { firstSeen: now, dismissals: {} }, now), undefined); +}); diff --git a/src/app/utils/securityNudge.ts b/src/app/utils/securityNudge.ts index 6bfa89836..38b82182a 100644 --- a/src/app/utils/securityNudge.ts +++ b/src/app/utils/securityNudge.ts @@ -9,12 +9,15 @@ * - setup-backup: no key backup at all (includes accounts that never set up * cross-signing: the setup flow does both). * + * - stale-sessions (#113, lowest priority): sessions of this account not used + * for 90+ days; Review opens Settings → Devices with them ticked to sign out. + * * Anything not yet known (crypto still loading) → no nudge. Timing: not in * the first 24 h on a device (onboarding), "Not now" snoozes 7 days, three * dismissals of a nudge stop it for good; a healthy device never sees one. */ -export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup'; +export type NudgeKind = 'verify-device' | 'connect-backup' | 'setup-backup' | 'stale-sessions'; export type SecurityState = { /** m.cross_signing.master account data present. */ @@ -38,6 +41,51 @@ export const nudgeFor = (s: SecurityState): NudgeKind | undefined => { return s.backupActive ? undefined : 'connect-backup'; }; +/** [Gitea #113] A session counts as stale after this long unused. */ +export const STALE_SESSION_MS = 90 * 24 * 60 * 60 * 1000; + +type SessionLike = { device_id: string; last_seen_ts?: number | null }; + +/** + * Pure: other sessions of this account not seen for STALE_SESSION_MS. Never + * the current session; a session with no last-seen time is left alone (we + * can't tell it's unused). + */ +export const staleSessions = ( + devices: T[], + currentDeviceId: string | undefined, + now: number, +): T[] => + devices.filter( + (d) => + d.device_id !== currentDeviceId && + typeof d.last_seen_ts === 'number' && + now - d.last_seen_ts >= STALE_SESSION_MS, + ); + +/** "1 session" / "3 sessions you haven't used in 3 months". */ +export const staleSessionsText = (count: number): string => + `You have ${count === 1 ? 'a session' : `${count} sessions`} you haven't used in 3 months. Signing ${ + count === 1 ? 'it' : 'them' + } out keeps your account safe if an old device is lost.`; + +/** + * The nudge to show: the device-security one first, then stale sessions, each + * only if its own snooze allows (so a snoozed security nudge doesn't hide the + * stale-sessions one, and vice versa). + */ +export const pickNudge = ( + security: NudgeKind | undefined, + staleCount: number, + record: NudgeRecord, + now: number, +): NudgeKind | undefined => { + const candidates: NudgeKind[] = []; + if (security) candidates.push(security); + if (staleCount > 0) candidates.push('stale-sessions'); + return candidates.find((k) => shouldShowNudge(k, record, now)); +}; + export const NUDGE_GRACE_MS = 24 * 60 * 60 * 1000; export const NUDGE_SNOOZE_MS = 7 * 24 * 60 * 60 * 1000; export const NUDGE_MAX_DISMISSALS = 3; @@ -87,6 +135,11 @@ export const NUDGE_COPY: Record = { text: "This device isn't using your key backup yet. Connect it so your encrypted messages stay readable if you lose it.", action: 'Connect', }, + 'stale-sessions': { + // The text depends on the count: see staleSessionsText. + text: '', + action: 'Review', + }, 'setup-backup': { text: "Your encryption keys aren't backed up. Set up key backup so you don't lose your encrypted messages if you log out or lose this device.", action: 'Set up', -- 2.47.3