Core Library
MSAL.js (@azure/msal-browser)
Core Library Version
2.23.0
Wrapper Library
MSAL React (@azure/msal-react)
Wrapper Library Version
1.3.2
Public or Confidential Client?
Public
Description
After the refresh token expires, the application redirects the user to the login page. When I log in again with valid credentials, it redirects to the main page, where a loading state is shown.
At this point:
- The first token API call is made with grant_type: authorization_code, and it succeeds with a 200 response.
- Immediately after, a second token API call is triggered using grant_type: refresh_token, but it uses an already expired refresh token and fails with a 400 error.
The error returned is:
AADB2C90080: The provided grant has expired. Please re-authenticate and try again.
Because of this failure, the application redirects back to the login page again. After logging in once more, the application finally navigates to the main page and works as expected.
Error Message
AADB2C90080: The provided grant has expired. Please re-authenticate and try again.
MSAL Logs
No response
Network Trace (Preferrably Fiddler)
MSAL Configuration
export const msalConfig: Configuration = {
auth: {
clientId: *** ***, // This is the ONLY mandatory field that you need to supply.
authority: *** ***, // Choose SUSI as your default authority.
knownAuthorities: [*** ***], // Mark your B2C tenant's domain as trusted.
redirectUri: *** ***, // You must register this URI on Azure Portal/App Registration. Defaults to window.location.origin
postLogoutRedirectUri: *** ***, // Indicates the page to navigate after logout.
},
cache: {
cacheLocation: 'localStorage', // Configures cache location. "sessionStorage" is more secure, but "localStorage" gives you SSO between tabs.
storeAuthStateInCookie: isIE || isEdge || isFirefox // Set this to "true" if you are having issues on IE11 or Edge
},
system: {
loggerOptions: {
loggerCallback: (level: any, message: any, containsPii: any) => {
if (containsPii) {
return;
}
switch (level) {
case LogLevel.Error:
// eslint-disable-next-line no-console
console.error(message);
return;
case LogLevel.Info:
// eslint-disable-next-line no-console
console.info(message);
return;
case LogLevel.Verbose:
// eslint-disable-next-line no-console
console.debug(message);
return;
case LogLevel.Warning:
// eslint-disable-next-line no-console
console.warn(message);
return;
}
}
}
}
};
Relevant Code Snippets
1.app.tsx
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import { AppProps } from 'next/app';
import Head from 'next/head';
import { Provider } from 'react-redux';
import { ConfigProvider } from 'antd';
// import { Provider, useStore } from 'react-redux';
// import { PersistGate } from 'redux-persist/integration/react';
// eslint-disable-next-line import/named
import { CacheProvider, EmotionCache } from '@emotion/react';
import { MsalProvider, useMsal } from '@azure/msal-react';
// eslint-disable-next-line import/named
import { PublicClientApplication, EventType, EventMessage, AuthenticationResult } from '@azure/msal-browser';
import { msalConfig, b2cPolicies } from 'authentication/authConfig';
import { ThemeProvider } from 'contexts/theme';
import store from 'stores';
// import { wrapper } from 'stores';
import { previousUrlTrackerPatchHistory } from 'utils/browser';
import { createEmotionCache } from 'utils/css';
import { CustomNavigationClient } from 'utils/msal/NavigationClient';
import PageLayout from 'components/pageLayout';
import NProgress from 'components/NProgress';
import 'styles/main.scss';
previousUrlTrackerPatchHistory();
export const msalInstance = new PublicClientApplication((typeof window !== 'undefined' && window?.location?.origin === process?.env?.*** ***) ? {
...msalConfig,
auth: {
...msalConfig?.auth,
clientId:.*** ***, // This is the ONLY mandatory field that you need to supply.
authority: *** *** // Choose SUSI as your default authority.
knownAuthorities: *** ***, // Mark your B2C tenant's domain as trusted.
redirectUri: ((typeof window !== 'undefined' && window?.location?.origin) ? window?.location?.origin : process?.env?.*** ***), // You must register this URI on Azure Portal/App Registration. Defaults to window.location.origin
postLogoutRedirectUri: ((typeof window !== 'undefined' && window?.location?.origin) ? window?.location?.origin : process?.env?.*** ***) // Indicates the page to navigate after logout.
}
} : {
...msalConfig,
auth: {
...msalConfig?.auth,
redirectUri: ((typeof window !== 'undefined' && window?.location?.origin) ? window?.location?.origin : process?.env?.*** ***), // You must register this URI on Azure Portal/App Registration. Defaults to window.location.origin
postLogoutRedirectUri: ((typeof window !== 'undefined' && window?.location?.origin) ? window?.location?.origin : process?.env?.*** ***) // Indicates the page to navigate after logout.
}
});
// Default to using the first account if no account is active on page load
if (!msalInstance.getActiveAccount() && msalInstance.getAllAccounts().length > 0) {
// Account selection logic is app dependent. Adjust as needed for different use cases.
msalInstance.setActiveAccount(msalInstance.getAllAccounts()[0]);
}
// Optional - This will update account state if a user signs in from another tab or window
msalInstance.enableAccountStorageEvents();
msalInstance.addEventCallback((event: EventMessage) => {
if (event.eventType === EventType.LOGIN_SUCCESS
|| event.eventType === EventType.ACQUIRE_TOKEN_SUCCESS
|| event.eventType === EventType.SSO_SILENT_SUCCESS
) {
const payload = event?.payload as AuthenticationResult;
msalInstance.setActiveAccount(payload?.account);
}
});
// Client-side cache, shared for the whole session of the user in the browser.
const clientSideEmotionCache = createEmotionCache();
interface MyAppProps extends AppProps {
emotionCache?: EmotionCache
}
export default function MyApp({
Component,
emotionCache = clientSideEmotionCache,
pageProps
}: MyAppProps): JSX.Element {
// The next 3 lines are optional. This is how you configure MSAL to take advantage of the router's navigate functions when MSAL redirects between pages in your app
const router = useRouter();
const navigationClient = new CustomNavigationClient(router);
msalInstance.setNavigationClient(navigationClient);
const { instance } = useMsal();
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [status, setStatus] = useState('' || null as string | null);
const [state, setState] = useState({ isRouteChanging: false, loadingKey: 0 });
// const store: any = useStore();
useEffect(() => {
const callbackId = instance.addEventCallback((event: EventMessage) => {
const payload = event?.payload as AuthenticationResult | any;
if ((event.eventType === EventType.LOGIN_SUCCESS || event.eventType === EventType.ACQUIRE_TOKEN_SUCCESS) && payload?.account) {
/**
* For the purpose of setting an active account for UI update, we want to consider only the auth
* response resulting from SUSI flow. "tfp" claim in the id token tells us the policy (NOTE: legacy
* policies may use "acr" instead of "tfp"). To learn more about B2C tokens, visit:
* https://docs.microsoft.com/en-us/azure/active-directory-b2c/tokens-overview
*/
if (payload?.idTokenClaims?.['tfp'] === b2cPolicies?.names?.editProfile) {
// retrieve the account from initial sing-in to the app
const originalSignInAccount = instance.getAllAccounts()
.find((account: any) =>
account?.idTokenClaims?.oid === payload?.idTokenClaims?.oid
&& account?.idTokenClaims?.sub === payload?.idTokenClaims?.sub
&& (account?.idTokenClaims?.['tfp'] === b2cPolicies?.names?.signUpSignIn || account?.idTokenClaims?.['tfp'] === b2cPolicies?.names?.signUpSignInCap)
);
const signUpSignInFlowRequest = {
authority: ((typeof window !== 'undefined' && window?.location?.origin === process?.env?.*** ***)
? b2cPolicies?.authorities?.signUpSignInCap
: b2cPolicies?.authorities?.signUpSignIn
),
account: originalSignInAccount
};
// silently login again with the signUpSignIn policy
instance.ssoSilent(signUpSignInFlowRequest);
}
}
if (event.eventType === EventType.SSO_SILENT_SUCCESS && payload?.account) {
setStatus('ssoSilent success');
}
});
return () => {
if (callbackId) {
instance.removeEventCallback(callbackId);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const handleRouteChangeStart = (): void => {
setState((prevState: { loadingKey: number }) => ({
...prevState,
isRouteChanging: true,
loadingKey: prevState.loadingKey ^ 1
}));
};
const handleRouteChangeEnd = (): void => {
setState(prevState => ({
...prevState,
isRouteChanging: false
}));
};
router?.events.on('routeChangeStart', handleRouteChangeStart);
router?.events.on('routeChangeComplete', handleRouteChangeEnd);
router?.events.on('routeChangeError', handleRouteChangeEnd);
return () => {
router?.events.off('routeChangeStart', handleRouteChangeStart);
router?.events.off('routeChangeComplete', handleRouteChangeEnd);
router?.events.off('routeChangeError', handleRouteChangeEnd);
};
}, [router?.events]);
useEffect(() => {
const storage = globalThis?.sessionStorage;
if (!storage) return;
// Set the previous path as the value of the current path.
const prevPath: any = storage.getItem('currentPath');
storage.setItem('prevPath', prevPath);
// Set the current path value by looking at the browser's location object.
storage.setItem('currentPath', `${globalThis.location.pathname}${globalThis.location.search}`);
}, [router?.asPath]);
return (
<CacheProvider value={ emotionCache }>
<Head>
<meta name="viewport" content="minimum-scale=1, initial-scale=1, width=device-width, shrink-to-fit=no, user-scalable=no, viewport-fit=cover" />
</Head>
<Provider store={ store }>
{ /* <PersistGate persistor={ store.__persistor } loading={ <div>Loading</div> }> */ }
<ThemeProvider>
<MsalProvider instance={ msalInstance }>
<ConfigProvider
theme={ {
token: {
colorPrimaryBg: 'var(--primary-text-color)',
colorBgContainerDisabled: 'var(--primary-bg-color)',
colorText: 'var(--primary-text-color)',
colorTextQuaternary: 'var(--tertiary-text-color)'
},
components: {
DatePicker: {
colorPrimary: 'var(--primary-block-color)',
activeBorderColor: 'var(--status-info-color)',
hoverBorderColor: 'none',
cellBgDisabled: 'var(--tertiary-bg-color)'
},
Switch: {
colorPrimary: 'var(--primary-block-color)',
colorPrimaryHover: 'var(--primary-block-color)',
},
InputNumber: {
colorTextPlaceholder: 'rgba(var(--primary-text-color--rgb), 0.5)',
colorTextDisabled: 'var(--primary-text-color)'
}
}
} }
>
<PageLayout>
<NProgress isRouteChanging={ state.isRouteChanging } key={ state.loadingKey } />
<Component { ...pageProps } />
</PageLayout>
</ConfigProvider>
</MsalProvider>
</ThemeProvider>
{ /* </PersistGate> */ }
</Provider>
</CacheProvider>
);
}
// export default wrapper.withRedux(MyApp);
2. executeWithBody.ts (Common component for API call)
import { ApiHeaders, FormData, JSON } from 'appConstants/APIService';
import axios, { Method } from 'axios';
import axiosRetry from 'axios-retry';
import { customNotification } from 'utils/common';
import { callMsIdTokenClaim } from 'utils/msal';
const client = axios.create({
baseURL: process?.env?.*** ***,
});
axiosRetry(client, {
retries: 3,
retryDelay: (retryCount) => retryCount * 1000,
});
const executeWithBody = async (apiUrl: string, apiMethod: any, apiBody: any, type?: string, isToaster?: boolean): Promise<any> => (
client({
url: apiUrl,
method: apiMethod as Method,
headers: {
...ApiHeaders,
'Ocp-Apim-Subscription-Key': *** ***,
'Content-Type': type === 'form' ? FormData : JSON,
'Authorization': `Bearer ${await callMsIdTokenClaim?.('accessToken')}`
},
data: apiBody
})
.then(response => {
// customNotification({
// message: response?.data?.message || response?.statusText,
// status: response?.data?.status || response?.status
// });
return response?.data;
})
.catch(error => {
isToaster && customNotification({ message: error?.response?.data?.detail?.[0]?.msg || error?.response?.statusText });
return error?.response;
})
);
export default executeWithBody;
3. executeWoBody.ts (Common component for API call)
import { ApiHeaders, FormData, JSON } from 'appConstants/APIService';
import axios, { Method } from 'axios';
import axiosRetry from 'axios-retry';
import { customNotification } from 'utils/common';
import { callMsIdTokenClaim } from 'utils/msal';
const client = axios.create({
baseURL: process?.env?.*** ***,
});
axiosRetry(client, {
retries: 3,
retryDelay: (retryCount) => retryCount * 1000,
});
const executeWoBody = async (apiUrl: string, apiMethod: any, type?: string, isToaster?: boolean): Promise<any> => (
client({
url: apiUrl,
method: apiMethod as Method,
headers: {
...ApiHeaders,
'Ocp-Apim-Subscription-Key': *** ***,
'Content-Type': type === 'form' ? FormData : JSON,
'Authorization': `Bearer ${await callMsIdTokenClaim?.('accessToken')}`
}
})
.then(response => {
// customNotification({
// message: response?.data?.message || response?.statusText,
// status: response?.data?.status || response?.status
// });
return response?.data;
})
.catch(error => {
isToaster && customNotification({ message: error?.response?.data?.detail?.[0]?.msg || error?.response?.statusText });
return error?.response;
})
);
export default executeWoBody;
4. MsIdTokenClaim.ts
import { loginRequest, loginRequestCap } from 'authentication/authConfig';
import { msalInstance } from 'pages/_app';
import _ from 'underscore';
export default async function callMsIdTokenClaim(type?: string): Promise<void> {
const account = msalInstance.getActiveAccount();
if (!account) {
throw Error('No active account! Verify a user has been signed in and setActiveAccount has been called.');
}
const response = await msalInstance.acquireTokenSilent(
(typeof window !== 'undefined' && window?.location?.origin === process?.env?.*** ***) ? {
...loginRequestCap,
account: account,
redirectUri: `${((typeof window !== 'undefined' && window?.location?.origin) ? window?.location?.origin : process?.env?.*** ***)}/blank` // You must register this URI on Azure Portal/App Registration. Defaults to window.location.origin
} : {
...loginRequest,
account: account,
extraQueryParameters: { dnsUrl: ((typeof window !== 'undefined' && window?.location?.host)
? (window?.location?.host.includes('localhost') ? `${process?.env?.*** ***}` : window?.location?.host)
: `${process?.env?.*** ***}`
) },
redirectUri: `${((typeof window !== 'undefined' && window?.location?.origin) ? window?.location?.origin : process?.env?.*** ***)}/blank` // You must register this URI on Azure Portal/App Registration. Defaults to window.location.origin
}
);
let msIdTokenResponse: any = response;
if (type && !_.isEmpty(type)) {
switch (type) {
case 'accessToken':
msIdTokenResponse = response?.accessToken || response?.idToken;
break;
case 'account':
msIdTokenResponse = response?.account;
break;
case 'authority':
msIdTokenResponse = response?.authority;
break;
case 'cloudGraphHostName':
msIdTokenResponse = response?.cloudGraphHostName;
break;
case 'code':
msIdTokenResponse = response?.code;
break;
case 'correlationId':
msIdTokenResponse = response?.correlationId;
break;
case 'expiresOn':
msIdTokenResponse = response?.expiresOn;
break;
case 'extExpiresOn':
msIdTokenResponse = response?.extExpiresOn;
break;
case 'familyId':
msIdTokenResponse = response?.familyId;
break;
case 'idToken':
msIdTokenResponse = response?.idToken;
break;
case 'idTokenClaims':
msIdTokenResponse = response?.idTokenClaims;
break;
case 'msGraphHost':
msIdTokenResponse = response?.msGraphHost;
break;
case 'scopes':
msIdTokenResponse = response?.scopes;
break;
case 'state':
msIdTokenResponse = response?.state;
break;
case 'tenantId':
msIdTokenResponse = response?.tenantId;
break;
case 'tokenType':
msIdTokenResponse = response?.tokenType;
break;
case 'uniqueId':
msIdTokenResponse = response?.uniqueId;
break;
default:
msIdTokenResponse = response;
break;
}
}
return msIdTokenResponse;
}
5. Page router is used in NextJs. So in each pages index file the below code is used with different router
import React, { useMemo } from 'react';
import { MsalAuthenticationTemplate, useMsal } from '@azure/msal-react';
import { InteractionType } from '@azure/msal-browser';
import { loginRequest, loginRequestCap } from 'authentication/authConfig';
import { useTheme } from 'hooks/theme';
import { whiteLabels } from 'utils/common';
import AccountInformation from 'routes/accountInformation';
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const ErrorComponent = ({error}: Record<any, any>) => {
const { instance } = useMsal();
React.useEffect(() => {
instance.loginRedirect((typeof window !== 'undefined' && window?.location?.origin === process?.env?.*** ***)
? loginRequestCap
: {
...loginRequest,
extraQueryParameters: { dnsUrl: ((typeof window !== 'undefined' && window?.location?.host)
? (window?.location?.host.includes('localhost') ? `${process?.env?.*** ***}` : window?.location?.host)
: `${process?.env?.*** ***}`
) }
}
)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div style={ { display: 'flex', alignItems: 'center', justifyContent: 'center' } }>
<h6>An Error Occurred: { error.errorCode }</h6>
</div>
);
};
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type
const Loading = () => {
return (
<div style={ { display: 'flex', alignItems: 'center', justifyContent: 'center' } }>
<h6>Authentication in progress...</h6>
</div>
);
};
export default function AccountInformationPage(): JSX.Element {
const { userTheme } = useTheme();
useMemo(() => {
whiteLabels?.({ brandingData: {}, appTheme: userTheme });
}, [userTheme]);
return (
<MsalAuthenticationTemplate
interactionType={ InteractionType.Redirect }
authenticationRequest={ (typeof window !== 'undefined' && window?.location?.origin === process?.env?.*** ***)
? loginRequestCap
: { ...loginRequest, extraQueryParameters: { dnsUrl: ((typeof window !== 'undefined' && window?.location?.host)
? (window?.location?.host.includes('localhost') ? `${process?.env?.*** ***}` : window?.location?.host)
: `${process?.env?.*** ***}`
) } }
}
errorComponent={ ErrorComponent }
loadingComponent={ Loading }
>
<AccountInformation />
</MsalAuthenticationTemplate>
);
}
Reproduction Steps
After the refresh token expires, the application redirects the user to the login page. When I log in again with valid credentials, it redirects to the main page, where a loading state is shown.
At this point:
- The first token API call is made with grant_type: authorization_code, and it succeeds with a 200 response.
- Immediately after, a second token API call is triggered using grant_type: refresh_token, but it uses an already expired refresh token and fails with a 400 error.
The error returned is:
AADB2C90080: The provided grant has expired. Please re-authenticate and try again.
Because of this failure, the application redirects back to the login page again. After logging in once more, the application finally navigates to the main page and works as expected.
Expected Behavior
After the refresh token expires, I want the application to prompt the user to log in only once with valid credentials. After a successful login, it should directly navigate to the main page without triggering additional token requests or redirecting back to the login page again.
Identity Provider
Entra ID (formerly Azure AD) / MSA
Browsers Affected (Select all that apply)
Chrome, Firefox, Edge, Safari
Regression
Same version as mentioned above i.e, "@azure/msal-browser": "^2.23.0"
Core Library
MSAL.js (@azure/msal-browser)
Core Library Version
2.23.0
Wrapper Library
MSAL React (@azure/msal-react)
Wrapper Library Version
1.3.2
Public or Confidential Client?
Public
Description
After the refresh token expires, the application redirects the user to the login page. When I log in again with valid credentials, it redirects to the main page, where a loading state is shown.
At this point:
The error returned is:
AADB2C90080: The provided grant has expired. Please re-authenticate and try again.
Because of this failure, the application redirects back to the login page again. After logging in once more, the application finally navigates to the main page and works as expected.
Error Message
AADB2C90080: The provided grant has expired. Please re-authenticate and try again.
MSAL Logs
No response
Network Trace (Preferrably Fiddler)
MSAL Configuration
Relevant Code Snippets
Reproduction Steps
After the refresh token expires, the application redirects the user to the login page. When I log in again with valid credentials, it redirects to the main page, where a loading state is shown.
At this point:
The error returned is:
AADB2C90080: The provided grant has expired. Please re-authenticate and try again.
Because of this failure, the application redirects back to the login page again. After logging in once more, the application finally navigates to the main page and works as expected.
Expected Behavior
After the refresh token expires, I want the application to prompt the user to log in only once with valid credentials. After a successful login, it should directly navigate to the main page without triggering additional token requests or redirecting back to the login page again.
Identity Provider
Entra ID (formerly Azure AD) / MSA
Browsers Affected (Select all that apply)
Chrome, Firefox, Edge, Safari
Regression
Same version as mentioned above i.e, "@azure/msal-browser": "^2.23.0"