So I have created these contexts to handle logging users in and retrieving the logged user to any component that might need it.
Here they are:
context.js
import React, { useReducer } from "react";
import { AuthReducer, initialState } from "./reducers";
const AuthStateContext = React.createContext();
const AuthDispatchContext = React.createContext();
export function useAuthState() {
const context = React.useContext(AuthStateContext);
if (context === undefined) {
throw new Error("useAuthState must be used within a AuthProvider");
}
return context;
}
export function useAuthDispatch() {
const context = React.useContext(AuthDispatchContext);
if (context === undefined) {
throw new Error("useAuthDispatch must be used within a AuthProvider");
}
return context;
}
export const AuthProvider = ({ children }) => {
const [user, dispatch] = useReducer(AuthReducer, initialState);
return (
<AuthStateContext.Provider value={user}>
<AuthDispatchContext.Provider value={dispatch}>
{children}
</AuthDispatchContext.Provider>
</AuthStateContext.Provider>
);
}
reducers.js
let user = localStorage.getItem("currentUser")
? JSON.parse(localStorage.getItem("currentUser")).user
: "";
let token = localStorage.getItem("currentUser")
? JSON.parse(localStorage.getItem("currentUser")).token
: "";
export const initialState = {
userDetails: user || "",
token: token || "",
loading: false,
errorMessage: null,
};
export const AuthReducer = (initialState, action) => {
switch (action.type) {
case "REQUEST_LOGIN":
return {
...initialState,
loading: true,
};
case "LOGIN_SUCCESS":
return {
...initialState,
userDetails: action.payload.user,
token: action.payload.token,
loading: false,
};
case "LOGOUT":
return {
...initialState,
userDetails: "",
token: "",
};
case "LOGIN_ERROR":
return {
...initialState,
loading: false,
errorMessage: action.error,
};
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
};
actions.js
const ROOT_URL = process.env.REACT_APP_API_HOST_URL;
export async function loginUser(dispatch, loginPayload) {
const requestOptions = {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(loginPayload),
};
try {
dispatch({ type: "REQUEST_LOGIN" });
let response = await fetch(`${ROOT_URL}/auth/login`, requestOptions);
let data = await response.json();
if (data.user) {
dispatch({ type: "LOGIN_SUCCESS", payload: data });
localStorage.setItem("currentUser", JSON.stringify(data));
return data;
}
dispatch({ type: "LOGIN_ERROR", error: data.errors[0] });
return;
} catch (error) {
dispatch({ type: "LOGIN_ERROR", error: error });
}
}
export async function logout(dispatch) {
dispatch({ type: "LOGOUT" });
localStorage.removeItem("currentUser");
localStorage.removeItem("token");
}
my question is how to expand this to check whether the JWT has expired or not every time the useAuthState() hook is called (if this is even the best way to go about things)? and then log the user out or perhaps refresh the token from the server without having to log the user out if possible.
Thanks in advance.
With JWT, you can decrypt your own token in a browser without a secret key. This way you can check if the JWT token is about or already expired. The secret key is only needed for the authenticity of where it's signed off. This is demonstrated well in JWT website.
If you wanted to be able to regenerate the key from expired JWT you can just set ignoreExpiration to true in jsonwebtoken's verify() function at your server, but then why even bother setting expiration time in the first place? It's best to only allow regenerating JWT when it's about to expire.
Related
I have a react app where I use the useContext and useReducer hooks for the login and storage. While the login part works, what I want achieve is to redirect user to a specific page post successful login. I am using react-router#6 and tried to use useNavigate() to navigate user to particular route though it doesn't seem to work.
const AuthService = async (dispatch) => {
const MSAL_CONFIG = {} // populate MSAL config for Microsoft Graph API for AD auth
const msalInstance = new msal.PublicClientApplication(MSAL_CONFIG);
try {
const loginResponse = await msalInstance.loginPopup(scopes);
var username = loginResponse.account.username;
var userid = username.slice(0, username.indexOf("#"));
const loginData = {
auth_token: loginResponse.idToken,
user: {
name: loginResponse.account.name,
id: userid,
email: username,
},
};
const sessionData = {
user_id: userid,
id_token: loginResponse.idToken,
access_token: loginResponse.accessToken,
}
sessionStorage.setItem("currentUser", JSON.stringify(loginData));
dispatch({ type: "LOGIN_SUCCESS", payload: loginData });
return { loginData: loginData, error: null };
// dispatch({ type: 'LOGIN_SUCCESS', payload: loginData });
//sessionStorage.setItem('currentUser', JSON.stringify(data));
} catch (err) {
console.log("+++ Login error : ", err);
dispatch({ type: "LOGIN_ERROR", error: err });
return { loginData: null, error: err };
}
};
In my header.jsx, I have below code to handle the login button. It makes a call to the above AuthService. The code post AuthService() call, i.e. the if block, doesn't take effect, so user never gets redirected to the dashboard page.
const handleLogin = async () => {
await AuthService(dispatch)
console.log("userDetails.token : " + userDetails.token)
if (Boolean(userDetails.token)) {
navigate("/dashboard");
}
};
If I'm correct in understanding that this AuthService function eventually resolves and that the dispatched LOGIN_SUCCESS action updates the userDetails variable that is selected from the auth context state, then I think you have all that you need and are close to a working solution. The issue is that the userDetails value from the render cycle the handleLogin is called in is closed over in callback scope, it will never be a different value. If the userDetails.token value is falsey when handleLogin is called, it will remain falsey in the entire callback scope.
The AuthService function appears to return the same loginData object that is passed in the dispatched LOGIN_SUCCESS action to the store. handleLogin should await this value and conditionally navigate.
const AuthService = async (dispatch) => {
...
try {
const { account, idToken } = await msalInstance.loginPopup(scopes);
const { name, username } = account;
const userid = username.slice(0, username.indexOf("#"));
const loginData = {
auth_token: idToken,
user: {
name,
id: userid,
email: username,
},
};
...
sessionStorage.setItem("currentUser", JSON.stringify(loginData));
dispatch({ type: "LOGIN_SUCCESS", payload: loginData });
return { loginData, error: null }; // <-- return value
} catch (error) {
dispatch({ type: "LOGIN_ERROR", error });
return { loginData: null, error }; // <-- return value
}
};
const handleLogin = async () => {
const { loginData } = await AuthService(dispatch);
if (loginData && loginData.auth_token) { // or loginData?.auth_token
navigate("/dashboard", { replace: true });
}
};
can somebody explain why this code dispatching 'actions.loginSuccess' when i get 401 error from server ?
isn't it should go to 'catch' part of axios request ?
Before i did it without redux toolkit features
const login = ({username, password}) => async dispatch => {
await axios.post(`${API_URL}/token/`, {username, password})
.then(response => {
dispatch(actions.loginSuccess({ client_id: response?.data.client_id }))
history.push('/')
})
.catch(e => {
dispatch(actions.loginError({ error: String(e) }))
})
}
//actions.js
const login = createAction('#USER/login')
const loginSuccess = createAction('#USER/login-success')
const loginError = createAction('#USER/login-error')
export const actions = {
login,
loginSuccess,
loginError
}
//reducers.js
export const userReducer = createReducer(initialState, builder => {
builder.addCase(actions.login, draft => {
draft.loading = true
})
builder.addCase(actions.loginSuccess, (draft, action) => {
draft.loading = false
draft.isLoggedIn = true
draft.data = { ...draft.data, client_id : action.client_id}
})
builder.addCase(actions.loginError, (draft, action) => {
draft.loading = false
draft.error = action.payload.error
draft.isLoggedIn = false
draft.isSignedup = false
})
}
can somebody explain why this code dispatching 'actions.loginSuccess'
when i get 401 error from server ? isn't it should go to 'catch' part
of axios request ?
// there's a difference beetween HTTP Status Code and Server Response Body Code.
// if HTTP status code is not 200, it should dispatched loginError()
// if HTTP status code is 200, and theres a response body JSON
// e.g
const resp = {
statusCode: 401,
message: 'unauthorized',
}
// You must make if conditions to handle that error code
Here's redux-toolkit version of your code to handle either HTTP status code 401, or body response code
// import axios & history
import { createSlice } from '#reduxjs/toolkit';
const initialState = {
data: {},
loading: false,
isLoggedIn: false,
isSignedup: false,
};
// Reducers
const userSlice = createSlice({
name: '#USER',
initialState: initialState,
reducers: {
loginStart(state) {
state.loading = true;
},
loginSuccess(state, action) {
state.data = {
...state.data,
client_id: action.payload.client_id
};
state.loading = false;
state.isLoggedIn = true;
},
loginError(state, action) {
state.loading = false;
state.error = action.payload.error;
state.isLoggedIn = false;
state.isSignedup = false;
},
},
});
// actions
export const { loginStart, loginSuccess, loginError } = userSlice.actions;
export default userSlice.reducer;
export const login = ({ username, password }) => async (dispatch) => {
dispatch(loginStart());
try {
const response = await axios.post(`${API_URL}/token/`, {
username,
password,
});
if(response && response.statusCode !== 200){
return dispatch(loginError({ error: response.message }));
}
dispatch(loginSuccess({ client_id: response?.data.client_id }));
history.push('/');
} catch (e) {
dispatch(loginError({ error: String(e) }));
}
};
don't forget to add userSlice into configureStore()
const reducer = {
"#USER": userReducers, //got from export default userSlice.reducer
};
export default configureStore({
reducer,
middleware,
devTools: process.env.NODE_ENV !== 'production',
});
I need help with custom hooks. I created a custom hook to make api calls (useApiCall), I am calling it from my authActions.js file to send username and password to the server to signing up a new user. I am getting an error saying I am miss using hooks in the useApiCall functional component.
When I remove the logic for useReducer in the useApiCall functional component, it says I am misusing useState which comes after useReducer. It will not let me use hooks in the React functional component useApiCall.
I know I am breaking the rule of hooks, but what rule is that? I compared my code with other custom hooks and there is no difference.
In the useApiCall function I striped away all of my logic and used a single useState and got the same error. What am I doing wrong?
Hook called from (authActions): https://github.com/SMasood1/Chat-App/blob/main/client/src/context/authContext/authAction.js
Custom Hook (useApiCall): https://github.com/SMasood1/Chat-App/blob/main/client/src/context/a
import { useState, useEffect, useReducer } from 'react';
// Can make this more elegant and able to handle different types of methods and headers
const FETCH_INIT = 'FETCH_INIT';
const FETCH_SUCCESS = 'FETCH_SUCCESS';
const FETCH_FAILURE = 'FETCH_FAILURE';
const dataReducer = (state, action) => {
switch (action.type) {
case FETCH_INIT:
return {
...state,
isLoading: true,
isError: false
}
case FETCH_SUCCESS:
return {
...state,
isLoading: false,
isError: false,
data: action.payload
}
case FETCH_FAILURE:
return {
...state,
isLoading: false,
isError: true,
data: action.error
}
default:
throw new Error();
}
}
export const useApiCall = (initialUrl, initialMethod, initialData) => {
const [state, dispatch] = useReducer(dataReducer, {
isLoading: false,
isError: null,
data: initialData ? initialData : ''
})
const [method, setMethod] = useState(initialMethod ? initialMethod : null);
const [url, setUrl] = useState(initialUrl ? initialUrl : '');
useEffect(() => {
const fetchData = async () => {
await dispatch({ type: FETCH_INIT });
let response;
try {
switch (method) {
case 'GET':
response = await fetch(url);
break;
case 'POST':
response = await fetch(url, {
method: method,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(state.data)
});
break;
default:
console.log('Incorrect HTTP Request Method');
}
if (response.ok) {
let resBody = await response.json();
dispatch({ type: FETCH_SUCCESS, payload: resBody });
} else {
let resBody = await response.json();
dispatch({ type: FETCH_FAILURE, error: resBody });
}
} catch (error) {
dispatch({ type: FETCH_FAILURE, error: 'Unable to send request!' });
}
}
if (method && url) {
fetchData();
}
}, [url, method, state.data]);
return [state, setUrl, setMethod]
}
Error I received is below:
Uncaught (in promise) Error: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen
for one of the following reasons:
You might have mismatching versions of React and the renderer (such as React DOM)
You might be breaking the Rules of Hooks
You might have more than one copy of React in the same app
I'm using useReducer to update the errorsState when user logged in and failed. I've read many solutions and it was said that dispatch is async and I know that so I put console.log inside the useEffect to see the errorsState change, but unfortunately it didn't changed. Here's my code
Login.jsx
export default function Login({ userProps }) {
//
// some variables and state
//
const { loading, user } = useLogin({ email: state.email }, state.submitted)
const [errors, dispatch] = useReducer(errorsReducer, errorsState)
useEffect(() => {
console.log("errors", errors) // it won't triggered because errors state didn't updating from UseLogin
}, [errors])
return content
}
Here is fetch function useLogin
AuthAction.js
export const useLogin = (data, submitted) => {
const [state, dispatch] = useReducer(userReducer, userState)
const [errors, errorsDispatch] = useReducer(errorsReducer, errorsState)
useEffect(() => {
if (!submitted) return
dispatch({
type: USER_ACTIONS.MAKE_REQUEST,
})
ticketApi.login(data).then(({ res, status }) => {
if (status !== "failed") {
// Save to local storage
const { token } = res
// set token to local storage
localStorage.setItem("jwtToken", token)
// Set token to Auth Header
setAuthToken(token)
// decode token to get user data with jwt-decode
const decoded = jwt_decode(token)
// set current user
return dispatch({
type: USER_ACTIONS.GET_USER,
payload: decoded,
})
}
dispatch({
type: USER_ACTIONS.END_REQUEST,
})
return errorsDispatch({
type: ERRORS_ACTIONS.GET_ERRORS,
payload: res.response.data,
})
})
}, [submitted])
return state
}
I've tried put console.log inside the ERRORS_ACTIONS.GET_ERRORS to see the response, and it was fine.
So where did i go wrong?
useReducer allows you to better manage complex states, it's not a state container, what you're doing there is to create 2 different states, one inside useLogin and the other in your Login component, return errors from your useLogin hook so the Login component can see it.
Login
export default function Login({ userProps }) {
//
// some variables and state
//
const { loading, user, errors } = useLogin({ email: state.email }, state.submitted)
useEffect(() => {
console.log("errors", errors)
}, [errors])
return content
}
useLogin
export const useLogin = (data, submitted) => {
const [state, dispatch] = useReducer(userReducer, userState)
const [errors, errorsDispatch] = useReducer(errorsReducer, errorsState)
useEffect(() => {
if (!submitted) return
dispatch({
type: USER_ACTIONS.MAKE_REQUEST,
})
ticketApi.login(data).then(({ res, status }) => {
if (status !== "failed") {
// Save to local storage
const { token } = res
// set token to local storage
localStorage.setItem("jwtToken", token)
// Set token to Auth Header
setAuthToken(token)
// decode token to get user data with jwt-decode
const decoded = jwt_decode(token)
// set current user
return dispatch({
type: USER_ACTIONS.GET_USER,
payload: decoded,
})
}
dispatch({
type: USER_ACTIONS.END_REQUEST,
})
return errorsDispatch({
type: ERRORS_ACTIONS.GET_ERRORS,
payload: res.response.data,
})
})
}, [submitted])
return { ...state, errors };
}
Since I want to setup Axios interceptors with React Context, the only solution that seems viable is creating an Interceptor component in order to use the useContext hook to access Context state and dispatch.
The problem is, this creates a closure and returns old data to the interceptor when it's being called.
I am using JWT authentication using React/Node and I'm storing access tokens using Context API.
This is how my Interceptor component looks like right now:
import React, { useEffect, useContext } from 'react';
import { Context } from '../../components/Store/Store';
import { useHistory } from 'react-router-dom';
import axios from 'axios';
const ax = axios.create();
const Interceptor = ({ children }) => {
const [store, dispatch] = useContext(Context);
const history = useHistory();
const getRefreshToken = async () => {
try {
if (!store.user.token) {
dispatch({
type: 'setMain',
loading: false,
error: false,
auth: store.main.auth,
brand: store.main.brand,
theme: store.main.theme,
});
const { data } = await axios.post('/api/auth/refresh_token', {
headers: {
credentials: 'include',
},
});
if (data.user) {
dispatch({
type: 'setStore',
loading: false,
error: false,
auth: store.main.auth,
brand: store.main.brand,
theme: store.main.theme,
authenticated: true,
token: data.accessToken,
id: data.user.id,
name: data.user.name,
email: data.user.email,
photo: data.user.photo,
stripeId: data.user.stripeId,
country: data.user.country,
messages: {
items: [],
count: data.user.messages,
},
notifications:
store.user.notifications.items.length !== data.user.notifications
? {
...store.user.notifications,
items: [],
count: data.user.notifications,
hasMore: true,
cursor: 0,
ceiling: 10,
}
: {
...store.user.notifications,
count: data.user.notifications,
},
saved: data.user.saved.reduce(function (object, item) {
object[item] = true;
return object;
}, {}),
cart: {
items: data.user.cart.reduce(function (object, item) {
object[item.artwork] = true;
return object;
}, {}),
count: Object.keys(data.user.cart).length,
},
});
} else {
dispatch({
type: 'setMain',
loading: false,
error: false,
auth: store.main.auth,
brand: store.main.brand,
theme: store.main.theme,
});
}
}
} catch (err) {
dispatch({
type: 'setMain',
loading: false,
error: true,
auth: store.main.auth,
brand: store.main.brand,
theme: store.main.theme,
});
}
};
const interceptTraffic = () => {
ax.interceptors.request.use(
(request) => {
request.headers.Authorization = store.user.token
? `Bearer ${store.user.token}`
: '';
return request;
},
(error) => {
return Promise.reject(error);
}
);
ax.interceptors.response.use(
(response) => {
return response;
},
async (error) => {
console.log(error);
if (error.response.status !== 401) {
return new Promise((resolve, reject) => {
reject(error);
});
}
if (
error.config.url === '/api/auth/refresh_token' ||
error.response.message === 'Forbidden'
) {
const { data } = await ax.post('/api/auth/logout', {
headers: {
credentials: 'include',
},
});
dispatch({
type: 'resetUser',
});
history.push('/login');
return new Promise((resolve, reject) => {
reject(error);
});
}
const { data } = await axios.post(`/api/auth/refresh_token`, {
headers: {
credentials: 'include',
},
});
dispatch({
type: 'updateUser',
token: data.accessToken,
email: data.user.email,
photo: data.user.photo,
stripeId: data.user.stripeId,
country: data.user.country,
messages: { items: [], count: data.user.messages },
notifications:
store.user.notifications.items.length !== data.user.notifications
? {
...store.user.notifications,
items: [],
count: data.user.notifications,
hasMore: true,
cursor: 0,
ceiling: 10,
}
: {
...store.user.notifications,
count: data.user.notifications,
},
saved: data.user.saved,
cart: { items: {}, count: data.user.cart },
});
const config = error.config;
config.headers['Authorization'] = `Bearer ${data.accessToken}`;
return new Promise((resolve, reject) => {
axios
.request(config)
.then((response) => {
resolve(response);
})
.catch((error) => {
reject(error);
});
});
}
);
};
useEffect(() => {
getRefreshToken();
if (!store.main.loading) interceptTraffic();
}, []);
return store.main.loading ? 'Loading...' : children;
}
export { ax };
export default Interceptor;
The getRefreshToken function is called every time a user refreshes the website to retrieve an access token if there is a refresh token in the cookie.
The interceptTraffic function is where the issue persists.
It consists of a request interceptor which appends a header with the access token to every request and a response interceptor which is used to handle access token expiration in order to fetch a new one using a refresh token.
You will notice that I am exporting ax (an instance of Axios where I added interceptors) but when it's being called outside this component, it references old store data due to closure.
This is obviously not a good solution, but that's why I need help organizing interceptors while still being able to access Context data.
Note that I created this component as a wrapper since it renders children that are provided to it, which is the main App component.
Any help is appreciated, thanks.
Common Approach (localStorage)
It is a common practice to store the JWT in the localStorage with
localStorage.setItem('token', 'your_jwt_eykdfjkdf...');
on login or page refresh, and make a module that exports an Axios instance with the token attached. We will get the token from localStorage
custom-axios.js
import axios from 'axios';
// axios instance for making requests
const axiosInstance = axios.create();
// request interceptor for adding token
axiosInstance.interceptors.request.use((config) => {
// add token to request headers
config.headers['Authorization'] = localStorage.getItem('token');
return config;
});
export default axiosInstance;
And then, just import the Axios instance we just created and make requests.
import axios from './custom-axios';
axios.get('/url');
axios.post('/url', { message: 'hello' });
Another approach (when you've token stored in the state)
If you have your JWT stored in the state or you can grab a fresh token from the state, make a module that exports a function that takes the token as an argument and returns an axios instance with the token attached like this:
custom-axios.js
import axios from 'axios';
const customAxios = (token) => {
// axios instance for making requests
const axiosInstance = axios.create();
// request interceptor for adding token
axiosInstance.interceptors.request.use((config) => {
// add token to request headers
config.headers['Authorization'] = token;
return config;
});
return axiosInstance;
};
export default customAxios;
And then import the function we just created, grab the token from state, and make requests:
import axios from './custom-axios';
// logic to get token from state (it may vary from your approach but the idea is same)
const token = useSelector(token => token);
axios(token).get('/url');
axios(token).post('/url', { message: 'hello' });
I have a template that works in a system with millions of access every day.
This solved my problems with refresh token and reattemp the request without crashing
First I have a "api.js" with axios, configurations, addresses, headers.
In this file there are two methods, one with auth and another without.
In this same file I configured my interceptor:
import axios from "axios";
import { ResetTokenAndReattemptRequest } from "domain/auth/AuthService";
export const api = axios.create({
baseURL: process.env.REACT_APP_API_URL,
headers: {
"Content-Type": "application/json",
},
});
export const apiSecure = axios.create({
baseURL: process.env.REACT_APP_API_URL,
headers: {
Authorization: "Bearer " + localStorage.getItem("Token"),
"Content-Type": "application/json",
},
export default api;
apiSecure.interceptors.response.use(
function (response) {
return response;
},
function (error) {
const access_token = localStorage.getItem("Token");
if (error.response.status === 401 && access_token) {
return ResetTokenAndReattemptRequest(error);
} else {
console.error(error);
}
return Promise.reject(error);
}
);
Then the ResetTokenAndReattemptRequest method. I placed it in another file, but you can place it wherever you want:
import api from "../api";
import axios from "axios";
let isAlreadyFetchingAccessToken = false;
let subscribers = [];
export async function ResetTokenAndReattemptRequest(error) {
try {
const { response: errorResponse } = error;
const retryOriginalRequest = new Promise((resolve) => {
addSubscriber((access_token) => {
errorResponse.config.headers.Authorization = "Bearer " + access_token;
resolve(axios(errorResponse.config));
});
});
if (!isAlreadyFetchingAccessToken) {
isAlreadyFetchingAccessToken = true;
await api
.post("/Auth/refresh", {
Token: localStorage.getItem("RefreshToken"),
LoginProvider: "Web",
})
.then(function (response) {
localStorage.setItem("Token", response.data.accessToken);
localStorage.setItem("RefreshToken", response.data.refreshToken);
localStorage.setItem("ExpiresAt", response.data.expiresAt);
})
.catch(function (error) {
return Promise.reject(error);
});
isAlreadyFetchingAccessToken = false;
onAccessTokenFetched(localStorage.getItem("Token"));
}
return retryOriginalRequest;
} catch (err) {
return Promise.reject(err);
}
}
function onAccessTokenFetched(access_token) {
subscribers.forEach((callback) => callback(access_token));
subscribers = [];
}
function addSubscriber(callback) {
subscribers.push(callback);
}