From a0da7611858e180686f371e52bdca6ef6f5ac5c6 Mon Sep 17 00:00:00 2001 From: CELin Date: Wed, 1 Oct 2025 12:33:45 +0200 Subject: [PATCH] feat: Implement token renewal service and integrate with authentication flow --- frontend/App.tsx | 4 + frontend/authConfig.ts | 2 +- frontend/hooks/useTokenRenewal.ts | 32 ++++++ frontend/services/tokenRenewalService.ts | 123 +++++++++++++++++++++++ frontend/services/userDataService.ts | 19 +++- frontend/utils/authErrorHandler.ts | 20 ++++ 6 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 frontend/hooks/useTokenRenewal.ts create mode 100644 frontend/services/tokenRenewalService.ts diff --git a/frontend/App.tsx b/frontend/App.tsx index 2d9c29d..c496ae4 100644 --- a/frontend/App.tsx +++ b/frontend/App.tsx @@ -2,8 +2,12 @@ import React from 'react'; import { RouterProvider } from 'react-router-dom'; import { router } from './router'; +import { useTokenRenewal } from './hooks/useTokenRenewal'; const App: React.FC = () => { + // Initialize token renewal service + useTokenRenewal(); + return ; }; diff --git a/frontend/authConfig.ts b/frontend/authConfig.ts index 913158b..592af98 100644 --- a/frontend/authConfig.ts +++ b/frontend/authConfig.ts @@ -14,7 +14,7 @@ export const msalConfig: Configuration = { }, cache: { cacheLocation: "localStorage", // This configures where your cache will be stored - storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge + storeAuthStateInCookie: true, // Enable cookies for better session persistence across browser sessions }, }; diff --git a/frontend/hooks/useTokenRenewal.ts b/frontend/hooks/useTokenRenewal.ts new file mode 100644 index 0000000..6471195 --- /dev/null +++ b/frontend/hooks/useTokenRenewal.ts @@ -0,0 +1,32 @@ +import { useEffect } from 'react'; +import { useIsAuthenticated } from '@azure/msal-react'; +import { tokenRenewalService } from '../services/tokenRenewalService'; +import { USE_MSAL_AUTH } from '../app.config'; + +/** + * Custom hook to manage token renewal for authenticated users + * Automatically starts/stops the renewal service based on authentication state + */ +export const useTokenRenewal = (): void => { + const isAuthenticated = useIsAuthenticated(); + + useEffect(() => { + // Only manage token renewal when using MSAL auth + if (!USE_MSAL_AUTH) { + return; + } + + if (isAuthenticated) { + console.log('User authenticated, starting token renewal service'); + tokenRenewalService.start(); + } else { + console.log('User not authenticated, stopping token renewal service'); + tokenRenewalService.stop(); + } + + // Cleanup on unmount + return () => { + tokenRenewalService.stop(); + }; + }, [isAuthenticated]); +}; \ No newline at end of file diff --git a/frontend/services/tokenRenewalService.ts b/frontend/services/tokenRenewalService.ts new file mode 100644 index 0000000..e84f78e --- /dev/null +++ b/frontend/services/tokenRenewalService.ts @@ -0,0 +1,123 @@ +import { msalInstance, loginRequest } from '../authConfig'; +import { InteractionRequiredAuthError } from '@azure/msal-browser'; + +class TokenRenewalService { + private renewalInterval: NodeJS.Timeout | null = null; + private isRenewing = false; + private readonly RENEWAL_INTERVAL = 30 * 60 * 1000; // 30 minutes + /** + * Start the token renewal service + */ + public start(): void { + if (this.renewalInterval) { + this.stop(); // Clear existing interval + } + + console.log('Starting token renewal service...'); + + // Initial token check + this.renewTokenIfNeeded(); + + // Set up periodic renewal + this.renewalInterval = setInterval(() => { + this.renewTokenIfNeeded(); + }, this.RENEWAL_INTERVAL); + } + + /** + * Stop the token renewal service + */ + public stop(): void { + if (this.renewalInterval) { + clearInterval(this.renewalInterval); + this.renewalInterval = null; + console.log('Token renewal service stopped'); + } + } + + /** + * Manually trigger token renewal + */ + public async renewToken(): Promise { + return this.renewTokenIfNeeded(); + } + + /** + * Check if token needs renewal and renew if necessary + */ + private async renewTokenIfNeeded(): Promise { + if (this.isRenewing) { + console.log('Token renewal already in progress, skipping...'); + return false; + } + + try { + this.isRenewing = true; + + const accounts = msalInstance.getAllAccounts(); + if (accounts.length === 0) { + console.log('No accounts found, cannot renew token'); + return false; + } + + const account = accounts[0]; + + // Check if token is close to expiry + if (!this.isTokenNearExpiry(account)) { + console.log('Token is still valid, no renewal needed'); + return true; + } + + console.log('Token is near expiry, attempting silent renewal...'); + + // Attempt silent token renewal + await msalInstance.acquireTokenSilent({ + ...loginRequest, + account: account, + forceRefresh: true, // Force refresh to get new token + }); + + console.log('Token renewed successfully via silent request'); + return true; + + } catch (error) { + console.warn('Silent token renewal failed:', error); + + if (error instanceof InteractionRequiredAuthError) { + console.log('Interactive authentication required - user will need to sign in again on next request'); + // Don't trigger popup automatically as it might be disruptive + // Let the user-triggered request handle the interactive flow + } + + return false; + } finally { + this.isRenewing = false; + } + } + + /** + * Check if the current token is near expiry + */ + private isTokenNearExpiry(_account: any): boolean { + // Always attempt renewal for proactive refreshing + // MSAL handles token expiry checks internally, so we'll rely on forceRefresh + return true; + } + + /** + * Get the renewal interval in milliseconds + */ + public getRenewalInterval(): number { + return this.RENEWAL_INTERVAL; + } + + /** + * Check if the service is currently running + */ + public isRunning(): boolean { + return this.renewalInterval !== null; + } +} + +// Export a singleton instance +export const tokenRenewalService = new TokenRenewalService(); \ No newline at end of file diff --git a/frontend/services/userDataService.ts b/frontend/services/userDataService.ts index cf56eda..a8b2aaa 100644 --- a/frontend/services/userDataService.ts +++ b/frontend/services/userDataService.ts @@ -2,7 +2,7 @@ import { SavedQuery } from '../types'; import { msalInstance, loginRequest } from '../authConfig'; import { USE_MSAL_AUTH, API_BASE_URL } from '../app.config'; import { mockDelay, mockSavedQueries } from './mockData'; -import { getAuthErrorMessage, isAuthenticationExpiredError } from '../utils/authErrorHandler'; +import { getAuthErrorMessage, isAuthenticationExpiredError, isRecoverableAuthError } from '../utils/authErrorHandler'; const getAccessToken = async (): Promise => { try { @@ -10,15 +10,26 @@ const getAccessToken = async (): Promise => { if (accounts.length === 0) { throw new Error("No signed-in user found."); } + + // Try silent token acquisition first const response = await msalInstance.acquireTokenSilent({ ...loginRequest, account: accounts[0], }); return response.accessToken; } catch (error) { - // Handle authentication errors with user-friendly messages - if (isAuthenticationExpiredError(error)) { - throw new Error(getAuthErrorMessage(error)); + // If silent token acquisition fails, check if we can recover with interactive auth + if (isRecoverableAuthError(error) || isAuthenticationExpiredError(error)) { + try { + console.log('Silent token acquisition failed, attempting popup refresh...'); + // Try popup for token refresh + const response = await msalInstance.acquireTokenPopup(loginRequest); + return response.accessToken; + } catch (popupError) { + console.error('Popup token refresh also failed:', popupError); + // Only after both silent and popup fail, throw the user-friendly error + throw new Error(getAuthErrorMessage(error)); + } } // Re-throw other errors as-is throw error; diff --git a/frontend/utils/authErrorHandler.ts b/frontend/utils/authErrorHandler.ts index b962e89..5d027da 100644 --- a/frontend/utils/authErrorHandler.ts +++ b/frontend/utils/authErrorHandler.ts @@ -45,6 +45,26 @@ export const isAuthenticationExpiredError = (error: any): boolean => { return false; }; +/** + * Check if an error is recoverable through interactive authentication (popup/redirect) + */ +export const isRecoverableAuthError = (error: any): boolean => { + if (error instanceof InteractionRequiredAuthError) { + return true; + } + + if (error instanceof AuthError) { + const recoverableCodes = [ + 'interaction_required', + 'consent_required', + 'login_required' + ]; + return recoverableCodes.some(code => error.errorCode?.includes(code)); + } + + return false; +}; + /** * Get a user-friendly error message for authentication errors */