React-Redux logout modal/prompt on JWT expiry - reactjs

My React-Redux app is using JWTs to handle authentication like so:
import axios from 'axios';
import { getLocalAccessToken, getLocalRefreshToken, updateLocalTokens } from './token-service';
async function attachAccessToken(reqConfig) {
const accessToken = getLocalAccessToken();
if (accessToken) reqConfig.headers.authorization = `Bearer ${accessToken}`;
return reqConfig;
}
export default (opts) => {
const API = axios.create(opts);
API.interceptors.request.use(attachAccessToken);
API.interceptors.response.use(
(res) => res.data,
async (err) => {
const { status, config: reqConfig, data: message } = err.response;
const isAuthTokenErr = !reqConfig.url.includes('/login') && status === 401 && !reqConfig._isRetried;
if (!isAuthTokenErr) return Promise.reject(message);
else {
reqConfig._isRetried = true;
try {
const { data: newTokens } = await axios.post('/auth/reauthorize', { refreshToken: getLocalRefreshToken() });
updateLocalTokens(newTokens);
return API(reqConfig); //retry initial request with new access token
} catch (reauthErr) {
const message = reauthErr.response.data;
return Promise.reject('Session expired. Please sign-in again');
}
}
}
);
return API;
};
Clients are given access to protected routes (views) as long as there is a user in the Redux store i.e.
import { Redirect, Route } from 'react-router-dom';
import { useSelector } from 'react-redux';
export default function ProtectedRoute({ component: Component, ...rest }) {
const isLoggedIn = useSelector(state => state.auth.user);
return (
<Route
{...rest}
render={(routeProps) => {
if (isLoggedIn) return <Component />;
else return <Redirect to="/auth/login" />;
}}
/>
);
}
When the refresh token expires, the client will no longer be able to access protected endpoints on the backend as desired.
That being said, on the frontend, the client remains logged in and able to access protected routes (views), albeit with no data loading.
My question is, for any given 401 unauthenticated response (apart from bad login credentials), how can I use the reauthErr to either:
a) Log the client out automatically i.e. clear the client's JWTs in LocalStorage as well as remove the user from the Redux store?
Or
b) more elegantly, prompt the client with a modal, for example, that their session has expired and that they will need to login again?
Thank you!

Related

Avoiding multiple API calls due to rerenders in React with Firebase auth

My web app uses Firebase Auth to handle user authentication along with a backend API, these are provided to the React app as a provider. The idea is that the backend API will verify the user's token when they sign in and deal with any custom claims / data that needs to be sent to the client.
The problem I'm having is that the provider is rerendering multiple times during the login flow, and each rerender is making an API call. I've managed to get the amount of rerenders down to two, but if I add other 'features' to the provider (e.g update the user's state if their access should change) then this adds to the amount of rerenders, sometimes exponentially, which leads me to suspect that the provider is rerendering as a result of setUserState being called, perhaps unnecessarily. Either way, it is clearly indicative of a problem somewhere in my code, which I've included below:
import {useState, useContext, createContext, useEffect} from 'react'
import {auth, provider} from './firebase'
import {getAuth, onAuthStateChanged, signInWithPopup, signOut} from 'firebase/auth'
import {api} from './axios'
export const UserContext = createContext(null)
export const useAuth = () => useContext(UserContext)
const verifyToken = token => {
return api({
method: 'post',
url: '/verifyToken',
headers: {token}
})
}
const UserProvider = props => {
const [userState, setUserState] = useState(null)
const [loading, setLoading] = useState(true)
const userSignedOut = async () => {
setLoading(true)
return await signOut(auth).then(() => {
setUserState(null)
}).catch(e => {
console.error(e)
}).finally(() => {
setLoading(false)
})
}
const userSignIn = async () => {
console.log('userSignIn')
setLoading(true)
try {
return await signInWithPopup(auth, provider)
} catch (e) {
console.error(e)
} finally {
if (!userState) {
setLoading(false)
}
}
}
const handleUserSignIn = async user => {
console.log('handleUserSignIn', user)
if (user && getAuth().currentUser) {
setLoading(true)
const idToken = await getAuth().currentUser.getIdToken(true)
const firebaseJWT = await getAuth().currentUser.getIdTokenResult()
if (!firebaseJWT) {throw(new Error('no jwt'))}
verifyToken(idToken).then(res => {
if (res.data.role !== firebaseJWT.claims.role) {
throw(new Error('role level claims mismatch'))
} else {
user.verifiedToken = res.data
console.log(`user ${user.uid} valid and token verified`, user)
setUserState(user)
setLoading(false)
}
}).catch(e => {
userSignedOut()
console.error('handleUserSignIn', e)
}).finally(() => {
setLoading(false)
})
} else {
console.log('no user')
userSignedOut()
}
}
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async user => {
console.log('onAuthStateChanged')
if (user?.uid && user.accessToken) {
await handleUserSignIn(user)
} else {
setUserState(null)
setLoading(false)
}
})
return () => unsubscribe()
}, [])
const value = {
signOut: userSignedOut, // for sign out button
signIn: userSignIn, // for sign in button
user: userState
}
return (
<UserContext.Provider value={value}>
{props.children}
</UserContext.Provider>
)
}
export default UserProvider
I tried to create a codesandbox for this, but unfortunately I was unable to simulate the Firebase auth functions.
The login flow is supposed to look like this:
The user signs in using their Google account.
The app is now loading, and the user cannot interact with it yet (they just get a spinning wheel).
The user's data and accessToken are sent to the backend API server. (function verifyToken)
The API server sets any custom claims and returns the verified token in its response, as well as the access that the user is supposed to have.
If the user's role / custom claims do not match what the API says they should be, the user is signed out.
The user's data is set using setUserState()
The app has finished loading, and the user is signed in.
I would like to avoid unnecessary rerenders and API calls and I suspect that some refactoring may be in order, but I'm not really sure what is best to do here.

