Skip to content
Merged
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
20 changes: 6 additions & 14 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -459,7 +451,7 @@ const App = () => {
return () => {
appStateSubscription.remove();
};
}, [SESSION_REFRESH_WINDOW_MS]);
}, []);

if (!appIsReady) {
return null;
Expand Down
98 changes: 98 additions & 0 deletions src/__tests__/services/sessionExpiry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions src/services/api/axios.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
28 changes: 28 additions & 0 deletions src/services/secureStorage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,34 @@ export async function clearAllAuthData(): Promise<void> {

// ─── 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<SessionValidityResult> {
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
*/
Expand Down
Loading