Handling Authenticated Requests
Auth systems vary, but on the web, they typically they fall into one of two categories:
- Cookie-based auth
- Token-based auth
In some ways, cookie-based auth is more straightforward because the browser takes care of this for you by sending cookies along with your API requests. Token-based auth, on the other hand, typically means it's up to your app to retrieve, store, and pass along a token with API requests.
Both have their pros and cons which go beyond the scope of this book. However, for our purposes, I want to go through how you might expose your API SDK to the places in your app that need them, which is usually in your action creators.
It's nice to have a single place in your app that makes external API requests because this lets you also handle things like session expiration and failed API requests centrally.
Creating an API wrapper
It's nice to make an API SDK available to our action creators. As it turns out, this is made possible by the redux-thunk
middleware we mentioned in earlier chapters.
When instantiating your thunk middleware, you can pass it extra arguments like so:
import thunk from 'redux-thunk'
import { applyMiddleware, createStore } from 'redux'
import rootReducer from './root-reducer'
const somethingExtra = {}
const store = createStore(
rootReducer,
applyMiddleware(thunk.withExtraArgument({ somethingExtra }))
)
By doing this, we'll expose somethingExtra
to all of our action creators like this:
export const doSomething = () => (dispatch, getStore, { somethingExtra }) => {
// now we'll have access to these extra arguments here
console.log(somethingExtra)
}
This is a great way to pass around our API wrapper since most likely, all of our API calls are going to happen inside these action creators. By passing them into the function like this, we also make it easier to write unit tests. Our test can pass in a fake SDK object instead of having to mock and stub imports.
How do we do global error handling?
I like to create a little API fetch helper that knows the "base URL" we're accessing, handles JSON parsing, and always passes along any necessary authentication tokens, etc. Also, since APIs often treat failed authentication the same way, it's nice to handle failed authentication errors in one spot instead of having to remember to check for it every place we call the API.
First, let's write a simple little API wrapper to demonstrate what I mean:
export default (url, opts) => {
// if we were doing session-based auth, there
// would be no need for this part
const headers = {
token: getTokenFromLocalStorage()
}
const combinedOptions = Object.assign({}, { headers }, opts)
return (
fetch('https://our.base-api-url.com' + url, combinedOptions)
// let's assume we're always getting JSON back
.then(res => {
// here we can check for whatever the API does
// when it fails auth
if (res.statusCode === 401) {
throw Error('rejected')
}
return res.json()
})
.catch(err => {
// here we want to handle the special case
// where authentication failed, but how
// do we do that?
if (err.message === 'rejected') {
// NOW WHAT!?
}
// otherwise we just want to let our action creator
// handle the normal rejection
throw err
})
)
}
This starts to solve some problems for us because with this approach we can at least centrally handle authentication failures and reduce boilerplate a bit. We'll end up with something we can pass as an "extra argument" to our action creators.
But it also doesn't solve everything for us because it'd be nice if we could dispatch an action on the store to handle the logged out scenario. The problem is, this API helper doesn't have access to the store!
To do this, we're going to have to get a bit more creative. Functions returning functions to the rescue again!
Instead, what if we turn this API helper into a function that returns our API helper. Then we can pass an argument that handles auth failures, and we can do this somewhere we do have access to the store instance: when we first create it.
Let me demonstrate. First, we modify our API helper to be a function that returns an API helper.
// get-api-fetcher.js
// now we take a function as an argument
// and we'll return our API helper, which
// in turn, returns a promise *phew!*
export default onAuthFailure => (url, opts) => {
const headers = {
token: getTokenFromLocalStorage()
}
const combinedOptions = Object.assign({}, { headers }, opts)
return (
fetch('https://our.base-api-url.com' + url, combinedOptions)
// let's assume we're always getting JSON back
.then(res => {
// here we can check for whatever the API does
// when it fails auth
if (res.statusCode === 401) {
throw Error('rejected')
}
return res.json()
})
.catch(err => {
// Now we can call the function
// in this scenario
if (err.message === 'rejected') {
onAuthFailure()
return
}
// other wise we just want to handle our normal
// rejection
throw err
})
)
}
Now, when we create the store, we can handle the auth failure case centrally by dispatching an action to handle the things we may want to do when logging someone out:
// create-store.js
import thunk from 'redux-thunk'
import { applyMiddleware, createStore } from 'redux'
import rootReducer from './root-reducer'
import getApiFetcher from './get-api-fetcher'
import { doLogout } from './auth/action-creators'
const store = createStore(
rootReducer,
applyMiddleware(
thunk.withExtraArgument({
apiFetch: getApiFetcher(() => {
// here we have access to the store instance!
store.dispatch(doLogout())
})
})
)
)
Now, all of our action creators will be passed apiFetch
as an extra argument, and if it fails due to authentication issues, it will dispatch our doLogout
action creator on the store.
A note on the doLogout action creator
Generally speaking, if the user is, in fact, logged out and they have failed auth, we should do a bit of cleanup. I tend to create a single doLogout
action creator and then use that either when a user clicks a "sign out" button or we notice their authentication has expired.
Usually, my doLogout
action creator does a few things:
- Clear out the cache (see the previous chapter)
- Delete the old and expired auth token
- Force a browser refresh to clear in-memory data
Here's an example that I copied and pasted straight out of a real app and added comments to:
// this is a tiny utility that provides a prettier
// API for try/catch
import tryIt from 'try-it'
const doLogout = (broadcast = true) => ({ dispatch }) => {
dispatch({ type: actions.DO_LOGOUT })
tryIt(() => {
// as an extra precaution, I like to listen to "storage"
// events which can be used to communicate across tabs.
// In this way, I can listen for this change and use it
// to trigger `doLogout` in other open tabs.
if (broadcast) {
window.localStorage.logout = true
}
window.localStorage.clear()
})
// here, we simply wipe the cache entirely
// and then by assigning window.location
// it forces a full refresh, which will flush
// any potentially sensitive data from memory.
clearAllCached().then(() => {
window.location = '/'
})
}
Chapter recap
- For apps that require auth, it's nice to create a single API wrapper that we use everywhere.
- This wrapper can be used to pass along auth tokens, parse JSON responses, and centralize the handling of authentication failures.
- It's nice to be able to dispatch
doLogout()
action on the store in cases where auth fails. - Using a function that returns a function allows us to do this as part of creating the store.
- A
doLogout()
action creator should clean up all sensitive data and potentially signal other open tabs to do the same.