use-eazy-auth: a library for simple auth management in React

by Giovanni Fumagalli - 11/01/2021
React

Authorizing and profiling users is a common use case in web applications. In this post we'll show how we handle user authentication in React with a use-eazy-auth, a library we use in all our React projects at INMAGIK, with advanced features and patterns such as:

  • custom hooks for dealing with user and auth state
  • user persistence (localstorage/session storage)
  • integration with react-router
  • custom refresh policies
  • support for both RxJS and Promises

The library is well tested, has a simple api, is highly configurable and has been recently re-written in typescript.

Authentication flow

The main feature of the library is handle user authentication against some external service, with a token based approach: we use a login function (loginCall) to obtain a token that will be used to perform authenticated requests. The login function accepts some generic credentials (such as username and password). Once the user is logged in, another function (meCall) is called in order to get information about the current user.

Token will be persisted in some storage (localstorage or similar). In this case, at the application boot, the meCall is also used to check if the token is still valid. The library can support refresh token functionality via configuration of another function referred as refreshCall.

When an user logged out or is kicked out from a 401 we erase tokens from storage. The default storage is window.localStorage but you can customize it passing an interface with the same api, plus the storage method can be async to support react-native AsyncStorage with no additional effort.

The library offers React hooks to access the current authentication state and user profile.

use-eazy-auth use the following authentication flow:

Eazy Auth Flow

Configuration

In order to use use-eazy-auth you must wrap the authenticated part of your React app with the default export from the library, the <Auth/> component, that must be configured with a few props to implement the authentication flow described above.

loginCall

When a user try to login we use a loginCall to determinate if given credentials are good. The function accepts some credentials object (of any shape) and must return an object with a mandatory accessToken key, and a refreshToken optional key.

The signature is the following:

interface AuthTokens {
  accessToken: any
  refreshToken?: any
}

type LoginCall (loginCredentials: any) => Promise<AuthTokens> | Observable<AuthTokens>

meCall

To determimante if a token is good and to fetch the user profile, we use a meCall with the following signature:

type MeCall = (accessToken: any, loginResponse?: any) => Promise | Observable

refreshTokenCall

