diff --git a/frontend/__tests__/utils/authErrorHandler.test.ts b/frontend/__tests__/utils/authErrorHandler.test.ts new file mode 100644 index 0000000..999120c --- /dev/null +++ b/frontend/__tests__/utils/authErrorHandler.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest'; +import { isAuthenticationExpiredError, getAuthErrorMessage, shouldRedirectToLogin } from '../../utils/authErrorHandler'; + +describe('authErrorHandler', () => { + describe('isAuthenticationExpiredError', () => { + it('should return true for InteractionRequiredAuthError-like objects', () => { + const error = { + name: 'InteractionRequiredAuthError', + message: 'interaction_required: AADSTS160021: Application requested a user session which does not exist' + }; + expect(isAuthenticationExpiredError(error)).toBe(true); + }); + + it('should return true for messages containing AADSTS160021', () => { + const error = new Error('interaction_required: AADSTS160021: Application requested a user session which does not exist'); + expect(isAuthenticationExpiredError(error)).toBe(true); + }); + + it('should return true for session expired patterns', () => { + const error = new Error('user session which does not exist'); + expect(isAuthenticationExpiredError(error)).toBe(true); + }); + + it('should return false for other errors', () => { + const error = new Error('Network error'); + expect(isAuthenticationExpiredError(error)).toBe(false); + }); + }); + + describe('getAuthErrorMessage', () => { + it('should return session expired message for authentication errors', () => { + const error = new Error('AADSTS160021: Application requested a user session which does not exist'); + const message = getAuthErrorMessage(error); + expect(message).toBe('Your session has expired. Please sign out and sign in again to continue.'); + }); + + it('should return permission message for 403 errors', () => { + const error = new Error('403 Forbidden'); + const message = getAuthErrorMessage(error); + expect(message).toBe("You don't have permission to access this resource. Please check your permissions or contact your administrator."); + }); + + it('should return default auth error message for other errors', () => { + const error = new Error('Some other error'); + const message = getAuthErrorMessage(error); + expect(message).toBe('Some other error'); + }); + }); + + describe('shouldRedirectToLogin', () => { + it('should return true for authentication expired errors', () => { + const error = new Error('AADSTS160021: Application requested a user session which does not exist'); + expect(shouldRedirectToLogin(error)).toBe(true); + }); + + it('should return false for other errors', () => { + const error = new Error('Network error'); + expect(shouldRedirectToLogin(error)).toBe(false); + }); + }); +}); diff --git a/frontend/pages/QueryGeneratorPage.tsx b/frontend/pages/QueryGeneratorPage.tsx index 5f432b8..9bb4823 100644 --- a/frontend/pages/QueryGeneratorPage.tsx +++ b/frontend/pages/QueryGeneratorPage.tsx @@ -7,6 +7,7 @@ import { getSavedQueries, saveQuery, updateSavedQuery, deleteSavedQuery } from ' import { generateIpynbContent, downloadFile } from '../services/notebookService'; import { QueryResultData, DbInfo, CollectionInfo, CosmosDBAccount, SelectedResource, DebuggingResult, AnalysisResult, NotebookStep, SavedQuery } from '../types'; import { mockECommerceDbInfo, mockCollectionInfoMap, mockFindUsersQuery, mockUserFindResult, mockSavedQueries } from '../services/mockData'; +import { getAuthErrorMessage, isAuthenticationExpiredError } from '../utils/authErrorHandler'; import QueryDisplay from '../components/QueryDisplay'; import QueryResult from '../components/QueryResult'; import Loader from '../components/Loader'; @@ -431,7 +432,10 @@ const QueryGeneratorPage: React.FC = ({ name, email, on setAzureAccounts(accounts); } catch (e) { if (e instanceof Error) { - if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { + // Check for authentication-related errors + if (isAuthenticationExpiredError(e)) { + setDbError(getAuthErrorMessage(e)); + } else if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { setDbError("Permission Denied: You may not have the required permissions to list Azure resources. Please contact your administrator."); } else { 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 = ({ name, email, on const queries = await getSavedQueries(); setSavedQueries(queries); } catch(e) { - // Handle error silently in the UI for now - console.error("Failed to fetch saved queries:", e); + // Log error details for debugging + if (e instanceof Error && isAuthenticationExpiredError(e)) { + console.error("Failed to fetch saved queries due to authentication error:", getAuthErrorMessage(e)); + } else { + console.error("Failed to fetch saved queries:", e); + } } finally { setIsLoadingSavedQueries(false); } @@ -552,7 +560,10 @@ const QueryGeneratorPage: React.FC = ({ name, email, on setAccountDatabases(dbs); } catch(e) { if (e instanceof Error) { - if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { + // Check for authentication-related errors first + if (isAuthenticationExpiredError(e)) { + setDbError(getAuthErrorMessage(e)); + } else if (e.message.includes('AuthorizationFailed') || e.message.includes('403')) { 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."); } else { setDbError(e.message); diff --git a/frontend/services/dbService.ts b/frontend/services/dbService.ts index 25d42ce..e1ff8a2 100644 --- a/frontend/services/dbService.ts +++ b/frontend/services/dbService.ts @@ -44,6 +44,7 @@ export async function deleteDocument(collectionName: string, resource: SelectedR import { DbInfo, CollectionInfo, CosmosDBAccount, SelectedResource, PaginatedDocumentsResponse, FoundDocumentResponse, DocumentHistoryResponse } from '../types'; import { msalInstance, loginRequest } from '../authConfig'; import { USE_MSAL_AUTH, API_BASE_URL } from '../app.config'; +import { getAuthErrorMessage, isAuthenticationExpiredError } from '../utils/authErrorHandler'; import { mockCosmosAccounts, mockDatabasesByAccountId, @@ -60,6 +61,32 @@ import { mockUpdateDocument } from './mockData'; +/** + * Helper function to get access token with proper error handling + */ +const getAuthenticatedToken = async (): Promise => { + try { + const accounts = msalInstance.getAllAccounts(); + if (accounts.length === 0) { + throw new Error("No signed-in user found."); + } + + 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)); + } + // Re-throw other errors as-is + throw error; + } +}; + /** * Fetches available Azure Cosmos DB resources from the backend. * @returns A promise that resolves with an array of Cosmos DB resources. @@ -73,34 +100,43 @@ export const getAzureCosmosAccounts = async (): Promise => { } // --- END DEVELOPMENT MOCK --- - const accounts = msalInstance.getAllAccounts(); - if (accounts.length === 0) { - throw new Error("No signed-in user found."); - } + try { + const accounts = msalInstance.getAllAccounts(); + if (accounts.length === 0) { + throw new Error("No signed-in user found."); + } - // acquire token for backend API (must be set in loginRequest.scopes) - const response = await msalInstance.acquireTokenSilent({ - ...loginRequest, - account: accounts[0], - }); + // acquire token for backend API (must be set in loginRequest.scopes) + const response = await msalInstance.acquireTokenSilent({ + ...loginRequest, + account: accounts[0], + }); - const accessToken = response.accessToken; - - console.log("Fetching Azure cosmosdb accounts from backend..."); - const responseApi = await fetch(`${API_BASE_URL}/azure/cosmos_accounts`, { - method: 'GET', - headers: { - 'Authorization': `Bearer ${accessToken}`, - }, - }); + const accessToken = response.accessToken; + + console.log("Fetching Azure cosmosdb accounts from backend..."); + const responseApi = await fetch(`${API_BASE_URL}/azure/cosmos_accounts`, { + method: 'GET', + headers: { + 'Authorization': `Bearer ${accessToken}`, + }, + }); - if (!responseApi.ok) { - const errorData = await responseApi.json().catch(() => ({})); - const errorMessage = errorData.detail || errorData.message || `Could not load Azure resource list from server. Status: ${responseApi.status}`; - throw new Error(errorMessage); + if (!responseApi.ok) { + const errorData = await responseApi.json().catch(() => ({})); + const errorMessage = errorData.detail || errorData.message || `Could not load Azure resource list from server. Status: ${responseApi.status}`; + throw new Error(errorMessage); + } + + return responseApi.json(); + } catch (error) { + // Handle authentication errors with user-friendly messages + if (isAuthenticationExpiredError(error)) { + throw new Error(getAuthErrorMessage(error)); + } + // Re-throw other errors as-is + throw error; } - - return responseApi.json(); }; @@ -129,18 +165,9 @@ export const getDatabasesForAccount = async (accountId: string): Promise => { - const accounts = msalInstance.getAllAccounts(); - if (accounts.length === 0) { - throw new Error("No signed-in user found."); + try { + const accounts = msalInstance.getAllAccounts(); + if (accounts.length === 0) { + throw new Error("No signed-in user found."); + } + 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)); + } + // Re-throw other errors as-is + throw error; } - const response = await msalInstance.acquireTokenSilent({ - ...loginRequest, - account: accounts[0], - }); - return response.accessToken; }; /** diff --git a/frontend/utils/authErrorHandler.ts b/frontend/utils/authErrorHandler.ts new file mode 100644 index 0000000..b962e89 --- /dev/null +++ b/frontend/utils/authErrorHandler.ts @@ -0,0 +1,92 @@ +import { InteractionRequiredAuthError, AuthError } from '@azure/msal-browser'; + +/** + * Check if an error is an authentication-related error that suggests the user needs to re-authenticate + */ +export const isAuthenticationExpiredError = (error: any): boolean => { + // MSAL InteractionRequiredAuthError + if (error instanceof InteractionRequiredAuthError) { + return true; + } + + // Check for specific error codes that indicate session expiration + if (error instanceof AuthError) { + // Common MSAL error codes for expired sessions + const expiredSessionCodes = [ + 'interaction_required', + 'invalid_grant', + 'token_expired', + 'refresh_token_expired' + ]; + return expiredSessionCodes.some(code => error.errorCode?.includes(code)); + } + + // Check error message for AADSTS codes that indicate session issues + if (typeof error === 'object' && error?.message) { + const errorMessage = error.message.toLowerCase(); + + // AADSTS160021: User session does not exist + // AADSTS50078: User session has expired + // AADSTS50079: User is required to enroll in multifactor authentication + // AADSTS700082: The refresh token has expired + const sessionExpiredPatterns = [ + 'aadsts160021', // Application requested a user session which does not exist + 'aadsts50078', // User session has expired + 'aadsts700082', // The refresh token has expired + 'interaction_required', + 'user session which does not exist', + 'session has expired', + 'refresh token has expired' + ]; + + return sessionExpiredPatterns.some(pattern => errorMessage.includes(pattern)); + } + + return false; +}; + +/** + * Get a user-friendly error message for authentication errors + */ +export const getAuthErrorMessage = (error: any): string => { + if (isAuthenticationExpiredError(error)) { + return "Your session has expired. Please sign out and sign in again to continue."; + } + + // Handle other authentication-related errors + if (error instanceof AuthError) { + switch (error.errorCode) { + case 'user_cancelled': + return "Sign-in was cancelled. Please try signing in again."; + case 'consent_required': + return "Additional permissions are required. Please sign in again to grant consent."; + case 'no_account_found': + return "No account found. Please sign in to continue."; + default: + return "Authentication failed. Please sign out and sign in again."; + } + } + + // Handle HTTP errors that might be authentication related + if (typeof error === 'object' && error?.message) { + const message = error.message.toLowerCase(); + + if (message.includes('401') || message.includes('unauthorized')) { + return "Your session has expired. Please sign out and sign in again to continue."; + } + + if (message.includes('403') || message.includes('forbidden')) { + return "You don't have permission to access this resource. Please check your permissions or contact your administrator."; + } + } + + return error?.message || "An authentication error occurred. Please sign out and sign in again."; +}; + +/** + * Check if an error suggests the user should be redirected to login + */ +export const shouldRedirectToLogin = (error: any): boolean => { + return isAuthenticationExpiredError(error) || + (error instanceof AuthError && error.errorCode === 'no_account_found'); +};