React OIDC signinSilent() function causing a page refresh - reactjs

Using oidc in react:
import { useAuth } from "react-oidc-context";
//This is inside AuthProvider from react-oidc-context
const MyComponent: React.FC<MyComponentProps> = () => {
const auth = useAuth();
auth.events.addAccessTokenExpiring(() => {
auth.signinSilent().then(user => {
console.log('signinSilent finished', user);
//this is where I reset auth token for http headers because it is being stored.
}).catch(error => {
console.log('signinSilent failed', error);
});
});
}
The config being used for OIDC is pretty simple:
const oidcConfig = {
authority: authConfig.authority,
client_id: authConfig.client_id,
redirect_uri: authConfig.redirect_uri,
scope: "openid offline_access",
};
This all ends up working. The addAccessTokenExpiring fires when the token is about done and I the signinSilent gets me a new one and I can reset my headers and then 401s won't happen when someone sits idle on the page for an hour.
The problem is signinSilent causes a refresh of the page to happen. If someone is sitting for an hour idle on the page, a refresh would most likely go unnoticed... However, if a form was halfway complete and they stepped away or something, that would just be gone on the page refresh.
Is there anyway to prevent the signinSilent from refreshing the page and actually just silently renewing the token?

Related

React Logout User and Redirect to Login Page when Refresh Token Expires

I am having this challenge in React JS. I have designed my system to use Token Refresh and Token Rotation, so when the token expires, the backend deletes the cookie automatically, which should also happen in the frontend by deleting the localStorage variable that stores the token and redirecting user to the login page. I am using Axios interceptors to automatically check on response errors if it is error 403 and hit on the /refresh endpoint with the refresh token. The challenge is, when this refresh fails, meaning the token has expired, I am unable to redirect the user automatically to the login page. That is, the localStorage token is not deleted which should happen when the refresh token fails. It takes 2 or 3-page refreshes for the token to be deleted and the user to be finally redirected to the login page. During these attempts, no data is loaded, which is expected since the backend has already logged out the user by deleting the cookie from the backend, hence it can be frustrating to the users. This is my code for further understanding.
axiosPrivate.js
import { setIsAuthenticated } from '../features/auth/authSlice';
import instance from "./axiosConfig";
import { memoizedRefreshToken } from "./axiosRefreshToken";
instance.interceptors.request.use(
async (config) => {
const authenticatedUser = JSON.parse(localStorage.getItem("authenticatedUser"));
if (authenticatedUser?.accessToken) {
config.headers = {
...config.headers,
authorization: `Bearer ${authenticatedUser?.accessToken}`,
};
}
return config;
},
(error) => Promise.reject(error)
);
instance.interceptors.response.use(
(response) => response,
async (error) => {
const config = error?.config;
if (error?.response?.status === 403 && !config?.sent) {
config.sent = true;
console.log("Inside If: ", config);
const result = await memoizedRefreshToken();
if (result?.accessToken) {
console.log("Access Token Returned: ", result)
config.headers = {
...config.headers,
authorization: `Bearer ${result?.accessToken}`,
};
} else {
console.log("No Access Token ")
store.dispatch(setIsAuthenticated(false));
}
return instance(config);
}
console.log("Outside If: ", config);
store.dispatch(setIsAuthenticated(false));
return Promise.reject(error);
}
);
export const axiosPrivate = instance;
axiosRefreshToken.js
import { store } from '../features/store';
import { setIsAuthenticated } from '../features/auth/authSlice';
import instance from "./axiosConfig";
const refreshTokenFn = async () => {
try {
const response = await instance.get("/auth/refresh");
const authenticatedUser = response.data;
if (!authenticatedUser?.accessToken) {
localStorage.removeItem("authenticatedUser");
store.dispatch(setIsAuthenticated(false));
}
localStorage.setItem("authenticatedUser", JSON.stringify(authenticatedUser));
store.dispatch(setIsAuthenticated(true));
return authenticatedUser;
} catch (error) {
localStorage.removeItem("authenticatedUser");
store.dispatch(setIsAuthenticated(false));
}
};
const maxAge = 10000;
export const memoizedRefreshToken = mem(refreshTokenFn, {
maxAge,
});
I have a feeling that the problem is in the axiosRefreshToken.js but I am unable to trace down what I am doing wrong. Kindly advise.
UPDATE
I am thinking that the issue is in the axiosRefreshToken.js where when there is no response, nothing is returned and the error catching after that does not as well work as expected. My expectation is that when there is no response, error catching under that kicks in and deletes the localStorage token immediately. But by debugging, it will take like 3 page refreshes to get that error catching working.
After so much digging and testing, I think the issue with the code was using memoization of the function in the axiosRefreshToken.js file. I removed the mem function and it automatically catches the error and fires the dispatch function instantly when the token is not refreshed. However, if there is a better way of handling this, I would gladly welcome it.

Logout all tabs with msal-react when using localStorage

