Skip to main content
Version: 5.0

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:

  1. Make some of our routes private so a user cannot navigate to them unless they are logged in.
  2. 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.

src/routes/Tabs.tsx

_51
import {
_51
IonIcon,
_51
IonLabel,
_51
IonRouterOutlet,
_51
IonTabBar,
_51
IonTabButton,
_51
IonTabs,
_51
} from '@ionic/react';
_51
import { ellipse, square, triangle } from 'ionicons/icons';
_51
import { Redirect, Route, useRouteMatch } from 'react-router-dom';
_51
import Tab1 from '../pages/Tab1';
_51
import Tab2 from '../pages/Tab2';
_51
import Tab3 from '../pages/Tab3';
_51
_51
const Tabs: React.FC = () => {
_51
const { url } = useRouteMatch();
_51
_51
return (
_51
<IonTabs>
_51
<IonRouterOutlet>
_51
<Route exact path={`${url}/tab1`}>
_51
<Tab1 />
_51
</Route>
_51
<Route exact path={`${url}/tab2`}>
_51
<Tab2 />
_51
</Route>
_51
<Route exact path={`${url}/tab3`}>
_51
<Tab3 />
_51
</Route>
_51
<Route exact path={url}>
_51
<Redirect to={`${url}/tab1`} />
_51
</Route>
_51
</IonRouterOutlet>
_51
<IonTabBar slot="bottom">
_51
<IonTabButton tab="tab1" href={`${url}/tab1`}>
_51
<IonIcon aria-hidden="true" icon={triangle} />
_51
<IonLabel>Tab 1</IonLabel>
_51
</IonTabButton>
_51
<IonTabButton tab="tab2" href={`${url}/tab2`}>
_51
<IonIcon aria-hidden="true" icon={ellipse} />
_51
<IonLabel>Tab 2</IonLabel>
_51
</IonTabButton>
_51
<IonTabButton tab="tab3" href={`${url}/tab3`}>
_51
<IonIcon aria-hidden="true" icon={square} />
_51
<IonLabel>Tab 3</IonLabel>
_51
</IonTabButton>
_51
</IonTabBar>
_51
</IonTabs>
_51
);
_51
};
_51
export default Tabs;

Next, refactor src/App.tsx to use the new Tabs component inside a /tabs route, with other routes outside of it.

src/App.tsx

_49
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react';
_49
import { IonReactRouter } from '@ionic/react-router';
_49
import { Redirect, Route } from 'react-router-dom';
_49
import AuthActionCompletePage from './pages/AuthActionCompletePage';
_49
import { AuthenticationProvider } from './providers/AuthenticationProvider';
_49
_49
/* Core CSS required for Ionic components to work properly */
_49
import '@ionic/react/css/core.css';
_49
_49
/* Basic CSS for apps built with Ionic */
_49
import '@ionic/react/css/normalize.css';
_49
import '@ionic/react/css/structure.css';
_49
import '@ionic/react/css/typography.css';
_49
_49
/* Optional CSS utils that can be commented out */
_49
import '@ionic/react/css/display.css';
_49
import '@ionic/react/css/flex-utils.css';
_49
import '@ionic/react/css/float-elements.css';
_49
import '@ionic/react/css/padding.css';
_49
import '@ionic/react/css/text-alignment.css';
_49
import '@ionic/react/css/text-transformation.css';
_49
_49
/* Theme variables */
_49
import Tabs from './routes/Tabs';
_49
import './theme/variables.css';
_49
_49
setupIonicReact();
_49
_49
const App: React.FC = () => (
_49
<IonApp>
_49
<AuthenticationProvider>
_49
<IonReactRouter>
_49
<IonRouterOutlet>
_49
<Route path="/tabs">
_49
<Tabs />
_49
</Route>
_49
<Route path="/auth-action-complete">
_49
<AuthActionCompletePage />
_49
</Route>
_49
<Route exact path="/">
_49
<Redirect to="/tabs" />
_49
</Route>
_49
</IonRouterOutlet>
_49
</IonReactRouter>
_49
</AuthenticationProvider>
_49
</IonApp>
_49
);
_49
_49
export default App;

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:

src/pages/LoginPage.tsx

_40
import {
_40
IonButton,
_40
IonContent,
_40
IonHeader,
_40
IonPage,
_40
IonTitle,
_40
IonToolbar,
_40
} from '@ionic/react';
_40
import { login } from '../utils/authentication';
_40
import { useHistory } from 'react-router';
_40
_40
const LoginPage: React.FC = () => {
_40
const history = useHistory();
_40
return (
_40
<IonPage>
_40
<IonHeader>
_40
<IonToolbar>
_40
<IonTitle>Login</IonTitle>
_40
</IonToolbar>
_40
</IonHeader>
_40
<IonContent fullscreen>
_40
<IonHeader collapse="condense">
_40
<IonToolbar>
_40
<IonTitle size="large">Login</IonTitle>
_40
</IonToolbar>
_40
</IonHeader>
_40
<IonButton
_40
onClick={async () => {
_40
await login();
_40
history.push('tabs/tab1');
_40
}}
_40
>
_40
Login
_40
</IonButton>
_40
</IonContent>
_40
</IonPage>
_40
);
_40
};
_40
_40
export default LoginPage;

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.

