Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions frontend/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RouterProvider router={router} />;
};

Expand Down
2 changes: 1 addition & 1 deletion frontend/authConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
};

Expand Down
32 changes: 32 additions & 0 deletions frontend/hooks/useTokenRenewal.ts
Original file line number Diff line number Diff line change
@@ -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]);
};
123 changes: 123 additions & 0 deletions frontend/services/tokenRenewalService.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
return this.renewTokenIfNeeded();
}

/**
* Check if token needs renewal and renew if necessary
*/
private async renewTokenIfNeeded(): Promise<boolean> {
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;
}
Comment on lines +101 to +105
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isTokenNearExpiry method always returns true, making it redundant. Consider removing this method and directly calling the renewal logic, or implement actual token expiry checking logic if needed.

Copilot uses AI. Check for mistakes.

/**
* 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();
19 changes: 15 additions & 4 deletions frontend/services/userDataService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,23 +2,34 @@ 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<string> => {
try {
const accounts = msalInstance.getAllAccounts();
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));
Copy link

Copilot AI Oct 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message is generated from the original error instead of popupError. This could provide misleading error information to the user when popup authentication fails. Consider using getAuthErrorMessage(popupError) or combining both error contexts.

Suggested change
throw new Error(getAuthErrorMessage(error));
throw new Error(getAuthErrorMessage(popupError));

Copilot uses AI. Check for mistakes.
}
}
// Re-throw other errors as-is
throw error;
Expand Down
20 changes: 20 additions & 0 deletions frontend/utils/authErrorHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down