Skip to content

Commit f79a69b

Browse files
authored
Merge pull request #14 from ChingEnLin/fix/authenticated_session
feat: Implement token renewal service and integrate with authenticati…
2 parents c66bf71 + a0da761 commit f79a69b

File tree

6 files changed

+195
-5
lines changed

6 files changed

+195
-5
lines changed

frontend/App.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@
22
import React from 'react';
33
import { RouterProvider } from 'react-router-dom';
44
import { router } from './router';
5+
import { useTokenRenewal } from './hooks/useTokenRenewal';
56

67
const App: React.FC = () => {
8+
// Initialize token renewal service
9+
useTokenRenewal();
10+
711
return <RouterProvider router={router} />;
812
};
913

frontend/authConfig.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export const msalConfig: Configuration = {
1414
},
1515
cache: {
1616
cacheLocation: "localStorage", // This configures where your cache will be stored
17-
storeAuthStateInCookie: false, // Set this to "true" if you are having issues on IE11 or Edge
17+
storeAuthStateInCookie: true, // Enable cookies for better session persistence across browser sessions
1818
},
1919
};
2020

frontend/hooks/useTokenRenewal.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useEffect } from 'react';
2+
import { useIsAuthenticated } from '@azure/msal-react';
3+
import { tokenRenewalService } from '../services/tokenRenewalService';
4+
import { USE_MSAL_AUTH } from '../app.config';
5+
6+
/**
7+
* Custom hook to manage token renewal for authenticated users
8+
* Automatically starts/stops the renewal service based on authentication state
9+
*/
10+
export const useTokenRenewal = (): void => {
11+
const isAuthenticated = useIsAuthenticated();
12+
13+
useEffect(() => {
14+
// Only manage token renewal when using MSAL auth
15+
if (!USE_MSAL_AUTH) {
16+
return;
17+
}
18+
19+
if (isAuthenticated) {
20+
console.log('User authenticated, starting token renewal service');
21+
tokenRenewalService.start();
22+
} else {
23+
console.log('User not authenticated, stopping token renewal service');
24+
tokenRenewalService.stop();
25+
}
26+
27+
// Cleanup on unmount
28+
return () => {
29+
tokenRenewalService.stop();
30+
};
31+
}, [isAuthenticated]);
32+
};
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { msalInstance, loginRequest } from '../authConfig';
2+
import { InteractionRequiredAuthError } from '@azure/msal-browser';
3+
4+
class TokenRenewalService {
5+
private renewalInterval: NodeJS.Timeout | null = null;
6+
private isRenewing = false;
7+
private readonly RENEWAL_INTERVAL = 30 * 60 * 1000; // 30 minutes
8+
/**
9+
* Start the token renewal service
10+
*/
11+
public start(): void {
12+
if (this.renewalInterval) {
13+
this.stop(); // Clear existing interval
14+
}
15+
16+
console.log('Starting token renewal service...');
17+
18+
// Initial token check
19+
this.renewTokenIfNeeded();
20+
21+
// Set up periodic renewal
22+
this.renewalInterval = setInterval(() => {
23+
this.renewTokenIfNeeded();
24+
}, this.RENEWAL_INTERVAL);
25+
}
26+
27+
/**
28+
* Stop the token renewal service
29+
*/
30+
public stop(): void {
31+
if (this.renewalInterval) {
32+
clearInterval(this.renewalInterval);
33+
this.renewalInterval = null;
34+
console.log('Token renewal service stopped');
35+
}
36+
}
37+
38+
/**
39+
* Manually trigger token renewal
40+
*/
41+
public async renewToken(): Promise<boolean> {
42+
return this.renewTokenIfNeeded();
43+
}
44+
45+
/**
46+
* Check if token needs renewal and renew if necessary
47+
*/
48+
private async renewTokenIfNeeded(): Promise<boolean> {
49+
if (this.isRenewing) {
50+
console.log('Token renewal already in progress, skipping...');
51+
return false;
52+
}
53+
54+
try {
55+
this.isRenewing = true;
56+
57+
const accounts = msalInstance.getAllAccounts();
58+
if (accounts.length === 0) {
59+
console.log('No accounts found, cannot renew token');
60+
return false;
61+
}
62+
63+
const account = accounts[0];
64+
65+
// Check if token is close to expiry
66+
if (!this.isTokenNearExpiry(account)) {
67+
console.log('Token is still valid, no renewal needed');
68+
return true;
69+
}
70+
71+
console.log('Token is near expiry, attempting silent renewal...');
72+
73+
// Attempt silent token renewal
74+
await msalInstance.acquireTokenSilent({
75+
...loginRequest,
76+
account: account,
77+
forceRefresh: true, // Force refresh to get new token
78+
});
79+
80+
console.log('Token renewed successfully via silent request');
81+
return true;
82+
83+
} catch (error) {
84+
console.warn('Silent token renewal failed:', error);
85+
86+
if (error instanceof InteractionRequiredAuthError) {
87+
console.log('Interactive authentication required - user will need to sign in again on next request');
88+
// Don't trigger popup automatically as it might be disruptive
89+
// Let the user-triggered request handle the interactive flow
90+
}
91+
92+
return false;
93+
} finally {
94+
this.isRenewing = false;
95+
}
96+
}
97+
98+
/**
99+
* Check if the current token is near expiry
100+
*/
101+
private isTokenNearExpiry(_account: any): boolean {
102+
// Always attempt renewal for proactive refreshing
103+
// MSAL handles token expiry checks internally, so we'll rely on forceRefresh
104+
return true;
105+
}
106+
107+
/**
108+
* Get the renewal interval in milliseconds
109+
*/
110+
public getRenewalInterval(): number {
111+
return this.RENEWAL_INTERVAL;
112+
}
113+
114+
/**
115+
* Check if the service is currently running
116+
*/
117+
public isRunning(): boolean {
118+
return this.renewalInterval !== null;
119+
}
120+
}
121+
122+
// Export a singleton instance
123+
export const tokenRenewalService = new TokenRenewalService();