Server-side redirects using react HOC when a httpOnly cookie is not present [duplicate]

So I'm creating authentication logic in my Next.js app. I created /api/auth/login page where I handle request and if user's data is good, I'm creating a httpOnly cookie with JWT token and returning some data to frontend. That part works fine but I need some way to protect some pages so only the logged users can access them and I have problem with creating a HOC for that.
The best way I saw is to use getInitialProps but on Next.js site it says that I shouldn't use it anymore, so I thought about using getServerSideProps but that doesn't work either or I'm probably doing something wrong.
This is my HOC code:
(cookie are stored under userToken name)
import React from 'react';
const jwt = require('jsonwebtoken');
const RequireAuthentication = (WrappedComponent) => {
return WrappedComponent;
};
export async function getServerSideProps({req,res}) {
const token = req.cookies.userToken || null;
// no token so i take user to login page
if (!token) {
res.statusCode = 302;
res.setHeader('Location', '/admin/login')
return {props: {}}
} else {
// we have token so i return nothing without changing location
return;
}
}
export default RequireAuthentication;
If you have any other ideas how to handle auth in Next.js with cookies I would be grateful for help because I'm new to the server side rendering react/auth.
You should separate and extract your authentication logic from getServerSideProps into a re-usable higher-order function.
For instance, you could have the following function that would accept another function (your getServerSideProps), and would redirect to your login page if the userToken isn't set.
export function requireAuthentication(gssp) {
return async (context) => {
const { req, res } = context;
const token = req.cookies.userToken;
if (!token) {
// Redirect to login page
return {
redirect: {
destination: '/admin/login',
statusCode: 302
}
};
}
return await gssp(context); // Continue on to call `getServerSideProps` logic
}
}
You would then use it in your page by wrapping the getServerSideProps function.
// pages/index.js (or some other page)
export const getServerSideProps = requireAuthentication(context => {
// Your normal `getServerSideProps` code here
})
Based on Julio's answer, I made it work for iron-session:
import { GetServerSidePropsContext } from 'next'
import { withSessionSsr } from '#/utils/index'
export const withAuth = (gssp: any) => {
return async (context: GetServerSidePropsContext) => {
const { req } = context
const user = req.session.user
if (!user) {
return {
redirect: {
destination: '/',
statusCode: 302,
},
}
}
return await gssp(context)
}
}
export const withAuthSsr = (handler: any) => withSessionSsr(withAuth(handler))
And then I use it like:
export const getServerSideProps = withAuthSsr((context: GetServerSidePropsContext) => {
return {
props: {},
}
})
My withSessionSsr function looks like:
import { GetServerSidePropsContext, GetServerSidePropsResult, NextApiHandler } from 'next'
import { withIronSessionApiRoute, withIronSessionSsr } from 'iron-session/next'
import { IronSessionOptions } from 'iron-session'
const IRON_OPTIONS: IronSessionOptions = {
cookieName: process.env.IRON_COOKIE_NAME,
password: process.env.IRON_PASSWORD,
ttl: 60 * 2,
}
function withSessionRoute(handler: NextApiHandler) {
return withIronSessionApiRoute(handler, IRON_OPTIONS)
}
// Theses types are compatible with InferGetStaticPropsType https://nextjs.org/docs/basic-features/data-fetching#typescript-use-getstaticprops
function withSessionSsr<P extends { [key: string]: unknown } = { [key: string]: unknown }>(
handler: (
context: GetServerSidePropsContext
) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>
) {
return withIronSessionSsr(handler, IRON_OPTIONS)
}
export { withSessionRoute, withSessionSsr }

How to use Auth0 with react-admin?

I'm trying to implement authentication using Auth0 in a react-admin v3 app. I need to implement an authProvider that talks with Auth0. This sounds like something that should be available somewhere, but the closest I could find was https://github.com/alexicum/merge-admin/blob/master/src/Auth/index.js, which is about 2 years old (the SDKs have changed since then).
Is there an Auth0 authProvider somewhere I can reuse, or do I have to implement it myself?
Thanks!
for reference sake, here's an example of a way to integrate react admin with auth0-react package
index.js
import { Auth0Provider } from "#auth0/auth0-react";
ReactDOM.render(
<Auth0Provider
domain="XXXXX.auth0.com"
clientId="XXXXX"
audience="https://XXXXX"
redirectUri={window.location.origin}
>
<React.StrictMode>
<App />
</React.StrictMode>
</Auth0Provider>,
document.getElementById("root")
);
App.js
import { withAuth0, withAuthenticationRequired } from "#auth0/auth0-react";
import ApolloClient from "apollo-boost";
// I'm using Hasura w/ JWT Auth, so here's an example of how to set Authorization Header
async componentDidMount() {
const token = await this.props.auth0.getAccessTokenSilently();
const client = new ApolloClient({
uri: "https://HASURA_URL/v1/graphql",
headers: {
Authorization: `Bearer ${token}`
},
});
buildHasuraProvider({ client }).then((dataProvider) =>
this.setState({ dataProvider })
);
}
export default withAuthenticationRequired(withAuth0(App));
I've created a sample application with Auth0 and react-admin way of auth
https://github.com/spintech-software/react-admin-auth0-example
Here is auth provider code for reference
import authConfig from "./authConfig";
import {Auth0Client} from '#auth0/auth0-spa-js';
const auth0 = new Auth0Client({
domain: authConfig.domain,
client_id: authConfig.clientID,
cacheLocation: 'localstorage',
useRefreshTokens: true
});
const CallbackURI = "http://localhost:3000/login"
export default {
// called when the user attempts to log in
login: (url) => {
if (typeof url === 'undefined') {
return auth0.loginWithRedirect({
redirect_uri: CallbackURI
})
}
return auth0.handleRedirectCallback(url.location);
},
// called when the user clicks on the logout button
logout: () => {
return auth0.isAuthenticated().then(function (isAuthenticated) {
if (isAuthenticated) { // need to check for this as react-admin calls logout in case checkAuth failed
return auth0.logout({
redirect_uri: window.location.origin,
federated: true // have to be enabled to invalidate refresh token
});
}
return Promise.resolve()
})
},
// called when the API returns an error
checkError: ({status}) => {
if (status === 401 || status === 403) {
return Promise.reject();
}
return Promise.resolve();
},
// called when the user navigates to a new location, to check for authentication
checkAuth: () => {
return auth0.isAuthenticated().then(function (isAuthenticated) {
if (isAuthenticated) {
return Promise.resolve();
}
return auth0.getTokenSilently({
redirect_uri: CallbackURI
})
})
},
// called when the user navigates to a new location, to check for permissions / roles
getPermissions: () => {
return Promise.resolve()
},
};
My answer is following react-admin approach where I use its authProvider like below. There are two main steps:
Get needed data from useAuth0 hook.
Convert authProvider into function where it takes the above values, and return an object like default.
// In App.js
import authProvider from './providers/authProvider';// my path is changed a bit
const App = () => {
const {
isAuthenticated,
logout,
loginWithRedirect,
isLoading,
error,
user,
} = useAuth0();
const customAuthProvider = authProvider({
isAuthenticated,
loginWithRedirect,
logout,
user,
});
return (
<Admin
{...otherProps}
authProvider={customAuthProvider}
>
{...children}
</Admin>
);
}
// My authProvider.js
const authProvider = ({
isAuthenticated,
loginWithRedirect,
logout,
user,
}) => ({
login: loginWithRedirect,
logout: () => logout({ returnTo: window.location.origin }),
checkError: () => Promise.resolve(),
checkAuth: () => (isAuthenticated ? Promise.resolve() : Promise.reject()),
getPermissions: () => Promise.reject('Unknown method'),
getIdentity: () =>
Promise.resolve({
id: user.id,
fullName: user.name,
avatar: user.picture,
}),
});
export default authProvider;
That's it.
It's more convenient to wrap the react-admin app with auth0 native login, and then provide react-admin dataProvider an http client that reads the jwt token stored in local storage by auth0.

