Axios interceptor with context - reactjs

I've got an auth context which handles login / logout, saves a token and user details in state as well as localStorage.
An axios instance (authAxios) used in the app intercepts all requests from within this AuthContext.
There's another context provider nested inside AuthContext which makes fetch requests upon login, and uses the authAxios instance. It requires the token to be available with authAxios based on the role level of user (admin, staff, etc). The inner context provider makes its requests from a useEffect with dependencies [ token / roleLevel ] that it's consuming from the AuthContext provider.
The problem arises when the app sees a user log out and a different user (of a different roleLevel) log in. The inner context provider's useEffect fires its request with authAxios, which strangely doesn't have the updated token. Logging the token from AuthContext spits out the new token, but authAxios is still with the old token for that first request even for the new user.
Code below:
/* authAxios */
import axios from "axios";
import {apiEndpoint} from "./constURLs";
const authAxios = axios.create({
baseURL: apiEndpoint,
});
export default authAxios;
/*************/
/* AuthContext.tsx */
import authAxios from "./authAxios";
export const AuthContext = React.createContext<AuthContextState>(defaultState);
export const AuthContextProvider = (children: React.ReactNode) => {
const [state, setState] = React.useState<AuthContextState>(retrieveFromStorage());
/* auth functionality */
React.useMemo(() => {
console.log("token", ctxState.token); // new token available here for the first request
authAxios.interceptors.request.use(req =>
if (ctxState.token) {
req.headers.Authorization = `Token ${ctxState.token}`;
}
return req;
}, err => {
if (DEBUG) {
console.error("authAxios error", err);
}
return Promise.reject(err);
})
}, [ctxState.token])
return (
<AuthContext.Provider value={state}>{children}</AuthContext.Provider>
)
/*************/
/* InnerContext.tsx */
export const InnerContext = React.createContext<InnerCtxState>(defaultState);
export const InnerContextProvider = (children: React.ReactNode) => {
const [state, setState] = React.useState<InnerCtxState>(retrieveFromStorage());
const {token} = React.useContext(AuthContext);
const updateState = React.useCallback((key: string) => {
authAxios.get() /* fetch request here */
}, [token]);
React.useEffect(() => {
if (token) {
updateState("foo");
}
}, [token, updateState])
return (
<InnerContext.Provider value={state}>{children}</InnerContext.Provider>
)
}
/*************/
/* App.tsx */
const App = () => {
return (
<AuthContextProvider>
<InnerContextProvider>
<Router/>
</InnerContextProvider>
</AuthContextProvider>
)
}
I've tried to attach the token to authAxios from localStorage directly, but even that doesn't seem to do the trick.
The interim workaround I'm using is to force reload through window.location.reload() in logoutHandler() in AuthContext
Thanks in advance.

Related

How can I make React app verify user token regularly?

