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:
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.