GT AI OS Community Edition v2.0.33
Security hardening release addressing CodeQL and Dependabot alerts: - Fix stack trace exposure in error responses - Add SSRF protection with DNS resolution checking - Implement proper URL hostname validation (replaces substring matching) - Add centralized path sanitization to prevent path traversal - Fix ReDoS vulnerability in email validation regex - Improve HTML sanitization in validation utilities - Fix capability wildcard matching in auth utilities - Update glob dependency to address CVE - Add CodeQL suppression comments for verified false positives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
192
apps/tenant-app/src/providers/idle-timer-provider.tsx
Normal file
192
apps/tenant-app/src/providers/idle-timer-provider.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useIdleTimer } from 'react-idle-timer';
|
||||
import { useAuthStore, useHasHydrated } from '@/stores/auth-store';
|
||||
import { refreshToken } from '@/services/auth';
|
||||
import { SessionTimeoutModal } from '@/components/session/session-timeout-modal';
|
||||
import { SESSION_CONFIG } from '@/config/session';
|
||||
|
||||
interface IdleTimerProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* GT 2.0 Idle Timer Provider
|
||||
*
|
||||
* OWASP/NIST Compliant Session Management (Issues #242, #264)
|
||||
*
|
||||
* This provider implements a hybrid approach:
|
||||
* - Server-side session tracking is AUTHORITATIVE (Issue #264)
|
||||
* - Client-side IdleTimer provides UX enhancement and backup
|
||||
*
|
||||
* Server signals processed:
|
||||
* - X-Session-Warning: <seconds> - Server says session is about to expire
|
||||
* - X-Session-Expired: idle|absolute - Server says session has expired
|
||||
*
|
||||
* Client-side configuration (secondary to server):
|
||||
* - Production: 30 minutes total, warning at 25 minutes (5 min before)
|
||||
* - Multi-tab sync: enabled via crossTab
|
||||
* - Only active when authenticated
|
||||
*
|
||||
* Uses react-idle-timer with promptBeforeIdle pattern
|
||||
* @see https://idletimer.dev for documentation
|
||||
* @see SESSION_CONFIG for timeout values
|
||||
*/
|
||||
export function IdleTimerProvider({ children }: IdleTimerProviderProps) {
|
||||
const { isAuthenticated, logout, setToken } = useAuthStore();
|
||||
const hasHydrated = useHasHydrated(); // Wait for Zustand to hydrate from localStorage
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('[IdleTimer] Provider mounted, isAuthenticated:', isAuthenticated, 'hasHydrated:', hasHydrated);
|
||||
console.log('[IdleTimer] Config:', {
|
||||
timeout: SESSION_CONFIG.TIMEOUT_MS,
|
||||
promptBeforeIdle: SESSION_CONFIG.PROMPT_BEFORE_IDLE_MS,
|
||||
});
|
||||
}, [isAuthenticated, hasHydrated]);
|
||||
|
||||
// Listen for server-side session signals (Issue #264)
|
||||
// The server is the authoritative source of truth for session state
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const handleServerWarning = (event: CustomEvent<{ secondsRemaining: number }>) => {
|
||||
console.log('[IdleTimer] Server session warning received:', event.detail.secondsRemaining, 'seconds remaining');
|
||||
// Show the warning modal if not already showing
|
||||
if (!showModal) {
|
||||
setShowModal(true);
|
||||
setRemainingTime(event.detail.secondsRemaining);
|
||||
}
|
||||
};
|
||||
|
||||
const handleServerExpired = (event: CustomEvent<{ reason: string }>) => {
|
||||
console.log('[IdleTimer] Server session expired:', event.detail.reason);
|
||||
setShowModal(false);
|
||||
// Map server expiry reason to logout reason
|
||||
const logoutReason = event.detail.reason === 'absolute' ? 'session_expired' : 'expired';
|
||||
logout(logoutReason);
|
||||
};
|
||||
|
||||
window.addEventListener('session-warning', handleServerWarning as EventListener);
|
||||
window.addEventListener('session-expired', handleServerExpired as EventListener);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('session-warning', handleServerWarning as EventListener);
|
||||
window.removeEventListener('session-expired', handleServerExpired as EventListener);
|
||||
};
|
||||
}, [showModal, logout]);
|
||||
|
||||
const handleOnPrompt = useCallback(() => {
|
||||
console.log('[IdleTimer] Session expiring soon - showing warning modal');
|
||||
setShowModal(true);
|
||||
// Initial remaining time in seconds
|
||||
setRemainingTime(Math.floor(SESSION_CONFIG.PROMPT_BEFORE_IDLE_MS / 1000));
|
||||
}, []);
|
||||
|
||||
const handleOnIdle = useCallback(() => {
|
||||
console.log('[IdleTimer] Session expired due to inactivity - logging out');
|
||||
setShowModal(false);
|
||||
logout('expired');
|
||||
}, [logout]);
|
||||
|
||||
const handleOnActive = useCallback(() => {
|
||||
// Note: onActive only fires when activate() is called while isPrompted
|
||||
// Not when user moves mouse during countdown
|
||||
console.log('[IdleTimer] Session reactivated');
|
||||
setShowModal(false);
|
||||
}, []);
|
||||
|
||||
const { activate, getRemainingTime, isPrompted } = useIdleTimer({
|
||||
// Total inactivity timeout (see SESSION_CONFIG for values)
|
||||
timeout: SESSION_CONFIG.TIMEOUT_MS,
|
||||
// Show prompt before timeout (see SESSION_CONFIG for values)
|
||||
promptBeforeIdle: SESSION_CONFIG.PROMPT_BEFORE_IDLE_MS,
|
||||
// Event handlers
|
||||
onPrompt: handleOnPrompt,
|
||||
onIdle: handleOnIdle,
|
||||
onActive: handleOnActive,
|
||||
// Events to track (react-idle-timer defaults + focus)
|
||||
events: SESSION_CONFIG.EVENTS as unknown as string[],
|
||||
// Throttle event processing for performance
|
||||
eventsThrottle: SESSION_CONFIG.EVENTS_THROTTLE_MS,
|
||||
// Multi-tab synchronization via BroadcastChannel
|
||||
crossTab: true,
|
||||
syncTimers: 200,
|
||||
// Only run when authenticated AND after Zustand has hydrated from localStorage
|
||||
// Fix for Issue #264: Timer was disabled during hydration race condition
|
||||
disabled: !hasHydrated || !isAuthenticated,
|
||||
// Start automatically when mounted
|
||||
startOnMount: true,
|
||||
});
|
||||
|
||||
// Update countdown every second when modal is shown
|
||||
useEffect(() => {
|
||||
let interval: NodeJS.Timeout | null = null;
|
||||
|
||||
if (showModal && isPrompted()) {
|
||||
interval = setInterval(() => {
|
||||
const remaining = Math.ceil(getRemainingTime() / 1000);
|
||||
setRemainingTime(remaining);
|
||||
|
||||
// Safety check - if remaining hits 0, ensure we log out
|
||||
if (remaining <= 0) {
|
||||
setShowModal(false);
|
||||
logout('expired');
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
};
|
||||
}, [showModal, isPrompted, getRemainingTime, logout]);
|
||||
|
||||
const handleExtendSession = useCallback(async () => {
|
||||
console.log('[IdleTimer] User extended session - refreshing token');
|
||||
try {
|
||||
// Refresh the JWT token (NIST/OWASP compliant - Issue #242)
|
||||
const result = await refreshToken();
|
||||
|
||||
if (result.success) {
|
||||
console.log('[IdleTimer] Token refreshed successfully');
|
||||
// Reset the idle timer and hide modal
|
||||
activate();
|
||||
setShowModal(false);
|
||||
} else if (result.error === 'absolute_timeout') {
|
||||
// 8-hour absolute session limit reached (Issue #242)
|
||||
// Show different message - user MUST re-login
|
||||
console.log('[IdleTimer] Absolute session timeout (8 hours) - forcing re-login');
|
||||
logout('session_expired');
|
||||
} else {
|
||||
console.error('[IdleTimer] Token refresh failed - logging out');
|
||||
logout('expired');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[IdleTimer] Error refreshing token:', error);
|
||||
logout('expired');
|
||||
}
|
||||
}, [activate, logout]);
|
||||
|
||||
const handleLogoutNow = useCallback(() => {
|
||||
console.log('[IdleTimer] User clicked logout from warning modal');
|
||||
setShowModal(false);
|
||||
logout('manual');
|
||||
}, [logout]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<SessionTimeoutModal
|
||||
open={showModal}
|
||||
remainingTime={remainingTime}
|
||||
onExtendSession={handleExtendSession}
|
||||
onLogout={handleLogoutNow}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
158
apps/tenant-app/src/providers/session-monitor.tsx
Normal file
158
apps/tenant-app/src/providers/session-monitor.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAuthStore, useHasHydrated } from '@/stores/auth-store';
|
||||
import { getAuthToken } from '@/services/auth';
|
||||
import { SessionTimeoutModal } from '@/components/session/session-timeout-modal';
|
||||
|
||||
interface SessionMonitorProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Polling interval: 60 seconds
|
||||
const POLL_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
/**
|
||||
* GT 2.0 Session Monitor (NIST SP 800-63B AAL2 Compliant)
|
||||
*
|
||||
* Server-authoritative session management with two timeout types:
|
||||
* - Idle timeout (30 min): Resets with activity - polling acts as heartbeat
|
||||
* - Absolute timeout (12 hr): Cannot be extended - forces re-authentication
|
||||
*
|
||||
* How it works:
|
||||
* 1. Polls /api/v1/auth/session/status every 60 seconds
|
||||
* 2. Polling resets idle timeout, so active users won't hit idle limit
|
||||
* 3. When absolute timeout < 30 min remaining, show informational notice
|
||||
* 4. User acknowledges notice (can't extend absolute timeout)
|
||||
* 5. If is_valid=false, force logout
|
||||
*/
|
||||
export function SessionMonitor({ children }: SessionMonitorProps) {
|
||||
const { isAuthenticated, logout } = useAuthStore();
|
||||
const hasHydrated = useHasHydrated();
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [remainingTime, setRemainingTime] = useState(0);
|
||||
const [hasAcknowledged, setHasAcknowledged] = useState(false);
|
||||
const intervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const countdownRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Check session status from server
|
||||
const checkSessionStatus = useCallback(async () => {
|
||||
// Get fresh token from store (not stale closure value) to avoid race condition
|
||||
const currentToken = getAuthToken();
|
||||
if (!isAuthenticated || !currentToken) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/auth/session/status', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${currentToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 503) {
|
||||
// Session expired or service unavailable - logout
|
||||
console.log('[SessionMonitor] Session invalid, logging out');
|
||||
logout('expired');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('[SessionMonitor] Session check failed:', response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.is_valid) {
|
||||
// Session expired
|
||||
console.log('[SessionMonitor] Server says session invalid');
|
||||
logout('expired');
|
||||
return;
|
||||
}
|
||||
|
||||
// Update remaining time (use absolute timeout for display)
|
||||
if (data.absolute_seconds_remaining !== null) {
|
||||
setRemainingTime(data.absolute_seconds_remaining);
|
||||
}
|
||||
|
||||
// Show warning if needed (and user hasn't already acknowledged)
|
||||
if (data.show_warning && !showModal && !hasAcknowledged) {
|
||||
console.log('[SessionMonitor] Absolute timeout approaching, showing notice');
|
||||
setShowModal(true);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SessionMonitor] Error checking session:', error);
|
||||
// On network error, don't logout immediately - wait for next poll
|
||||
}
|
||||
}, [isAuthenticated, showModal, hasAcknowledged, logout]);
|
||||
|
||||
// Start/stop polling based on auth state
|
||||
useEffect(() => {
|
||||
if (!hasHydrated || !isAuthenticated) {
|
||||
// Clear any existing interval and reset state
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
setHasAcknowledged(false); // Reset on logout
|
||||
return;
|
||||
}
|
||||
|
||||
// Initial check
|
||||
checkSessionStatus();
|
||||
|
||||
// Start polling
|
||||
intervalRef.current = setInterval(checkSessionStatus, POLL_INTERVAL_MS);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [hasHydrated, isAuthenticated, checkSessionStatus]);
|
||||
|
||||
// Countdown timer when modal is shown
|
||||
useEffect(() => {
|
||||
if (showModal && remainingTime > 0) {
|
||||
countdownRef.current = setInterval(() => {
|
||||
setRemainingTime(prev => {
|
||||
const newTime = prev - 1;
|
||||
if (newTime <= 0) {
|
||||
// Time's up - logout
|
||||
setShowModal(false);
|
||||
logout('expired');
|
||||
return 0;
|
||||
}
|
||||
return newTime;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (countdownRef.current) {
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [showModal, logout]);
|
||||
|
||||
// Handle "I Understand" button - just dismiss the notice
|
||||
const handleAcknowledge = useCallback(() => {
|
||||
console.log('[SessionMonitor] User acknowledged session expiration notice');
|
||||
setShowModal(false);
|
||||
setHasAcknowledged(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<SessionTimeoutModal
|
||||
open={showModal}
|
||||
remainingTime={remainingTime}
|
||||
onAcknowledge={handleAcknowledge}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user