src/pages/Tab1.tsx

_32
import {
_32
IonButton,
_32
IonContent,
_32
IonHeader,
_32
IonPage,
_32
IonTitle,
_32
IonToolbar,
_32
} from '@ionic/react';
_32
import { logout } from '../utils/authentication';
_32
import './Tab1.css';
_32
_32
const Tab1: React.FC = () => {
_32
return (
_32
<IonPage>
_32
<IonHeader>
_32
<IonToolbar>
_32
<IonTitle>Tab 1</IonTitle>
_32
</IonToolbar>
_32
</IonHeader>
_32
<IonContent fullscreen>
_32
<IonHeader collapse="condense">
_32
<IonToolbar>
_32
<IonTitle size="large">Tab 1</IonTitle>
_32
</IonToolbar>
_32
</IonHeader>
_32
<IonButton onClick={logout}>Logout</IonButton>
_32
</IonContent>
_32
</IonPage>
_32
);
_32
};
_32
_32
export default Tab1;

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 to tabs/tab1 upon success.
  • If we are on tabs/tab1, we can press the logout button, but we remain on tabs/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:


_11
import { ReactNode, useSyncExternalStore } from 'react';
_11
import { getSnapshot, subscribe } from '../utils/session-store';
_11
import { Redirect } from 'react-router-dom';
_11
_11
type Props = { children?: ReactNode };
_11
_11
export 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.

src/App.tsx

_56
import { IonApp, IonRouterOutlet, setupIonicReact } from '@ionic/react';
_56
import { IonReactRouter } from '@ionic/react-router';
_56
import { Redirect, Route } from 'react-router-dom';
_56
import AuthActionCompletePage from './pages/AuthActionCompletePage';
_56
import { AuthenticationProvider } from './providers/AuthenticationProvider';
_56
_56
/* Core CSS required for Ionic components to work properly */
_56
import '@ionic/react/css/core.css';
_56
_56
/* Basic CSS for apps built with Ionic */
_56
import '@ionic/react/css/normalize.css';
_56
import '@ionic/react/css/structure.css';
_56
import '@ionic/react/css/typography.css';
_56
_56
import { PrivateRoute } from './routes/PrivateRoute';

With this in place:

  • When we load the app, we are redirected to either http://localhost:8100/login or http://localhost:8100/tabs/tab1 depending on our current authentication status.
  • When we press the Logout button on tabs/tab1 we are automatically redirected to the LoginPage 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.

Terminal

_10
npm install axios

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.

src/utils/backend-api.ts

_13
import axios from 'axios';
_13
_13
const baseURL = 'https://cs-demo-api.herokuapp.com';
_13
_13
const client = axios.create({
_13
baseURL,
_13
headers: {
_13
Accept: 'application/json',
_13
'Content-Type': 'application/json',
_13
},
_13
});
_13
_13
export { client };

We start with a very basic Axios client that connects to our backend API.

src/utils/backend-api.ts

_17
import axios, { InternalAxiosRequestConfig } from 'axios';
_17
_17
const baseURL = 'https://cs-demo-api.herokuapp.com';
_17
_17
const client = axios.create({
_17
baseURL,
_17
headers: {
_17
Accept: 'application/json',
_17
'Content-Type': 'application/json',
_17
},
_17
});
_17
_17
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
_17
return config;
_17
});
_17
_17
export { client };

Create a stub for the interceptor. Since we want to modify the request, we need to attach the function to client.interceptors.request.

src/utils/backend-api.ts

_19
import axios, { InternalAxiosRequestConfig } from 'axios';
_19
import { getSnapshot } from './session-store';
_19
_19
const baseURL = 'https://cs-demo-api.herokuapp.com';
_19
_19
const client = axios.create({
_19
baseURL,
_19
headers: {
_19
Accept: 'application/json',
_19
'Content-Type': 'application/json',
_19
},
_19
});
_19
_19
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
_19
const session = getSnapshot();
_19
return config;
_19
});
_19
_19
export { client };

Get the session.

src/utils/backend-api.ts

