React Token Auth


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 registration
  • isLoggedIn() to check is the user logged in
  • subscribe() to give the provider a function that should be called after any token change
  • unsubscribe() 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.

Similar posts

Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 22

    0
    Извиняюсь, но мой английский еще не настолько хорош, чтобы грамотно сформулировать этот вопрос, поэтому я спрошу на русском.

    Спасибо за статью. Я тоже долгое время занимался данной задачей.
    Скажите что вы делаете в следующих случаях:
    1. Что происходит когда токен становится невалидным и его обновление проходит неудачно?
    2. Что если время на устройстве пользователя не совпадает с временем на сервере? Ведь тогда isExpired() может работать не корректно.
    3. Как быть если сайт открыт в двух вкладках одновременно и на одной из них токен обновился, а на другой нет? Ведь метод getToken() не проверяет наличие нового токена в localStorage.
    4. Что если при истекшем токене несколько запросов посылаются одновременно? Т. е. в этом случае для каждого из запросов isExpired() вернет false и для запустится обновление токена.
      0

      Рад, что сие пригодилось) По вопросам:


      1. Внутри метода setToken(token), в случае пустого токена localStoarge будет очищен, после чего все подписчики узнают, что сессия превратилась в тыкву. Пример кода из самой статьи немного упрощен, и если сервер будет присылать к примеру объекты с описание ошибки, они будут благополучно складываться под видом токенов. В коде на GitHub я оставил реализацию обновления токена пользователям библиотеки и ожидаю, что в случае провала обновления вернется null (onUpdateToken?: (token: T) => Promise<T | null>)
      2. В документации к JWT говорится, что exp должен быть типа NumericDate (rfc7519(4.1.4)), там же говорится, что NumericDate это "A JSON numeric value representing the number of seconds from 1970-01-01T00:00:00Z UTC until the specified UTC date/time, ignoring leap seconds". В общем все должно быть хорошо.
      3. Да, это хороший кейс, я его не учел. Чтобы его покрыть можно дополнительно подписаться на обновления localStorage. Данная подписка будет срабатывать только в том случае, когда обновление произошло в другой вкладке, собственно для синхронизации вкладок (Using the Web Storage API(MDN))
        0
        1. Понял. Спасибо.
        2. Насколько я понимаю это избавляет от проблем с разными часовыми поясами, но если на устройстве часы просто отстают, тогда же NumericDate на устройстве будет меньше чем на сервере?! Хотя синхронизацию времени наверное тоже надо оставить на совести пользователя.
        3. Спасибо. Я тоже пришел к такому варианту. Или же каждый раз брать токен из localStorage. Интересно было как вы это решили.
        4. Четвертый вопрос я дописал позже, и вы на него не ответили. )
          0
          по пункту 4. из опыта могу предложить такую схему:
          — обернуть все запросы в проверку токена (например с помощью промисов)
          — если токен истёк — сначала выполнится запрос на получение нового токена, потом запрос данных
          — тут важно, чтобы при нескольких запросах отправлялся только 1 запрос на получение нового токена, тоже реализуемо
            0
            1. Я осознал. Да, если выставить на клиенте ручками время значительно отличающееся от серверного, то все может поломаться (и мы к примеру будем бесконечно обновлять токены, что не есть хорошо). Я бы наверно исходил из потребностей в данном случае: если мы ожидаем, что расхождение спокойно может быть в несколько минут, простым решением будет полагать, что за n минут (например 1, 5, 10...) до истечения токена уже идти обновлять его; если нужно точнее, то можно спрашивать у сервера не абсолютное время истечения, а относительное (через столько-то секунд), тогда погрешность будет в величину ожидания ответа от сервера.
              Я еще осознал, что я упустил момент, когда мы отправили истекший токен на сервер и получили ошибку в ответе. Для решения этой проблемы к данному решению нужно допилить обновление токена на этапе проверки ответа от сервера.
            2. Спамить localStorage тоже не очень хорошо, лучше когда наш js спит и дергается только по необходимости.
            3. Да, этот кейс имеет смысл исправить даже в текущей реализации, если не забуду — поправлю. Сходу могу придумать вариант, где из метода getToken (который асинхронный) мы возвращаем промис, который резолвится только после окончания обновления токена:


              let isUpdating = false;
              let resolvers = [];
              const checkExpiry = async () => {
                  if (isExpired(_token)) {
                      isUpdating = true;
              
                      const newToken = await onUpdateToken(privateToken);
              
                      resolvers.forEach(resolver => resolver(newToken));
                      isUpdating = false;
                      resolvers = [];
              
                      ...
                  }
              };
              
              const getToken = async () => {
                  if (isUpdating) {
                      return new Promise(resolve => {
                          resolvers.push(resolve);
                      });
                  }
              
                  await checkExpiry();
              
                  ...
              };

              Но нужно быть очень аккуратным и обвесить все железными проверками на ексепшены =) а то можно начать терять промисы. Я думаю, если посидеть подумать, можно что-то поинтереснее придумать, но как пруф оф концепт должно работать)


              0
              1. Нумерация сбивается… (здесь должно быть 2.)
                В предыдущем ответе нумерация должна была начинаться с двойки… сорри, если ввел в заблуждение
        0
        Я на клиенте вообще не проверяю, что время токена вышло. Пришел запрос на сервер, если время вышло и refresh token жив, то отправляю в заголовке новый. Клиент смотрит на этот заголовок и если он не пустой, то сохраняет новый токен.
          0

          Правильно я понимаю, что для этого вам приходится в каждом запросе отправлять refresh token?

            –1
            refresh token зашит в JWT
              0
              Чтоооо??? 0_о Зачем он тогда вообще вам нужен? Refresh token нужен для повышения безопасности если украден access token. А у вас получается что если украли access token, то украли и refresh token.
                0
                Вот знал что прилетит. А Refresh token у Вас в блокнот записан, его никто не упрет? В моем случае refresh token определяет сессию. Также есть fingerprint клиента (хешь user agent + ip), тоже зашит в jwt. Допустим JWT уперли и начинают просится в гости, fingerprint не совпал сессия удаляется (вместе с refresh token).
                  0

                  Во первых не в блокнот. И упепеть его могут, но не так легко как access token.
                  Во вторых можно и без рефреш токена, но его тогда можно просто не обновлять. Зачем зашивать его в access?
                  Ну а отпечаток это хорошо и правильно, но к моему вопросу не относится.

                    0
                    Я не вижу причин этого не делать. У меня клиент отправляет только jwt, Нет заморочек с повторными запросами, перевыпусками токенов и т.д. Пользователь зашел в профиль увидел свою активность по сессиям (детальную). Увидел что то не то, дропул лишнее и живет спокойно.
                    Где вы храните access token и refresh token?
                      0
                      Я храню в localStorage или в куках — по разному.
                      Я не говорю что вы сделали что-то ужасное. Просто почему бы вам вообще не отказаться от refresh токена? Пусть будет только access и все.
                      Вот тогда у вас действительно не будет заморочек. А сейчас получается у вас возможен такой вариант:
                      1. Клиент отправит на сервер несколько запросов одновременно с устаревшим токеном
                      2. В запросе, который дойдет до сервера первым — будет заменен токен
                      3. Все остальные запросы будут отклонены, так как в них токен еще старый (истекший и с уже невалидным refresh токеном)
                        –1
                        refresh token нужен чтобы идентифицировать сессию. Убил в базе refresh, новый access не получишь и все access выписанные c этим refresh тоже не работают. refresh живет два месяца, затем пользователь заходит по логину и паролю, текущая удаляется, новый refresh.

                        Отправил пользователь 5ть запросов параллельно получил 5ть access токенов, если они в запросе не валидны. Все они на клиенте друг друга перезапишут. Далее будет валидный токен. Минус лишь в том что при каждом получении access токена идет запрос в базу. Если вы контролируете клиент, то не проблема. Если сторонняя компания использует api, то ограничиваете ее на сервере (баните большом количестве запросов с невалидным токеном).
                          0
                          ОМГ. Я понял. Ваш JWT это не JWT.
                            0
                            ОМГ, очень аргументированно. Может объясните что в Вашем понимании JWT?
                              0
                              Что такое JWT вы можете загуглить.
                              Объясню что меня удивляет в ваших ответах:
                              1. Вы пишете:
                              Минус лишь в том что при каждом получении access токена идет запрос в базу.

                              Т. е. логично предположить что при обычном запросе вы к базе не обращаетесь. И это правильно. От части это преимущество использования JWT. Но в то же время вы пишете:
                              Убил в базе refresh, новый access не получишь и все access выписанные c этим refresh тоже не работают.

                              Получается что вы всеравно каждый раз лезете в базу чтобы достать оттуда refresh токен для проверки access токена.

                              2. При использовании JWT refresh токен используется для выдачи нового токена, а для валидации access токена он не требуется, так как там сверяется сигнатура. Исходя из ваших слов — у вас не так.

                              3. При использовании JWT refresh токен является ОДНОРАЗОВЫМ. И его нужно беречь еще больше чем access токен, так как даже если у вас украдут только access токен, то пользоваться им смогут только пока он не устареет (обычно минут 15, а в вашем случае всего 2 месяца). А если украдут оба токена, то пользоваться ими смогут бесконечно.

                              4. Вы пишете:
                              Отправил пользователь 5ть запросов параллельно получил 5ть access токенов, если они в запросе не валидны.

                              Итак пришел первый запрос. Сервер формирует новый access (у вас refresh) токен — старый стирает. Получается что остальные запросы со старыми токенами должны быть невалидными так как в них зашит старый refresh токен. Каким образом получается что у вас они всеравно получат новые токены?
                                0
                                Если у Вас упрут access, то и упрут refresh и будут безконтрольно выписывать access токены. Если он конечно не в памяти или не в блокноте. В базу я лезу раз в 30 минут (можно чаще, можно реже). Это позволяет контролировать access токены. Пользователь имеет возможность закрыть лавочку по выписыванию токенов. В моем случае не смогут им пользоваться, т.к fingerprint сразу прикроет лавочку (можно делать более детальный fingerprint на клиенте). Refresh токен живет два месяца или пока пользователь сессию не закроет.

                                P.S. Простите что не по библейским законам живу, зато на клиенте не надо париться по выписыванию токенов.
          0
          Storing tokens in local storage creates security vulnerability and allow attacker to stole token via:
          — XSS attack
          — NPM supply chain attack (e.g. by injecting malicious code in NPM dependency and it may not even be your direct dependency like [your app] -> [direct dependecy] ->… -> [malicios module])
          — Browser extension supply chain attack or phishing attack (e.g. by injecting malicious code in existing extension or tricking user to install doubtful extension)

          I highly recomend to store tokens in cookies with HttpOnly flag, that will prevent all listed types of attacks
            0

            Но ведь cookies отправляются безусловно, на любой запрос соответствующий настройке cookie. Разве это не является бОльшей уязвимостью, чем все описанные выше?

              0

              Thank you for the comment, that's true, I forgot even to mention this problem in the article.
              The problem is that you may not always have own backend. And if the domains of the app and server differ you will not be able to use cookies.
              Even in this case, it will be better not to store token in the local storage, but in the memory. It is much safer if you ask the user to log in again after closing the app.


              But it is so annoying to ask people to input login/password every time, isn't it?)) And people are lazy… and what if we protected by VPN for example… is it enough to allow saving tokens in local storage? What your sales and information security department think about it? ^_^


              I think the documentation of oauth0 might be useful in this question. They give recommendations about how to store tokens: Store Tokens (Auth0 Docs)

            Only users with full accounts can post comments. Log in, please.