From 8df2f346c3c485238d0bd40274b62a8d357ce45b Mon Sep 17 00:00:00 2001 From: No-bodyq Date: Sun, 28 Jun 2026 12:43:15 +0100 Subject: [PATCH] enforce hard session expiry on foreground and in request interceptor --- App.tsx | 20 ++-- src/__tests__/services/sessionExpiry.test.ts | 98 ++++++++++++++++++++ src/services/api/axios.config.ts | 13 +++ src/services/secureStorage.ts | 28 ++++++ 4 files changed, 145 insertions(+), 14 deletions(-) create mode 100644 src/__tests__/services/sessionExpiry.test.ts diff --git a/App.tsx b/App.tsx index 2693fab..5c04907 100644 --- a/App.tsx +++ b/App.tsx @@ -39,7 +39,7 @@ import { } from './src/services/pushNotifications'; import { requestQueue } from './src/services/requestQueue'; import { searchIndexService } from './src/services/searchIndex'; -import { initializeSecureStorage } from './src/services/secureStorage'; +import { checkSessionValidity, initializeSecureStorage } from './src/services/secureStorage'; import socketService from './src/services/socket'; import { syncService } from './src/services/syncService'; // Fixed naming convention from the merge conflict import { useAppStore, useDeviceStore, useNotificationStore } from './src/store'; // Added missing store imports @@ -220,8 +220,6 @@ const App = () => { prepareApp(); }, []); - const SESSION_REFRESH_WINDOW_MS = 5 * 60 * 1000; - useEffect(() => { // ===== CRITICAL PATH — runs immediately ===== // These tasks are essential for core app functionality and must complete @@ -394,30 +392,24 @@ const App = () => { const { isAuthenticated, refreshToken, - sessionExpiresAt, setUser, setTokens, setSessionExpiringSoon, logout, } = useAppStore.getState(); - if (!isAuthenticated || !refreshToken || !sessionExpiresAt) { - return; - } + if (!isAuthenticated || !refreshToken) return; - const now = Date.now(); - const msUntilExpiry = sessionExpiresAt - now; + const { valid, expiringSoon } = await checkSessionValidity(); - if (msUntilExpiry <= 0) { + if (!valid) { logout(); Alert.alert('Session expired', 'Your session has expired. Please log in again.'); return; } - if (msUntilExpiry <= SESSION_REFRESH_WINDOW_MS) { + if (expiringSoon) { setSessionExpiringSoon(true); - Alert.alert('Session expiring soon', 'Refreshing your session to keep you signed in.'); - try { const refreshedSession = await mobileAuthService.refreshSession(); setUser(refreshedSession.user); @@ -459,7 +451,7 @@ const App = () => { return () => { appStateSubscription.remove(); }; - }, [SESSION_REFRESH_WINDOW_MS]); + }, []); if (!appIsReady) { return null; diff --git a/src/__tests__/services/sessionExpiry.test.ts b/src/__tests__/services/sessionExpiry.test.ts new file mode 100644 index 0000000..61e0f51 --- /dev/null +++ b/src/__tests__/services/sessionExpiry.test.ts @@ -0,0 +1,98 @@ +import { checkSessionValidity } from '../../services/secureStorage'; + +jest.mock('expo-secure-store', () => ({ + getItemAsync: jest.fn(), + setItemAsync: jest.fn(), + deleteItemAsync: jest.fn(), + WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'WHEN_UNLOCKED_THIS_DEVICE_ONLY', +})); + +jest.mock('../../utils/logger', () => ({ + default: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('../../config', () => ({ + getEnv: jest.fn(() => 'https://api.test.example.com'), +})); + +import * as SecureStore from 'expo-secure-store'; + +const mockGetItem = SecureStore.getItemAsync as jest.Mock; + +const NOW = 1_700_000_000_000; + +beforeEach(() => { + jest.useFakeTimers({ now: NOW }); + mockGetItem.mockReset(); +}); + +afterEach(() => { + jest.useRealTimers(); +}); + +describe('checkSessionValidity', () => { + it('returns invalid when no sessionExpiresAt is stored', async () => { + // access token present, session expires at absent + mockGetItem.mockResolvedValue(null); + + const result = await checkSessionValidity(); + + expect(result.valid).toBe(false); + expect(result.expiringSoon).toBe(false); + expect(result.msUntilExpiry).toBe(0); + }); + + it('returns invalid when session is already expired', async () => { + const expiredAt = NOW - 60_000; // expired 1 minute ago + mockGetItem.mockResolvedValue(String(expiredAt)); + + const result = await checkSessionValidity(); + + expect(result.valid).toBe(false); + expect(result.expiringSoon).toBe(false); + expect(result.msUntilExpiry).toBeLessThan(0); + }); + + it('returns valid + expiringSoon when session expires within 5 minutes', async () => { + const expiresAt = NOW + 3 * 60 * 1_000; // 3 minutes from now + mockGetItem.mockResolvedValue(String(expiresAt)); + + const result = await checkSessionValidity(); + + expect(result.valid).toBe(true); + expect(result.expiringSoon).toBe(true); + expect(result.msUntilExpiry).toBeCloseTo(3 * 60 * 1_000, -2); + }); + + it('returns valid + not expiringSoon when session has > 5 minutes remaining', async () => { + const expiresAt = NOW + 30 * 60 * 1_000; // 30 minutes from now + mockGetItem.mockResolvedValue(String(expiresAt)); + + const result = await checkSessionValidity(); + + expect(result.valid).toBe(true); + expect(result.expiringSoon).toBe(false); + }); + + it('treats a session expiring in exactly 5 minutes as expiringSoon', async () => { + const expiresAt = NOW + 5 * 60 * 1_000 - 1; // 1 ms inside the window + mockGetItem.mockResolvedValue(String(expiresAt)); + + const result = await checkSessionValidity(); + + expect(result.expiringSoon).toBe(true); + }); + + it('treats a session expiring in exactly 5 minutes + 1 ms as not expiringSoon', async () => { + const expiresAt = NOW + 5 * 60 * 1_000 + 1; // 1 ms outside the window + mockGetItem.mockResolvedValue(String(expiresAt)); + + const result = await checkSessionValidity(); + + expect(result.expiringSoon).toBe(false); + }); +}); diff --git a/src/services/api/axios.config.ts b/src/services/api/axios.config.ts index 8b2663b..114091c 100644 --- a/src/services/api/axios.config.ts +++ b/src/services/api/axios.config.ts @@ -150,6 +150,19 @@ apiClient.interceptors.request.use( return config; } + // Hard-block any authenticated request when the session has already expired. + // The foreground check in App.tsx handles proactive refresh; this is the + // safety net for requests that slip through while the app is in use. + const { isAuthenticated, sessionExpiresAt } = useAppStore.getState(); + if (isAuthenticated && sessionExpiresAt !== null && Date.now() >= sessionExpiresAt) { + useAppStore.getState().logout(); + return Promise.reject({ + message: 'Session expired. Please log in again.', + code: 'SESSION_EXPIRED', + status: 401, + }); + } + const token = await getAccessToken(); if (token) { diff --git a/src/services/secureStorage.ts b/src/services/secureStorage.ts index 19cf2fe..41e2e83 100644 --- a/src/services/secureStorage.ts +++ b/src/services/secureStorage.ts @@ -377,6 +377,34 @@ export async function clearAllAuthData(): Promise { // ─── Session validity ───────────────────────────────────────────────────────── +const SESSION_EXPIRY_SOON_MS = 5 * 60 * 1_000; // 5 minutes + +export interface SessionValidityResult { + valid: boolean; + expiringSoon: boolean; + msUntilExpiry: number; +} + +/** + * Check if the user session is valid based on stored expiration time. + * Reads from SecureStore — use this as the authoritative source on foreground. + */ +export async function checkSessionValidity(): Promise { + const expiresAt = await getSessionExpiresAt(); + + if (!expiresAt) return { valid: false, expiringSoon: false, msUntilExpiry: 0 }; + + const msUntilExpiry = expiresAt - Date.now(); + + if (msUntilExpiry <= 0) return { valid: false, expiringSoon: false, msUntilExpiry }; + + return { + valid: true, + expiringSoon: msUntilExpiry < SESSION_EXPIRY_SOON_MS, + msUntilExpiry, + }; +} + /** * Check if the user session is valid based on stored expiration time */