frontend/services/userDataService.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,34 @@ import { SavedQuery } from '../types';
22
import { msalInstance, loginRequest } from '../authConfig';
33
import { USE_MSAL_AUTH, API_BASE_URL } from '../app.config';
44
import { mockDelay, mockSavedQueries } from './mockData';
5-
import { getAuthErrorMessage, isAuthenticationExpiredError } from '../utils/authErrorHandler';
5+
import { getAuthErrorMessage, isAuthenticationExpiredError, isRecoverableAuthError } from '../utils/authErrorHandler';
66

77
const getAccessToken = async (): Promise<string> => {
88
try {
99
const accounts = msalInstance.getAllAccounts();
1010
if (accounts.length === 0) {
1111
throw new Error("No signed-in user found.");
1212
}
13+
14+
// Try silent token acquisition first
1315
const response = await msalInstance.acquireTokenSilent({
1416
...loginRequest,
1517
account: accounts[0],
1618
});
1719
return response.accessToken;
1820
} catch (error) {
19-
// Handle authentication errors with user-friendly messages
20-
if (isAuthenticationExpiredError(error)) {
21-
throw new Error(getAuthErrorMessage(error));
21+
// If silent token acquisition fails, check if we can recover with interactive auth
22+
if (isRecoverableAuthError(error) || isAuthenticationExpiredError(error)) {
23+
try {
24+
console.log('Silent token acquisition failed, attempting popup refresh...');
25+
// Try popup for token refresh
26+
const response = await msalInstance.acquireTokenPopup(loginRequest);
27+
return response.accessToken;
28+
} catch (popupError) {
29+
console.error('Popup token refresh also failed:', popupError);
30+
// Only after both silent and popup fail, throw the user-friendly error
31+
throw new Error(getAuthErrorMessage(error));
32+
}
2233
}
2334
// Re-throw other errors as-is
2435
throw error;

frontend/utils/authErrorHandler.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,26 @@ export const isAuthenticationExpiredError = (error: any): boolean => {
4545
return false;
4646
};
4747

48+
/**
49+
* Check if an error is recoverable through interactive authentication (popup/redirect)
50+
*/
51+
export const isRecoverableAuthError = (error: any): boolean => {
52+
if (error instanceof InteractionRequiredAuthError) {
53+
return true;
54+
}
55+
56+
if (error instanceof AuthError) {
57+
const recoverableCodes = [
58+
'interaction_required',
59+
'consent_required',
60+
'login_required'
61+
];
62+
return recoverableCodes.some(code => error.errorCode?.includes(code));
63+
}
64+
65+
return false;
66+
};
67+
4868
/**
4969
* Get a user-friendly error message for authentication errors
5070
*/

0 commit comments

Comments
 (0)