Skip to content

Commit 76c9866

Browse files
authored
fix: refresh token silently (#3806)
1 parent 391ad07 commit 76c9866

File tree

3 files changed

+114
-12
lines changed

3 files changed

+114
-12
lines changed

src/index.tsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { AuthenticationResult } from '@azure/msal-browser';
1+
import { AuthenticationResult, InteractionRequiredAuthError } from '@azure/msal-browser';
22
import '@ms-ofb/officebrowserfeedbacknpm/styles/officebrowserfeedback.css';
33
import 'bootstrap/dist/css/bootstrap-grid.min.css';
44
import ReactDOM from 'react-dom/client';
@@ -95,8 +95,10 @@ function refreshAccessToken() {
9595
appStore.dispatch(getConsentedScopesSuccess(authResponse.scopes));
9696
}
9797
})
98-
.catch(() => {
99-
// ignore the error as it means that a User login is required
98+
.catch((error) => {
99+
if (!(error instanceof InteractionRequiredAuthError)) {
100+
throw new Error(`Error refreshing access token: ${error}`);
101+
}
100102
});
101103
}
102104
refreshAccessToken();

src/modules/authentication/AuthenticationWrapper.spec.ts

Lines changed: 59 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,17 @@ jest.mock('./msal-app.ts', () => {
1212
const msalApplication = {
1313
account: null,
1414
getAccount: jest.fn(),
15+
getAccountByHomeId: jest.fn((id) => ({
16+
homeAccountId: id,
17+
environment: 'environment',
18+
tenantId: 'tenantId',
19+
username: 'username',
20+
idTokenClaims: { sid: 'test-sid', login_hint: '[email protected]' }
21+
})),
1522
logoutRedirect: jest.fn(),
1623
logoutPopup: jest.fn(),
24+
// Mock getAllAccounts but don't set a default return value
25+
// Each test will configure this as needed
1726
getAllAccounts: jest.fn(),
1827
loginPopup: jest.fn(() => {
1928
return Promise.resolve({
@@ -43,6 +52,20 @@ jest.mock('./msal-app.ts', () => {
4352
})
4453
describe('AuthenticationWrapper should', () => {
4554

55+
const mockAccount = {
56+
homeAccountId: 'homeAccountId',
57+
environment: 'environment',
58+
tenantId: 'tenantId',
59+
username: 'username'
60+
};
61+
62+
beforeEach(() => {
63+
jest.clearAllMocks();
64+
// Set default mock implementation for most tests
65+
const { msalApplication } = require('./msal-app.ts');
66+
msalApplication.getAllAccounts.mockReturnValue([mockAccount]);
67+
});
68+
4669
it('log out a user and call removeItem with the home_account_key', () => {
4770
authenticationWrapper.logOut();
4871
expect(window.localStorage.removeItem).toHaveBeenCalledWith(HOME_ACCOUNT_KEY);
@@ -59,8 +82,36 @@ describe('AuthenticationWrapper should', () => {
5982
});
6083

6184
it('clear the cache by calling removeItem with all available msal keys', () => {
85+
// Mock the environment to have MSAL keys in localStorage
86+
87+
// First save original implementation of localStorage methods
88+
const originalGetItem = window.localStorage.getItem;
89+
const originalKeys = Object.keys;
90+
91+
// Mock Object.keys to return MSAL-like keys when called on localStorage
92+
Object.keys = jest.fn().mockImplementation((obj) => {
93+
if (obj === localStorage) {
94+
return ['homeAccountId-login.windows.net-idtoken', 'other-key'];
95+
}
96+
return originalKeys(obj);
97+
});
98+
99+
// Make sure getHomeAccountId returns a value that will match our keys
100+
jest.spyOn(window.localStorage, 'getItem').mockImplementation((key) => {
101+
if (key === HOME_ACCOUNT_KEY) {
102+
return 'homeAccountId';
103+
}
104+
return originalGetItem.call(window.localStorage, key);
105+
});
106+
62107
authenticationWrapper.clearCache();
63-
expect(window.localStorage.removeItem).toHaveBeenCalled();
108+
109+
// Verify removeItem was called with the expected key
110+
expect(window.localStorage.removeItem).toHaveBeenCalledWith('homeAccountId-login.windows.net-idtoken');
111+
112+
// Restore original implementations
113+
Object.keys = originalKeys;
114+
jest.spyOn(window.localStorage, 'getItem').mockRestore();
64115
});
65116

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

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

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

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

92147
it('get auth token with a valid homeAccountId as returned by the auth call', async () => {
148+
const { msalApplication } = require('./msal-app.ts');
149+
msalApplication.getAllAccounts.mockReturnValue([mockAccount]);
93150
const token = await authenticationWrapper.getToken();
94151
expect(token.account!.homeAccountId).toBe('homeAccountId');
95152
});

src/modules/authentication/AuthenticationWrapper.ts

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export class AuthenticationWrapper implements IAuthenticationWrapper {
6565
}
6666
return authResult;
6767
} catch (error) {
68-
throw error;
68+
throw new Error(`Error occurred during login: ${error}`);
6969
}
7070
}
7171

@@ -121,7 +121,7 @@ export class AuthenticationWrapper implements IAuthenticationWrapper {
121121
try {
122122
return await this.loginWithInteraction(scopes);
123123
} catch (error) {
124-
throw error;
124+
throw new Error(`Error occurred while consenting to scopes: ${error}`);
125125
}
126126
}
127127

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

148-
public async getToken() {
148+
public async getToken(): Promise<AuthenticationResult> {
149+
const account = this.getAccount();
150+
if (!account) {
151+
// If no active account, check cache without triggering interaction
152+
const allAccounts = msalApplication.getAllAccounts();
153+
if (allAccounts.length > 0) {
154+
// Try silent acquisition with the first cached account
155+
const silentRequest: SilentRequest = {
156+
scopes: defaultScopes,
157+
authority: this.getAuthority(),
158+
account: allAccounts[0],
159+
redirectUri: getCurrentUri(),
160+
forceRefresh: false
161+
};
162+
163+
try {
164+
// Attempt silent acquisition
165+
const result = await msalApplication.acquireTokenSilent(silentRequest);
166+
this.storeHomeAccountId(result.account!);
167+
return result;
168+
} catch (error) {
169+
throw new Error(`Silent token acquisition failed for cached account: ${error}`);
170+
}
171+
} else {
172+
throw new Error('No active or cached account found. User login required.');
173+
}
174+
}
175+
176+
// We have an active account, try to get token silently
149177
const silentRequest: SilentRequest = {
150178
scopes: defaultScopes,
151179
authority: this.getAuthority(),
152-
account: this.getAccount(),
180+
account,
153181
redirectUri: getCurrentUri(),
154-
claims: this.claimsAvailable ? this.getClaims() : undefined
182+
claims: this.claimsAvailable ? this.getClaims() : undefined,
183+
forceRefresh: false
155184
};
156-
const response: AuthenticationResult = await msalApplication.acquireTokenSilent(silentRequest);
157-
return response;
185+
186+
try {
187+
return await msalApplication.acquireTokenSilent(silentRequest);
188+
} catch (error) {
189+
if (error instanceof InteractionRequiredAuthError) {
190+
// Attempt silent refresh first
191+
try {
192+
silentRequest.forceRefresh = true;
193+
return await msalApplication.acquireTokenSilent(silentRequest);
194+
} catch (refreshError) {
195+
// If refresh also fails, throw error indicating interaction is needed.
196+
throw new Error(`Silent token refresh failed, login required: ${refreshError}`);
197+
}
198+
}
199+
throw new Error(`Token acquisition failed: ${error}`);
200+
}
158201
}
159202

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

0 commit comments

Comments
 (0)