Skip to content

Commit f9ed2be

Browse files
feat: session keep-alive engine
1 parent bfbd616 commit f9ed2be

5 files changed

Lines changed: 255 additions & 0 deletions

File tree

src/routes/index.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { AppletNotFoundPopup } from 'shared/components';
1313
import { NoPermissionPopup } from 'shared/components/NoPermissionPopup';
1414
import { useSessionBanners } from 'shared/hooks/useSessionBanners';
1515
import { useFeatureFlags } from 'shared/hooks/useFeatureFlags';
16+
import { SessionKeepAlive } from 'shared/hooks/useSessionKeepAlive';
1617

1718
import history from './history';
1819

@@ -57,6 +58,7 @@ const AppRoutes = () => {
5758
)}
5859
<AppletNotFoundPopup />
5960
<NoPermissionPopup />
61+
<SessionKeepAlive />
6062
</Router>
6163
</>
6264
);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { useSessionKeepAlive } from './useSessionKeepAlive';
2+
3+
// Must render inside <Router>: the hook logs out via useLogout, which relies on useNavigate.
4+
export const SessionKeepAlive = () => {
5+
useSessionKeepAlive();
6+
7+
return null;
8+
};
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export * from './SessionKeepAlive';
2+
export * from './useSessionKeepAlive';
3+
export * from './useSessionKeepAlive.const';
4+
export * from './useSessionKeepAlive.types';
5+
export * from './useSessionKeepAlive.utils';
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { act } from '@testing-library/react';
2+
3+
import { refreshTokens } from 'shared/api';
4+
import { authStorage } from 'shared/utils';
5+
import { SessionStorageKeys } from 'shared/utils/storage';
6+
import { useFeatureFlags } from 'shared/hooks/useFeatureFlags';
7+
import { useLogout } from 'shared/hooks/useLogout';
8+
import { renderHookWithProviders } from 'shared/utils/renderHookWithProviders';
9+
import { getPreloadedState } from 'shared/tests/getPreloadedState';
10+
import { state as authState } from 'modules/Auth/state/Auth.state';
11+
12+
import { useSessionKeepAlive } from './useSessionKeepAlive';
13+
import { MS_IN_MIN, MS_IN_SEC } from './useSessionKeepAlive.const';
14+
15+
vi.mock('shared/api', () => ({ refreshTokens: vi.fn() }));
16+
vi.mock('shared/hooks/useLogout', () => ({ useLogout: vi.fn() }));
17+
// Pinned so the suite does not depend on the .env a developer happens to have locally.
18+
vi.mock('./useSessionKeepAlive.utils', () => ({
19+
resolveSessionConfig: () => ({ idleTimeoutMs: 30 * 60 * 1000, refreshLeadMs: 90 * 1000 }),
20+
}));
21+
22+
const mockedRefreshTokens = vi.mocked(refreshTokens);
23+
const mockedLogout = vi.fn();
24+
25+
const IDLE_TIMEOUT_MS = 30 * MS_IN_MIN;
26+
const TOKEN_LIFETIME_MS = 15 * MS_IN_MIN;
27+
const REFRESH_LEAD_MS = 90 * MS_IN_SEC;
28+
29+
const tokenExpiringIn = (ms: number) =>
30+
`header.${btoa(JSON.stringify({ exp: Math.floor((Date.now() + ms) / 1000) }))}.signature`;
31+
32+
const setFlag = (enableSessionKeepAlive: boolean) =>
33+
vi.mocked(useFeatureFlags).mockReturnValue({
34+
featureFlags: { enableSessionKeepAlive },
35+
resetLDContext: vi.fn(),
36+
} as never);
37+
38+
const renderEngine = () =>
39+
renderHookWithProviders(useSessionKeepAlive, {
40+
preloadedState: { ...getPreloadedState(), auth: { ...authState, isAuthorized: true } },
41+
});
42+
43+
describe('useSessionKeepAlive', () => {
44+
beforeEach(() => {
45+
vi.clearAllMocks();
46+
vi.useFakeTimers();
47+
vi.setSystemTime(new Date('2026-07-30T10:00:00Z'));
48+
sessionStorage.clear();
49+
50+
vi.mocked(useLogout).mockReturnValue(mockedLogout);
51+
mockedRefreshTokens.mockResolvedValue({ accessToken: 'a', refreshToken: 'r' });
52+
setFlag(true);
53+
authStorage.setAccessToken(tokenExpiringIn(TOKEN_LIFETIME_MS));
54+
});
55+
56+
afterEach(() => {
57+
vi.useRealTimers();
58+
});
59+
60+
test('refreshes one lead interval before the token expires', () => {
61+
renderEngine();
62+
63+
act(() => {
64+
vi.advanceTimersByTime(TOKEN_LIFETIME_MS - REFRESH_LEAD_MS - MS_IN_SEC);
65+
});
66+
expect(mockedRefreshTokens).not.toHaveBeenCalled();
67+
68+
act(() => {
69+
vi.advanceTimersByTime(2 * MS_IN_SEC);
70+
});
71+
expect(mockedRefreshTokens).toHaveBeenCalledTimes(1);
72+
});
73+
74+
test('re-arms from the newly issued token', async () => {
75+
renderEngine();
76+
mockedRefreshTokens.mockImplementation(async () => {
77+
authStorage.setAccessToken(tokenExpiringIn(TOKEN_LIFETIME_MS));
78+
79+
return { accessToken: 'a', refreshToken: 'r' };
80+
});
81+
82+
await act(async () => {
83+
vi.advanceTimersByTime(TOKEN_LIFETIME_MS - REFRESH_LEAD_MS);
84+
});
85+
await act(async () => {
86+
vi.advanceTimersByTime(TOKEN_LIFETIME_MS - REFRESH_LEAD_MS);
87+
});
88+
89+
expect(mockedRefreshTokens).toHaveBeenCalledTimes(2);
90+
});
91+
92+
test('logs out once the idle timeout elapses', () => {
93+
renderEngine();
94+
95+
act(() => {
96+
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - MS_IN_SEC);
97+
});
98+
expect(mockedLogout).not.toHaveBeenCalled();
99+
100+
act(() => {
101+
vi.advanceTimersByTime(2 * MS_IN_SEC);
102+
});
103+
expect(mockedLogout).toHaveBeenCalledWith({ shouldSoftLock: true });
104+
});
105+
106+
test('activity pushes the logout deadline out', () => {
107+
renderEngine();
108+
109+
act(() => {
110+
vi.advanceTimersByTime(IDLE_TIMEOUT_MS - MS_IN_MIN);
111+
window.dispatchEvent(new Event('keydown'));
112+
});
113+
act(() => {
114+
vi.advanceTimersByTime(2 * MS_IN_MIN);
115+
});
116+
117+
expect(mockedLogout).not.toHaveBeenCalled();
118+
});
119+
120+
test('logs out when the refresh is rejected', async () => {
121+
mockedRefreshTokens.mockRejectedValue(new Error('refresh rejected'));
122+
renderEngine();
123+
124+
await act(async () => {
125+
vi.advanceTimersByTime(TOKEN_LIFETIME_MS - REFRESH_LEAD_MS);
126+
});
127+
128+
expect(mockedLogout).toHaveBeenCalledWith({ shouldSoftLock: true });
129+
});
130+
131+
test('caps the lead so a short-lived token does not refresh on every tick', () => {
132+
authStorage.setAccessToken(tokenExpiringIn(MS_IN_MIN));
133+
renderEngine();
134+
135+
act(() => {
136+
vi.advanceTimersByTime(MS_IN_MIN / 2 - MS_IN_SEC);
137+
});
138+
expect(mockedRefreshTokens).not.toHaveBeenCalled();
139+
140+
act(() => {
141+
vi.advanceTimersByTime(2 * MS_IN_SEC);
142+
});
143+
expect(mockedRefreshTokens).toHaveBeenCalledTimes(1);
144+
});
145+
146+
test('does nothing at all when the flag is off', () => {
147+
setFlag(false);
148+
renderEngine();
149+
150+
act(() => {
151+
vi.advanceTimersByTime(IDLE_TIMEOUT_MS * 2);
152+
window.dispatchEvent(new Event('keydown'));
153+
});
154+
155+
expect(mockedRefreshTokens).not.toHaveBeenCalled();
156+
expect(mockedLogout).not.toHaveBeenCalled();
157+
expect(sessionStorage.getItem(SessionStorageKeys.LastActivityAt)).toBeNull();
158+
});
159+
});
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { useEffect, useRef } from 'react';
2+
3+
import { auth } from 'redux/modules';
4+
import { refreshTokens } from 'shared/api';
5+
import { authStorage, getTokenExpiration } from 'shared/utils';
6+
import { useFeatureFlags } from 'shared/hooks/useFeatureFlags';
7+
import { useLogout } from 'shared/hooks/useLogout';
8+
9+
import { getLastActivityAt, startActivityTracking, stopActivityTracking } from './activityTracker';
10+
import { resolveSessionConfig } from './useSessionKeepAlive.utils';
11+
12+
export const useSessionKeepAlive = () => {
13+
const { featureFlags } = useFeatureFlags();
14+
const isAuthorized = auth.useAuthorized();
15+
const logout = useLogout();
16+
17+
// Refreshed every render so the logout never closes over a stale email or workspace.
18+
const logoutRef = useRef(logout);
19+
logoutRef.current = logout;
20+
21+
const isEnabled = !!featureFlags.enableSessionKeepAlive && isAuthorized;
22+
23+
useEffect(() => {
24+
if (!isEnabled) return;
25+
26+
const { idleTimeoutMs, refreshLeadMs } = resolveSessionConfig();
27+
let refreshTimer: ReturnType<typeof setTimeout>;
28+
let logoutTimer: ReturnType<typeof setTimeout>;
29+
let hasEnded = false;
30+
31+
const endSession = () => {
32+
if (hasEnded) return;
33+
hasEnded = true;
34+
logoutRef.current({ shouldSoftLock: true });
35+
};
36+
37+
const schedule = () => {
38+
if (hasEnded) return;
39+
clearTimeout(refreshTimer);
40+
clearTimeout(logoutTimer);
41+
42+
const idleDeadline = (getLastActivityAt() ?? Date.now()) + idleTimeoutMs;
43+
const msUntilLogout = idleDeadline - Date.now();
44+
if (msUntilLogout <= 0) return endSession();
45+
46+
logoutTimer = setTimeout(endSession, msUntilLogout);
47+
48+
// Read at decision time: another tab or an earlier refresh may have replaced the token.
49+
const expiresAt = getTokenExpiration(authStorage.getAccessToken());
50+
if (expiresAt === null) return;
51+
52+
// Capped so a token shorter-lived than the lead does not refresh on every tick.
53+
const lead = Math.min(refreshLeadMs, (expiresAt - Date.now()) / 2);
54+
refreshTimer = setTimeout(refresh, Math.max(expiresAt - lead - Date.now(), 0));
55+
};
56+
57+
const refresh = async () => {
58+
try {
59+
await refreshTokens();
60+
schedule();
61+
} catch {
62+
endSession();
63+
}
64+
};
65+
66+
const handleVisibilityChange = () => {
67+
if (document.visibilityState === 'visible') schedule();
68+
};
69+
70+
startActivityTracking(schedule);
71+
document.addEventListener('visibilitychange', handleVisibilityChange);
72+
schedule();
73+
74+
return () => {
75+
clearTimeout(refreshTimer);
76+
clearTimeout(logoutTimer);
77+
stopActivityTracking();
78+
document.removeEventListener('visibilitychange', handleVisibilityChange);
79+
};
80+
}, [isEnabled]);
81+
};

0 commit comments

Comments
 (0)