I'm working on an internal facing React web app that uses React-MSAL for authentication and useSWR for all of our GET requests. I've been working on the authentication flow on and off for a few months, and I've been struggling to resolve several issues, primarily the silent sso -> redirect flow on token expiry.
I have a simple MSAL setup and SWR config (simplified / truncated code):
// main.tsx
export const msalInstance = new PublicClientApplication(msalConfig);
msalInstance.initialize().then(() => {
const accounts = msalInstance.getAllAccounts();
if (accounts.length > 0) {
msalInstance.setActiveAccount(accounts[0]);
}
msalInstance.addEventCallback((event: EventMessage) => {
if (event.eventType === EventType.LOGIN_SUCCESS && event.payload) {
const payload = event.payload as AuthenticationResult;
const account = payload.account;
msalInstance.setActiveAccount(account);
}
});
...
root.render(
<BrowserRouter>
<App pca={msalInstance} />
</BrowserRouter>
);
});
// App.tsx render
<SWRConfig
value={{
provider: () => new Map(),
fetcher: fetcher
}}>
<MsalProvider instance={pca}>
<Pages /> // react router routes
</MsalProvider>
</SWRConfig>
Initially my goal was to wrap all of our requests in hooks to avoid littering the source code with auth validation, token retrieval/refresh, redirect handling, etc. in every component with a get request. So I set up a fetcher and request hooks, as well as a hook for getting / refreshing the token, that look like this:
// fetcher.tsx
export async function fetcher(params: string[]) {
const [url, token] = params;
const requestOptions = getRequestOptions(token); // returns headers
const res = await fetch(url, requestOptions);
const body = await res.json();
if (!res.ok) {
const error = new Error(`${res.status}: ${JSON.stringify(body.response)}`);
throw error;
}
return body;
}
// useCurrentUser.tsx
export function useCurrentUser(): SWRResponse<UserType,Error> {
const token = useToken();
return useSWRImmutable(token ? [getCurrentUserUrl(), token] : null);
}
// useToken.tsx
export const useToken = () => {
const { instance } = useMsal();
const [token, setToken] = useState<string | null>(null);
useEffect(() => {
const fetchAccessToken = async (): Promise<void> => {
try {
const silentAuthResponse = await instance.acquireTokenSilent(loginRequest);
setToken(silentAuthResponse.idToken);
return;
} catch (silentAuthError) {
if (silentAuthError instanceof InteractionRequiredAuthError) {
try {
await instance.acquireTokenRedirect(loginRequest);
} catch (interactiveAuthError) {
const error = new Error(`Auth error: acquireTokenRedirect failed`);
throw error;
}
} else {
const error = new Error(`Auth error: acquireTokenSilent failed`);
throw error;
}
}
};
fetchAccessToken();
}, [token, instance]);
return token;
};
And finally (sorry for all the snippets) we wrap the majority of the app in a protected component flow that ensures the active user has the right backend permissions & is authenticated (using the msal authentication template).
// RequireEnabled.tsx
export const RequireEnabled: React.FC<RequireEnabledProps> = ({ children }) => {
const { data, isLoading, error } = useCurrentUser();
const [currentUser, setCurrentUser] = useState<UserType>();
const [userError, setUserError] = useState<Error | null>(null);
useEffect(() => {
if (data) {
setCurrentUser(data);
}
if (error) {
const dataError = new Error("Failed to get current user");
setUserError(dataError);
}
}, [data, error]);
if (userError) return <ErrorDisplay error={userError} />;
if (isLoading || !currentUser) return <LoadingSpinner />;
return currentUser.enabled ? <>{ children }</> : <Navigate to='/not-enabled' replace />
};
export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
...
return (
<MsalAuthenticationTemplate
interactionType={InteractionType.Redirect}
authenticationRequest={loginRequest}
loadingComponent={LoadingSpinner} >
<RequireEnabled>{ children }</RequireEnabled>
</MsalAuthenticationTemplate>
);
}
My idea was that I could put the useToken hook inside the request hooks so that the components only need to call useSomeRequest() and it will automatically grab the token before reach request, attempt silent sso, then attempt interactive login with a redirect. But for a slew of reasons, I haven't been able to get it to work. (Some of the code might look peculiar on account of me experimenting with it for the last several days straight).
My primary issue is that the useCurrentUser hook, which wraps every component, seems to break when the token expires and it tries to revalidate. Refocusing on the page after a token expires triggers revalidation, which as far as I can tell skips the useCurrentUser hook (ergo, the useToken() call) and goes straight to the fetcher, calling the backend with the expired token, and returning a 401. So, after 30 mins of inactivity, when the user returns to the app, they arrive on a 401 error screen due to useCurrentUser() revalidating. Navigating to a new page seems to rerun useToken() because it clears the 401 and loads the new page / makes new requests without error. But without navigating away (even refreshing doesn't work), it never gets past the error screen, leading to bad user experience (I'd at least like to redirect them to login automatically, but the sso silent should work here, since it allows me to navigate to another page without logging in again.) Another issue is that every solution I try requires 30 minutes of inactivity to trigger this behavior, making it very burdensome to test.
One issue is that it's trying to revalidate despite using useSWRImmutable. I can get around this by adding revalidateOnFocus: false so it's not a huge issue, but if I understand correctly, I shouldn't have to include this option with the immutable hook? Anyway, less important.
My best guess is that when SWR recognizes a cache key it skips the hook logic and just reruns the fetch, which still has the expired token. I read that I could resolve this by including the token as a key in a key array, but that didn't change anything, presumably because it's still not rerunning useToken().
I've tried a bunch of different setups with similar results. I don't understand why useToken() isn't rerunning or why it continues to return the old token, or where else I can put the token refresh code so that a 401 error will actually trigger the refresh/redirect. I tried putting useToken() directly in the ProtectedRoute component, and rewriting the error handling in the useCurrentUser hook. Ideally, the solution will be able to abstract most of the request / auth logic into hooks so they don't litter the component code, but at this point I just want to get it to work.
I've also had some issues with SWR and MSAL. I can't use the MSAL hooks (or the useToken hook) inside the fetcher because it's not a hook or component. And I can't use the other MSAL instance methods "outside the React context" which is a constraint I've found vague and not well documented. I had an ugly solution previously to get around this, before writing the useToken hook:
const acquireIdToken = async (msalInstance: IPublicClientApplication) => {
await Promise.resolve(); // found this online, can't remember why it was necessary but it wasn't working without it
const accounts = msalInstance.getAllAccounts();
if (accounts.length === 0) {
throw Error("Error: No active account");
}
const request = {
scopes: ["User.Read"],
account: accounts[0],
};
const authResult = await msalInstance.acquireTokenSilent(request);
return authResult.idToken;
};
So all of this to say, I'm a bit confused and feel like I've strayed from the standard practice architecture in trying to combine these two packages. Unfortunately I've got limited time to work on it, so I'm trying to avoid replacing any of the packages or switching to axios. Apologies for the long post. I've been googling this for days trying to figure it out, and I'm getting tunnel vision and feel like I'm losing track of the real issue. Any help, guidance, suggestions would be greatly appreciated.
1 Answer 1
Maybe I am wrong but is the issue that a deeper nested component's api call(via hook) is being fired before the app has time to do some part of its authentication process. Meaning you get 401 since you haven't 'timed' some logic concerning the token in the app?
If this is the case, then it's because react builds the app bottom up, in that case the solution for me was to conditionally render the part of the app that requires some state to be set. In my app I simply have a useState(false) that is turned into 'true' by a simple effect.
{someState && <App />}