diff --git a/.env-cmdrc b/.env-cmdrc index b0fd1cc..dbb40a2 100644 --- a/.env-cmdrc +++ b/.env-cmdrc @@ -14,7 +14,7 @@ "REACT_APP_BACKEND_PROTOCOL": "https", "REACT_APP_BACKEND_HOST": "forms.lioner.com", "REACT_APP_BACKEND_PORT": "8090", - "REACT_APP_ADOBE_API_KEY": "ee4433f258a74641ae6d502fd41cf703", + "REACT_APP_ADOBE_API_KEY": "7f498776ecdf47399d7e123d85b9fe1b", "REACT_APP_BACKEND_API_PATH": "/api" } } \ No newline at end of file diff --git a/package.json b/package.json index e742b02..5c21250 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,8 @@ "dependencies": { "@ant-design/colors": "^6.0.0", "@ant-design/icons": "^4.7.0", + "@azure/msal-browser": "^4.26.1", + "@azure/msal-react": "^3.0.21", "@casl/ability": "^6.5.0", "@casl/react": "^3.1.0", "@emotion/cache": "^11.10.3", diff --git a/src/auth/axiosMsalInterceptor.js b/src/auth/axiosMsalInterceptor.js new file mode 100644 index 0000000..aede2f9 --- /dev/null +++ b/src/auth/axiosMsalInterceptor.js @@ -0,0 +1,58 @@ +import axios from 'axios'; +import { InteractionRequiredAuthError } from "@azure/msal-browser"; + +// ⚠️ UPDATE THIS SCOPE if needed +const apiRequest = { + scopes: ["api://d82560a8-8fac-401d-9173-10668acb7dfa/access_as_user"] +}; + +/** + * Sets up an Axios interceptor to acquire an MSAL token and handle 401 errors. + */ +export const setupMsalAxiosInterceptor = (msalInstance, dispatch, handleLogoutFunction) => { + + // REQUEST Interceptor: Attach the token + axios.interceptors.request.use(async (config) => { + const accounts = msalInstance.getAllAccounts(); + + if (accounts.length > 0) { + apiRequest.account = accounts[0]; + + try { + // Attempt to acquire the token silently + const response = await msalInstance.acquireTokenSilent(apiRequest); + config.headers.Authorization = `Bearer ${response.accessToken}`; + } catch (error) { + if (error instanceof InteractionRequiredAuthError) { + console.warn("MSAL: Silent token acquisition failed. Request sent without token."); + } else { + console.error("MSAL: Token acquisition failed.", error); + } + } + } + + // Keep your custom header + config.headers['X-Authorization'] = process.env.REACT_APP_API_KEY; + return config; + }, (error) => { + return Promise.reject(error); + }); + + // RESPONSE Interceptor: Handle 401 Unauthorized + axios.interceptors.response.use( + (response) => response, + async (error) => { + if (error.response && error.response.status === 401) { + console.error("401 Unauthorized received. Forcing MSAL logout."); + + // 1. Clear Redux/local storage state + await dispatch(handleLogoutFunction()); + + // 2. Clear MSAL session and redirect to login + msalInstance.logoutRedirect(); + } + + return Promise.reject(error); + } + ); +}; \ No newline at end of file diff --git a/src/auth/axiosMsalInterceptor/index.js b/src/auth/axiosMsalInterceptor/index.js new file mode 100644 index 0000000..86ac6b1 --- /dev/null +++ b/src/auth/axiosMsalInterceptor/index.js @@ -0,0 +1,44 @@ +import axios from 'axios'; + +// NOTE: This scope MUST match the one used in your AutoLogoutProvider +const apiRequest = { + scopes: ["api://d82560a8-8fac-401d-9173-10668acb7dfa/access_as_user"] +}; + +/** + * Sets up an Axios request interceptor to automatically acquire an MSAL token + * and attach it to the Authorization header before sending the request. + * @param {object} msalInstance - The PublicClientApplication instance from MSAL. + */ +export const setupMsalAxiosInterceptor = (msalInstance) => { + axios.interceptors.request.use(async (config) => { + // 1. Check if user is logged in + const accounts = msalInstance.getAllAccounts(); + + if (accounts.length > 0) { + // Set the account for the token request + apiRequest.account = accounts[0]; + + try { + // 2. Attempt to acquire the token silently (fastest way) + const response = await msalInstance.acquireTokenSilent(apiRequest); + + // 3. Attach the Access Token to the Authorization header + config.headers.Authorization = `Bearer ${response.accessToken}`; + } catch (error) { + // If silent acquisition fails (e.g., token expired, or user needs interaction), + // we allow the request to proceed without a token. The response interceptor + // or the AutoLogoutProvider will handle the eventual 401/logout. + console.warn("MSAL: Silent token acquisition failed. Request sent without token.", error); + } + } + + // 4. Return the (potentially modified) config + return config; + }, (error) => { + return Promise.reject(error); + }); + + // You should use the existing response interceptor in auth/index.js to handle 401s + // for non-idle requests, which should call dispatch(handleLogoutFunction()) and instance.logoutRedirect(). +}; \ No newline at end of file diff --git a/src/auth/msalConfig.js b/src/auth/msalConfig.js new file mode 100644 index 0000000..fe32239 --- /dev/null +++ b/src/auth/msalConfig.js @@ -0,0 +1,34 @@ +import { LogLevel } from '@azure/msal-browser'; + +// 1. MSAL Configuration (msalConfig) +export const msalConfig = { + auth: { + // Application (client) ID of your React SPA from Azure Portal + clientId: "d82560a8-8fac-401d-9173-10668acb7dfa", + // Authority URL (e.g., https://login.microsoftonline.com/YOUR_TENANT_ID) + authority: "https://login.microsoftonline.com/cb3fc669-059d-4f8c-85eb-5c6564032c53", + // Must match the Redirect URI set in the Azure Portal + redirectUri: "http://localhost:3000/client", + }, + cache: { + cacheLocation: "sessionStorage", // Recommended location + storeAuthStateInCookie: false, + }, + system: { + loggerOptions: { + loggerCallback: (level, message, containsPii) => { + if (containsPii) { return; } + if (level === LogLevel.Error) { console.error(message); } + }, + piiLoggingEnabled: false, + logLevel: LogLevel.Warning, + } + } +}; + +// 2. Scopes for your Spring Boot API (loginRequest) +export const loginRequest = { + // This scope tells Azure AD that you want permission to call your API + // The scope is typically your API's App ID URI + '/.default' + scopes: ["api://d82560a8-8fac-401d-9173-10668acb7dfa/.default"] +}; \ No newline at end of file diff --git a/src/components/AutoLogoutProvider.js b/src/components/AutoLogoutProvider.js index 2dbdc25..74d739d 100644 --- a/src/components/AutoLogoutProvider.js +++ b/src/components/AutoLogoutProvider.js @@ -1,5 +1,8 @@ -import React, { createContext, useState, useEffect } from 'react'; +import React, { createContext, useState, useEffect, useRef, useCallback} from 'react'; +import { useMsal, useIsAuthenticated } from "@azure/msal-react"; +import { InteractionRequiredAuthError } from "@azure/msal-browser"; import {handleLogoutFunction} from "../auth"; +import { useDispatch } from 'react-redux'; import {dispatch} from "../store"; import {useNavigate} from "react-router-dom"; import axios from "axios"; @@ -12,136 +15,59 @@ import {ChangePasswordWindow} from "../layout/MainLayout/Header/HeaderContent/Pr import {ThemeProvider} from "@emotion/react"; const TimerContext = createContext(); +const IDLE_TIMEOUT_SECONDS = 3600; const AutoLogoutProvider = ({ children }) => { - const [lastRequestTime, setLastRequestTime] = useState(Date.now()); - const navigate = useNavigate(); - const [logoutInterval, setLogoutInterval] = useState(30); - const [state, setState] = useState('Active'); - - const [isTempWindowOpen, setIsTempWindowOpen] = useState(false); - const [forceChangePassword, setForceChangePassword] = useState(false); - - useEffect(() => { - const userData = getUserData(); - const checked = localStorage.getItem("checkPasswordExpired"); - if(userData !== null){ - //system user - if(checked === "false"){ - axios.get(`${apiPath}${GET_USER_PASSWORD_DURATION}`,{ - params:{ - id: userData.id - } - }) - .then((response) => { - if (response.status === 200) { - setForceChangePassword(response.data.expired); - if(!response.data.expired){ - localStorage.setItem("checkPasswordExpired",true); - } - } - }) - .catch(error => { - console.log(error); - return false; - }); - } + const dispatch = useDispatch(); + // ⚡️ Use the inProgress property from useMsal to guard against premature logout + const { instance, accounts, inProgress } = useMsal(); + + const isAuthenticated = accounts.length > 0; + const idleTimerRef = useRef(null); + + // The core logout logic + const forceLogout = useCallback(() => { + + // 🛑 CRITICAL GUARD CLAUSE: + // Do NOT log out if: + // 1. MSAL is currently busy (acquiring token, handling redirect, etc.) + // 2. No accounts are logged in (to prevent unnecessary calls) + if (inProgress !== "none" || !isAuthenticated) { + console.log("MSAL is busy or user is not authenticated. Aborting idle logout attempt."); + return; } - }, []); - - const onIdle = () => { - setLastRequestTime(Date.now()); - setState('Idle') - } - const onActive = () => { - setLastRequestTime(Date.now()); - setState('Active') - } + console.warn('Idle timeout reached. Forcing logout.'); - const { - getRemainingTime, - //getTabId, - isLastActiveTab, - } = useIdleTimer({ - onIdle, - onActive, - timeout: 60_000, - throttle: 500, - crossTab: true, - syncTimers: 200, - }) - - const lastActiveTab = isLastActiveTab() === null ? 'loading' : isLastActiveTab() - //const tabId = getTabId() === null ? 'loading' : getTabId().toString() + // 1. Clear Redux/local storage state + dispatch(handleLogoutFunction()); + + // 2. Perform MSAL logout (This is what triggers the Azure AD sign out page) + instance.logoutRedirect(); + + }, [dispatch, instance, isAuthenticated, inProgress]); // Dependencies updated + // useEffect for monitoring activity and setting up the timer useEffect(() => { - const userData = getUserData(); - if(!isObjEmpty(userData)){ - axios.get(`${apiPath}${GET_IDLE_LOGOUT_TIME}`, - ) - .then((response) => { - if (response.status === 200) { - setLastRequestTime(Date.now()); - setLogoutInterval(parseInt(response.data.data)); - } - }) - .catch(error => { - console.log(error); - return false; - }); - } - else{ - //navigate('/login'); - } - }, []); + console.log('AutoLogoutProvider Render/Effect:', { + isAuthenticated, + inProgress, + hasAccessToken: !!localStorage.getItem('accessToken') // Check legacy token + }); + // ... (Your existing logic to set up the idle timer and reset it on activity) + // Ensure that your timer starts ONLY if isAuthenticated is true AND inProgress is "none" - useEffect(() => { - const interval = setInterval(async () => { - const currentTime = Date.now(); - getRemainingTime(); - if(state !== "Active" && lastActiveTab){ - const timeElapsed = currentTime - lastRequestTime; - // if (timeElapsed >= logoutInterval * 60 * 1000) { - // await dispatch(handleLogoutFunction()); - // await navigate('/login'); - // await window.location.reload(); - // } - } - }, 1000); // Check every second + if (isAuthenticated && inProgress === "none") { + // Start the timer + idleTimerRef.current = setTimeout(forceLogout, IDLE_TIMEOUT_SECONDS * 1000); + } return () => { - clearInterval(interval); + clearTimeout(idleTimerRef.current); }; - }, [lastRequestTime,logoutInterval]); - - useEffect(() => { - //if user data from parent are not null - if(forceChangePassword){ - setIsTempWindowOpen(true); - } - }, [forceChangePassword]); - - const handleTempClose = (event, reason) => { - if (reason && reason === "backdropClick") - return; - setIsTempWindowOpen(false); - }; + }, [isAuthenticated, inProgress, forceLogout]); // Depend on MSAL state - return ( - - {children} - - - - - ); + return children; }; export { TimerContext, AutoLogoutProvider }; \ No newline at end of file diff --git a/src/pages/pdf/PdfSearchPage/PdfSearchForm.js b/src/pages/pdf/PdfSearchPage/PdfSearchForm.js index 1eda7a2..025a647 100644 --- a/src/pages/pdf/PdfSearchPage/PdfSearchForm.js +++ b/src/pages/pdf/PdfSearchPage/PdfSearchForm.js @@ -320,106 +320,6 @@ const PdfSearchForm = ({applySearch, setExpanded,expanded, clientId}) => { } - - {/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */}