diff --git a/README.md b/README.md index 70b7c82..b1ba8cd 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,43 @@ This project is built with: Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish. +## Session Timeout Behavior + +This application implements automatic session timeout to align with security best practices (OWASP Session Management Cheat Sheet, NIST 800-63B Section 7.1). + +### Backend Configuration + +The backend uses a sliding window session model with two independent timeouts: + +| Timeout | Default | Description | +|---------|---------|-------------| +| Idle | 15 minutes | If no authenticated request is made within this window, the session expires | +| Absolute | 8 hours | Hard cap from session creation. Activity cannot extend past this point | + +Both are configurable via environment variables: `SESSION_IDLE_TIMEOUT` and `SESSION_ABSOLUTE_TIMEOUT` (values in seconds). + +### How It Works + +- **Sliding Window**: Every authenticated request automatically resets the idle clock +- **Active User**: Session keeps extending up to the 8-hour absolute maximum +- **Idle User**: After 15 minutes of inactivity, the session expires and the next request returns 401 +- **Heartbeat**: The frontend sends a periodic `GET /api/v1/auth/me` every 5 minutes to keep sessions alive during passive activities like reading long pages + +### Frontend UX + +- **Warning Dialog**: When the user is within 3 minutes of session expiry, a warning dialog appears with a countdown timer +- **Extend Session**: Users can click "Keep Me Signed In" to refresh the session via `POST /api/v1/auth/sessions/refresh` +- **Graceful Expiry**: When a session expires, the user is redirected to the login page with a gentle message: "Your session has expired due to inactivity" +- **No Hard Logouts**: The frontend never forcefully logs out an active user; expiry only occurs after API confirmation (401 response) + +### API Endpoints + +| Endpoint | Purpose | +|----------|---------| +| `POST /api/v1/auth/sessions/refresh` | Explicitly refresh session (extends idle timeout) | +| `GET /api/v1/auth/sessions` | List all active sessions for the user | +| `DELETE /api/v1/auth/sessions/:id` | Revoke a specific session | + ## Can I connect a custom domain to my Lovable project? Yes, you can! diff --git a/src/App.tsx b/src/App.tsx index 9371f8a..1749584 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -104,6 +104,8 @@ function ScrollToTop() { import { AuthProvider, useAuth } from "@/contexts/AuthContext"; import { OrgProvider } from "@/contexts/OrgContext"; import { Navigate } from "react-router-dom"; +import { useSessionTimeout } from "@/hooks/useSessionTimeout"; +import { SessionWarning } from "@/components/auth/SessionWarning"; /** Redirects already-authenticated users away from guest-only pages (e.g. /login). */ function GuestRoute({ children }: { children: React.ReactNode }) { @@ -148,9 +150,31 @@ function RequireAuth({ children }: { children: React.ReactNode }) { return <>{children}; } +/** + * Handles session timeout detection, warning dialog, and heartbeat. + * Rendered inside AuthProvider so it can access auth state. + */ +function SessionTimeoutManager() { + const { isAuthenticated, logout } = useAuth(); + const { isWarningOpen, countdownSeconds, extendSession } = useSessionTimeout( + isAuthenticated, + logout + ); + + return ( + logout()} + /> + ); +} + function AppRoutes() { return ( + {/* Marketing pages */} diff --git a/src/components/auth/SessionWarning.tsx b/src/components/auth/SessionWarning.tsx new file mode 100644 index 0000000..35900dd --- /dev/null +++ b/src/components/auth/SessionWarning.tsx @@ -0,0 +1,66 @@ +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { AlertTriangle } from 'lucide-react'; + +interface SessionWarningProps { + open: boolean; + countdownSeconds: number; + onExtend: () => void; + onLogout: () => void; +} + +/** + * Warning dialog shown before automatic session timeout. + * Gives the user an opportunity to extend their session. + */ +export function SessionWarning({ + open, + countdownSeconds, + onExtend, + onLogout, +}: SessionWarningProps) { + const minutes = Math.floor(countdownSeconds / 60); + const seconds = countdownSeconds % 60; + const timeDisplay = `${minutes}:${seconds.toString().padStart(2, '0')}`; + + return ( + {}}> + e.preventDefault()}> + + + + Session Expiring Soon + + + Your session will expire in{' '} + + {timeDisplay} + {' '} + due to inactivity. + + + +
+ If you do not extend your session, you will be logged out automatically + and any unsaved work may be lost. +
+ + + + + +
+
+ ); +} diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 883ce89..46677e4 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -21,14 +21,18 @@ interface AuthContextType { canViewSystemLogs: boolean; mfaCompliance: MfaComplianceSummary | null; requiresMfaEnrollment: boolean; + /** True when the previous session ended due to timeout/expiry rather than manual logout. */ + sessionExpired: boolean; login: (email: string, password: string, rememberMe?: boolean, skipNavigate?: boolean) => Promise; verifyTotp: (code: string, isBackupCode?: boolean, skipNavigate?: boolean) => Promise; verifyWebAuthn: () => Promise; - logout: () => Promise; + logout: (expired?: boolean) => Promise; refreshUser: () => Promise; refreshCompliance: () => Promise; /** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */ checkOrgAdmin: () => Promise; + /** Clear the sessionExpired flag (e.g., after showing the message to the user). */ + clearSessionExpired: () => void; } const AuthContext = createContext(null); @@ -87,6 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { const [mfaCompliance, setMfaCompliance] = useState(loadMfaCompliance); const [requiresMfaEnrollment, setRequiresMfaEnrollment] = useState(false); const [isLoading, setIsLoading] = useState(true); + const [sessionExpired, setSessionExpired] = useState(false); const navigate = useNavigate(); // Helper to check if user is admin/owner in any org @@ -215,6 +220,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (response.user) { setUser(response.user); + setSessionExpired(false); if (response.mfa_compliance) { setMfaCompliance(response.mfa_compliance); persistMfaCompliance(response.mfa_compliance); @@ -278,7 +284,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, [navigate, checkOrgAdmin]); - const logout = useCallback(async () => { + const logout = useCallback(async (expired = false) => { try { await api.auth.logout(); } finally { @@ -288,10 +294,15 @@ export function AuthProvider({ children }: { children: ReactNode }) { setMfaCompliance(null); persistMfaCompliance(null); setRequiresMfaEnrollment(false); + setSessionExpired(expired); navigate('/login'); } }, [navigate]); + const clearSessionExpired = useCallback(() => { + setSessionExpired(false); + }, []); + return ( {children} diff --git a/src/hooks/useSessionTimeout.ts b/src/hooks/useSessionTimeout.ts new file mode 100644 index 0000000..4737766 --- /dev/null +++ b/src/hooks/useSessionTimeout.ts @@ -0,0 +1,179 @@ +import { useState, useEffect, useRef, useCallback } from 'react'; +import { api, tokenManager } from '@/lib/api'; + +// Configuration constants (in milliseconds) +const IDLE_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes +const WARNING_BEFORE_MS = 3 * 60 * 1000; // Show warning 3 minutes before timeout +const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000; // Heartbeat every 5 minutes + +interface UseSessionTimeoutReturn { + isWarningOpen: boolean; + countdownSeconds: number; + extendSession: () => Promise; +} + +/** + * Manages session timeout behavior: + * - Detects user activity to track idle time + * - Shows a warning dialog before the session expires + * - Sends periodic heartbeats to keep the session alive during passive activity + * - Automatically logs out when the session expires + * + * The backend sliding window is authoritative; this hook provides UX around it. + */ +export function useSessionTimeout( + isAuthenticated: boolean, + onLogout: () => Promise +): UseSessionTimeoutReturn { + const [isWarningOpen, setIsWarningOpen] = useState(false); + const [countdownSeconds, setCountdownSeconds] = useState(0); + + // Use refs to avoid stale closures in event listeners and intervals + const lastActivityRef = useRef(Date.now()); + const warningShownRef = useRef(false); + const heartbeatIntervalRef = useRef | null>(null); + const countdownIntervalRef = useRef | null>(null); + const activityCheckIntervalRef = useRef | null>(null); + const isAuthenticatedRef = useRef(isAuthenticated); + const onLogoutRef = useRef(onLogout); + + // Keep refs in sync with props + useEffect(() => { + isAuthenticatedRef.current = isAuthenticated; + }, [isAuthenticated]); + + useEffect(() => { + onLogoutRef.current = onLogout; + }, [onLogout]); + + // Update last activity timestamp + const updateActivity = useCallback(() => { + lastActivityRef.current = Date.now(); + }, []); + + // Perform logout when session expires + const performLogout = useCallback(async (reason: string) => { + if (import.meta.env.DEV) { + console.log(`[SessionTimeout] Logging out: ${reason}`); + } + // Clear warning state + setIsWarningOpen(false); + warningShownRef.current = false; + // Perform logout + await onLogoutRef.current(); + }, []); + + // Extend the session by calling the refresh endpoint + const extendSession = useCallback(async () => { + try { + await api.auth.refreshSession(); + // Reset activity and warning state + lastActivityRef.current = Date.now(); + setIsWarningOpen(false); + warningShownRef.current = false; + if (import.meta.env.DEV) { + console.log('[SessionTimeout] Session extended successfully'); + } + } catch (error) { + console.error('[SessionTimeout] Failed to extend session:', error); + // If refresh fails, perform logout + await performLogout('refresh failed'); + } + }, [performLogout]); + + // Setup activity listeners and timers + useEffect(() => { + if (!isAuthenticated) { + // Clean up any existing state when logged out + setIsWarningOpen(false); + warningShownRef.current = false; + return; + } + + // Reset activity timestamp on mount/auth change + lastActivityRef.current = Date.now(); + warningShownRef.current = false; + + // Activity event listeners + const activityEvents = ['mousedown', 'keydown', 'touchstart', 'scroll']; + const handleActivity = () => { + lastActivityRef.current = Date.now(); + + // If warning is open and user becomes active, extend session automatically + if (warningShownRef.current) { + extendSession(); + } + }; + + activityEvents.forEach((event) => { + window.addEventListener(event, handleActivity, { passive: true }); + }); + + // Heartbeat: keep session alive during passive reading + heartbeatIntervalRef.current = setInterval(() => { + if (!isAuthenticatedRef.current) return; + + api.users.me().catch(async (error) => { + if (error?.code === 401) { + await performLogout('heartbeat received 401'); + } + }); + }, HEARTBEAT_INTERVAL_MS); + + // Activity check: determine when to show warning or logout + activityCheckIntervalRef.current = setInterval(() => { + if (!isAuthenticatedRef.current) return; + + const idleTime = Date.now() - lastActivityRef.current; + const timeUntilTimeout = IDLE_TIMEOUT_MS - idleTime; + + // If idle time exceeds timeout, logout + if (idleTime >= IDLE_TIMEOUT_MS) { + performLogout('idle timeout exceeded'); + return; + } + + // Show warning if we're within the warning threshold and not already shown + if (timeUntilTimeout <= WARNING_BEFORE_MS && !warningShownRef.current) { + warningShownRef.current = true; + setIsWarningOpen(true); + setCountdownSeconds(Math.ceil(timeUntilTimeout / 1000)); + + // Start countdown + countdownIntervalRef.current = setInterval(() => { + const remaining = IDLE_TIMEOUT_MS - (Date.now() - lastActivityRef.current); + if (remaining <= 0) { + // Time's up - logout + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + performLogout('countdown reached zero'); + } else { + setCountdownSeconds(Math.ceil(remaining / 1000)); + } + }, 1000); + } + }, 5000); // Check every 5 seconds + + return () => { + activityEvents.forEach((event) => { + window.removeEventListener(event, handleActivity); + }); + if (heartbeatIntervalRef.current) { + clearInterval(heartbeatIntervalRef.current); + } + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + if (activityCheckIntervalRef.current) { + clearInterval(activityCheckIntervalRef.current); + } + }; + }, [isAuthenticated, extendSession, performLogout]); + + return { + isWarningOpen, + countdownSeconds, + extendSession, + }; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index d2a2e78..c3f516a 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -100,6 +100,24 @@ export interface LoginResponse { is_first_user?: boolean; } +export interface Session { + id: string; + created_at: string; + expires_at: string; + last_used_at: string | null; + ip_address: string | null; + user_agent: string | null; + is_current: boolean; +} + +export interface SessionsResponse { + sessions: Session[]; +} + +export interface RefreshSessionResponse { + expires_at: string; +} + export interface TotpEnrollResponse { secret: string; provisioning_uri: string; @@ -333,6 +351,14 @@ export const tokenManager = { hasValidToken: (): boolean => { return tokenManager.getToken() !== null; }, + + updateExpiry: (expiresAt: string): void => { + localStorage.setItem(TOKEN_EXPIRY_KEY, expiresAt); + }, + + getExpiry: (): string | null => { + return localStorage.getItem(TOKEN_EXPIRY_KEY); + }, }; // Error types that indicate the session/token is truly invalid @@ -511,6 +537,22 @@ export const api = { tokenManager.clearToken(); } }, + + refreshSession: async () => { + const response = await request('/auth/sessions/refresh', { + method: 'POST', + }); + tokenManager.updateExpiry(response.expires_at); + return response; + }, + + listSessions: () => + request('/auth/sessions', {}), + + revokeSession: (sessionId: string) => + request<{ message: string }>(`/auth/sessions/${encodeURIComponent(sessionId)}`, { + method: 'DELETE', + }), }, users: { diff --git a/src/pages/auth/LoginPage.tsx b/src/pages/auth/LoginPage.tsx index 7d36387..aebfd97 100644 --- a/src/pages/auth/LoginPage.tsx +++ b/src/pages/auth/LoginPage.tsx @@ -46,7 +46,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise {/* ignore — user will just land on normal login */}); }, [cliToken]); + // Clear session expired flag once the user sees the login page + useEffect(() => { + if (sessionExpired) { + clearSessionExpired(); + } + }, [sessionExpired, clearSessionExpired]); + const finishCliFlow = useCallback((token: string) => { if (!cliRedirectUrl) return false; // cliRedirectUrl already ends with "token=" — just append the value @@ -955,6 +962,12 @@ export default function LoginPage() { {oidcError && (

{oidcError}

)} + {sessionExpired && ( +
+ + Your session has expired due to inactivity. Please sign in again to continue. +
+ )}
diff --git a/tests/api.session.test.ts b/tests/api.session.test.ts new file mode 100644 index 0000000..9d580ae --- /dev/null +++ b/tests/api.session.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { api, tokenManager } from '../src/lib/api'; + +// Mock localStorage for Node.js test environment +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(global, 'localStorage', { + value: localStorageMock, +}); + +describe('Session API', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + global.fetch = fetchMock; + localStorage.clear(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('refreshSession', () => { + it('calls the correct endpoint and updates token expiry', async () => { + const mockResponse = { + success: true, + code: 200, + message: 'Session refreshed', + data: { expires_at: '2026-04-26T18:30:00Z' }, + }; + + fetchMock.mockResolvedValueOnce({ + json: () => Promise.resolve(mockResponse), + }); + + // Set a token first + tokenManager.setToken('test-token', '2026-04-26T17:00:00Z'); + + const result = await api.auth.refreshSession(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/auth/sessions/refresh'), + expect.objectContaining({ + method: 'POST', + headers: expect.objectContaining({ + Authorization: 'Bearer test-token', + }), + }) + ); + expect(result.expires_at).toBe('2026-04-26T18:30:00Z'); + expect(tokenManager.getExpiry()).toBe('2026-04-26T18:30:00Z'); + }); + }); + + describe('listSessions', () => { + it('calls the correct endpoint', async () => { + const mockResponse = { + success: true, + code: 200, + message: 'Sessions listed', + data: { + sessions: [ + { + id: 'sess-1', + created_at: '2026-04-26T10:00:00Z', + expires_at: '2026-04-26T18:00:00Z', + last_used_at: '2026-04-26T17:00:00Z', + ip_address: '127.0.0.1', + user_agent: 'Mozilla/5.0', + is_current: true, + }, + ], + }, + }; + + fetchMock.mockResolvedValueOnce({ + json: () => Promise.resolve(mockResponse), + }); + + tokenManager.setToken('test-token'); + + const result = await api.auth.listSessions(); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/auth/sessions'), + expect.objectContaining({ + credentials: 'include', + }) + ); + expect(result.sessions).toHaveLength(1); + expect(result.sessions[0].id).toBe('sess-1'); + }); + }); + + describe('revokeSession', () => { + it('encodes session ID and calls the correct endpoint', async () => { + const mockResponse = { + success: true, + code: 200, + message: 'Session revoked', + data: {}, + }; + + fetchMock.mockResolvedValueOnce({ + json: () => Promise.resolve(mockResponse), + }); + + tokenManager.setToken('test-token'); + + await api.auth.revokeSession('sess-abc/123'); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/auth/sessions/sess-abc%2F123'), + expect.objectContaining({ + method: 'DELETE', + }) + ); + }); + }); + + describe('tokenManager', () => { + it('updateExpiry sets the expiry in localStorage', () => { + tokenManager.setToken('test-token'); + tokenManager.updateExpiry('2026-04-26T20:00:00Z'); + expect(tokenManager.getExpiry()).toBe('2026-04-26T20:00:00Z'); + }); + + it('getExpiry returns null when no expiry is set', () => { + localStorage.clear(); + expect(tokenManager.getExpiry()).toBeNull(); + }); + }); +}); diff --git a/tests/useSessionTimeout.test.ts b/tests/useSessionTimeout.test.ts new file mode 100644 index 0000000..96a12c2 --- /dev/null +++ b/tests/useSessionTimeout.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useSessionTimeout } from '../src/hooks/useSessionTimeout'; + +// Mock localStorage +const localStorageMock = (() => { + let store: Record = {}; + return { + getItem: (key: string) => store[key] || null, + setItem: (key: string, value: string) => { store[key] = value; }, + removeItem: (key: string) => { delete store[key]; }, + clear: () => { store = {}; }, + }; +})(); + +Object.defineProperty(global, 'localStorage', { + value: localStorageMock, +}); + +// Mock fetch +const fetchMock = vi.fn(); +global.fetch = fetchMock; + +describe('useSessionTimeout', () => { + const mockLogout = vi.fn().mockResolvedValue(undefined); + + beforeEach(() => { + vi.useFakeTimers(); + localStorage.clear(); + fetchMock.mockReset(); + mockLogout.mockClear(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should not show warning when user is active', () => { + const { result } = renderHook(() => + useSessionTimeout(true, mockLogout) + ); + + expect(result.current.isWarningOpen).toBe(false); + }); + + it('should show warning after idle period approaches timeout', () => { + const { result } = renderHook(() => + useSessionTimeout(true, mockLogout) + ); + + // Fast-forward 12 minutes (warning threshold is 3 min before 15 min timeout) + act(() => { + vi.advanceTimersByTime(12 * 60 * 1000); + }); + + // Fast-forward past the activity check interval (5 seconds) + act(() => { + vi.advanceTimersByTime(10 * 1000); + }); + + expect(result.current.isWarningOpen).toBe(true); + expect(result.current.countdownSeconds).toBeGreaterThan(0); + }); + + it('should call logout when session expires', async () => { + const { result } = renderHook(() => + useSessionTimeout(true, mockLogout) + ); + + // Fast-forward past the 15-minute timeout + act(() => { + vi.advanceTimersByTime(16 * 60 * 1000); + }); + + // Wait for async logout + await act(async () => { + vi.advanceTimersByTime(10 * 1000); + }); + + expect(mockLogout).toHaveBeenCalled(); + }); + + it('should extend session when extendSession is called', async () => { + fetchMock.mockResolvedValueOnce({ + json: () => + Promise.resolve({ + success: true, + code: 200, + message: 'Session refreshed', + data: { expires_at: '2026-04-26T20:00:00Z' }, + }), + }); + + const { result } = renderHook(() => + useSessionTimeout(true, mockLogout) + ); + + // Trigger warning + act(() => { + vi.advanceTimersByTime(12 * 60 * 1000); + }); + act(() => { + vi.advanceTimersByTime(10 * 1000); + }); + + expect(result.current.isWarningOpen).toBe(true); + + // Extend session + await act(async () => { + await result.current.extendSession(); + }); + + expect(result.current.isWarningOpen).toBe(false); + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/auth/sessions/refresh'), + expect.objectContaining({ method: 'POST' }) + ); + }); + + it('should send heartbeat periodically', () => { + fetchMock.mockResolvedValue({ + json: () => + Promise.resolve({ + success: true, + code: 200, + message: 'OK', + data: { user: { id: '1', email: 'test@example.com' } }, + }), + }); + + renderHook(() => useSessionTimeout(true, mockLogout)); + + // Fast-forward past heartbeat interval (5 minutes) + act(() => { + vi.advanceTimersByTime(5 * 60 * 1000 + 1000); + }); + + expect(fetchMock).toHaveBeenCalledWith( + expect.stringContaining('/users/me'), + expect.any(Object) + ); + }); + + it('should logout on 401 heartbeat response', async () => { + fetchMock.mockRejectedValueOnce({ code: 401, message: 'Unauthorized' }); + + renderHook(() => useSessionTimeout(true, mockLogout)); + + // Fast-forward past heartbeat interval + await act(async () => { + vi.advanceTimersByTime(5 * 60 * 1000 + 1000); + }); + + // Wait for the async catch block + await act(async () => { + await Promise.resolve(); + }); + + expect(mockLogout).toHaveBeenCalled(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..a024ac1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'vitest/config'; +import path from 'path'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, +});