I have a React app using Firebase Auth and an Express backend. I have React contexts set up for the user's authentication process and for the loading state of the app. Currently, when a user signs in, the following happens:
The app goes into a loading state
The app sends an API request to the backend to verify the user's token
The backend queries the database and then sets the user's custom claims with their permissions and sends a response with the verified token & claims
The loading state is cleared, and the app becomes useable
The user's routes / nav menu options etc are then determined by the user's permissions according to the backend - i.e, if a user doesn't have permission for a certain area of the site, its routes and nav menu items are not loaded.
My authentication context is as follows:
import { createContext, useContext, useState, useEffect } from "react";
/**
* auth = getAuth()
* provider = new GoogleAuthProvider()
*/
import { auth, provider } from "providers/firebase";
import {
getAuth,
onAuthStateChanged,
signInWithPopup,
signOut as firebaseSignOut
} from "firebase/auth";
import { api } from "providers/axios";
import { useLoading } from "providers/loading";
const UserContext = createContext(null);
export const useAuth = () => useContext(UserContext);
const verifyToken = (token) =>
api({
method: "post",
url: "/user/auth",
headers: {
token
}
});
const UserProvider = (props) => {
const [user, setUser] = useState(null);
const { loading, setLoading } = useLoading();
const signIn = async () => {
setLoading(true);
try {
const result = await signInWithPopup(auth, provider);
console.log("auth signInWithPopup", result.user.email);
} catch (e) {
setUser(null);
console.error(e);
setLoading(false);
}
};
const signOut = async () => {
let userSigningOut = user;
try {
await firebaseSignOut(auth);
setUser(null);
console.log("signed out");
} catch (e) {
console.error(e);
} finally {
return (userSigningOut = null);
}
};
const verifyUser = async (user) => {
try {
if (!user) {
throw "no user";
}
const token = await getAuth().currentUser.getIdToken(true);
if (!token) {
throw "no token";
}
const jwt = await getAuth().currentUser.getIdTokenResult();
if (!jwt) {
throw "no jwt";
}
const verifyTokenResponse = await verifyToken(token);
if (verifyTokenResponse.data.role !== jwt.claims.role) {
throw "role level claims mismatch";
} else {
user.verifiedToken = verifyTokenResponse.data;
console.log(`User ${user.uid} verified`);
setUser(user);
}
} catch (e) {
signOut();
console.error(e);
}
};
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, async (user) => {
setLoading(true);
try {
if (user) {
console.log("onAuthStateChanged", user?.email);
await verifyUser(user);
} else {
throw "no user";
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
});
return unsubscribe;
}, []);
return (
<UserContext.Provider
value={{
signIn,
signOut,
user
}}
>
{props.children}
</UserContext.Provider>
);
};
export default UserProvider;
The problem is that if the user or their permissions are modified, the changes are not reflected in the app until the user performs a hard refresh.
What I'd like to achieve is for the user's token to be re-verified via the backend upon every page change (or similar) and then if their permissions etc have changed, the app then rerenders reflecting the changes. I think this could be achieved by triggering a rerender of a certain part of UserContext after taking it out of the main function, but I'm not sure how to proceed with that.
After #samthecodingman's comment, I added another state for the user's database entry and have achieved the desired outcome with the following changes to UserProvider:
useEffect(() => {
if (user) {
const userDataRef = ref(db, `/users/${user.uid}`);
return onValue(userDataRef, async snapshot => {
await verifyUser(user);
setUserData(snapshot.val());
})
}
}, [user]);
return (
<UserContext.Provider
value={{
signIn,
signOut,
user,
userData
}}
>
{props.children}
</UserContext.Provider>
);

How to persist user state in Next.js app with useContext

I'm working on a web application with react and Next.js and I also have a Node.js API separated as a back-end.
I have a login form where I send the data to the API to recover JWT, when I do that, everything works fine, but after redirecting the user to the protected route "dashboard", or after a refresh the user context gets lost.
Here is the protected route :
import React, {useContext, useEffect} from 'react'
import { useRouter } from "next/router";
import { Context } from '../../context/context';
export default function DashboardIndexView() {
const router = useRouter();
const {isUserAuthenticated, setUserToken, userToken} = useContext(Context);
useEffect(() => {
isUserAuthenticated()
? router.push("/dashboard")
: router.push("/authentication/admin");
}, []);
return (
<>
<h1>Dashboard index view</h1>
</>
)
}
and here is the context file :
import React, {createContext, useEffect, useState} from 'react'
import { useRouter } from "next/router";
import axios from 'axios'
export const Context = createContext(null)
const devURL = "http://localhost:4444/api/v1/"
export const ContextProvider = ({children}) => {
const router = useRouter()
const [user, setUser] = useState()
const [userToken, setUserToken] = useState()
const [loading, setLoading] = useState(false)
const [successMessage, setSuccessMessage] = useState("")
const [errorMessage, setErrorMessage] = useState("")
const Login = (em,pass) => {
setLoading(true)
axios.post(devURL+"authentication/login", {
email : em,
password : pass
})
.then((res)=>{
setSuccessMessage(res.data.message)
setErrorMessage(null)
setUser(res.data.user)
setUserToken(res.data.token)
localStorage.setItem('userToken', res.data.token)
localStorage.setItem('user', res.data.user)
setLoading(false)
})
.catch((err)=>{
setErrorMessage(err.response.data.message)
setSuccessMessage(null)
setLoading(false)
})
}
const Logout = () => {
setUserToken()
setUser()
localStorage.setItem('userToken', null)
localStorage.setItem('user', null)
router.push('/authentication/admin')
}
const isUserAuthenticated = () => !!userToken
return (
<Context.Provider value={{
Login,
user,
loading,
userToken,
setUserToken,
Logout,
successMessage,
setSuccessMessage,
setErrorMessage,
isUserAuthenticated,
errorMessage}}>
{children}
</Context.Provider>
)
}
How can I keep the user on the dashboard page even when a refresh happens ?
It's normal for useContext() to lose its value on a page refresh. Contexts don't persist any data, they simply share the data between components. In, Next.js, it can work between pages because Next.js handles navigation on the client side. But as you've noticed, as soon as you refresh, the app is mounted from scratch and this time the context never gets the value of the JWT because the JWT was never sent on this new instance of your app.
The solution, at a high-level, is to store the JWT somewhere (localStorage or cookie) and inject the value in your Context.Provider. You're already setting the values in localStorage now you just need a useEffect that will read them and add them to the context:
useEffect(() => {
setUser(localStorage.get('user'));
setUserToken(localStorage.get('userToken'));
}, []);
But the real solution, in my opinion, is to use https://next-auth.js.org/ instead. It handles security concerns and is a well-known library for Next.js

aws-amplify-react and nextjs breaks my user context

I cant figure out why but when I use cognito with my own custom user context everything works just fine but as soon as I use withAuthenticator higher order component it breaks my user context and I cant for the life of me figure out why, or even how to fix it. Ill post my user context file below for reference and tell you where it breaks.
import { Auth } from 'aws-amplify'
import {createContext, useState, useEffect, useMemo} from 'react'
//TODO must redo cognito from scratch and will probably be able to keep this user context untouched
export const UserContext = createContext(null)
export const UserProvider = ({children}) => {
const [ user, setUser ] = useState(null)
const [ userEmail, setUserEmail ] = useState(null)
const [ signInError, setSignInError ] = useState(false)
useEffect(()=>{
// AWS Cognito
Auth.currentAuthenticatedUser().then(x=>setUser(x)).catch((err)=>setUser(null))
},[])
const handleSignInError = () => {
console.log(signInError)
}
const login = (username, password) => {
signInError && setSignInError(false)
Auth.signIn(username, password)
.then( x => {
setUser(x)
console.log('Welcome: ' + x.challengeParam.userAttributes.email)
setUserEmail(x.challengeParam.userAttributes.email)
setSignInError(false)
})
.catch((err)=>{
console.log(err.code)
if(err.code === 'UserNotFoundException' || 'NotAuthorizedException'){
err.message = 'Invalid username or password'
setSignInError(true)
console.log(err.message)
}
})
}
const logout = () => {
Auth.signOut().then((x)=>{
setUser(null)
setUserEmail(null)
return x
})
}
const signup = (username, email, password) => {
Auth.signUp({ username, password, attributes: { email } })
.then( x => {
setUser(x)
return x
})
.catch((err)=>{
if(err.code){
err.message = 'Your Username or Password was incorrect'
}
throw err
})
}
const vals = useMemo( () => ({user, login, logout, signup, handleSignInError, userEmail, signInError}), [user, userEmail, signInError])
return(
<UserContext.Provider value={vals}>
{children}
</UserContext.Provider>
)
}
Under the login function it now returns user not found after I wrap a component and npm i aws-amplify-react. The funny thing is when I uninstall it I still get the same error and cant go back without fully removing amplify and going through a complete amplify init again. Even more confusing, My app is hosted on vercel and that breaks after I attempt to do this on my local machine. If im not missing something there and my app does break in the cloud even though I dont push my modified code then im guessing cognito is getting something in the cloud when I attempt this on my local machine and then screwing up my untouched copy on vercel????? Since then Ive also tried using next-auth which makes me think I should just stick to front end work or find a better solution? any help would be appreciated. Ill revert to my old setup and rebuild my cognito and amplify from scratch just to get it going again.
You need to call Cognito configure prior to calling your auth provider. Place it before you define your auth provider or context.
Auth.configure({...your_config})
const UserContext = () => {};
I also use a auth hook with my context that removes the need for a HOC.
import { useContext } from 'react';
export const useAuth = () => useContext(UserContext);
// use it in components and pages
const user = useAuth();
Ensure that your configuration is using all of the proper types. If you don't, it sometimes fails silently. For example ENV files are always passed as strings so some options must be cast to the proper type like cookie expires
{
authenticationFlowType: 'USER_SRP_AUTH',
cookieStorage: {
...other settings
expires: Number(process.env.NEXT_PUBLIC_COGNITO_COOKIE_EXPIRES),
}
};
You will also need to call Auth.configure on every page that you need access to Congito auth inside of getStaticPaths, getStaticProps, and getServerSideProps. This is because they are independently called from your app during build or on a server.
Auth.configure({...your_config})
const getStaticProps = () => {};
const getStaticPaths = () => {};
const getServerSideProps = () => {};
If you can use it, their hosted UI is pretty good.
Lastly, AWS has a few libraries for Amplify and I use #aws-amplify/auth - I don't know if this makes a difference.
I added the config file to my _app.js and set ssr: true for ssr authentication
import Amplify from 'aws-amplify'
import config from '../src/aws-exports'
Amplify.configure({...config, ssr: true})
Here is my working user context. I removed the signup function and will add it later once i work on it and test it.
import { Auth } from 'aws-amplify'
import {createContext, useState, useEffect, useMemo} from 'react'
export const UserContext = createContext(null)
export const UserProvider = ({children}) => {
const [ user, setUser ] = useState(null)
const [ userEmail, setUserEmail ] = useState(null)
const [ signInError, setSignInError ] = useState(false)
const [sub, setSub] = useState(null)
useEffect(()=>{
// AWS Cognito
Auth.currentAuthenticatedUser()
.then(x=>{
setUser(x.username)
setUserEmail(x.attributes.email)
setSub(x.attributes.sub)
})
.catch((err)=>{
console.log(err)
setUser(null)
})
},[])
const handleSignInError = () => {
console.log(signInError)
}
const login = (username, password) => {
signInError && setSignInError(false);
Auth.signIn(username, password)
.then((x) => {
setUser(x.username)
setSignInError(false)
console.log(x)
})
.catch((err)=>{
console.log(err)
setSignInError(true)
})
}
const logout = () => {
Auth.signOut().then((x)=>{
setUser(null)
setUserEmail(null)
setSub(null)
})
}
}
const vals = useMemo( () => ({user, sub, login, logout, handleSignInError, userEmail, signInError}), [user, userEmail, signInError, sub])
return(
<UserContext.Provider value={vals}>
{children}
</UserContext.Provider>
)
}

