How to implement sliding session refreshes with Apollo and React

October 13, 2020

ReactApolloJWT

Our team is helping to build Clinnect, a service for medical professionals and their staff to send and receive patient referrals. One of our security requirements is that user authentication expire after one hour.

It's easy to set an expiry time on a JWT. But that introduces some problems:

  • Users have to log in every hour
  • Unsaved data can be lost when authentication expires unexpectedly
  • We only notice the JWT is expired when a request to the server fails

To solve these problems, we need to silently refresh user authentication and periodically check if their authentication has expired so we can redirect them to the login page.

Our solution

When we make a request through the ApolloClient, we attach a header with the user's authorization information to every request. We call this function attachHeader, but it may have a different name in your code base. We can expand on the attachHeader function to check our current token and potentially replace it.

The attachHeader function is where we should refresh the JWT, but we also need to consider what to do when a user is inactive. We don't want the user to return to Clinnect, make a request, and then be redirected to the login page. So every minute, we'll check to see if the token is expired.

attachHeader will handle refreshing, and the setInterval check will log the user out.

Breaking down our solution

Here is our original attachHeader function:

./graphql/client.js

import ApolloClient from 'apollo-boost'
const client = new ApolloClient({
uri: process.env.REACT_APP_API_URL,
request: attachHeader
})
// all this version of attachHeader does is send the token
function attachHeader(operation) {
const token = window.localStorage.authToken
// we can check our token here
operation.setContext({
headers: {
authorization: token ? `Bearer ${token}` : ''
}
})
}
export default client

First, we want some constants for controlling our timing. Our tokens expire every hour, and we want to refresh them whenever we make a request nearer than 40 minutes from expiry.

We'll also need to periodically check to see if the token has expired. If we don't handle this, users returning to Clinnect will have to fail a request to the server instead of being redirected to the login page while away.

const REFRESH_40_MINUTES_FROM_EXPIRY = 40 * 60
const TOKEN_CHECK_TIME = 60 * 1000

Next, we'll need a mutation we can call to refresh the token. This refresh mutation should return a new token to us if we call it with authorization attached.

import gql from 'graphql-tag'
const REFRESH = gql`
mutation refresh {
refresh {
authToken
}
}
`

We'll also need a way to log the user out. In order to control which parts of Clinnect users can access, our React frontend keeps track of whether or not they're logged in. We can't directly modify state, so our only option is to redirect users to a page that will log them out.

To do this we create ./history.js which exports a browser history object.

import { createBrowserHistory } from 'history'
export default createBrowserHistory()

We use ./history.js in our Router.

import history from './history'
import client from './graphql/client'
ReactDOM.render(
<ApolloProvider client={client}>
<Router history={history}>
<App />
</Router>
</ApolloProvider>,
document.getElementById('root')
)

And we use history in ./graphql/client to log the user out.

import history from '../history'
// ...
history.push('/logout')
// ...

Let's start putting the pieces together. We need to check the token every minute, so we'll use setInterval and write a checkToken function for logging the user out if the token is nearing expiration.

function checkToken() {
// log the user out if the token is within a minute of expiring
}
setInterval(checkToken, TOKEN_CHECK_TIME)

The checkToken function calls isTokenValid, which returns false if the token is about to expire. If there is a token close to expiration, then the user is logged out.

import JwtDecode from 'jwt-decode'
function isTokenValid() {
const { exp } = JwtDecode(window.localStorage.authToken)
const currentTime = new Date().getTime() / 1000
// Check if token would be expired before next check, to ensure we don't make
// any requests with an invalid token
return !(currentTime + tokenCheckTime / 1000 > exp)
}
function checkToken() {
if (window.localStorage.authToken && !isTokenValid()) {
history.push('/logout')
}
}

This solution will handle tokens that expire while the user has the tab open. But it doesn't catch tokens that are seconds away from expiring when the user visits Clinnect. To catch this edge case, we'll call checkToken once on startup. All together, this is how we log the user out.

