Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,8 @@ REACT_APP_MIXPANEL_FORCE_ENABLE=false
REACT_APP_DD_APP_ID=
REACT_APP_DD_CLIENT_TOKEN=
REACT_APP_DD_VERSION=local
REACT_APP_DD_TRACING_URLS=http://localhost:3000
REACT_APP_DD_TRACING_URLS=http://localhost:3000

# Session keep-alive
REACT_APP_IDLE_TIMEOUT_MIN=30
REACT_APP_REFRESH_LEAD_SEC=90
2 changes: 2 additions & 0 deletions src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { AppletNotFoundPopup } from 'shared/components';
import { NoPermissionPopup } from 'shared/components/NoPermissionPopup';
import { useSessionBanners } from 'shared/hooks/useSessionBanners';
import { useFeatureFlags } from 'shared/hooks/useFeatureFlags';
import { SessionKeepAlive } from 'shared/hooks/useSessionKeepAlive';

import history from './history';

Expand Down Expand Up @@ -57,6 +58,7 @@ const AppRoutes = () => {
)}
<AppletNotFoundPopup />
<NoPermissionPopup />
<SessionKeepAlive />
</Router>
</>
);
Expand Down
1 change: 1 addition & 0 deletions src/setupTests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ vi.mock('shared/hooks/useFeatureFlags', () => ({
enableMfa: false,
enableAdminAnnouncementBanner: false,
enableAuditLogs: true,
enableSessionKeepAlive: false,
},
resetLDContext: vi.fn(),
})),
Expand Down
129 changes: 128 additions & 1 deletion src/shared/api/api.utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,131 @@
import { shouldNotSkipRoute } from './api.utils';
import axios, { AxiosError } from 'axios';

import { authStorage } from 'shared/utils/authStorage';

import { refreshTokenAndReattemptRequest, refreshTokens, shouldNotSkipRoute } from './api.utils';
import { signInRefreshTokenApi } from './api';

vi.mock('./api', () => ({ signInRefreshTokenApi: vi.fn() }));
vi.mock('axios', () => ({ default: vi.fn() }));

const mockedSignInRefreshTokenApi = vi.mocked(signInRefreshTokenApi);
const mockedAxios = vi.mocked(axios);

const tokens = { accessToken: 'new-access', refreshToken: 'new-refresh', tokenType: 'Bearer' };

const resolveWith = (result: typeof tokens | undefined, delayMs = 0) =>
mockedSignInRefreshTokenApi.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve({ data: { result } } as never), delayMs);
}),
);

describe('refreshTokens', () => {
beforeEach(() => {
vi.clearAllMocks();
authStorage.setAccessToken('old-access');
authStorage.setRefreshToken('old-refresh');
});

test('stores both tokens and returns them', async () => {
resolveWith(tokens);

await expect(refreshTokens()).resolves.toEqual({
accessToken: 'new-access',
refreshToken: 'new-refresh',
});
expect(authStorage.getAccessToken()).toBe('new-access');
expect(authStorage.getRefreshToken()).toBe('new-refresh');
});

test('sends the stored refresh token', async () => {
resolveWith(tokens);
await refreshTokens();

expect(mockedSignInRefreshTokenApi).toHaveBeenCalledWith({ refreshToken: 'old-refresh' });
});

test('overlapping callers share a single request', async () => {
resolveWith(tokens, 10);

const results = await Promise.all([refreshTokens(), refreshTokens(), refreshTokens()]);

expect(mockedSignInRefreshTokenApi).toHaveBeenCalledTimes(1);
expect(results[0]).toBe(results[1]);
expect(results[1]).toBe(results[2]);
});

test('a later caller triggers a new request', async () => {
resolveWith(tokens);

await refreshTokens();
await refreshTokens();

expect(mockedSignInRefreshTokenApi).toHaveBeenCalledTimes(2);
});

test('rejects every overlapping caller when the request fails', async () => {
mockedSignInRefreshTokenApi.mockRejectedValue(new Error('network down'));

await expect(Promise.all([refreshTokens(), refreshTokens()])).rejects.toThrow('network down');
expect(mockedSignInRefreshTokenApi).toHaveBeenCalledTimes(1);
});

test('allows a retry after a failure', async () => {
mockedSignInRefreshTokenApi.mockRejectedValueOnce(new Error('network down'));
await expect(refreshTokens()).rejects.toThrow('network down');

resolveWith(tokens);

await expect(refreshTokens()).resolves.toEqual({
accessToken: 'new-access',
refreshToken: 'new-refresh',
});
});

test('rejects when the response is missing a token', async () => {
resolveWith(undefined);

await expect(refreshTokens()).rejects.toThrow('Access token refresh failed.');
expect(authStorage.getAccessToken()).toBe('old-access');
});
});