Optionally, we can configure a refreshTokenCall to try refreshing your token. (The refresh call is optional if isn't provided we skip the refresh).

type RefreshTokenCall= (refreshToken: any) => Promise<AuthTokens> | Observable<AuthTokens>

A quick example

Ok, stop talking ... let's code!

This is a tipical use-eazy-auth setup.

We use window.fetch but you can use rxjx/ajax or your favorite Promise based fetching library as well.

// src/App.js
import Auth from 'use-eazy-auth'

const loginCall = ({ email, password }) => fetch('/api/login', {
  method: 'POST',
  body: JSON.stringify({ email, password }),
  headers: {
    'Content-Type': 'application/json'
  },
}).then(response => response.json()).then(data => ({
  // NOTE: WE MUST ENFORCE THIS SHAPE! In order to make use-eazy-auth
  // understand your data!
  accessToken: data.access,
  // NOTE: We can omit refreshToken if we don't have a refreshTokenCall
  refreshToken: data.refresh,
}))

const meCall = (accessToken) => fetch('/api/me', {
  headers: {
    'Authorization': `Token ${accessToken}`
  },
}).then(response => response.json())

const refreshTokenCall = (refreshToken) => fetch('/api/refresh', {
  method: 'POST',
  body: JSON.stringify({ token: refreshToken }),
  headers: {
    'Content-Type': 'application/json'
  },
}).then(response => response.json()).then(data => ({
  // NOTE: WE MUST ENFORCE THIS SHAPE! In order to make use-eazy-auth
  // understand your data!
  accessToken: data.access,
  refreshToken: data.refresh,
}))


const App = () => (
  <Auth
    loginCall={loginCall}
    meCall={meCall}
    refreshTokenCall={refreshTokenCall}
  >
    {/* The rest of your app */}
  </App>
)

export default App

Views and routing setup

Ok, we see how to configure use-eazy-auth but in a typical app we have different pages:

  • a page where you can login
  • a page where you MUST be authenticated (a profile page)
  • a page were you CAN be authenticated or not, for example the home page of an ecommerce.

Lucky you use-eazy-auth ships with the popular react-router library integration.

Let's try to implement a based ecomerce layout with use-eazy-auth. We'll also see the various provided by the library in action.

// src/App.js
import Auth from 'use-eazy-auth'
import { BrowserRouter as Router } from 'react-router-dom'
import { AuthRoute, GuestRoute, MaybeAuthRoute } from 'use-eazy-auth/routes'
import { meCall, refreshTokenCall, loginCall } from './authCalls'
import Login from './pages/Login'
import Cart from './pages/Cart'
import Home from './pages/Home'

const App = () => (
  <Auth
    loginCall={loginCall}
    meCall={meCall}
    refreshTokenCall={refreshTokenCall}
  >
    <Router>
      <AuthRoute
        // Guest user go to /login
        redirectTo='/login'
        path='/cart'
      >
        <Cart />
      </AuthRoute>
      <GuestRoute
        // Authenticated user go to /
        redirectTo='/'
        path='/login'
      >
        <Login />
      </GuestRoute>
      <MaybeAuthRoute path='/' exact>
        <Home />
      </MaybeAuthRoute>
    </Router>
  </App>
)

Ok, now let's implement the login page using the use-eazy-auth hooks.

// src/pages/Login.js
import { useEffect, useState } from "react"
import { useAuthActions, useAuthState } from "use-eazy-auth"

export default function Login() {
  const { loginLoading, loginError } = useAuthState()
  const { login, clearLoginError } = useAuthActions()

  // Clear login error when Login component unmount
  useEffect(() => () => clearLoginError(), [clearLoginError])

  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")

  return (
    <form
      onSubmit={e => {
        e.preventDefault()
        if (email !== "" && password !== "") {
          login({ email, password })
        }
      }}
    >
      <div>
        <div>
          <h1>Login</h1>
        </div>
        <div>
          <input
            placeholder="@email"
            type="text"
            value={email}
            onChange={e => {
              clearLoginError()
              setEmail(e.target.value)
            }}
          />
        </div>
        <div>
          <input
            placeholder="password"
            type="password"
            value={password}
            onChange={e => {
              clearLoginError()
              setPassword(e.target.value)
            }}
          />
        </div>
        <button disabled={loginLoading}>
          {!loginLoading ? "Login!" : "Logged in..."}
        </button>
        {loginError && <div>Bad combination of email and password.</div>}
      </div>
    </form>
  )
}
// src/pages/Cart.js
import { useAuthUser, useAuthState, useAuthActions } from "use-eazy-auth"

export default function Cart() {
  const { logout } = useAuthActions()
  const { user } = useAuthUser()

  return (
    <div>
      <button onClick={() => logout()}>Logout</button>
      <p>
        The {user.name}'s cart.
      <p>
      {/* ... */}
    </div>
  )
}
// src/pages/Home.js
import { useAuthActions, useAuthState } from "use-eazy-auth"

export default function Home() {
  const { authenticated } = useAuthState()
  const { user } = useAuthUser()

  return (
    <div>
      {authenticated ? 'Welcome Guest' : `Welcome ${user.name}`}
      {/* ... */}
    </div>
  )
}

Data fetching integration

use-eazy-auth provides two wrappers for making authenticated API calls, one for returning Promises and one to deal with RxJs Observables.

These wrappers allow the library to handle expired or invalid token within the authentication flow. When the actual call to authenticated APIs rejects with a status code 401 (unauthorized): first a token refresh is attempted (if refreshCall is configured). If the refresh succeeds, the API call is replied with the new token, otherwise the user is logged out.

In the example above, we used fetch as a data fetching function, let's see how this could have been done with other popular solutions such as SWR, react-query and react-rocketjump.

SWR

import useSWR, { SWRConfig } from 'swr'
import { useAuthActions } from 'use-eazy-auth'
import { meCall, refreshTokenCall, loginCall } from './authCalls'

function Dashboard() {
  const { data: todos } = useSWR('/api/todos')
  // ...
}

function ConfigureAuthFetch({ children }) {
  const { callAuthApiPromise } = useAuthActions()
  return (
    <SWRConfig
      value={{
        fetcher: (...args) =>
          callAuthApiPromise(
            token => (url, options) =>
              fetch(url, {
                ...options,
                headers: {
                  ...options?.headers,
                  Authorization: `Bearer ${token}`,
                },
              })
                // NOTE: Use eazy auth needs a Rejection with shape:
                // { status: number }
                .then(res => (res.ok ? res.json() : Promise.reject(res))),
            ...args
          ),
      }}
    >
      {children}
    </SWRConfig>
  )
}

function App() {
  return (
    <Auth loginCall={login} meCall={me} refreshTokenCall={refresh}>
      <ConfgureAuthFetch>
        <Dashboard />
      </ConfgureAuthFetch>
    </Auth>
  )
}

react-query

import { useQuery } from 'react-query'
import { useAuthActions } from 'use-eazy-auth'

export default function Dashboard() {
  const { callAuthApiPromise } = useAuthActions()
  const { data: todos } = useQuery(['todos'], () =>
    callAuthApiPromise((token) => () =>
      fetch(`/api/todos`, {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      }).then((res) => (res.ok ? res.json() : Promise.reject(res)))
    )
  )
  // ...
}

react-rocketjump

import { ConfigureRj, rj, useRunRj } from 'react-rocketjump'
import { useAuthActions } from "use-eazy-auth"

const Todos = rj({
  effectCaller: rj.configured(),
  effect: (token) => () =>
    fetch(`/api/todos/`, {
      headers: {
        Authorization: `Bearer ${token}`,
      },
    }).then((res) => (res.ok ? res.json() : Promise.reject(res))),
})

export default function Dashboard() {
  const [{ data: todos }] = useRunRj(Todos)
  // ...
}

function ConfigureAuthFetch({ children }) {
  const { callAuthApiObservable } = useAuthActions()
  // NOTE: react-rocketjump supports RxJs Observables
  return (
    <ConfigureRj effectCaller={callAuthApiObservable}>
      {children}
    </ConfigureRj>
  )
}

function App() {
  return (
    <Auth loginCall={login} meCall={me} refreshTokenCall={refresh}>
      <ConfgureAuthFetch>
        <Dashboard />
      </ConfgureAuthFetch>
    </Auth>
  )
}

Final notes

This was an introduction to use-eazy-auth, please visit the project page for more info.

We're working on another post about integration of a React frontend and a Django (python) backend with use-eazy-auth.