How to implement sliding session refreshes with Apollo and React
October 13, 2020
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 tokenfunction attachHeader(operation) {const token = window.localStorage.authToken// we can check our token hereoperation.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 * 60const 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 tokenreturn !(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 * 1000function isTokenValid() {const { exp } = JwtDecode(window.localStorage.authToken)const currentTime = new Date().getTime() / 1000return !(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 = falsefunction attachHeader(operation) {const token = window.localStorage.authTokenif (token) {const { exp } = JwtDecode(window.localStorage.authToken)const currentTime = new Date().getTime() / 1000const refreshTime = exp - REFRESH_40_MINUTES_FROM_EXPIRY// check for stale tokenif (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 = trueconst response = await client.mutate({mutation: REFRESH})const newToken = response.data.refresh.authTokenwindow.localStorage.authToken = newTokenrefreshing = 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 * 60const TOKEN_CHECK_TIME = 60 * 1000let refreshing = falseconst client = new ApolloClient({uri: process.env.REACT_APP_API_URL,request: attachHeader})const REFRESH = gql`mutation refresh {refresh {authToken}}`async function updateToken() {refreshing = trueconst response = await client.mutate({mutation: REFRESH})const newToken = response.data.refresh.authTokenwindow.localStorage.authToken = newTokenrefreshing = false}function attachHeader(operation) {const token = window.localStorage.authTokenif (token) {const { exp } = JwtDecode(window.localStorage.authToken)const currentTime = new Date().getTime() / 1000const refreshTime = exp - REFRESH_40_MINUTES_FROM_EXPIRYif (currentTime > refreshTime && !refreshing) {updateToken()}operation.setContext({headers: {authorization: `Bearer ${token}`}})}}function isTokenValid() {const { exp } = JwtDecode(window.localStorage.authToken)const currentTime = new Date().getTime() / 1000return !(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.