describe('refreshTokenAndReattemptRequest', () => {
const failedRequest = {
response: { config: { url: '/applets', method: 'get', headers: { 'X-Custom': '1' } } },
} as unknown as AxiosError;

beforeEach(() => {
vi.clearAllMocks();
authStorage.setRefreshToken('old-refresh');
});

test('replays the original request with the new token', async () => {
resolveWith(tokens);
mockedAxios.mockResolvedValue({ data: 'replayed' } as never);

await expect(refreshTokenAndReattemptRequest(failedRequest)).resolves.toEqual({
data: 'replayed',
});
expect(mockedAxios).toHaveBeenCalledWith(
expect.objectContaining({
url: '/applets',
headers: { 'X-Custom': '1', Authorization: 'Bearer new-access' },
}),
);
});

test('propagates a failed refresh without replaying', async () => {
mockedSignInRefreshTokenApi.mockRejectedValue(new Error('refresh rejected'));

await expect(refreshTokenAndReattemptRequest(failedRequest)).rejects.toThrow(
'refresh rejected',
);
expect(mockedAxios).not.toHaveBeenCalled();
});
});

describe('shouldNotSkipRoute', () => {
const testCases = [
Expand Down
78 changes: 41 additions & 37 deletions src/shared/api/api.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,45 +26,49 @@ export const getRefreshTokenData = (config: InternalAxiosRequestConfig) => {
config.headers['Authorization'] = `bearer ${refreshToken}`;
};

export const refreshTokenAndReattemptRequest = async (err: AxiosError) => {
try {
const { response: errorResponse } = err;
const oldRefreshToken = authStorage.getRefreshToken();

const {
data: { result },
} = await signInRefreshTokenApi({
refreshToken: oldRefreshToken,
});
const { accessToken, refreshToken } = result ?? {};

return new Promise((resolve, reject) => {
if (!accessToken || !refreshToken) {
return reject(new Error('Access token refresh failed.'));
}

authStorage.setAccessToken(accessToken);
authStorage.setRefreshToken(refreshToken);
const originalConfig = errorResponse?.config;

try {
resolve(
axios({
...originalConfig,
headers: {
...(originalConfig?.headers && originalConfig.headers),
Authorization: `Bearer ${accessToken}`,
},
...(originalConfig?.data && { data: JSON.parse(originalConfig.data) }),
}),
);
} catch (error) {
reject(error);
}
const requestNewTokens = async () => {
const {
data: { result },
} = await signInRefreshTokenApi({
refreshToken: authStorage.getRefreshToken(),
});
const { accessToken, refreshToken } = result ?? {};

if (!accessToken || !refreshToken) {
throw new Error('Access token refresh failed.');
}

authStorage.setAccessToken(accessToken);
authStorage.setRefreshToken(refreshToken);

return { accessToken, refreshToken };
};

let pendingRefresh: ReturnType<typeof requestNewTokens> | null = null;

// Callers that overlap share one request instead of each rotating the token separately.
export const refreshTokens = () => {
if (!pendingRefresh) {
pendingRefresh = requestNewTokens().finally(() => {
pendingRefresh = null;
});
} catch (error) {
return Promise.reject(error);
}

return pendingRefresh;
};

export const refreshTokenAndReattemptRequest = async (err: AxiosError) => {
const { accessToken } = await refreshTokens();
const originalConfig = err.response?.config;

return axios({
...originalConfig,
headers: {
...originalConfig?.headers,
Authorization: `Bearer ${accessToken}`,
},
...(originalConfig?.data && { data: JSON.parse(originalConfig.data) }),
});
};

export const shouldNotSkipRoute = (url: string) =>
Expand Down
1 change: 1 addition & 0 deletions src/shared/hooks/useFeatureFlags.const.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export const FeatureFlagDefaults: FeatureFlags = {
enableMfa: true,
enableAdminAnnouncementBanner: false,
enableAuditLogs: false,
enableSessionKeepAlive: false,
};

export const PROHIBITED_PII_KEYS = ['firstName', 'lastName', 'email'];
8 changes: 8 additions & 0 deletions src/shared/hooks/useSessionKeepAlive/SessionKeepAlive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { useSessionKeepAlive } from './useSessionKeepAlive';

// Must render inside <Router>: the hook logs out via useLogout, which relies on useNavigate.
export const SessionKeepAlive = () => {
useSessionKeepAlive();

return null;
};
109 changes: 109 additions & 0 deletions src/shared/hooks/useSessionKeepAlive/activityTracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { SessionStorageKeys } from 'shared/utils/storage';

import { getLastActivityAt, startActivityTracking, stopActivityTracking } from './activityTracker';
import { ACTIVITY_EVENTS, ACTIVITY_THROTTLE_MS } from './useSessionKeepAlive.const';

const storedActivity = () => sessionStorage.getItem(SessionStorageKeys.LastActivityAt);

describe('activityTracker', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-07-30T10:00:00Z'));
sessionStorage.clear();
});

afterEach(() => {
stopActivityTracking();
vi.useRealTimers();
});

test('seeds the timestamp when nothing is stored', () => {
startActivityTracking();

expect(getLastActivityAt()).toBe(Date.now());
});

test('adopts an existing timestamp instead of resetting it', () => {
const earlier = Date.now() - 600000;
sessionStorage.setItem(SessionStorageKeys.LastActivityAt, String(earlier));

startActivityTracking();

expect(getLastActivityAt()).toBe(earlier);
});

test.each(ACTIVITY_EVENTS)('records activity on %s', (event) => {
startActivityTracking();
const seeded = getLastActivityAt();

vi.advanceTimersByTime(ACTIVITY_THROTTLE_MS + 1);
window.dispatchEvent(new Event(event));

expect(getLastActivityAt()).toBeGreaterThan(Number(seeded));
expect(getLastActivityAt()).toBe(Date.now());
});

test('throttles repeated events', () => {
startActivityTracking();
vi.advanceTimersByTime(ACTIVITY_THROTTLE_MS + 1);
window.dispatchEvent(new Event('mousemove'));

const afterFirst = storedActivity();

vi.advanceTimersByTime(1000);
window.dispatchEvent(new Event('mousemove'));
window.dispatchEvent(new Event('mousemove'));

expect(storedActivity()).toBe(afterFirst);
});

test('records again once the throttle window passes', () => {
startActivityTracking();
vi.advanceTimersByTime(ACTIVITY_THROTTLE_MS + 1);
window.dispatchEvent(new Event('mousemove'));
const afterFirst = storedActivity();

vi.advanceTimersByTime(ACTIVITY_THROTTLE_MS + 1);
window.dispatchEvent(new Event('mousemove'));

expect(storedActivity()).not.toBe(afterFirst);
});

test('notifies the caller when activity is recorded', () => {
const onActivity = vi.fn();
startActivityTracking(onActivity);

vi.advanceTimersByTime(ACTIVITY_THROTTLE_MS + 1);
window.dispatchEvent(new Event('keydown'));

expect(onActivity).toHaveBeenCalledTimes(1);
});

test('stops recording once tracking is stopped', () => {
startActivityTracking();
stopActivityTracking();
const beforeEvent = storedActivity();

vi.advanceTimersByTime(ACTIVITY_THROTTLE_MS + 1);
window.dispatchEvent(new Event('mousemove'));

expect(storedActivity()).toBe(beforeEvent);
});

test('starting twice leaves only one set of listeners', () => {
const onActivity = vi.fn();
startActivityTracking(onActivity);
startActivityTracking(onActivity);

vi.advanceTimersByTime(ACTIVITY_THROTTLE_MS + 1);
window.dispatchEvent(new Event('keydown'));

expect(onActivity).toHaveBeenCalledTimes(1);
});

test('ignores a corrupt stored value', () => {
sessionStorage.setItem(SessionStorageKeys.LastActivityAt, 'not-a-number');

expect(getLastActivityAt()).toBeNull();
});
});
Loading
Loading