Problem
Authorization is one of the first problems developers face upon starting a new project. And one of the most common types of authorization (from my experience) is the token-based authorization (usually using JWT).
From my perspective, this article looks like "what I wanted to read two weeks ago". My goal was to write minimalistic and reusable code with a clean and straightforward interface. I had the next requirements for my implementation of the auth management:
- Tokens should be stored in local storage
- Tokens should be restored on page reload
- Access token should be passed in the network requests
- After expiration access token should be updated by refresh token if the last one is presented
- React components should have access to the auth information to render appropriate UI
- The solution should be made with pure React (without Redux, thunk, etc..)
For me one of the most challenging questions were:
- How to keep in sync React components state and local storage data?
- How to get the token inside fetch without passing it through the whole elements tree (especially if we want to use this fetch in thunk actions later for example)
But let's solve the problems step by step. Firstly we will create a token provider
to store tokens and provide possibility to listen to changes. After that, we will create an auth provider
, actually wrapper around token provider
to create hooks for React components, fetch on steroids and some additional methods. And in the end, we will look at how to use this solution in the project.
I just wanna npm install ...
and go production
I already gathered the package that contains all described below (and a bit more). You just need to install it by the command:
npm install react-token-auth
And follow examples in the react-token-auth GitHub repository.
Solution
Before solving the problem I will make an assumption that we have a backend that returns an object with access and refresh tokens. Each token has a JWT
format. Such an object may look like:
{
"accessToken": "...",
"refreshToken": "..."
}
Actually, the structure of the tokens object is not critical for us. In the simplest case, it might be a string with an infinite access token. But we want to look at how to manage a situation when we have two tokens, one of them may expire, and the second one might be used to update the first one.
JWT
If you don't know what is the JWT token the best option is to go to jwt.io and look at how does it work. Now it is important that JWT token contains encoded (in Base64
format) information about the user that allows authenticate him on the server.
Usually JWT token contains 3 parts divided by dots and looks like:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJuYW1lIjoiSm9obiBEb2UiLCJpYXQiOjE1MTYyMzkwMjIsImV4cCI6MTUxNjIzOTAyMn0.yOZC0rjfSopcpJ-d3BWE8-BkoLR_SCqPdJpq8Wn-1Mc
If we decode the middle part (eyJu...Mn0
) of this token, we will get the next JSON:
{
"name": "John Doe",
"iat": 1516239022,
"exp": 1516239022
}
With this information, we will be able to get the expiration date of the token.
Token provider
As I mentioned before, our first step is creating the token provider. The token provider will work directly with local storage and all changes of token we will do through it. It will allow us to listen to changes from anywhere and immediately notify the listeners about changes (but about it a bit later). The interface of the provider will have the next methods:
getToken()
to get the current token (it will be used in fetch)setToken()
to set token after login, logout or registrationisLoggedIn()
to check is the user logged insubscribe()
to give the provider a function that should be called after any token changeunsubscribe()
to remove subscriber
Function createTokenProvider()
will create an instance of the token provider with the described interface:
const createTokenProvider = () => {
/* Implementation */
return {
getToken,
isLoggedIn,
setToken,
subscribe,
unsubscribe,
};
};
All the next code should be inside the createTokenProvider function.
Let's start by creating a variable for storing tokens and restoring the data from local storage (to be sure that the session will not be lost after page reload):
let _token: { accessToken: string, refreshToken: string } =
JSON.parse(localStorage.getItem('REACT_TOKEN_AUTH') || '') || null;
Now we need to create some additional functions to work with JWT tokens. At the current moment, the JWT token looks like a magic string, but it is not a big deal to parse it and try to extract the expiration date. The function getExpirationDate()
will take a JWT token as a parameter and return expiration date timestamp on success (or null
on failure):
const getExpirationDate = (jwtToken?: string): number | null => {
if (!jwtToken) {
return null;
}
const jwt = JSON.parse(atob(jwtToken.split('.')[1]));
// multiply by 1000 to convert seconds into milliseconds
return jwt && jwt.exp && jwt.exp * 1000 || null;
};
And one more util function isExpired()
to check is the timestamp expired. This function returns true if the expiration timestamp presented and if it is less than Date.now()
.
const isExpired = (exp?: number) => {
if (!exp) {
return false;
}
return Date.now() > exp;
};
Time to create first function of the token provider interface. Function getToken()
should return token and update it if it is necessary. This function should be async
because it may make a network request to update token.
Using created earlier functions we can check is the access tokens expired or not (isExpired(getExpirationDate(_token.accessToken))
). And in the first case to make a request for updating token. After that, we can save tokens (with the not implemented yet function setToken()
). And finally, we can return access token:
const getToken = async () => {
if (!_token) {
return null;
}
if (isExpired(getExpirationDate(_token.accessToken))) {
const updatedToken = await fetch('/update-token', {
method: 'POST',
body: _token.refreshToken
})
.then(r => r.json());
setToken(updatedToken);
}
return _token && _token.accessToken;
};
Function isLoggedIn()
will be simple: it will return true if _tokens
is not null
and will not check for access token expiration (in this case we will not know about expiration access token until we get fail on getting token, but usually it is sufficient, and let us keep function isLoggedIn synchronous):
const isLoggedIn = () => {
return !!_token;
};
I think it is a good time to create functionality for managing observers. We will implement something similar to the Observer pattern, and first of all, will create an array to store all our observers. We will expect that each element in this array is the function we should call after each change of tokens:
let observers: Array<(isLogged: boolean) => void> = [];
Now we can create methods subscribe()
and unsubscribe()
. The first one will add new observer to the created a bit earlier array, second one will remove observer from the list.
const subscribe = (observer: (isLogged: boolean) => void) => {
observers.push(observer);
};
const unsubscribe = (observer: (isLogged: boolean) => void) => {
observers = observers.filter(_observer => _observer !== observer);
};
You already can see from the interface of the functions subscribe()
and unsubscribe()
that we will send to observers only the fact of is the user logged in. But in general, you could send everything you want (the whole token, expiration time, etc...). But for our purposes, it will be enough to send a boolean flag.
Let's create a small util function notify()
that will take this flag and send to all observers:
const notify = () => {
const isLogged = isLoggedIn();
observers.forEach(observer => observer(isLogged));
};
And last but not least function we need to implement is the setToken()
. The purpose of this function is saving tokens in local storage (or clean local storage if the token is empty) and notifying observers about changes. So, I see the goal, I go to the goal.
const setToken = (token: typeof _token) => {
if (token) {
localStorage.setItem('REACT_TOKEN_AUTH', JSON.stringify(token));
} else {
localStorage.removeItem('REACT_TOKEN_AUTH');
}
_token = token;
notify();
};
Be sure, if you came to this point in the article and found it useful, you already made me happier. Here we finish with the token provider. You can look at your code, play with it and check that it works. In the next part on top of this, we will create more abstract functionality that will be already useful in any React application.
Auth provider
Let's create a new class of objects that we will call as an Auth provider. The interface will contain 4 methods: hook useAuth()
to get fresh status from React component, authFetch()
to make requests to the network with the actual token and login()
, logout()
methods which will proxy calls to the method setToken()
of the token provider (in this case, we will have only one entry point to the whole created functionality, and the rest of the code will not have to know about existing of the token provider). As before we will start from the function creator:
export const createAuthProvider = () => {
/* Implementation */
return {
useAuth,
authFetch,
login,
logout
}
};
First of all, if we want to use a token provider we need to create an instance of it:
const tokenProvider = createTokenProvider();
Methods login()
and logout()
simply pass token to the token provider. I separated these methods only for explicit meaning (actually passing empty/null token removes data from local storage):
const login: typeof tokenProvider.setToken = (newTokens) => {
tokenProvider.setToken(newTokens);
};
const logout = () => {
tokenProvider.setToken(null);
};
The next step is the fetch function. According to my idea, this function should have exactly the same interface as original fetch and return the same format but should inject access token to each request.
Fetch function should take two arguments: request info (usually URL) and request init (an object with method, body. headers and so on); and returns promise for the response:
const authFetch = async (input: RequestInfo, init?: RequestInit): Promise<Response> => {
const token = await tokenProvider.getToken();
init = init || {};
init.headers = {
...init.headers,
Authorization: `Bearer ${token}`,
};
return fetch(input, init);
};
Inside the function we made two things: took a token from the token provider by statement await tokenProvider.getToken();
(getToken
already contains the logic of updating the token after expiration) and injecting this token into Authorization
header by the line Authorization: 'Bearer ${token}'
. After that, we simply return fetch with updated arguments.
So, we already can use the auth provider to save tokens and use them from fetch. The last problem is that we can not react to the token changes from our components. Time to solve it.
As I told before we will create a hook useAuth()
that will provide information to the component is the user logged or not. To be able to do that we will use hook useState()
to keep this information. It is useful because any changes in this state will cause rerender of components that use this hook.
And we already prepared everything to be able to listen to changes in local storage. A common way to listen to any changes in the system with hooks is using the hook useEffect()
. This hook takes two arguments: function and list of dependencies. The function will be fired after the first call of useEffect
and then relaunched after any changes in the list of dependencies. In this function, we can start to listen to changes in local storage. But what is important we can return from this function… new function and, this new function will be fired either before relaunching the first one or after the unmounting of the component. In the new function, we can stop listening to the changes and React guarantees, that this function will be fired (at least if no exception happens during this process). Sounds a bit complicated but just look at the code:
const useAuth = () => {
const [isLogged, setIsLogged] = useState(tokenProvider.isLoggedIn());
useEffect(() => {
const listener = (newIsLogged: boolean) => {
setIsLogged(newIsLogged);
};
tokenProvider.subscribe(listener);
return () => {
tokenProvider.unsubscribe(listener);
};
}, []);
return [isLogged] as [typeof isLogged];
};
And that's all. We've just created compact and reusable token auth storage with clear API. In the next part, we will look at some usage examples.
Usage
To start to use what we implemented above, we need to create an instance of the auth provider. It will give us access to the functions useAuth()
, authFetch()
, login()
, logout()
related to the same token in the local storage (in general, nothing prevents you to create different instances of auth provider for different tokens, but you will need to parametrize the key you use to store data in the local storage):
export const {useAuth, authFetch, login, logout} = createAuthProvider();
Login form
Now we can start to use the functions we got. Let's start with the login form component. This component should provide inputs for the user's credentials and save it in the internal state. On submit we need to send a request with the credentials to get tokens and here we can use the function login()
to store received tokens:
const LoginComponent = () => {
const [credentials, setCredentials] = useState({
name: '',
password: ''
});
const onChange = ({target: {name, value}}: ChangeEvent<HTMLInputElement>) => {
setCredentials({...credentials, [name]: value})
};
const onSubmit = (event?: React.FormEvent) => {
if (event) {
event.preventDefault();
}
fetch('/login', {
method: 'POST',
body: JSON.stringify(credentials)
})
.then(r => r.json())
.then(token => login(token))
};
return <form onSubmit={onSubmit}>
<input name="name"
value={credentials.name}
onChange={onChange}/>
<input name="password"
value={credentials.password}
onChange={onChange}/>
</form>
};
And that's all, it's everything we need to store the token. After that, when a token is received, we will not need to apply extra effort to bring it to fetch or in components, because it is already implemented inside the auth provider.
Registration form is similar, there are only differences in the number and names of input fields, so I will omit it here.
Router
Also, we can implement routing using the auth provider. Let's assume that we have two packs of routes: one for the registered user and one for not registered. To split them we need to check do we have a token in local storage or not, and here we can use hook useAuth()
:
export const Router = () => {
const [logged] = useAuth();
return <BrowserRouter>
<Switch>
{!logged && <>
<Route path="/register" component={Register}/>
<Route path="/login" component={Login}/>
<Redirect to="/login"/>
</>}
{logged && <>
<Route path="/dashboard" component={Dashboard} exact/>
<Redirect to="/dashboard"/>
</>}
</Switch>
</BrowserRouter>;
};
And the nice thing that it will be rerendered after any changes in local storage, because of useAuth
has a subscription to these changes.
Fetch requests
And then we can get data protected by the token using authFetch
. It has the same interface as fetch, so if you already use fetch in the code you can simply replace it by authFetch
:
const Dashboard = () => {
const [posts, setPosts] = useState([]);
useEffect(() => {
authFetch('/posts')
.then(r => r.json())
.then(_posts => setPosts(_posts))
}, []);
return <div>
{posts.map(post => <div key={post.id}>
{post.message}
</div>)}
</div>
};
Summary
We did it. It was an interesting journey, but it also has the end (maybe even happy).
We started with the understanding of problems with storing authorization tokens. Then we implemented a solution and finally looked at the examples of how it might be used in the React application.
As I told before, you can find my implementation on GitHub in the library. It solves a bit more generic problem and does not make assumptions about the structure of the object with tokens or how to update the token, so you will need to provide some extra arguments. But the idea of the solution is the same and the repository also contains instructions on how to use it.
Here I can say Thank you for the reading of the article and I hope it was helpful for You.