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
8 changes: 5 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AuthenticationResult } from '@azure/msal-browser';
import { AuthenticationResult, InteractionRequiredAuthError } from '@azure/msal-browser';
import '@ms-ofb/officebrowserfeedbacknpm/styles/officebrowserfeedback.css';
import 'bootstrap/dist/css/bootstrap-grid.min.css';
import ReactDOM from 'react-dom/client';
Expand Down Expand Up @@ -95,8 +95,10 @@ function refreshAccessToken() {
appStore.dispatch(getConsentedScopesSuccess(authResponse.scopes));
}
})
.catch(() => {
// ignore the error as it means that a User login is required
.catch((error) => {
if (!(error instanceof InteractionRequiredAuthError)) {
throw new Error(`Error refreshing access token: ${error}`);
}
});
}
refreshAccessToken();
Expand Down
61 changes: 59 additions & 2 deletions src/modules/authentication/AuthenticationWrapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,17 @@ jest.mock('./msal-app.ts', () => {
const msalApplication = {
account: null,
getAccount: jest.fn(),
getAccountByHomeId: jest.fn((id) => ({
homeAccountId: id,
environment: 'environment',
tenantId: 'tenantId',
username: 'username',
idTokenClaims: { sid: 'test-sid', login_hint: 'user@example.com' }
})),
logoutRedirect: jest.fn(),
logoutPopup: jest.fn(),
// Mock getAllAccounts but don't set a default return value
// Each test will configure this as needed
getAllAccounts: jest.fn(),
loginPopup: jest.fn(() => {
return Promise.resolve({
Expand Down Expand Up @@ -43,6 +52,20 @@ jest.mock('./msal-app.ts', () => {
})
describe('AuthenticationWrapper should', () => {

const mockAccount = {
homeAccountId: 'homeAccountId',
environment: 'environment',
tenantId: 'tenantId',
username: 'username'
};

beforeEach(() => {
jest.clearAllMocks();
// Set default mock implementation for most tests
const { msalApplication } = require('./msal-app.ts');
msalApplication.getAllAccounts.mockReturnValue([mockAccount]);
});

it('log out a user and call removeItem with the home_account_key', () => {
authenticationWrapper.logOut();
expect(window.localStorage.removeItem).toHaveBeenCalledWith(HOME_ACCOUNT_KEY);
Expand All @@ -59,8 +82,36 @@ describe('AuthenticationWrapper should', () => {
});

it('clear the cache by calling removeItem with all available msal keys', () => {
// Mock the environment to have MSAL keys in localStorage

// First save original implementation of localStorage methods
const originalGetItem = window.localStorage.getItem;
const originalKeys = Object.keys;

// Mock Object.keys to return MSAL-like keys when called on localStorage
Object.keys = jest.fn().mockImplementation((obj) => {
if (obj === localStorage) {
return ['homeAccountId-login.windows.net-idtoken', 'other-key'];
}
return originalKeys(obj);
});

// Make sure getHomeAccountId returns a value that will match our keys
jest.spyOn(window.localStorage, 'getItem').mockImplementation((key) => {
if (key === HOME_ACCOUNT_KEY) {
return 'homeAccountId';
}
return originalGetItem.call(window.localStorage, key);
});

authenticationWrapper.clearCache();
expect(window.localStorage.removeItem).toHaveBeenCalled();

// Verify removeItem was called with the expected key
expect(window.localStorage.removeItem).toHaveBeenCalledWith('homeAccountId-login.windows.net-idtoken');

// Restore original implementations
Object.keys = originalKeys;
jest.spyOn(window.localStorage, 'getItem').mockRestore();
});

it('clear user current session, calling removeItem from localStorage and window.sessionStorage.clear', () => {
Expand All @@ -75,21 +126,27 @@ describe('AuthenticationWrapper should', () => {
});

it('return undefined when getAccount is called and number of accounts is zero', () => {
const { msalApplication } = require('./msal-app.ts');
msalApplication.getAllAccounts.mockReturnValueOnce([]);
const account = authenticationWrapper.getAccount();
expect(account).toBeUndefined();
})
});

it('Log a user in with the appropriate homeAccountId as returned by the auth call', async () => {
const logIn = await authenticationWrapper.logIn();
expect(logIn.account!.homeAccountId).toBe('homeAccountId');
});

it('get consented scopes along with a valid homeAccountId as returned by the auth call', async () => {
const { msalApplication } = require('./msal-app.ts');
msalApplication.getAllAccounts.mockReturnValue([mockAccount]);
const consentToScopes = await authenticationWrapper.consentToScopes();
expect(consentToScopes.account!.homeAccountId).toBe('homeAccountId');
});

it('get auth token with a valid homeAccountId as returned by the auth call', async () => {
const { msalApplication } = require('./msal-app.ts');
msalApplication.getAllAccounts.mockReturnValue([mockAccount]);
const token = await authenticationWrapper.getToken();
expect(token.account!.homeAccountId).toBe('homeAccountId');
});
Expand Down
57 changes: 50 additions & 7 deletions src/modules/authentication/AuthenticationWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ export class AuthenticationWrapper implements IAuthenticationWrapper {
}
return authResult;
} catch (error) {
throw error;
throw new Error(`Error occurred during login: ${error}`);
}
}

Expand Down Expand Up @@ -121,7 +121,7 @@ export class AuthenticationWrapper implements IAuthenticationWrapper {
try {
return await this.loginWithInteraction(scopes);
} catch (error) {
throw error;
throw new Error(`Error occurred while consenting to scopes: ${error}`);
}
}

Expand All @@ -145,16 +145,59 @@ export class AuthenticationWrapper implements IAuthenticationWrapper {
return allAccounts[0];
}

public async getToken() {
public async getToken(): Promise<AuthenticationResult> {
const account = this.getAccount();
if (!account) {
// If no active account, check cache without triggering interaction
const allAccounts = msalApplication.getAllAccounts();
if (allAccounts.length > 0) {
// Try silent acquisition with the first cached account
const silentRequest: SilentRequest = {
scopes: defaultScopes,
authority: this.getAuthority(),
account: allAccounts[0],
redirectUri: getCurrentUri(),
forceRefresh: false
};

try {
// Attempt silent acquisition
const result = await msalApplication.acquireTokenSilent(silentRequest);
this.storeHomeAccountId(result.account!);
return result;
} catch (error) {
throw new Error(`Silent token acquisition failed for cached account: ${error}`);
}
} else {
throw new Error('No active or cached account found. User login required.');
}
}

// We have an active account, try to get token silently
const silentRequest: SilentRequest = {
scopes: defaultScopes,
authority: this.getAuthority(),
account: this.getAccount(),
account,
redirectUri: getCurrentUri(),
claims: this.claimsAvailable ? this.getClaims() : undefined
claims: this.claimsAvailable ? this.getClaims() : undefined,
forceRefresh: false
};
const response: AuthenticationResult = await msalApplication.acquireTokenSilent(silentRequest);
return response;

try {
return await msalApplication.acquireTokenSilent(silentRequest);
} catch (error) {
if (error instanceof InteractionRequiredAuthError) {
// Attempt silent refresh first
try {
silentRequest.forceRefresh = true;
return await msalApplication.acquireTokenSilent(silentRequest);
} catch (refreshError) {
// If refresh also fails, throw error indicating interaction is needed.
throw new Error(`Silent token refresh failed, login required: ${refreshError}`);
}
}
throw new Error(`Token acquisition failed: ${error}`);
}
}

private async getAuthResult(scopes: string[] = [], sessionId?: string): Promise<AuthenticationResult> {
Expand Down
Loading