Skip to content

Commit c66bf71

Browse files
authored
Merge pull request #12 from ChingEnLin/feat/add_auth_error_handling
feat: Implement authentication error handling
2 parents 6e5740d + fbe4540 commit c66bf71

File tree

5 files changed

+256
-67
lines changed

5 files changed

+256
-67
lines changed
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { isAuthenticationExpiredError, getAuthErrorMessage, shouldRedirectToLogin } from '../../utils/authErrorHandler';
3+
4+
describe('authErrorHandler', () => {
5+
describe('isAuthenticationExpiredError', () => {
6+
it('should return true for InteractionRequiredAuthError-like objects', () => {
7+
const error = {
8+
name: 'InteractionRequiredAuthError',
9+
message: 'interaction_required: AADSTS160021: Application requested a user session which does not exist'
10+
};
11+
expect(isAuthenticationExpiredError(error)).toBe(true);
12+
});
13+
14+
it('should return true for messages containing AADSTS160021', () => {
15+
const error = new Error('interaction_required: AADSTS160021: Application requested a user session which does not exist');
16+
expect(isAuthenticationExpiredError(error)).toBe(true);
17+
});
18+
19+
it('should return true for session expired patterns', () => {
20+
const error = new Error('user session which does not exist');
21+
expect(isAuthenticationExpiredError(error)).toBe(true);
22+
});
23+
24+
it('should return false for other errors', () => {
25+
const error = new Error('Network error');
26+
expect(isAuthenticationExpiredError(error)).toBe(false);
27+
});
28+
});
29+
30+
describe('getAuthErrorMessage', () => {
31+
it('should return session expired message for authentication errors', () => {
32+
const error = new Error('AADSTS160021: Application requested a user session which does not exist');
33+
const message = getAuthErrorMessage(error);
34+
expect(message).toBe('Your session has expired. Please sign out and sign in again to continue.');
35+
});
36+
37+
it('should return permission message for 403 errors', () => {
38+
const error = new Error('403 Forbidden');
39+
const message = getAuthErrorMessage(error);
40+
expect(message).toBe("You don't have permission to access this resource. Please check your permissions or contact your administrator.");
41+
});
42+
43+
it('should return default auth error message for other errors', () => {
44+
const error = new Error('Some other error');
45+
const message = getAuthErrorMessage(error);
46+
expect(message).toBe('Some other error');
47+
});
48+
});
49+
50+
describe('shouldRedirectToLogin', () => {
51+
it('should return true for authentication expired errors', () => {
52+
const error = new Error('AADSTS160021: Application requested a user session which does not exist');
53+
expect(shouldRedirectToLogin(error)).toBe(true);
54+
});
55+
56+
it('should return false for other errors', () => {
57+
const error = new Error('Network error');
58+
expect(shouldRedirectToLogin(error)).toBe(false);
59+
});
60+
});
61+
});

