Re_Figma_Code/src/contexts/AuthContext.tsx

698 lines
23 KiB
TypeScript

/**
* Authentication Context
* Provides unified authentication interface that works with both:
* - Backend token exchange (localhost/development)
* - Auth0 Provider (production)
*/
import { createContext, useContext, useEffect, useState, ReactNode, useRef } from 'react';
import { Auth0Provider, useAuth0 as useAuth0Hook } from '@auth0/auth0-react';
import { TokenManager, isTokenExpired } from '../utils/tokenManager';
import { exchangeCodeForTokens, refreshAccessToken, getCurrentUser, logout as logoutApi } from '../services/authApi';
interface User {
userId?: string;
employeeId?: string;
email?: string;
firstName?: string;
lastName?: string;
displayName?: string;
department?: string;
designation?: string;
role?: 'USER' | 'MANAGEMENT' | 'ADMIN';
sub?: string;
name?: string;
picture?: string;
nickname?: string;
}
interface AuthContextType {
isAuthenticated: boolean;
isLoading: boolean;
user: User | null;
error: Error | null;
login: () => Promise<void>;
logout: () => Promise<void>;
getAccessTokenSilently: () => Promise<string | null>;
refreshTokenSilently: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
interface AuthProviderProps {
children: ReactNode;
}
/**
* Check if running on localhost
* Note: Function reserved for future use
* @internal - Reserved for future use
*/
export const _isLocalhost = (): boolean => {
return (
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1' ||
window.location.hostname === ''
);
};
/**
* Backend-based Auth Provider (for localhost)
*/
function BackendAuthProvider({ children }: { children: ReactNode }) {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [user, setUser] = useState<User | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Check authentication status on mount
// Only check if we're not in the middle of a logout process
useEffect(() => {
// PRIORITY 1: Check for logout flags in sessionStorage (survives page reload during logout)
const logoutFlag = sessionStorage.getItem('__logout_in_progress__');
const forceLogout = sessionStorage.getItem('__force_logout__');
if (logoutFlag === 'true' || forceLogout === 'true') {
// Remove flags
sessionStorage.removeItem('__logout_in_progress__');
sessionStorage.removeItem('__force_logout__');
// Clear all tokens one more time (aggressive)
TokenManager.clearAll();
// Also manually clear everything
try {
localStorage.clear();
sessionStorage.clear();
} catch (e) {
console.error('Error clearing storage:', e);
}
// Set unauthenticated state
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
setError(null);
return;
}
// PRIORITY 2: Check if URL has logout parameter (from redirect)
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('logout') || urlParams.has('okta_logged_out')) {
TokenManager.clearAll();
localStorage.clear();
sessionStorage.clear();
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
// Clean URL but preserve okta_logged_out flag if it exists (for prompt=login)
const cleanParams = new URLSearchParams();
if (urlParams.has('okta_logged_out')) {
cleanParams.set('okta_logged_out', 'true');
}
const newUrl = cleanParams.toString() ? `/?${cleanParams.toString()}` : '/';
window.history.replaceState({}, document.title, newUrl);
return;
}
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first
// This is critical for production mode where we need to exchange code for tokens
// before we can verify session with server
if (window.location.pathname === '/login/callback') {
// Don't check auth status here - let the callback handler do its job
// The callback handler will set isAuthenticated after successful token exchange
return;
}
// PRIORITY 4: Check authentication status
const token = TokenManager.getAccessToken();
const refreshToken = TokenManager.getRefreshToken();
const userData = TokenManager.getUserData();
const hasAuthData = token || refreshToken || userData;
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In production: Always verify with server (cookies are sent automatically)
// In development: Check local auth data first
if (isProductionMode) {
// Production: Verify session with server via httpOnly cookie
if (!isLoggingOut) {
checkAuthStatus();
} else {
setIsLoading(false);
}
} else {
// Development: If no auth data exists, user is not authenticated
if (!hasAuthData) {
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
if (!isLoggingOut) {
checkAuthStatus();
} else {
setIsLoading(false);
}
}
}, [isLoggingOut]);
// Silent refresh interval
useEffect(() => {
if (!isAuthenticated) return;
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
const checkAndRefresh = async () => {
if (isProductionMode) {
// In production, proactively refresh the session every 10 minutes
// The httpOnly cookie will be sent automatically
try {
await refreshTokenSilently();
} catch (error) {
console.error('Silent refresh failed:', error);
}
} else {
// In development, check token expiration
const token = TokenManager.getAccessToken();
if (token && isTokenExpired(token, 5)) {
// Token expires in less than 5 minutes, refresh it
try {
await refreshTokenSilently();
} catch (error) {
console.error('Silent refresh failed:', error);
}
}
}
};
// Check every 10 minutes in production, 5 minutes in development
const intervalMs = isProductionMode ? 10 * 60 * 1000 : 5 * 60 * 1000;
const interval = setInterval(checkAndRefresh, intervalMs);
return () => clearInterval(interval);
}, [isAuthenticated]);
// Handle callback from OAuth redirect
// Use ref to prevent duplicate calls (React StrictMode runs effects twice in dev)
const callbackProcessedRef = useRef(false);
useEffect(() => {
// Skip if already processed or not on callback page
if (callbackProcessedRef.current || window.location.pathname !== '/login/callback') {
return;
}
const handleCallback = async () => {
// Mark as processed immediately to prevent duplicate calls
callbackProcessedRef.current = true;
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
const errorParam = urlParams.get('error');
// Clean URL immediately to prevent re-running on re-renders
window.history.replaceState({}, document.title, '/login/callback');
if (errorParam) {
setError(new Error(`Authentication error: ${errorParam}`));
setIsLoading(false);
return;
}
if (!code) {
setIsLoading(false);
return;
}
try {
setIsLoading(true);
setIsAuthenticated(false);
setError(null);
// IMPORTANT: redirectUri must match the one used in initial Okta authorization request
// This is the frontend callback URL, NOT the backend URL
// Backend will use this same URI when exchanging code with Okta
const redirectUri = `${window.location.origin}/login/callback`;
const result = await exchangeCodeForTokens(code, redirectUri);
setUser(result.user);
setIsAuthenticated(true);
setError(null);
// Clean URL after success
window.history.replaceState({}, document.title, '/');
} catch (err: any) {
console.error('❌ Token exchange error in AuthContext:', err);
setError(err);
setIsAuthenticated(false);
setUser(null);
// Reset ref on error so user can retry if needed
callbackProcessedRef.current = false;
} finally {
setIsLoading(false);
}
};
handleCallback();
}, []); // Empty deps - only run once on mount
const checkAuthStatus = async () => {
// Don't check auth status if we're in the middle of logging out
if (isLoggingOut) {
setIsLoading(false);
return;
}
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
setIsLoading(true);
// PRODUCTION MODE: Verify session via httpOnly cookie
// The cookie is sent automatically with the request (withCredentials: true)
if (isProductionMode) {
const storedUser = TokenManager.getUserData();
// Try to get current user from server - this validates the httpOnly cookie
try {
const userData = await getCurrentUser();
setUser(userData);
TokenManager.setUserData(userData);
setIsAuthenticated(true);
} catch (error: any) {
// If 401, try to refresh the token (refresh token is also in httpOnly cookie)
if (error?.response?.status === 401) {
try {
await refreshTokenSilently();
// Retry getting user after refresh
const userData = await getCurrentUser();
setUser(userData);
TokenManager.setUserData(userData);
setIsAuthenticated(true);
} catch {
// Refresh failed - clear user data and show login
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
} else if (error?.isConnectionError) {
// Backend not reachable - use stored user data if available
if (storedUser) {
setUser(storedUser);
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
setUser(null);
}
} else {
// Other error - clear and show login
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
}
return;
}
// DEVELOPMENT MODE: Check local token
const token = TokenManager.getAccessToken();
const storedUser = TokenManager.getUserData();
// If no token at all, user is not authenticated
if (!token) {
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
// Check if token is expired
if (isTokenExpired(token)) {
// Token expired, try to refresh
try {
await refreshTokenSilently();
// Refresh succeeded, check again
const newToken = TokenManager.getAccessToken();
if (newToken && !isTokenExpired(newToken)) {
const refreshedUser = TokenManager.getUserData();
if (refreshedUser) {
setUser(refreshedUser);
setIsAuthenticated(true);
} else {
// Fetch user data
try {
const userData = await getCurrentUser();
setUser(userData);
TokenManager.setUserData(userData);
setIsAuthenticated(true);
} catch {
// Token might be invalid, clear everything
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
}
} else {
// Refresh failed, user is logged out
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
} catch {
// Refresh failed, clear everything
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
} else {
// Token is valid
if (storedUser) {
setUser(storedUser);
setIsAuthenticated(true);
} else {
// Fetch user data to ensure token is still valid
try {
const userData = await getCurrentUser();
setUser(userData);
TokenManager.setUserData(userData);
setIsAuthenticated(true);
} catch {
// Token might be invalid, clear everything
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
}
}
} catch (err: any) {
console.error('Error checking auth status:', err);
setError(err);
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
} finally {
setIsLoading(false);
}
};
const login = async () => {
try {
setError(null);
// Redirect to Okta login
const oktaDomain = import.meta.env.VITE_OKTA_DOMAIN || 'https://dev-830839.oktapreview.com';
const clientId = import.meta.env.VITE_OKTA_CLIENT_ID || '0oa2jgzvrpdwx2iqd0h8';
const redirectUri = `${window.location.origin}/login/callback`;
const responseType = 'code';
const scope = 'openid profile email';
const state = Math.random().toString(36).substring(7);
// Check if we're coming from a logout - if so, add prompt=login to force re-authentication
const urlParams = new URLSearchParams(window.location.search);
const isAfterLogout = urlParams.has('logout') || urlParams.has('okta_logged_out');
let authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
`client_id=${clientId}&` +
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
`response_type=${responseType}&` +
`scope=${encodeURIComponent(scope)}&` +
`state=${state}`;
// Add prompt=login if coming from logout to force re-authentication
// This ensures Okta requires login even if a session still exists
if (isAfterLogout) {
authUrl += `&prompt=login`;
}
window.location.href = authUrl;
} catch (err: any) {
setError(err);
throw err;
}
};
const logout = async () => {
try {
// CRITICAL: Get id_token from TokenManager before clearing anything
// Okta logout endpoint works better with id_token_hint to properly end the session
// Note: Currently not used but kept for future Okta integration
void TokenManager.getIdToken();
// Set logout flag to prevent auto-authentication after redirect
// This must be set BEFORE clearing storage so it survives
sessionStorage.setItem('__logout_in_progress__', 'true');
sessionStorage.setItem('__force_logout__', 'true');
setIsLoggingOut(true);
// Reset auth state FIRST to prevent any re-authentication
setIsAuthenticated(false);
setUser(null);
setError(null);
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
// Call backend logout API to clear server-side session and httpOnly cookies
// IMPORTANT: This MUST be called before clearing local storage to clear httpOnly cookies
try {
await logoutApi();
} catch (err) {
console.error('🚪 Logout API error:', err);
console.warn('🚪 Backend logout failed - httpOnly cookies may not be cleared');
// Continue with logout even if API call fails
}
// Clear all authentication data EXCEPT the logout flags and id_token (we need it for Okta logout)
// Clear tokens but preserve logout flags
const logoutInProgress = sessionStorage.getItem('__logout_in_progress__');
const forceLogout = sessionStorage.getItem('__force_logout__');
// Use TokenManager.clearAll() but then restore logout flags
TokenManager.clearAll();
// Restore logout flags immediately after clearAll
if (logoutInProgress) sessionStorage.setItem('__logout_in_progress__', logoutInProgress);
if (forceLogout) sessionStorage.setItem('__force_logout__', forceLogout);
// Small delay to ensure sessionStorage is written before redirect
await new Promise(resolve => setTimeout(resolve, 100));
// Redirect directly to login page with flags
// The okta_logged_out flag will trigger prompt=login in the login() function
// This forces re-authentication even if Okta session still exists
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
window.location.replace(loginUrl);
} catch (error) {
console.error('🚪 Logout error:', error);
// Force redirect even on error - clear everything and redirect to login
try {
localStorage.clear();
sessionStorage.clear();
sessionStorage.setItem('__logout_in_progress__', 'true');
const loginUrl = `${window.location.origin}/?okta_logged_out=true&logout=${Date.now()}`;
window.location.replace(loginUrl);
} catch {
// Last resort - redirect to home
window.location.replace('/?logout=' + Date.now());
}
}
};
const getAccessTokenSilently = async (): Promise<string | null> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In production mode, tokens are in httpOnly cookies
// We can't access them directly, but API calls will include them automatically
if (isProductionMode) {
// If user is authenticated, return a placeholder indicating cookies are used
// The actual token is in httpOnly cookie and sent automatically with requests
if (isAuthenticated) {
return 'cookie-based-auth'; // Placeholder - actual auth via cookies
}
// Try to refresh the session
try {
await refreshTokenSilently();
return isAuthenticated ? 'cookie-based-auth' : null;
} catch {
return null;
}
}
// Development mode: tokens in localStorage
const token = TokenManager.getAccessToken();
if (token && !isTokenExpired(token)) {
return token;
}
// Try to refresh
try {
await refreshTokenSilently();
return TokenManager.getAccessToken();
} catch {
return null;
}
};
const refreshTokenSilently = async (): Promise<void> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
const newToken = await refreshAccessToken();
// In production, refresh might not return token (it's in httpOnly cookie)
// but if the call succeeded, the session is valid
if (isProductionMode) {
// Session refreshed via cookies
return;
}
if (newToken) {
// Token refreshed successfully (development mode)
return;
}
throw new Error('Failed to refresh token');
} catch (err) {
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
throw err;
}
};
const value: AuthContextType = {
isAuthenticated,
isLoading,
user,
error,
login,
logout,
getAccessTokenSilently,
refreshTokenSilently,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
* Auth0-based Auth Provider (for production)
* Note: Reserved for future use when Auth0 integration is needed
* @internal - Reserved for future use
*/
export function _Auth0AuthProvider({ children }: { children: ReactNode }) {
return (
<Auth0Provider
domain="https://dev-830839.oktapreview.com/oauth2/default/v1"
clientId="0oa2j8slwj5S4bG5k0h8"
authorizationParams={{
redirect_uri: window.location.origin + '/login/callback',
}}
onRedirectCallback={(_appState) => {
// Auth0 redirect callback handled
}}
>
<Auth0ContextWrapper>{children}</Auth0ContextWrapper>
</Auth0Provider>
);
}
/**
* Wrapper to convert Auth0 hook to our context format
*/
function Auth0ContextWrapper({ children }: { children: ReactNode }) {
const {
isAuthenticated: auth0IsAuthenticated,
isLoading: auth0IsLoading,
user: auth0User,
error: auth0Error,
loginWithRedirect,
logout: logoutAuth0,
getAccessTokenSilently: getAuth0Token,
} = useAuth0Hook();
const getAccessTokenSilently = async (): Promise<string | null> => {
try {
return await getAuth0Token();
} catch {
return null;
}
};
const refreshTokenSilently = async (): Promise<void> => {
// Auth0 handles refresh automatically
try {
await getAuth0Token();
} catch {
// Silently fail, Auth0 will handle it
}
};
const value: AuthContextType = {
isAuthenticated: auth0IsAuthenticated,
isLoading: auth0IsLoading,
user: auth0User as User | null,
error: auth0Error as Error | null,
login: loginWithRedirect,
logout: logoutAuth0,
getAccessTokenSilently,
refreshTokenSilently,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
/**
* Main Auth Provider - uses backend for token exchange in all environments
* Backend handles Okta token exchange securely with client secret
*/
export function AuthProvider({ children }: AuthProviderProps) {
// Always use BackendAuthProvider so backend handles token exchange
// This keeps the client secret secure on the server
return <BackendAuthProvider>{children}</BackendAuthProvider>;
}
/**
* Hook to use auth context
*/
export function useAuth(): AuthContextType {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}
/**
* Helper function to check if user is admin
*/
export function isAdmin(user: User | null): boolean {
return user?.role === 'ADMIN';
}
/**
* Helper function to check if user is management
*/
export function isManagement(user: User | null): boolean {
return user?.role === 'MANAGEMENT';
}
/**
* Helper function to check if user has management access (MANAGEMENT or ADMIN)
*/
export function hasManagementAccess(user: User | null): boolean {
return user?.role === 'MANAGEMENT' || user?.role === 'ADMIN';
}
/**
* Helper function to check if user has admin access (ADMIN only)
*/
export function hasAdminAccess(user: User | null): boolean {
return user?.role === 'ADMIN';
}