619 lines
20 KiB
TypeScript
619 lines
20 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;
|
|
isAdmin?: boolean;
|
|
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
|
|
*/
|
|
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') {
|
|
console.log('🔴 Logout flag detected - PREVENTING auto-authentication');
|
|
console.log('🔴 Clearing ALL authentication data and showing login screen');
|
|
|
|
// 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);
|
|
|
|
console.log('🔴 Logout complete - user should see login screen');
|
|
return;
|
|
}
|
|
|
|
// PRIORITY 2: Check if URL has logout parameter (from redirect)
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
if (urlParams.has('logout')) {
|
|
console.log('🔴 Logout parameter in URL - clearing everything');
|
|
TokenManager.clearAll();
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
setIsAuthenticated(false);
|
|
setUser(null);
|
|
setIsLoading(false);
|
|
// Clean URL
|
|
window.history.replaceState({}, document.title, '/');
|
|
return;
|
|
}
|
|
|
|
// PRIORITY 3: Check if this is a logout redirect by checking if all tokens are cleared
|
|
const token = TokenManager.getAccessToken();
|
|
const refreshToken = TokenManager.getRefreshToken();
|
|
const userData = TokenManager.getUserData();
|
|
const hasAuthData = token || refreshToken || userData;
|
|
|
|
// If no auth data exists, we're likely after a logout - set unauthenticated state immediately
|
|
if (!hasAuthData) {
|
|
console.log('🔴 No auth data found - setting unauthenticated state');
|
|
setIsAuthenticated(false);
|
|
setUser(null);
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
// PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out
|
|
if (!isLoggingOut) {
|
|
checkAuthStatus();
|
|
} else {
|
|
console.log('🔴 Skipping checkAuthStatus - logout in progress');
|
|
setIsLoading(false);
|
|
}
|
|
}, [isLoggingOut]);
|
|
|
|
// Silent refresh interval
|
|
useEffect(() => {
|
|
if (!isAuthenticated) return;
|
|
|
|
const checkAndRefresh = async () => {
|
|
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 5 minutes
|
|
const interval = setInterval(checkAndRefresh, 5 * 60 * 1000);
|
|
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`;
|
|
console.log('📥 Authorization Code Received:', {
|
|
code: code.substring(0, 10) + '...',
|
|
redirectUri,
|
|
fullUrl: window.location.href,
|
|
note: 'redirectUri is frontend URL (not backend) - must match Okta registration',
|
|
});
|
|
|
|
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) {
|
|
console.log('🔴 Skipping checkAuthStatus - logout in progress');
|
|
setIsLoading(false);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setIsLoading(true);
|
|
|
|
const token = TokenManager.getAccessToken();
|
|
const storedUser = TokenManager.getUserData();
|
|
|
|
console.log('🔍 Checking auth status:', { hasToken: !!token, hasUser: !!storedUser, isLoggingOut });
|
|
|
|
// If no token at all, user is not authenticated
|
|
if (!token) {
|
|
console.log('🔍 No token found - setting unauthenticated');
|
|
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 = 'https://dev-830839.oktapreview.com';
|
|
const clientId = '0oa2j8slwj5S4bG5k0h8';
|
|
const redirectUri = `${window.location.origin}/login/callback`;
|
|
const responseType = 'code';
|
|
const scope = 'openid profile email';
|
|
const state = Math.random().toString(36).substring(7);
|
|
|
|
const authUrl = `${oktaDomain}/oauth2/default/v1/authorize?` +
|
|
`client_id=${clientId}&` +
|
|
`redirect_uri=${encodeURIComponent(redirectUri)}&` +
|
|
`response_type=${responseType}&` +
|
|
`scope=${encodeURIComponent(scope)}&` +
|
|
`state=${state}`;
|
|
|
|
window.location.href = authUrl;
|
|
} catch (err: any) {
|
|
setError(err);
|
|
throw err;
|
|
}
|
|
};
|
|
|
|
const logout = async () => {
|
|
console.log('🚪 LOGOUT FUNCTION CALLED - Starting logout process');
|
|
console.log('🚪 Current auth state:', { isAuthenticated, hasUser: !!user, isLoading });
|
|
|
|
try {
|
|
// Set logout flag to prevent auto-authentication after redirect
|
|
sessionStorage.setItem('__logout_in_progress__', 'true');
|
|
setIsLoggingOut(true);
|
|
|
|
console.log('🚪 Step 1: Resetting auth state...');
|
|
// Reset auth state FIRST to prevent any re-authentication
|
|
setIsAuthenticated(false);
|
|
setUser(null);
|
|
setError(null);
|
|
setIsLoading(true); // Set loading to prevent checkAuthStatus from running
|
|
console.log('🚪 Step 1: Auth state reset complete');
|
|
|
|
// 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 {
|
|
console.log('🚪 Step 2: Calling backend logout API to clear httpOnly cookies...');
|
|
await logoutApi();
|
|
console.log('🚪 Step 2: Backend logout API completed - httpOnly cookies should be cleared');
|
|
} 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
|
|
console.log('========================================');
|
|
console.log('LOGOUT - Clearing all authentication data');
|
|
console.log('========================================');
|
|
|
|
// Use TokenManager.clearAll() which does comprehensive cleanup
|
|
TokenManager.clearAll();
|
|
console.log('tryng to clrear all(localStorage and sessionStorage)')
|
|
// Double-check: Clear everything one more time as backup
|
|
try {
|
|
// Get all localStorage keys and remove them
|
|
const localStorageKeys: string[] = [];
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
const key = localStorage.key(i);
|
|
if (key) localStorageKeys.push(key);
|
|
}
|
|
localStorageKeys.forEach(key => {
|
|
try {
|
|
localStorage.removeItem(key);
|
|
} catch (e) {
|
|
console.warn(`Failed to remove localStorage key ${key}:`, e);
|
|
}
|
|
});
|
|
localStorage.clear();
|
|
|
|
// Get all sessionStorage keys and remove them
|
|
const sessionStorageKeys: string[] = [];
|
|
for (let i = 0; i < sessionStorage.length; i++) {
|
|
const key = sessionStorage.key(i);
|
|
if (key) sessionStorageKeys.push(key);
|
|
}
|
|
sessionStorageKeys.forEach(key => {
|
|
try {
|
|
sessionStorage.removeItem(key);
|
|
} catch (e) {
|
|
console.warn(`Failed to remove sessionStorage key ${key}:`, e);
|
|
}
|
|
});
|
|
sessionStorage.clear();
|
|
|
|
console.log('Final verification - Storage cleared:');
|
|
console.log(`localStorage.length: ${localStorage.length}`);
|
|
console.log(`sessionStorage.length: ${sessionStorage.length}`);
|
|
|
|
if (localStorage.length > 0) {
|
|
console.error('ERROR: localStorage still has items:', Object.keys(localStorage));
|
|
}
|
|
if (sessionStorage.length > 0) {
|
|
console.error('ERROR: sessionStorage still has items:', Object.keys(sessionStorage));
|
|
}
|
|
} catch (e) {
|
|
console.error('Error in final cleanup:', e);
|
|
}
|
|
|
|
// Final verification BEFORE redirect
|
|
console.log('All authentication data cleared. Final verification:');
|
|
console.log(`localStorage.length: ${localStorage.length}`);
|
|
console.log(`sessionStorage.length: ${sessionStorage.length}`);
|
|
|
|
if (localStorage.length > 0) {
|
|
console.error('CRITICAL: localStorage still has items before redirect!', Object.keys(localStorage));
|
|
// Force clear one more time
|
|
const remainingKeys = Object.keys(localStorage);
|
|
remainingKeys.forEach(key => localStorage.removeItem(key));
|
|
localStorage.clear();
|
|
}
|
|
if (sessionStorage.length > 0) {
|
|
console.error('CRITICAL: sessionStorage still has items before redirect!', Object.keys(sessionStorage));
|
|
const remainingKeys = Object.keys(sessionStorage);
|
|
remainingKeys.forEach(key => sessionStorage.removeItem(key));
|
|
sessionStorage.clear();
|
|
}
|
|
|
|
console.log('Redirecting to login...');
|
|
|
|
// Ensure logout flag is set before redirect
|
|
sessionStorage.setItem('__logout_in_progress__', 'true');
|
|
|
|
// Small delay to ensure sessionStorage is written before redirect
|
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
|
|
// Use replace instead of href to prevent browser history issues
|
|
// DON'T use setTimeout - redirect immediately after clearing
|
|
if (isLocalhost()) {
|
|
// Direct redirect to login page for localhost - force full page reload
|
|
// Add timestamp to force fresh page load
|
|
window.location.replace('/?logout=' + Date.now());
|
|
} else {
|
|
// For production, redirect to Okta logout then to login
|
|
const oktaDomain = 'https://dev-830839.oktapreview.com';
|
|
const loginUrl = `${window.location.origin}/?logout=${Date.now()}`;
|
|
const logoutUrl = `${oktaDomain}/oauth2/default/v1/logout?post_logout_redirect_uri=${encodeURIComponent(loginUrl)}`;
|
|
window.location.replace(logoutUrl);
|
|
}
|
|
} catch (error) {
|
|
console.error('Logout error:', error);
|
|
// Force redirect even on error
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
window.location.replace('/');
|
|
}
|
|
};
|
|
|
|
const getAccessTokenSilently = async (): Promise<string | null> => {
|
|
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> => {
|
|
try {
|
|
const newToken = await refreshAccessToken();
|
|
if (newToken) {
|
|
// Token refreshed successfully
|
|
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)
|
|
*/
|
|
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) => {
|
|
console.log('Auth0 Redirect Callback:', {
|
|
appState,
|
|
returnTo: appState?.returnTo || window.location.pathname,
|
|
});
|
|
}}
|
|
>
|
|
<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 - conditionally uses backend or Auth0
|
|
*/
|
|
export function AuthProvider({ children }: AuthProviderProps) {
|
|
if (isLocalhost()) {
|
|
return <BackendAuthProvider>{children}</BackendAuthProvider>;
|
|
}
|
|
return <Auth0AuthProvider>{children}</Auth0AuthProvider>;
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
|