import JwtDecode from 'jwt-decode'
import history from '../history'
const TOKEN_CHECK_TIME = 60 * 1000
function isTokenValid() {
const { exp } = JwtDecode(window.localStorage.authToken)
const currentTime = new Date().getTime() / 1000
return !(currentTime + tokenCheckTime / 1000 > exp)
}
function checkToken() {
if (window.localStorage.authToken && !isTokenValid()) {
history.push('/logout')
}
}
setInterval(checkToken, TOKEN_CHECK_TIME)
checkToken()

Now we need a way to refresh the token.

When a user makes a request to the server, the token will be in one of two states:

  • Stale. We want to replace this token because it's nearing expiry.
  • Fresh. We don't need to replace any token younger than 20 minutes.

Because of our solution above, the token will never be expired. One thing we do have to consider is that calling refresh will mean attachHeader runs again. To avoid an infinite loop of refresh calls, we'll need a refreshing control variable.

import JwtDecode from 'jwt-decode'
let refreshing = false
function attachHeader(operation) {
const token = window.localStorage.authToken
if (token) {
const { exp } = JwtDecode(window.localStorage.authToken)
const currentTime = new Date().getTime() / 1000
const refreshTime = exp - REFRESH_40_MINUTES_FROM_EXPIRY
// check for stale token
if (currentTime > refreshTime && !refreshing) {
// TODO: handle update
}
operation.setContext({
headers: {
authorization: `Bearer ${token}`
}
})
}
}

The final piece is to handle the token refresh. To do this, we call refresh, are given a fresh token, and replace the current one we have saved. We can then set refreshing to false.

async function updateToken() {
refreshing = true
const response = await client.mutate({
mutation: REFRESH
})
const newToken = response.data.refresh.authToken
window.localStorage.authToken = newToken
refreshing = false
}
// ...
if (currentTime > refreshTime && !refreshing) {
updateToken()
}
// ...

Code in full

import gql from 'graphql-tag'
import ApolloClient from 'apollo-boost'
import JwtDecode from 'jwt-decode'
import history from '../history'
const REFRESH_40_MINUTES_FROM_EXPIRY = 40 * 60
const TOKEN_CHECK_TIME = 60 * 1000
let refreshing = false
const client = new ApolloClient({
uri: process.env.REACT_APP_API_URL,
request: attachHeader
})
const REFRESH = gql`
mutation refresh {
refresh {
authToken
}
}
`
async function updateToken() {
refreshing = true
const response = await client.mutate({
mutation: REFRESH
})
const newToken = response.data.refresh.authToken
window.localStorage.authToken = newToken
refreshing = false
}
function attachHeader(operation) {
const token = window.localStorage.authToken
if (token) {
const { exp } = JwtDecode(window.localStorage.authToken)
const currentTime = new Date().getTime() / 1000
const refreshTime = exp - REFRESH_40_MINUTES_FROM_EXPIRY
if (currentTime > refreshTime && !refreshing) {
updateToken()
}
operation.setContext({
headers: {
authorization: `Bearer ${token}`
}
})
}
}
function isTokenValid() {
const { exp } = JwtDecode(window.localStorage.authToken)
const currentTime = new Date().getTime() / 1000
return !(currentTime + TOKEN_CHECK_TIME / 1000 > exp)
}
function checkToken() {
if (window.localStorage.authToken && !isTokenValid()) {
history.push('/logout')
}
}
checkToken()
setInterval(checkToken, TOKEN_CHECK_TIME)
export default client

Have a hard problem?

We built this solution for Clinnect, a secure online portal for physicians to send and receive patient referrals. If you're looking to solve hard problems, we can help. Reach out to us at info@twostoryrobot.com and we’ll chat.

You can learn more about how Clinnect is changing referrals from the podcast Fixing Faxes hosted by Angela Hapke and Jonathan Bowers.