_22
import axios, { InternalAxiosRequestConfig } from 'axios';
_22
import { getSnapshot } from './session-store';
_22
_22
const baseURL = 'https://cs-demo-api.herokuapp.com';
_22
_22
const client = axios.create({
_22
baseURL,
_22
headers: {
_22
Accept: 'application/json',
_22
'Content-Type': 'application/json',
_22
},
_22
});
_22
_22
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
_22
const session = getSnapshot();
_22
if (session?.accessToken && config.headers) {
_22
config.headers.Authorization = `Bearer ${session.accessToken}`;
_22
}
_22
return config;
_22
});
_22
_22
export { client };

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.

src/utils/backend-api.ts

_13
import axios from 'axios';
_13
_13
const baseURL = 'https://cs-demo-api.herokuapp.com';
_13
_13
const client = axios.create({
_13
baseURL,
_13
headers: {
_13
Accept: 'application/json',
_13
'Content-Type': 'application/json',
_13
},
_13
});
_13
_13
export { client };

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.

src/utils/backend-api.ts

_22
import axios, { InternalAxiosRequestConfig } from 'axios';
_22
import { getSnapshot } from './session-store';
_22
_22
const baseURL = 'https://cs-demo-api.herokuapp.com';
_22
_22
const client = axios.create({
_22
baseURL,
_22
headers: {
_22
Accept: 'application/json',
_22
'Content-Type': 'application/json',
_22
},
_22
});
_22
_22
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
_22
const session = getSnapshot();
_22
if (session?.accessToken && config.headers) {
_22
config.headers.Authorization = `Bearer ${session.accessToken}`;
_22
}
_22
return config;
_22
});
_22
_22
export { client };

Start with the existing code in src/utils/backend-api.ts.

src/utils/backend-api.ts

_24
import axios, { InternalAxiosRequestConfig } from 'axios';
_24
import { getSnapshot } from './session-store';
_24
_24
const baseURL = 'https://cs-demo-api.herokuapp.com';
_24
_24
const client = axios.create({
_24
baseURL,
_24
headers: {
_24
Accept: 'application/json',
_24
'Content-Type': 'application/json',
_24
},
_24
});
_24
_24
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
_24
const session = getSnapshot();
_24
if (session?.accessToken && config.headers) {
_24
config.headers.Authorization = `Bearer ${session.accessToken}`;
_24
}
_24
return config;
_24
});
_24
_24
client.interceptors.response.use((response) => response);
_24
_24
export { client };

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.

src/utils/backend-api.ts

_29
import axios, { InternalAxiosRequestConfig } from 'axios';
_29
import { getSnapshot } from './session-store';
_29
_29
const baseURL = 'https://cs-demo-api.herokuapp.com';
_29
_29
const client = axios.create({
_29
baseURL,
_29
headers: {
_29
Accept: 'application/json',
_29
'Content-Type': 'application/json',
_29
},
_29
});
_29
_29
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
_29
const session = getSnapshot();
_29
if (session?.accessToken && config.headers) {
_29
config.headers.Authorization = `Bearer ${session.accessToken}`;
_29
}
_29
return config;
_29
});
_29
_29
client.interceptors.response.use(
_29
(response) => response,
_29
(error) => {
_29
return Promise.reject(error);
_29
}
_29
);
_29
_29
export { client };

We need to handle the error. For now just reject.

src/utils/backend-api.ts

_33
export { client };
_33
import axios, { InternalAxiosRequestConfig } from 'axios';
_33
import { getSnapshot, setSession } from './session-store';
_33
_33
const baseURL = 'https://cs-demo-api.herokuapp.com';
_33
_33
const client = axios.create({
_33
baseURL,
_33
headers: {
_33
Accept: 'application/json',
_33
'Content-Type': 'application/json',
_33
},
_33
});
_33
_33
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
_33
const session = getSnapshot();
_33
if (session?.accessToken && config.headers) {
_33
config.headers.Authorization = `Bearer ${session.accessToken}`;
_33
}
_33
return config;
_33
});
_33
_33
client.interceptors.response.use(
_33
(response) => response,
_33
(error) => {
_33
if (error.response.status === 401) {
_33
setSession(null);
_33
}
_33
return Promise.reject(error);
_33
}
_33
);
_33
_33
export { client };

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.

src/utils/backend-api.ts

_22
import axios, { InternalAxiosRequestConfig } from 'axios';
_22
import { getSnapshot } from './session-store';
_22
_22
const baseURL = 'https://cs-demo-api.herokuapp.com';
_22
_22
const client = axios.create({
_22
baseURL,
_22
headers: {
_22
Accept: 'application/json',
_22
'Content-Type': 'application/json',
_22
},
_22
});
_22
_22
client.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
_22
const session = getSnapshot();
_22
if (session?.accessToken && config.headers) {
_22
config.headers.Authorization = `Bearer ${session.accessToken}`;
_22
}
_22
return config;
_22
});
_22
_22
export { client };

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!! 🤓