How to define PrivateRoute in React.js which works only after authentication?

I have a backend API to which I send email and password. In return, it provides an auth token after successful authentication. I have written the code for sending this API request in a file auth.js. It looks like this:
import axios from "axios";
export const auth = {
isAuthenticated: false,
login(user) {
const config = {
headers: {
"Content-Type": "application/json"
}
};
const body = JSON.stringify({ email: user.email, password: user.password });
return axios
.post("http://localhost:5000/userauth/login", body, config)
.then(res => {
localStorage.setItem("token", res.data.token);
this.isAuthenticated = true;
return res.data;
})
.catch(err => {
this.isAuthenticated = false;
console.log(err);
});
}
};
I am calling auth in App.js. Inside this I file I have a private route '/dashboard' which can be accessed only after authentication. If not authenticated, it redirects to '/login' route.
Here is the code for it:
import { auth } from "./actions/auth";
// rest of imports ...
export default function App() {
return (
<Router>
<Route path="/" exact component={Home} />
<Route path="/login" component={Login} />
<PrivateRoute path="/dashboard" component={Dashboard} />
</Router>
);
}
// ... ...
const PrivateRoute = ({ component: Component, ...rest }) => (
<Route
{...rest}
render={props =>
auth.isAuthenticated === true ? (
<Component {...props} />
) : (
<Redirect to="/login" />
)
}
/>
);
Also, this onSubmit function of my login form looks like this:
onSubmit(e) {
e.preventDefault();
const user = {
email: this.state.email,
password: this.state.password
};
auth.login(user).then(res => {
if (res) {
this.props.history.push("/dashboard");
}
});
}
Now whenever I input the correct email and password in login form, I successfully redirect to /dashboard route. But if I reload /dashboard route even after login, It again sends me back to login page.
From my understanding, it should stay on the dashboard page as isAuthenticated is set to true after login.
Thus my private route should have worked. Then what am I missing? Also for the record, I am following this tutorial for creating PrivateRoute.
When you reload the page, react state is lost.
You already saved the token when login is succcessfull. So we can take advantage of that token to recover authentication state.
As long as you have a token and the token is not expired yet, the user can stay authenticated.
To check token expiration, we can use jwt-token package.
You can modify your auth.js like this to accomplish this:
import axios from "axios";
import jwt_decode from "jwt-decode";
export const auth = {
isAuthenticated: isValidToken(),
login(user) {
const config = {
headers: {
"Content-Type": "application/json"
}
};
const body = JSON.stringify({ email: user.email, password: user.password });
return axios
.post("http://localhost:5000/userauth/login", body, config)
.then(res => {
localStorage.setItem("token", res.data.token);
this.isAuthenticated = true; //we may remove this line if it works without it
return res.data;
})
.catch(err => {
this.isAuthenticated = false;
console.log(err);
});
}
};
const isValidToken = () => {
const token = localStorage.getItem("token");
if (token && isValid(token)) {
return true;
}
return false;
};
const isValid = token => {
const decoded = jwt_decode(token);
const currentTime = Date.now() / 1000;
if (currentTime > decoded.exp) {
return false;
}
return true;
};
React state resets after refresh.
Your problem is your auth state is resetting after refresh.
You should make sure you re-authenticate yourself everytime when page refreshes, ideally you should do it in routes component in useEffect() or componentDidMount() depending on whether you are using hooks or class.
As #Steve pointed out, you can just grab the token from localStorage.

