Compare commits
1 Commits
main
...
oidc-client
| Author | SHA1 | Date | |
|---|---|---|---|
| 9e5427a262 |
@@ -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!
|
||||
|
||||
+24
@@ -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 (
|
||||
<SessionWarning
|
||||
open={isWarningOpen}
|
||||
countdownSeconds={countdownSeconds}
|
||||
onExtend={extendSession}
|
||||
onLogout={() => logout()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SessionTimeoutManager />
|
||||
<OrgProvider>
|
||||
<Routes>
|
||||
{/* Marketing pages */}
|
||||
|
||||
@@ -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 (
|
||||
<Dialog open={open} onOpenChange={() => {}}>
|
||||
<DialogContent className="sm:max-w-md" onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-amber-500" />
|
||||
Session Expiring Soon
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Your session will expire in{' '}
|
||||
<span className="font-mono font-semibold text-foreground">
|
||||
{timeDisplay}
|
||||
</span>{' '}
|
||||
due to inactivity.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-2 text-sm text-muted-foreground">
|
||||
If you do not extend your session, you will be logged out automatically
|
||||
and any unsaved work may be lost.
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-col gap-2 sm:flex-row sm:justify-end">
|
||||
<Button variant="outline" onClick={onLogout}>
|
||||
Log Out Now
|
||||
</Button>
|
||||
<Button onClick={onExtend} autoFocus>
|
||||
Keep Me Signed In
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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<LoginResult>;
|
||||
verifyTotp: (code: string, isBackupCode?: boolean, skipNavigate?: boolean) => Promise<void>;
|
||||
verifyWebAuthn: () => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
logout: (expired?: boolean) => Promise<void>;
|
||||
refreshUser: () => Promise<void>;
|
||||
refreshCompliance: () => Promise<void>;
|
||||
/** Re-check org membership & admin status. Exposed so post-setup pages can update the context. */
|
||||
checkOrgAdmin: () => Promise<void>;
|
||||
/** Clear the sessionExpired flag (e.g., after showing the message to the user). */
|
||||
clearSessionExpired: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
@@ -87,6 +91,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [mfaCompliance, setMfaCompliance] = useState<MfaComplianceSummary | null>(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 (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
@@ -303,6 +314,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
canViewSystemLogs: user?.can_view_system_logs ?? false,
|
||||
mfaCompliance,
|
||||
requiresMfaEnrollment,
|
||||
sessionExpired,
|
||||
login,
|
||||
verifyTotp,
|
||||
verifyWebAuthn,
|
||||
@@ -310,6 +322,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
refreshUser,
|
||||
refreshCompliance,
|
||||
checkOrgAdmin,
|
||||
clearSessionExpired,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void>
|
||||
): 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<number>(Date.now());
|
||||
const warningShownRef = useRef<boolean>(false);
|
||||
const heartbeatIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const activityCheckIntervalRef = useRef<ReturnType<typeof setInterval> | 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,
|
||||
};
|
||||
}
|
||||
@@ -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<RefreshSessionResponse>('/auth/sessions/refresh', {
|
||||
method: 'POST',
|
||||
});
|
||||
tokenManager.updateExpiry(response.expires_at);
|
||||
return response;
|
||||
},
|
||||
|
||||
listSessions: () =>
|
||||
request<SessionsResponse>('/auth/sessions', {}),
|
||||
|
||||
revokeSession: (sessionId: string) =>
|
||||
request<{ message: string }>(`/auth/sessions/${encodeURIComponent(sessionId)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
},
|
||||
|
||||
users: {
|
||||
|
||||
@@ -46,7 +46,7 @@ async function completeOidcFlow(oidcSessionId: string, token: string): Promise<s
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const { login, verifyTotp, refreshUser, user, isLoading: authLoading, checkOrgAdmin } = useAuth();
|
||||
const { login, verifyTotp, refreshUser, user, isLoading: authLoading, checkOrgAdmin, sessionExpired, clearSessionExpired } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [searchParams] = useSearchParams();
|
||||
@@ -88,6 +88,13 @@ export default function LoginPage() {
|
||||
.catch(() => {/* 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 && (
|
||||
<p className="text-sm text-destructive mt-2">{oidcError}</p>
|
||||
)}
|
||||
{sessionExpired && (
|
||||
<div className="mt-4 p-3 rounded-md bg-amber-50 border border-amber-200 text-amber-800 text-sm flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>Your session has expired due to inactivity. Please sign in again to continue.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
@@ -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<string, string> = {};
|
||||
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<typeof vi.fn>;
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<string, string> = {};
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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'),
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user