frontend/pages/QueryGeneratorPage.tsx

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getSavedQueries, saveQuery, updateSavedQuery, deleteSavedQuery } from '
77
import { generateIpynbContent, downloadFile } from '../services/notebookService';
88
import { QueryResultData, DbInfo, CollectionInfo, CosmosDBAccount, SelectedResource, DebuggingResult, AnalysisResult, NotebookStep, SavedQuery } from '../types';
99
import { mockECommerceDbInfo, mockCollectionInfoMap, mockFindUsersQuery, mockUserFindResult, mockSavedQueries } from '../services/mockData';
10+
import { getAuthErrorMessage, isAuthenticationExpiredError } from '../utils/authErrorHandler';
1011
import QueryDisplay from '../components/QueryDisplay';
1112
import QueryResult from '../components/QueryResult';
1213
import Loader from '../components/Loader';
@@ -431,7 +432,10 @@ const QueryGeneratorPage: React.FC<QueryGeneratorPageProps> = ({ name, email, on
431432
setAzureAccounts(accounts);
432433
} catch (e) {
433434
if (e instanceof Error) {
434-
if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) {
435+
// Check for authentication-related errors
436+
if (isAuthenticationExpiredError(e)) {
437+
setDbError(getAuthErrorMessage(e));
438+
} else if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) {
435439
setDbError("Permission Denied: You may not have the required permissions to list Azure resources. Please contact your administrator.");
436440
} else {
437441
setDbError("Could not load Azure accounts from server. Ensure the backend is running and you have permissions.");
@@ -450,8 +454,12 @@ const QueryGeneratorPage: React.FC<QueryGeneratorPageProps> = ({ name, email, on
450454
const queries = await getSavedQueries();
451455
setSavedQueries(queries);
452456
} catch(e) {
453-
// Handle error silently in the UI for now
454-
console.error("Failed to fetch saved queries:", e);
457+
// Log error details for debugging
458+
if (e instanceof Error && isAuthenticationExpiredError(e)) {
459+
console.error("Failed to fetch saved queries due to authentication error:", getAuthErrorMessage(e));
460+
} else {
461+
console.error("Failed to fetch saved queries:", e);
462+
}
455463
} finally {
456464
setIsLoadingSavedQueries(false);
457465
}
@@ -552,7 +560,10 @@ const QueryGeneratorPage: React.FC<QueryGeneratorPageProps> = ({ name, email, on
552560
setAccountDatabases(dbs);
553561
} catch(e) {
554562
if (e instanceof Error) {
555-
if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) {
563+
// Check for authentication-related errors first
564+
if (isAuthenticationExpiredError(e)) {
565+
setDbError(getAuthErrorMessage(e));
566+
} else if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) {
556567
setDbError("Permission Denied: You may not have the required Azure role (e.g., 'Cosmos DB Operator') to access databases for this account. Please check your permissions.");
557568
} else {
558569
setDbError(e.message);

frontend/services/dbService.ts

Lines changed: 70 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ export async function deleteDocument(collectionName: string, resource: SelectedR
4444
import { DbInfo, CollectionInfo, CosmosDBAccount, SelectedResource, PaginatedDocumentsResponse, FoundDocumentResponse, DocumentHistoryResponse } from '../types';
4545
import { msalInstance, loginRequest } from '../authConfig';
4646
import { USE_MSAL_AUTH, API_BASE_URL } from '../app.config';
47+
import { getAuthErrorMessage, isAuthenticationExpiredError } from '../utils/authErrorHandler';
4748
import {
4849
mockCosmosAccounts,
4950
mockDatabasesByAccountId,
@@ -60,6 +61,32 @@ import {
6061
mockUpdateDocument
6162
} from './mockData';
6263

64+
/**
65+
* Helper function to get access token with proper error handling
66+
*/
67+
const getAuthenticatedToken = async (): Promise<string> => {
68+
try {
69+
const accounts = msalInstance.getAllAccounts();
70+
if (accounts.length === 0) {
71+
throw new Error("No signed-in user found.");
72+
}
73+
74+
const response = await msalInstance.acquireTokenSilent({
75+
...loginRequest,
76+
account: accounts[0],
77+
});
78+
79+
return response.accessToken;
80+
} catch (error) {
81+
// Handle authentication errors with user-friendly messages
82+
if (isAuthenticationExpiredError(error)) {
83+
throw new Error(getAuthErrorMessage(error));
84+
}
85+
// Re-throw other errors as-is
86+
throw error;
87+
}
88+
};
89+
6390
/**
6491
* Fetches available Azure Cosmos DB resources from the backend.
6592
* @returns A promise that resolves with an array of Cosmos DB resources.
@@ -73,34 +100,43 @@ export const getAzureCosmosAccounts = async (): Promise<CosmosDBAccount[]> => {
73100
}
74101
// --- END DEVELOPMENT MOCK ---
75102

76-
const accounts = msalInstance.getAllAccounts();
77-
if (accounts.length === 0) {
78-
throw new Error("No signed-in user found.");
79-
}
103+
try {
104+
const accounts = msalInstance.getAllAccounts();
105+
if (accounts.length === 0) {
106+
throw new Error("No signed-in user found.");
107+
}
80108

81-
// acquire token for backend API (must be set in loginRequest.scopes)
82-
const response = await msalInstance.acquireTokenSilent({
83-
...loginRequest,
84-
account: accounts[0],
85-
});
109+
// acquire token for backend API (must be set in loginRequest.scopes)
110+
const response = await msalInstance.acquireTokenSilent({
111+
...loginRequest,
112+
account: accounts[0],
113+
});
86114

87-
const accessToken = response.accessToken;
88-
89-
console.log("Fetching Azure cosmosdb accounts from backend...");
90-
const responseApi = await fetch(`${API_BASE_URL}/azure/cosmos_accounts`, {
91-
method: 'GET',
92-
headers: {
93-
'Authorization': `Bearer ${accessToken}`,
94-
},
95-
});
115+
const accessToken = response.accessToken;
116+
117+
console.log("Fetching Azure cosmosdb accounts from backend...");
118+
const responseApi = await fetch(`${API_BASE_URL}/azure/cosmos_accounts`, {
119+
method: 'GET',
120+
headers: {
121+
'Authorization': `Bearer ${accessToken}`,
122+
},
123+
});
96124

97-
if (!responseApi.ok) {
98-
const errorData = await responseApi.json().catch(() => ({}));
99-
const errorMessage = errorData.detail || errorData.message || `Could not load Azure resource list from server. Status: ${responseApi.status}`;
100-
throw new Error(errorMessage);
125+
if (!responseApi.ok) {
126+
const errorData = await responseApi.json().catch(() => ({}));
127+
const errorMessage = errorData.detail || errorData.message || `Could not load Azure resource list from server. Status: ${responseApi.status}`;
128+
throw new Error(errorMessage);
129+
}
130+
131+
return responseApi.json();
132+
} catch (error) {
133+
// Handle authentication errors with user-friendly messages
134+
if (isAuthenticationExpiredError(error)) {
135+
throw new Error(getAuthErrorMessage(error));
136+
}
137+
// Re-throw other errors as-is
138+
throw error;
101139
}
102-
103-
return responseApi.json();
104140
};
105141

106142

@@ -129,18 +165,9 @@ export const getDatabasesForAccount = async (accountId: string): Promise<DbInfo[
129165
// --- END DEVELOPMENT MOCK ---
130166

131167
console.log(`Fetching databases for account ID ${accountId} from backend...`);
132-
const accounts = msalInstance.getAllAccounts();
133-
if (accounts.length === 0) {
134-
throw new Error("No signed-in user found.");
135-
}
136-
137-
// acquire token for backend API (must be set in loginRequest.scopes)
138-
const tokenResponse = await msalInstance.acquireTokenSilent({
139-
...loginRequest,
140-
account: accounts[0],
141-
});
142-
143-
const accessToken = tokenResponse.accessToken;
168+
169+
// Use helper function to get authenticated token with proper error handling
170+
const accessToken = await getAuthenticatedToken();
144171

145172
const response = await fetch(`${API_BASE_URL}/azure/account_details`, {
146173
method: 'POST',
@@ -179,18 +206,9 @@ export const getCollectionInfo = async (collectionName: string, resource: Select
179206
return Promise.reject(new Error(`Mock collection info not found for ${collectionName}`));
180207
}
181208
// --- END DEVELOPMENT MOCK ---
182-
const accounts = msalInstance.getAllAccounts();
183-
if (accounts.length === 0) {
184-
throw new Error("No signed-in user found.");
185-
}
186-
187-
// acquire token for backend API (must be set in loginRequest.scopes)
188-
const tokenResponse = await msalInstance.acquireTokenSilent({
189-
...loginRequest,
190-
account: accounts[0],
191-
});
192-
193-
const accessToken = tokenResponse.accessToken;
209+
210+
// Use helper function to get authenticated token with proper error handling
211+
const accessToken = await getAuthenticatedToken();
194212

195213
console.log(`Fetching info for collection: ${collectionName} from backend...`);
196214
const response = await fetch(`${API_BASE_URL}/azure/collection_info`, {
@@ -242,13 +260,10 @@ export const runMongoQuery = async (accountId: string, query: string, resource:
242260
}
243261
// --- END DEVELOPMENT MOCK ---
244262
console.log(`Fetching databases for account ID ${accountId} from backend...`);
245-
const accounts = msalInstance.getAllAccounts();
246-
const tokenResponse = await msalInstance.acquireTokenSilent({
247-
...loginRequest,
248-
account: accounts[0],
249-
});
250-
251-
const accessToken = tokenResponse.accessToken;
263+
264+
// Use helper function to get authenticated token with proper error handling
265+
const accessToken = await getAuthenticatedToken();
266+
252267
console.log(`Sending query for execution on ${resource.databaseName} to backend...`);
253268
const response = await fetch(`${API_BASE_URL}/query/execute`, {
254269
method: 'POST',

frontend/services/userDataService.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,17 +2,27 @@ 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';
56

67
const getAccessToken = async (): Promise<string> => {
7-
const accounts = msalInstance.getAllAccounts();
8-
if (accounts.length === 0) {
9-
throw new Error("No signed-in user found.");
8+
try {
9+
const accounts = msalInstance.getAllAccounts();
10+
if (accounts.length === 0) {
11+
throw new Error("No signed-in user found.");
12+
}
13+
const response = await msalInstance.acquireTokenSilent({
14+
...loginRequest,
15+
account: accounts[0],
16+
});
17+
return response.accessToken;
18+
} catch (error) {
19+
// Handle authentication errors with user-friendly messages
20+
if (isAuthenticationExpiredError(error)) {
21+
throw new Error(getAuthErrorMessage(error));
22+
}
23+
// Re-throw other errors as-is
24+
throw error;
1025
}
11-
const response = await msalInstance.acquireTokenSilent({
12-
...loginRequest,
13-
account: accounts[0],
14-
});
15-
return response.accessToken;
1626
};
1727

1828
/**

0 commit comments

Comments
 (0)