Skip to content

Commit 4c3b9e8

Browse files
feat(auth): client-side idle-session timeout (AUTH-1 slice 1) (#675)
The Authentication-policy 'Idle timeout' had no observable effect in the browser: the backend measures idle by HTTP request activity and slides the session window on every authenticated request, but the SPA polls several endpoints every 15-60s and holds a persistent SSE stream, so the window never elapsed even when the user walked away. Add useIdleLogout, mounted from AppFrame (the authenticated shell), which measures REAL user input (pointer/keyboard/touch) instead of HTTP traffic. On inactivity for the configured window it revokes the session (POST /api/v1/auth/logout) and returns to /login. The window is read from GET /api/v1/auth-policy with a 15-minute safe default (matching the backend), and the activity clock is shared across tabs via localStorage so one active tab keeps all alive. New spec frontend-session-idle (5 ACs, 6 tests). Does NOT yet address the absolute-timeout ceiling in the refresh-cookie path or server-side slide-on-user-activity hardening (AUTH-1 slices b/c).
1 parent f139379 commit 4c3b9e8

5 files changed

Lines changed: 460 additions & 1 deletion

File tree

frontend/src/components/shell/AppFrame.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { Sidebar } from './Sidebar';
44
import { TopBar } from './TopBar';
55
import { ErrorBoundary } from './ErrorBoundary';
66
import { usePreferencesStore } from '@/store/usePreferencesStore';
7+
import { useIdleLogout } from '@/hooks/useIdleLogout';
78

89
// AppFrame — the persistent shell that wraps every authenticated route.
910
//
10-
// Spec: frontend-foundation C-08, C-09, AC-10.
11+
// Spec: frontend-foundation C-08, C-09, AC-10; frontend-session-idle.
1112

1213
export function AppFrame() {
1314
// Reconcile per-user UI preferences with the server once the
@@ -18,6 +19,11 @@ export function AppFrame() {
1819
void hydratePreferences();
1920
}, [hydratePreferences]);
2021

22+
// Enforce the operator-configured idle timeout against REAL user activity
23+
// (background polling slides the server-side window, so a client timer is
24+
// what actually terminates an unattended session). Spec frontend-session-idle.
25+
useIdleLogout();
26+
2127
return (
2228
<div
2329
style={{
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import { useEffect, useRef } from 'react';
2+
import { useQuery } from '@tanstack/react-query';
3+
import { api, onAuthFailure } from '@/api/client';
4+
import { useAuthStore } from '@/store/useAuthStore';
5+
6+
// useIdleLogout — client-side inactivity timeout.
7+
//
8+
// WHY this exists (and why the server-side window is not enough): the backend
9+
// enforces the configured idle window in internal/identity/sessions.go, but it
10+
// measures "idle" by HTTP request activity. The SPA polls several endpoints
11+
// every 15-60s and holds a persistent SSE stream, so every background request
12+
// slides the server's expires_at forward — the session never goes idle even
13+
// when the human has walked away. This hook measures REAL user activity
14+
// (pointer / keyboard / touch), so the Authentication-policy "Idle timeout"
15+
// setting actually terminates an unattended session and returns to /login.
16+
//
17+
// It reads the operator-configured window from GET /api/v1/auth-policy and
18+
// falls back to the backend default (15 min) when that can't be read — failing
19+
// toward enforcing a timeout rather than leaving the session open forever.
20+
//
21+
// Mounted once from AppFrame (the authenticated shell), so it is armed only
22+
// while a user is signed in.
23+
//
24+
// Spec: frontend-session-idle.
25+
26+
// Matches DefaultSessionInactivityWindow in internal/identity/sessions.go.
27+
const DEFAULT_IDLE_SECONDS = 15 * 60;
28+
29+
// Shared "last user activity" timestamp (epoch ms) across tabs of the same
30+
// origin. Activity in any tab keeps every tab's session alive; without this a
31+
// background tab would log the user out while they work in another.
32+
export const ACTIVITY_STORAGE_KEY = 'ow.session.lastActivity';
33+
34+
// How often we re-check elapsed idle time. This is the granularity of the
35+
// check, not the timeout itself.
36+
const CHECK_INTERVAL_MS = 5_000;
37+
38+
// Throttle how often raw activity events rewrite the shared timestamp so a
39+
// burst of mousemove events is cheap.
40+
const ACTIVITY_WRITE_THROTTLE_MS = 1_000;
41+
42+
// Real user-input signals only. Deliberately NOT HTTP/visibility driven — the
43+
// whole point is to ignore background traffic and tab focus.
44+
const ACTIVITY_EVENTS: readonly string[] = [
45+
'mousemove',
46+
'mousedown',
47+
'keydown',
48+
'wheel',
49+
'scroll',
50+
'touchstart',
51+
];
52+
53+
function readLastActivity(fallback: number): number {
54+
try {
55+
const raw = localStorage.getItem(ACTIVITY_STORAGE_KEY);
56+
const n = raw ? Number(raw) : NaN;
57+
if (Number.isFinite(n)) return n;
58+
} catch {
59+
/* localStorage unavailable (private mode / SSR) — use the fallback */
60+
}
61+
return fallback;
62+
}
63+
64+
export function useIdleLogout(): void {
65+
const identity = useAuthStore((s) => s.identity);
66+
67+
const { data: policy } = useQuery({
68+
queryKey: ['auth-policy'],
69+
queryFn: async () => {
70+
const { data, error, response } = await api.GET('/api/v1/auth-policy');
71+
if (error || !response.ok) throw new Error('failed to load auth policy');
72+
return data!;
73+
},
74+
// The window changes rarely; one fetch per shell mount is plenty. A short
75+
// refetch interval would itself be background traffic, which is the very
76+
// thing this hook exists to stop counting as activity.
77+
staleTime: 5 * 60 * 1000,
78+
});
79+
80+
const idleSeconds = policy?.session_idle_timeout_seconds ?? DEFAULT_IDLE_SECONDS;
81+
82+
// Hold the latest window in a ref so a policy change does not tear down and
83+
// rebuild the listeners/interval.
84+
const idleMsRef = useRef(idleSeconds * 1000);
85+
idleMsRef.current = idleSeconds * 1000;
86+
87+
useEffect(() => {
88+
// Arm only while authenticated.
89+
if (!identity) return;
90+
91+
let lastWrite = 0;
92+
let loggingOut = false;
93+
94+
const markActivity = (): void => {
95+
const t = Date.now();
96+
if (t - lastWrite < ACTIVITY_WRITE_THROTTLE_MS) return;
97+
lastWrite = t;
98+
try {
99+
localStorage.setItem(ACTIVITY_STORAGE_KEY, String(t));
100+
} catch {
101+
/* ignore — readLastActivity falls back to lastWrite via the closure */
102+
}
103+
};
104+
105+
const logout = async (): Promise<void> => {
106+
if (loggingOut) return;
107+
loggingOut = true;
108+
// Best-effort server-side revoke so the cookie session is actually dead,
109+
// not merely hidden from this tab. Redirect regardless of the result.
110+
try {
111+
await api.POST('/api/v1/auth/logout');
112+
} catch {
113+
/* ignore — we still clear the client and redirect */
114+
}
115+
try {
116+
localStorage.removeItem(ACTIVITY_STORAGE_KEY);
117+
} catch {
118+
/* ignore */
119+
}
120+
onAuthFailure();
121+
};
122+
123+
const check = (): void => {
124+
if (loggingOut) return;
125+
const last = readLastActivity(lastWrite || Date.now());
126+
if (Date.now() - last >= idleMsRef.current) {
127+
void logout();
128+
}
129+
};
130+
131+
// Seed an initial timestamp so a freshly-mounted shell is not treated as
132+
// already idle.
133+
lastWrite = 0;
134+
markActivity();
135+
136+
for (const ev of ACTIVITY_EVENTS) {
137+
window.addEventListener(ev, markActivity, { passive: true });
138+
}
139+
const interval = window.setInterval(check, CHECK_INTERVAL_MS);
140+
141+
return () => {
142+
window.clearInterval(interval);
143+
for (const ev of ACTIVITY_EVENTS) {
144+
window.removeEventListener(ev, markActivity);
145+
}
146+
};
147+
}, [identity]);
148+
}
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
// @spec frontend-session-idle
2+
//
3+
// AC traceability (this file):
4+
//
5+
// AC-01 test('frontend-session-idle/AC-01 — idle past the window logs out (revoke + redirect)')
6+
// AC-02 test('frontend-session-idle/AC-02 — user input before the window resets the clock')
7+
// AC-03 test('frontend-session-idle/AC-03 — honors the configured window; falls back to 15m default')
8+
// AC-04 test('frontend-session-idle/AC-04 — a fresher cross-tab timestamp prevents logout')
9+
// AC-05 test('frontend-session-idle/AC-05 — AppFrame mounts the hook; listeners are user-input only')
10+
11+
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
12+
import { act, renderHook } from '@testing-library/react';
13+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
14+
import { readFileSync } from 'node:fs';
15+
import { resolve } from 'node:path';
16+
import type { ReactNode } from 'react';
17+
18+
import { api, onAuthFailure } from '@/api/client';
19+
import { ACTIVITY_STORAGE_KEY, useIdleLogout } from '@/hooks/useIdleLogout';
20+
import { useAuthStore } from '@/store/useAuthStore';
21+
22+
vi.mock('@/api/client', () => ({
23+
api: { GET: vi.fn(), POST: vi.fn() },
24+
onAuthFailure: vi.fn(),
25+
}));
26+
27+
const mockedGet = vi.mocked(api.GET);
28+
const mockedPost = vi.mocked(api.POST);
29+
const mockedAuthFailure = vi.mocked(onAuthFailure);
30+
31+
const T0 = new Date('2026-01-01T00:00:00Z').getTime();
32+
33+
// Policy GET result helpers (the openapi-fetch result shape).
34+
function policyOk(idleSeconds: number) {
35+
return {
36+
data: {
37+
require_mfa: false,
38+
session_idle_timeout_seconds: idleSeconds,
39+
session_absolute_timeout_seconds: 43200,
40+
updated_at: '2026-01-01T00:00:00Z',
41+
},
42+
error: undefined,
43+
response: { ok: true } as Response,
44+
};
45+
}
46+
function policyFail() {
47+
return { data: undefined, error: { code: 'x' }, response: { ok: false } as Response };
48+
}
49+
50+
function wrapper({ children }: { children: ReactNode }) {
51+
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
52+
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
53+
}
54+
55+
// Render the hook and let the auth-policy query settle so idleMsRef is primed.
56+
async function mount() {
57+
const view = renderHook(() => useIdleLogout(), { wrapper });
58+
await act(async () => {
59+
await vi.advanceTimersByTimeAsync(0);
60+
});
61+
return view;
62+
}
63+
64+
async function advance(ms: number) {
65+
await act(async () => {
66+
await vi.advanceTimersByTimeAsync(ms);
67+
});
68+
}
69+
70+
describe('frontend-session-idle — client idle logout', () => {
71+
beforeEach(() => {
72+
vi.useFakeTimers();
73+
vi.setSystemTime(T0);
74+
mockedGet.mockReset();
75+
mockedPost.mockReset();
76+
mockedAuthFailure.mockReset();
77+
mockedPost.mockResolvedValue({ data: {}, error: undefined, response: { ok: true } } as never);
78+
try {
79+
localStorage.clear();
80+
} catch {
81+
/* ignore */
82+
}
83+
useAuthStore.getState().setIdentity({
84+
id: 'u1',
85+
username: 'alice',
86+
email: 'alice@example.com',
87+
role: 'admin',
88+
permissions: [],
89+
mfaEnabled: false,
90+
});
91+
});
92+
93+
afterEach(() => {
94+
vi.useRealTimers();
95+
useAuthStore.getState().clear();
96+
});
97+
98+
// @ac AC-01
99+
test('frontend-session-idle/AC-01 — idle past the window logs out (revoke + redirect)', async () => {
100+
mockedGet.mockResolvedValue(policyOk(60) as never);
101+
await mount();
102+
103+
// No activity for 65s (> 60s window) → revoke + redirect.
104+
await advance(65_000);
105+
106+
expect(mockedPost).toHaveBeenCalledWith('/api/v1/auth/logout');
107+
expect(mockedAuthFailure).toHaveBeenCalledTimes(1);
108+
});
109+
110+
// @ac AC-02
111+
test('frontend-session-idle/AC-02 — user input before the window resets the clock', async () => {
112+
mockedGet.mockResolvedValue(policyOk(60) as never);
113+
await mount();
114+
115+
// Sit idle for 50s, then the user presses a key (resets the clock).
116+
await advance(50_000);
117+
act(() => {
118+
window.dispatchEvent(new Event('keydown'));
119+
});
120+
// 50s more: 50s since the keypress (< 60s) → still NOT logged out.
121+
await advance(50_000);
122+
123+
expect(mockedAuthFailure).not.toHaveBeenCalled();
124+
expect(mockedPost).not.toHaveBeenCalled();
125+
});
126+
127+
// @ac AC-03
128+
test('frontend-session-idle/AC-03 — honors the configured window; falls back to 15m default', async () => {
129+
// Configured short window: fires at the configured value, not before.
130+
mockedGet.mockResolvedValue(policyOk(120) as never);
131+
await mount();
132+
await advance(100_000); // < 120s
133+
expect(mockedAuthFailure).not.toHaveBeenCalled();
134+
await advance(30_000); // now > 120s
135+
expect(mockedAuthFailure).toHaveBeenCalledTimes(1);
136+
});
137+
138+
// @ac AC-03
139+
test('frontend-session-idle/AC-03b — policy unavailable falls back to the 15-minute default', async () => {
140+
mockedGet.mockResolvedValue(policyFail() as never);
141+
await mount();
142+
// 5 minutes idle is well past a short window but under the 15-min default →
143+
// must NOT log out (proves the safe default, not a 0/immediate timeout).
144+
await advance(5 * 60_000);
145+
expect(mockedAuthFailure).not.toHaveBeenCalled();
146+
});
147+
148+
// @ac AC-04
149+
test('frontend-session-idle/AC-04 — a fresher cross-tab timestamp prevents logout', async () => {
150+
mockedGet.mockResolvedValue(policyOk(60) as never);
151+
await mount();
152+
153+
// This tab sees no local input, but another tab records activity by writing
154+
// the shared key. Keep it fresh across the window.
155+
for (let i = 0; i < 6; i++) {
156+
localStorage.setItem(ACTIVITY_STORAGE_KEY, String(Date.now()));
157+
await advance(20_000); // 20s < 60s window each step
158+
}
159+
160+
expect(mockedAuthFailure).not.toHaveBeenCalled();
161+
expect(mockedPost).not.toHaveBeenCalled();
162+
});
163+
164+
// @ac AC-05
165+
test('frontend-session-idle/AC-05 — AppFrame mounts the hook; listeners are user-input only', () => {
166+
const appFrame = readFileSync(resolve(__dirname, '../../src/components/shell/AppFrame.tsx'), 'utf8');
167+
expect(appFrame).toContain('useIdleLogout');
168+
169+
const hook = readFileSync(resolve(__dirname, '../../src/hooks/useIdleLogout.ts'), 'utf8');
170+
for (const ev of ['mousemove', 'mousedown', 'keydown', 'wheel', 'scroll', 'touchstart']) {
171+
expect(hook).toContain(`'${ev}'`);
172+
}
173+
// Tab focus / visibility and background HTTP must NOT count as activity.
174+
expect(hook).not.toContain('visibilitychange');
175+
});
176+
});

0 commit comments

Comments
 (0)