Protect the Routes
Overview
Now that we are authenticating with a provider we need to look at protecting our routes. This protection takes two major forms:
- Make some of our routes private so a user cannot navigate to them unless they are logged in.
- Protecting our backend API such that users cannot access data without a valid access token. Our role is to pass the access token to our API.
We will also see how to handle the possibility that our APIs may now issue 401 errors in cases where our access token has expired or is otherwise invalid.
We will build upon the application we created in the getting started tutorial in order to implement private routes for our application. We will also add HTTP interceptors to attach access tokens to outgoing requests and to handle potential 401 errors in responses.
Let's Code
As mentioned previously, this tutorial builds upon the application created when doing the getting started tutorial. If you have the code from when you performed that tutorial, then you are good to go. If you need the code you can make a copy from our GitHub repository.
Refactor Pages and Routing
Up to now we have taken a rather simplistic but unrealistic approach of performing the login from the Tab1Page
. Most applications would have a dedicated LoginPage
outside of the rest of the application. Let's refactor our application to have this.
Extract the Tabs
We will extract the tabs into their own component with each of pages within the tabs being served as a child of a newly created /tabs
route.
First create the src/routes/Tabs.tsx
component.
Next, refactor src/App.tsx
to use the new Tabs
component inside a /tabs
route, with other routes outside of it.
Notice that we now have two IonRouterOutlet
components in our application. The first is defined in src/App.tsx
and
defines all of our root-level routes (currently /tabs
and /auth-action-complete
). The second is defined in
src/routes/Tabs.tsx
and defines all of the routes under /tabs
.
Create a Login Page
We want to have a dedicated login page that is outside of the Tabs
so create a src/pages/LoginPage.tsx
file
with the following contents:
Notice that this is very similar to the current contents of src/pages/Tab1.tsx
with only a login button. The
login button click handler has the additional functionality of navigating to tabs/tab1
upon successful login.
Limit Tab1 to Logout
Now that we have a dedicated LoginPage
, modify src/pages/Tab1.tsx
such that it only performs a logout operation.
At this point:
- When we load the app, we are directed to
http://localhost:8100/tabs/tab1
and we can navigate from tab to tab. This is true regardless of whether or not we are logged in or not. - If we load
http://localhost:8100/login
we can perform a login operation and the application will navigate totabs/tab1
upon success. - If we are on
tabs/tab1
, we can press the logout button, but we remain ontabs/tab1
after logout.
We will address these behaviors in the next section by creating a PrivateRoute
component.
Private Route Component
The PrivateRoute
needs to subscribe to the session store. It will redirect to /login
when we are not logged in.
Otherwise it will display its child components. The code for this is:
_11import { ReactNode, useSyncExternalStore } from 'react';_11import { getSnapshot, subscribe } from '../utils/session-store';_11import { Redirect } from 'react-router-dom';_11_11type Props = { children?: ReactNode };_11_11export const PrivateRoute = ({ children }: Props) => {_11 const session = useSyncExternalStore(subscribe, getSnapshot);_11 if (!session) return <Redirect to="/login" />;_11 return <>{children}</>;_11};
We can now wrap the Tabs
component in src/App.tsx
with the PrivateRoute
component.
With this in place:
- When we load the app, we are redirected to either
http://localhost:8100/login
orhttp://localhost:8100/tabs/tab1
depending on our current authentication status. - When we press the
Logout
button ontabs/tab1
we are automatically redirected to theLoginPage
after the user is logged out.
At this point we move on to handling our our HTTP requests and responses.
Provide the Access Token
When a user logs in using Auth Connect the application receives an AuthResult
that represents the authentication session. The AuthResult
object provides access to several types of tokens:
- ID Token: The ID token contains information pertaining to the identity of the authenticated user. The information within this token is typically consumed by the client application.
- Access Token: The access token signifies that the user has properly authenticated. This token is typically sent to the application's backend APIs as a bearer token. The token is verified by the API to grant access to the protected resources exposed by the API. Since this token is used in communications with the backend API, a common security practice is to give it a very limited lifetime.
- Refresh Token: Since access tokens typically have a short lifetime, longer lived refresh tokens are used to extend the length of a user's authentication session by allowing the access tokens to be refreshed.
The most common way for the backend API to protect its routes is to require that requests include a valid access token. As such, we are going to have to send the access token with each request.
It is common to use a library like Axios to perform HTTP operations. Axios also makes it easy to modify outgoing requests via Interceptors. We will use these to add the access token to outbound HTTP requests. Note that this is just an example of the type of thing you need do. You do not have to use Axios, but can use whatever technology you would like to use.
We will create a utility module that manages an Axios client
for our backend API. Once that exists, we will add an interceptor to the client
that adds the access token to outbound requests.
We start with a very basic Axios client that connects to our backend API.
Create a stub for the interceptor. Since we want to modify the request, we need to attach the function to client.interceptors.request
.
Get the session.
If an access token exists, attach it to the header.
We start with a very basic Axios client that connects to our backend API.
Create a stub for the interceptor. Since we want to modify the request, we need to attach the function to client.interceptors.request
.
Get the session.
If an access token exists, attach it to the header.
If we have an access token, it will now be attached to any request that is sent to https://cs-demo-api.herokuapp.com
(our backend API). Note that you could have multiple APIs that all recognize the provided access token. In that case you would create various utility modules with similar code. Providing proper abstraction layers for such a scenario is left as an exercise for the reader.
If you have already implemented the code that refreshes the session, this interceptor is also a good place to perform a refresh. You would place that code right before getting the session to ensure that the session you are obtaining is fresh.
Handle 401 Errors
Now that the access token is sent to the backend, we need to also handle the case where the backend rejects the access token resulting in a 401 - Unauthorized response status. Since we need to examine the response, this interceptor is attached to client.interceptors.response
.
The interceptor needs to be built out to clear the session data and navigate to the login page when a 401 error occurs.
Start with the existing code in src/utils/backend-api.ts
.
Create a placeholder where we will add our interceptor code to client.interceptors.response
. Note that we do not do anything for a successful response.
We need to handle the error. For now just reject.
If a 401
error occurs, set the session to null
. This will clear the session and trigger our PrivateRoute
to
redirect to /login
if we are on a page that requires authentication.
Start with the existing code in src/utils/backend-api.ts
.
Create a placeholder where we will add our interceptor code to client.interceptors.response
. Note that we do not do anything for a successful response.
We need to handle the error. For now just reject.
If a 401
error occurs, set the session to null
. This will clear the session and trigger our PrivateRoute
to
redirect to /login
if we are on a page that requires authentication.
Next Steps
Currently, if we have an AuthResult
with an access token we assume the user is properly authenticated. If you would like to expand this logic to first make sure the access token has not expired, and try to refresh it if it has, then please have a look at the tutorial on refreshing the session.
Happy coding!! 🤓