I have a React 18.x with NextJS 12.x application that uses msal-react 1.4.4 (relies on msal-browser 2.28.0) and Azure B2C for authentication. My config is like this:
export const msalConfig: Configuration = {
auth: {
clientId: clientId as string,
authority: `https://${tenant}.b2clogin.com/${tenant}.onmicrosoft.com/${b2cPolicy}`,
knownAuthorities: [`${tenant}.b2clogin.com`],
redirectUri: '/openid-redirect',
postLogoutRedirectUri: '/',
},
cache: {
cacheLocation: 'localStorage',
},
};
Pages are protected by a <MsalAuthenticationTemplate interactionType={InteractionType.Redirect} ...> component to enforce login via the redirect flow. This all works fine.
In my navigration bar I have a logout button:
const handleLogout = () => {
msalInstance.logoutRedirect({
account: msalInstance.getActiveAccount(),
});
};
<button onClick={() => handleLogout()}>
Logout
</button>
And the tab itself gets logged out just fine.
All other tabs loose access to the access_token and other information so they are effectively "logged out" and cannot call API's with bearer authentication anymore. However, the UI of that application does not show the user as logged out.
I had expected all other tabs to notice the logout and update accordingly, possibly redirecting users to another page. Or at the least I would've expected the loggerCallback I've configured to show there's an event or notification that some other tab has logged us out.
If I manually do window.addEventListener('storage', evt => console.log(evt)); I do see the other tabs notice storage is being cleared.
I found another related question which is about cross-device logout, which I expect to rely on the OpenID Session Management spec. I guess that solution could work for me, but the other question nor answer contain a working solution for that either.
The relevant MSDN documentation doesn't mention anything about "multiple tabs" or something similar.
How can I configure my application and msal-react to notice sign outs from other tabs?
Workaround
For now we've used the following workaround:
export function useLogoutInOtherTabsListener() {
const router = useRouter();
useEffect(() => {
const handler = (evt: StorageEvent) => {
if (evt.key === 'logout-event' && evt.newValue === 'started') {
msalInstance.logoutRedirect({
onRedirectNavigate: () => false, // No need to do redirects via msal, we'll just route the user to '/' ourselves
});
router.push('/');
}
};
window.addEventListener('storage', handler);
return () => {
window.removeEventListener('storage', handler);
};
}, [router]);
}
export function logoutAllTabs(): void {
// We'd prefer to use an msal-mechanism, but so far couldn't find any.
// See also: https://stackoverflow.com/q/73051848/419956
window.localStorage.setItem('logout-event', 'started');
window.localStorage.removeItem('logout-event');
msalInstance.logoutRedirect();
}
And call useLogoutInOtherTabsListener() in _app.tsx.
Clearing the session ids should be able to log out of all the pages. To do this we can use the concept of the front channel logout .
In front channel logout we basically load a different page which will do all the logging out process and clear cache and stop local access to the site. Here the page will be loaded in a hidden iframe and perform only the sign-out operation.
But for this to work we have to set system.allowRedirectInIframe to true. Also we have to register logout url in the portal.
const msal = new PublicClientApplication({
auth: {
clientId: "my-client-id"
},
system: {
allowRedirectInIframe: true
}
})
// Automatically on page load
msal.logoutRedirect({
onRedirectNavigate: () => {
// Return false to stop navigation after local logout
return false;
}
});
Refer this following documentation the above code is from there.

(jwt refresh token) Persisting a globel state in reactjs

I am currently using the django backend with jwt refresh token to persist a user login on my webpage. I have defined a refresh token hook here to get refresh token when the access token is expired or page is refreshed.
import Axios from '../utils/Axios';
import useAuth from './useAuth';
const useRefreshToken = () => {
const { setAuth } = useAuth();
const refresh = async () => {
const response = await Axios.post('account/auth/refresh/', {
'refresh': localStorage.getItem('refresh_token'),
withCredentials: true
});
setAuth(prev => {
return { ...prev, accessToken: response.data.access }
});
return response.data.access;
}
return refresh;
}
export default useRefreshToken;
After the user refreshed the page, it will trigger the refresh function to obtain another access token by sending out a refresh token to the api endpoint, and using setAuth to assign the new accessToken. And I realized that after I refreshed the page, the auth state will be emptied, making the spread operator of ...prev meaningless. Is there are any ways to presistent the current auth state after refreshing?
I don't really want to use localstore to do that, because my protected route condition depends on rather a user exist, so if I can just use localstore in here, I can just assign a user: 'whatever I type', it will still pass the auth?.user checking.

How to send firebase refreshToken ? React