Why does the following HOC keep refreshing even though it is returning a memoized component?

For context, I am working with integrating a Django Rest Framework backend with Next.js + Next-Auth. I have most of the integration down, except one part. The requirement is to have a refresh token system that will try to refresh the access token when it is almost expired. Here is the logic that I have:
/api/auth/[...nextauth].ts
import { NextApiRequest, NextApiResponse } from "next";
import NextAuth from "next-auth";
import { NextAuthOptions } from "next-auth";
import Providers from "next-auth/providers";
import axios from "axios";
import { AuthenticatedUser } from "../../../types";
import { JwtUtils, UrlUtils } from "../../../constants/Utils";
namespace NextAuthUtils {
export const refreshToken = async function (refreshToken) {
try {
const response = await axios.post(
// "http://localhost:8000/api/auth/token/refresh/",
UrlUtils.makeUrl(
process.env.BACKEND_API_BASE,
"auth",
"token",
"refresh",
),
{
refresh: refreshToken,
},
);
const { access, refresh } = response.data;
// still within this block, return true
return [access, refresh];
} catch {
return [null, null];
}
};
}
const settings: NextAuthOptions = {
secret: process.env.SESSION_SECRET,
session: {
jwt: true,
maxAge: 24 * 60 * 60, // 24 hours
},
jwt: {
secret: process.env.JWT_SECRET,
},
providers: [
Providers.Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
],
callbacks: {
async signIn(user: AuthenticatedUser, account, profile) {
// may have to switch it up a bit for other providers
if (account.provider === "google") {
// extract these two tokens
const { accessToken, idToken } = account;
// make a POST request to the DRF backend
try {
const response = await axios.post(
// tip: use a seperate .ts file or json file to store such URL endpoints
// "http://127.0.0.1:8000/api/social/login/google/",
UrlUtils.makeUrl(
process.env.BACKEND_API_BASE,
"social",
"login",
account.provider,
),
{
access_token: accessToken, // note the differences in key and value variable names
id_token: idToken,
},
);
// extract the returned token from the DRF backend and add it to the `user` object
const { access_token, refresh_token } = response.data;
user.accessToken = access_token;
user.refreshToken = refresh_token;
return true; // return true if everything went well
} catch (error) {
return false;
}
}
return false;
},
async jwt(token, user: AuthenticatedUser, account, profile, isNewUser) {
if (user) {
const { accessToken, refreshToken } = user;
// reform the `token` object from the access token we appended to the `user` object
token = {
...token,
accessToken,
refreshToken,
};
// remove the tokens from the user objects just so that we don't leak it somehow
delete user.accessToken;
delete user.refreshToken;
return token;
}
// token has been invalidated, try refreshing it
if (JwtUtils.isJwtExpired(token.accessToken as string)) {
const [
newAccessToken,
newRefreshToken,
] = await NextAuthUtils.refreshToken(token.refreshToken);
if (newAccessToken && newRefreshToken) {
token = {
...token,
accessToken: newAccessToken,
refreshToken: newRefreshToken,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000 + 2 * 60 * 60),
};
return token;
}
// unable to refresh tokens from DRF backend, invalidate the token
return {
...token,
exp: 0,
};
}
// token valid
return token;
},
async session(session, userOrToken) {
session.accessToken = userOrToken.accessToken;
return session;
},
},
};
export default (req: NextApiRequest, res: NextApiResponse) =>
NextAuth(req, res, settings);
Next, the example in the Next-Auth documentation shows the use of useSession() hook. But I am not a fan of it because:
It does not update the state of the session once the access token is refreshed unless the window itself is refreshed (it is an open issue)
It feels like a lot of code repetition on every component that wants to use the session, with the guards that check the existence of session object, whether the session is loading etc. So I wanted to use a HOC.
As such, I came up with the following solutions:
constants/Hooks.tsx
import { Session } from "next-auth";
import { useEffect, useMemo, useState } from "react";
export function useAuth(refreshInterval?: number): [Session, boolean] {
/*
custom hook that keeps the session up-to-date by refreshing it
#param {number} refreshInterval: The refresh/polling interval in seconds. default is 10.
#return {tuple} A tuple of the Session and boolean
*/
const [session, setSession] = useState<Session>(null);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
async function fetchSession() {
let sessionData: Session = null;
setLoading(true);
const response = await fetch("/api/auth/session");
if (response.ok) {
const data: Session = await response.json();
if (Object.keys(data).length > 0) {
sessionData = data;
}
}
setSession(sessionData);
setLoading(false);
}
refreshInterval = refreshInterval || 10;
fetchSession();
const interval = setInterval(() => fetchSession(), refreshInterval * 1000);
return () => clearInterval(interval);
}, []);
return [session, loading];
}
constants/HOCs.tsx
import { Session } from "next-auth";
import { signIn } from "next-auth/client";
import React from "react";
import { useAuth } from "./Hooks";
type TSessionProps = {
session: Session;
};
export function withAuth<P extends object>(Component: React.ComponentType<P>) {
return React.memo(function (props: Exclude<P, TSessionProps>) {
const [session, loading] = useAuth(); // custom hook call
if (loading) {
return <h2>Loading...</h2>;
}
if (!loading && !session) {
return (
<>
Not signed in <br />
<button onClick={() => signIn()}>Sign in</button>
<pre>{!session && "User is not logged in"}</pre>
</>
);
}
return <Component {...props} session={session} />;
});
}
Then, in a component where I have periodic data fetching requirements (I know this could be achieved in a much better way, this is just a contrived example where I am trying to simulate user inactivity but the app can still work in the background if needed), I am using the HOC:
pages/posts.tsx
import React, { useEffect, useState } from "react";
import Post from "../components/Post";
import { withAuth } from "../constants/HOCs";
import { TPost } from "../constants/Types";
import Link from "next/link";
function Posts(props) {
const { session } = props;
// const [session, loading] = useAuth();
const [posts, setPosts] = useState<TPost[]>([]);
const [fetchingPosts, setFetchingPosts] = useState<boolean>(false);
useEffect(() => {
if (!session) {
return;
}
async function getPosts() {
setFetchingPosts(true);
const response = await fetch("http://127.0.0.1:8000/api/posts", {
method: "get",
headers: new Headers({
Authorization: `Bearer ${session?.accessToken}`,
}),
});
if (response.ok) {
const posts: TPost[] = await response.json();
setPosts(posts);
}
setFetchingPosts(false);
}
// initiate the post fetching mechanism once
getPosts();
const intervalId = setInterval(() => getPosts(), 10 * 1000);
// useEffect cleanup
return () => clearInterval(intervalId);
}, [JSON.stringify(session)]);
// {
// loading && <h2>Loading...</h2>;
// }
// {
// !loading && !session && (
// <>
// Not signed in <br />
// <button onClick={() => signIn()}>Sign in</button>
// <pre>{!session && "User is not logged in"}</pre>
// </>
// );
// }
return (
<div>
<h2>Fetched at {JSON.stringify(new Date())}</h2>
<Link href="/">Back to homepage</Link>
{posts.map((post) => (
<Post key={post.title} post={post} />
))}
</div>
);
}
export default withAuth(Posts);
The problem is that the entire page gets re-rendered due to the withAuth HOC and possibly due to the useAuth hook every 10 seconds. However, I have had no luck trying to debug it. Maybe I am missing something key in my React concepts. I appreciate any and all suggestions/help possible. Thanks in advance.
PS. I am aware of a solution that uses SWR library, but I would like to avoid using that library if at all possible.
I ended up using the useSwr() hook after spending an unworldly amount of time trying to fix this issue. Also ended up writing this article for those who are interested.