ReactJS with IdentityServer4 and oidc-client-js Error: "No code in response"

I am trying to get ReactJS working with IdentityServer4 via oidc-client-js library.
PROBLEM
I click the login button and I get redirected to IdentityServer4, once I login, I get redirect to /login-callback and then from there I get redirected to / but I get the following error in the console:
I am not sure what I'm doing wrong, but I've tried all sorts and nothing seems to work.
CODE
All code is open-source and sits here.
App.jsx
// other code ommited, but this is a route
<AuthHandshake path="/login-callback" />
AuthHandshake.jsx
import React from "react";
import { AuthConsumer } from "../AuthProvider/AuthProvider";
function AuthHandshake() {
return <AuthConsumer>{value => value.loginCallback()}</AuthConsumer>;
}
export default AuthHandshake;
AuthProvider.jsx
import React, { useState } from "react";
import { navigate } from "#reach/router";
import { UserManager, WebStorageStateStore } from "oidc-client";
import AuthContext from "../../contexts/AuthContext";
import { IDENTITY_CONFIG } from "../../utils/authConfig";
IDENTITY_CONFIG.userStore = new WebStorageStateStore({
store: window.localStorage
});
const userManager = new UserManager(IDENTITY_CONFIG);
const login = () => {
console.log("Login button click handled.");
userManager.signinRedirect();
};
const logout = () => {
userManager.signoutRedirect();
};
export function AuthProvider(props) {
const [user, setUser] = useState(null);
const loginCallback = () => {
userManager.signinRedirectCallback().then(
user => {
window.history.replaceState(
{},
window.document.title,
window.location.origin
);
setUser(user);
navigate("/");
},
error => {
console.error(error);
}
);
};
const providerValue = {
login: login,
logout: logout,
loginCallback: loginCallback,
isAuthenticated: user
};
const Provider = () => {
if (user) {
return (
<AuthContext.Provider value={providerValue}>
{props.children}
</AuthContext.Provider>
);
} else {
return <div className="auth-provider">{props.children}</div>;
}
};
return (
<>
<AuthContext.Provider value={providerValue}>
{props.children}
</AuthContext.Provider>
</>
);
}
export const AuthConsumer = AuthContext.Consumer;
On the IdentityServer side, I have set the post logout redirect to the same thing /login-callback
new Client
{
ClientId = "bejebeje-react-local",
ClientName = "Bejebeje ReactJS SPA Client",
AllowedGrantTypes = GrantTypes.Code,
RequirePkce = true,
RequireClientSecret = false,
RequireConsent = false,
RedirectUris = { "http://localhost:1234/login-callback" },
PostLogoutRedirectUris = { "http://localhost:1234/logout-callback" },
AllowedCorsOrigins = { "http://localhost:1234" },
AllowedScopes = { "openid", "profile", "bejebeje-api-local" },
AllowOfflineAccess = true,
RefreshTokenUsage = TokenUsage.ReUse,
}
Where am I going wrong?
Try this here..
https://github.com/JwanKhalaf/Bejebeje.React/blob/bug/fix-no-code-response-on-redirect/src/components/AuthProvider/AuthProvider.jsx#L15
Basically, when you are sending a login request from the client (OIDC-client.js), the client sends a unique identifier (State) along with the login request in the URL. The client saves this value in the storage option you choose (local/session). After successful sign-in, server issues tokens in the response redirect url and it also includes the unique identifier client initially sent in login request. Watch the login request url and response url in chrome Developer Tools.
When OIDC Client Library receives the sign-in response, it grabs the unique identifier from the URL and matches with the value it kept in the local/session storage at the time of login request. If they don't match, the client will not accept the token issued by the server.
This is just one level of additional security to make sure, the client is not accepting any tokens issued by any random server or any bookmarked URL.
Hope this helps.!!
usermanager.signinRedirect({ state: { bar: 15 } });
I had to add response_mode: 'query' to the UserManager.
var mgr = new Oidc.UserManager({ response_mode: 'query' });
mgr.signinRedirectCallback().then(res => {
window.location = "/signin-callback";
}).catch(error => {
window.location = "/";
})

Resources