Logout all tabs with msal-react when using localStorage - reactjs

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.

Related

React OIDC signinSilent() function causing a page refresh

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?

How to re-route a React-Admin url

I'm trying to configure the login page for my React-Admin app. I'm using authProvider and LoginPage components, as per the documentation and my login process is generally working.
I have the app running locally on port 3000. But when I go to http://localhost:3000, I'm automatically redirected to http://localhost:3000/#/login.
What specifically is driving that redirection? I don't specify that .../#/login url within the app itself.
I'm using an old version of React-Admin (2.9), which I understand uses Redux. Is that redirection to .../#/login a function of Redux? Or of React-Admin itself?
My understanding is I can maybe use HashHistory or BrowserHistory to prevent the # - but not sure if that's compatible with React-Admin.
The actual issue I'm having is that once I deploy the app to my domain, the login process behaves differently compared to when I run on localhost - which is making pre-deployment testing difficult.
That is, http://localhost:3000 and http://localhost:3000/#/login both allow me to login successfully. But when I deploy to my domain, http://www.example.com allows me to login, while http://www.example.com/#/login does not.
Any idea why this would be? And can I configure a React-Admin app to not re-route to http://www.example.com/#/login?
"If the promise is rejected, react-admin redirects by default to the /login page.
You can override where to redirect the user in checkAuth(), by rejecting an object with a redirectTo property:"
React-admuin 2.9:
https://marmelab.com/react-admin/doc/2.9/Authentication.html#checking-credentials-during-navigation
// in src/authProvider.js (React-admin 2.9)
import { AUTH_LOGIN, AUTH_LOGOUT, AUTH_ERROR, AUTH_CHECK } from 'react-admin';
export default (type, params) => {
if (type === AUTH_LOGIN) {
// ...
}
if (type === AUTH_LOGOUT) {
// ...
}
if (type === AUTH_ERROR) {
// ...
}
if (type === AUTH_CHECK) {
return localStorage.getItem('token') ? Promise.resolve() : Promise.reject({ redirectTo: '/no-access' });
}
return Promise.reject('Unknown method');
};
React-admin 4.3: https://marmelab.com/react-admin/AuthProviderWriting.html
// in src/authProvider.js
export default {
login: ({ username, password }) => { /* ... */ },
checkError: (error) => { /* ... */ },
checkAuth: () => localStorage.getItem('auth')
? Promise.resolve()
: Promise.reject({ redirectTo: '/no-access' }),
// ...
}

Directly redirect to AAD login page on hitting the URL in browser

Using MSAL 2.0 in React we can easily achieve redirect to login page using below methods
Login with pop-up
Login with redirect
But both the methods can be performed on a login/signin button action.
In my case, I want to redirect my React app directly to login page, when the URL is hit in the browser i.e. without button action.
Also, while doing the redirect I also need to pass my multiple scopes, for user consent.
I did some research but didn't found any feasible solutions.
Please can anyone help me with this ?
When an application starts it always start with the app.js. In the app.js class constructor you can check your token if it is valid/invalid and or if expired.
In order to redirect your React app directly to login page try this:
state = {
show: false;
}
componentWillReceiveProps(nextProps) {
if (nextProps.children !== this.props.children) {
this.checkAuth();
}
}
componentDidMount() {
this.checkAuth();
}
checkAuth() {
Api.checkAuth()
.then(result => {
if (result.success) {
this.setState({ show: true });
} else {
return <Redirect to='/login' />
}
});
}

How to redirect user back to initially-requested page after authentication with React-Router v6?

I have a situation where, if a user isn't authenticated, they get redirected to the login page.
Once the user logs in, they get sent back to the main page.
This is a pretty typical situation with React, but I'm wondering how I can redirect the user back to a certain page after they authenticate.
Suppose the user tries to access /app/topics, which would be a private route. They would get redirected to /, where they have to authenticate. Afterwards, they get redirected to /app/about once they authenticated.
How can I ensure that the user gets redirected back to /app/topics instead?
The About component would look something like,
const About = ({ user }) => {
const navigate = useNavigate();
React.useEffect(() => {
if (!user) navigate("/");
}, [user]);
return (
<div>
<h2>About</h2>
</div>
);
};
export default About;
And Home (or the 'login page') would look like this,
const Home = ({ user, setUser }) => {
const navigate = useNavigate();
React.useEffect(() => {
if (user) navigate("/app/about");
}, [user]);
return (
<div>
<h2>Login</h2>
<input />
<button onClick={() => setUser(1)}>login</button>
</div>
);
};
export default Home;
I'm aware the this line,
if (user) navigate("/app/about");
Is why the user gets redirected to About upon authenticating, but I'm wondering how I can change this up, so that the user is redirected back to Topics.
I've tried a combination of different approached. The most promising thing that I've tried was saving the requested uri into my Redux state. This cause issue with my existing code, however.
I've also tried saving state with Navigate or useNavigate.
If I recall correctly, React-Router v5 had a redirect prop or something of the sort, that redirected the user back to a certain page.
I would just fallback to good old query parametr in url, just upon redirecting to login page, put query param, from or something in url, and upon successfull login do the redirect, this has the huge advatage that if you take him to different page or for some reason he has to reload page, he keeps this info.
React router v6 documentation provides an exemple that answers you requirements, here is their sandbox.
They use the from property of location's state:
function LoginPage() {
let navigate = useNavigate();
let location = useLocation();
let auth = useAuth();
let from = location.state?.from?.pathname || "/";
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
let formData = new FormData(event.currentTarget);
let username = formData.get("username") as string;
auth.signin(username, () => {
// Send them back to the page they tried to visit when they were
// redirected to the login page. Use { replace: true } so we don't create
// another entry in the history stack for the login page. This means that
// when they get to the protected page and click the back button, they
// won't end up back on the login page, which is also really nice for the
// user experience.
navigate(from, { replace: true });
});
}

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