react firebase auth - getting custom claims in real-time not working in useEffect - cannot read property getIDtokenresult

Can anyone help me with this please with this useEffect in React? I am updating custom claims in firebase auth via a firestore document called user_claims. In here it has a user role. Instead of waiting an hour for the token to be refreshed....I want to refresh it as soon as I make a change to the user_claims collection. Based on the below I am getting the error:
Unhandled Rejection (TypeError): Cannot read property 'getIdTokenResult' of undefined
Have I put my listeners in the wrong order or should I have two useEffects? Not sure what I need to do and would appreciate guidance. I want to get the user role - in real-time into the AuthContext for use throughout the app.
thanks all in advance for your help and guidance, as always.
import React, { useEffect, useState, createContext } from 'react'
import {firebase, firestore} from './firebase'
export const AuthContext = createContext()
export const AuthProvider = ({ children }) => {
const auth=firebase.auth();
const [currentUser, setCurrentUser] = useState();
const [error, setError] = useState();
useEffect(() => {
async function reportAdminStatus() {
const result = await currentUser.getIdTokenResult(false)
const isAdmin = result.claims.isAdmin
if (isAdmin) {
console.log("Custom claims say I am an admin!")
}
else {
console.log("Custom claims say I am not an admin.")
}
}
auth.onIdTokenChanged(user => {
if (user) {
console.log(`new ID token for ${user.uid}`)
setCurrentUser(user)
reportAdminStatus()
}
})
auth.onAuthStateChanged(async (user) => {
function listenToClaims() {
firestore
.collection('user_claims')
.doc(currentUser.uid)
.onSnapshot(onNewClaims)
}
let synced
function onNewClaims(snapshot) {
const data = snapshot.data()
console.log('New claims doc\n', data)
if (data._synced) {
if (synced &&
!data._synced.isEqual(synced)) {
// Force a refresh of the user's ID token
console.log('Refreshing token')
currentUser.getIdToken(true)
}
synced = data._synced
}
}
if (user) {
try {
const idTokenResult = await user.getIdTokenResult();
setCurrentUser({...user, role: idTokenResult.claims.role, isAdmin: idTokenResult.claims.isAdmin, group: idTokenResult.claims.group });
setError(undefined);
listenToClaims();
reportAdminStatus();
} catch (e) {
setError(e);
}
} else {
setCurrentUser(undefined);
}
});
return [currentUser, error]
}, []);
return (
<AuthContext.Provider value={{ currentUser }}>
{children}
</AuthContext.Provider>
)
}

Resources