I am using firebase for the first time. (React app) I have 3 social Auth Provider for sign-in. Google, Facebook and Apple. Everything works great until here. But after 1 hour my token expires and I have to sign-out and sign-in again for refreshing my token. I saved the expiration time to my localStorage to check if token expires or not, if yes I invoke the signOut() function manually. But it doesn't solve the problem and not a good approach. I can't find how to refreshToken in firebase. And also, am I need to check expires again and send refreshToken or I have to refresh token on every time page refresh ?
import React from 'react'
import { useHistory } from "react-router";
import auth from "../utils/Auth";
export const useSocialAuth = () => {
const history = useHistory();
const providerFunc = (socialProvider:any) => {
const provider = socialProvider();
provider
.then((result: any) => {
console.log(result)
auth.login(() => {
localStorage.setItem('exp', result.user._delegate.stsTokenManager.expirationTime)
localStorage.setItem("userID", result.user.uid);
localStorage.setItem("tocaToken", result.user.multiFactor.user.accessToken);
history.push("/");
});
})
.catch((err:any) => console.log(err));
}
return providerFunc;
};
I solved the refresh token problem. All you guys need to add:
firebase.auth().currentUser.getIdToken(true)
When you make call from a browser .getIdToken(true) will automatically refresh your token. Make call like this:
firebase.auth().currentUser.getIdToken(/ forceRefresh / true)
.then(function(idToken) {
}).catch(function(error) {
});
More info: https://firebase.google.com/docs/reference/js/v8/firebase.User#getidtoken

React Next js app redirect to login is premature

After a lot of searching for several hours, I have the following code to redirect from a user profile page if not logged in.
NOTE: Simply showing a not authorized page is easy but its the redirect thats messing things up.
The code does the job of redirecting when user is not logged in.
const Dashboard = () => {
const [user, { mutate }] = useCurrentUser();
const router = useRouter();
useEffect(() => {
// redirect to login if user is not authenticated
if (!user) router.push('/login');
}, [user]);
...
The problem is when a user is logged in and directly goes to /user/dashboard route, for a split second, user is undefined may be so it redirects to login. When it gets to login, it finds that user is authenticated so redirects to home page because I am redirecting a logged in user to home page.
How to prevent that split second of "not a user" status when page is first loading?
I tried -
getInitialProps
getServerSideProps - Cant use router because next router can only be used on client side
componentDidMount - UseEffectI tried above is the equivalent correct?
Edit: Based on answer below, I tried this but still directly takes user to login first. I am using react cookies and I do see loggedIn cookie as true when user is logged in and its not set when user is not logged in.
Dashboard.getInitialProps = ({ req, res }) => {
console.log(req.headers.cookie)
var get_cookies = function(request) {
var cookies = {};
request.headers && request.headers.cookie.split(';').forEach(function(cookie) {
var parts = cookie.match(/(.*?)=(.*)$/)
cookies[ parts[1].trim() ] = (parts[2] || '').trim();
});
return cookies;
};
//console.log(get_cookies(req)['loggedIn']);
if (get_cookies(req)['loggedIn'] == true) {
console.log("entered logged in")
return {loggedIn: true};
}
else {
console.log("entered not logged in")// can see this on server console log
// User is not logged in, redirect.
if (res) {
// We're on the server.
res.writeHead(301, { Location: '/login' });
res.end();
} else {
// We're on the client.
Router.push('/login');
}
}
}
You can implement redirect when not authenticated in getServerSideProps
Below example is based on JWT Authentication with cookies.
export const getServerSideProps = async (ctx) => {
const cookie = ctx.req.headers.cookie;
const config = {
headers: {
cookie: cookie ?? null
}
}
let res;
try {
// your isAuthenticated check
const res = await axios('url', config);
return { props: { user: res.data } };
} catch (err) {
console.error(err);
ctx.res.writeHead(302, {
Location: 'redirectUrl'
})
ctx.res.end();
return;
return { props: { user: null } };
}
}
You should be able to use getInitialProps to redirect. You just need to check whether you're on the server or the client and use the proper redirect method. You can't use hooks in getInitialProps so your useCurrentUser approach won't work and you'll need some other way to check whether the user is authed. I don't know anything about the structure of your application, but it's probably just some kind of request to wherever you're storing the session.
import Router from 'next/router';
const Dashboard = (props) => {
// props.user is guaranteed to be available here...
};
Dashboard.getInitialProps = async ({ res }) => {
// Check authentication.
// Await the response so that the redirect doesn't happen prematurely.
const user = await ...
// User is logged in, return the data you need for the page.
if (user) {
return { user };
}
// User is not logged in, redirect.
if (res) {
// We're on the server.
// Make the redirect temporary so it doesn't get cached.
res.writeHead(307, { Location: '/login' });
res.end();
} else {
// We're on the client.
Router.push('/login');
}
};
After many hours of struggle, there was one number that was breaking this.
Instead of
res.writeHead(301, { Location: '/login' });
I used
res.writeHead(307, { Location: '/login' });
and it worked.
301 is a permanent redirect so if we use that, when the user logs in, the browser still holds the redirect cache.
From next js docs
Next.js allows you to specify whether the redirect is permanent or not with the permanent field. This is required unless you need to specify the statusCode manually
When permanent is set to true we use a status code of 308 and also set a Refresh header for backwards compatibility with IE11.
When permanent is set to false we use a status code of 307 which is not cached by browsers and signifies the redirect is temporary.
Next.js permits the following status codes:
-301 Moved `Permanently`
-302 Found
-303 See Other
-307 `Temporary` Redirect
-308 Permanent Redirect

Resources