Getting Started with Auth Connect in @ionic/react
In this tutorial we will walk through the basic setup and use of Ionic's Auth Connect in an @ionic/react
application.
In this tutorial, you will learn how to:
- Install and configure Auth Connect
- Perform Login and Logout operations
- Check if the user is authenticated
- Obtain the tokens from Auth Connect
- Integrate Identity Vault with Auth Connect
The source code for the Ionic application created in this tutorial can be found here
Generate the Application
The first step to take is to generate the application:
_10ionic start getting-started-ac-react tabs --type=react
Now that the application has been generated, let's also add the iOS and Android platforms.
Open the capacitor.config.ts
file and change the appId
to something unique like io.ionic.gettingstartedacreact
:
_10import { CapacitorConfig } from "@capacitor/cli";_10_10const config: CapacitorConfig = {_10 appId: "io.ionic.gettingstartedacreact",_10 appName: "getting-started-ac-react",_10 webDir: "build",_10 bundledWebRuntime: false,_10};_10_10export default config;
Next, open index.tsx
and remove React's strict mode. Your root should only render <App />
like so:
_10..._10const container = document.getElementById("root");_10const root = createRoot(container!);_10root.render(<App />);_10...
Then, build the application, then install and create the platforms:
_10npm run build_10ionic cap add android_10ionic cap add ios
Finally, in order to ensure that a cap sync
is run with each build, add it to the build script in the package.json
file as such:
_10"scripts": {_10 "build": "react-scripts build && cap sync",_10 ..._10},
Install Auth Connect
In order to install Auth Connect, you will need to use ionic enterprise register
to register your product key. This will create a .npmrc
file containing the product key.
If you have already performed that step for your production application, you can just copy the .npmrc
file from your production project. Since this application is for learning purposes only, you don't need to obtain another key.
You can now install Auth Connect and sync the platforms:
_10npm install @ionic-enterprise/auth
Configure Auth Connect
Our next step is to configure Auth Connect. Create a file named src/providers/AuthProvider.tsx
and fill it with the following boilerplate content:
_20import { ProviderOptions } from "@ionic-enterprise/auth";_20import { isPlatform } from "@ionic/react";_20import { PropsWithChildren, createContext } from "react";_20_20const isNative = isPlatform("hybrid");_20_20const options: ProviderOptions = {_20 clientId: "",_20 discoveryUrl: "",_20 scope: "openid offline_access",_20 audience: "",_20 redirectUri: isNative ? "" : "",_20 logoutUrl: isNative ? "" : "",_20};_20_20export const AuthContext = createContext<{}>({});_20_20export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {_20 return <AuthContext.Provider value={{}}>{children}</AuthContext.Provider>;_20};
Open src/App.tsx
and wrap IonReactRouter
with the provider:
_14..._14import { AuthProvider } from './providers/AuthProvider';_14_14setupIonicReact();_14_14const App: React.FC = () => (_14 <IonApp>_14 <AuthProvider>_14 <IonReactRouter>_14 ..._14 </IonReactRouter>_14 </AuthProvider>_14 </IonApp>_14);
Auth Connect Options
The options
object is passed to the login()
function when we establish the authentication session. As you can see, there are several items that we need to fill in. Specifically: audience
, clientId
, scope
, discoveryUrl
, redirectUri
, and logoutUrl
.
Obtaining this information likely takes a little coordination with whoever administers our backend services. In our case, we have a team that administers our Auth0 services and they have given us the following information:
- Application ID:
yLasZNUGkZ19DGEjTmAITBfGXzqbvd00
- Audience:
https://io.ionic.demo.ac
- Metadata Document URL:
https://dev-2uspt-sz.us.auth0.com/.well-known/openid-configuration
- Web Redirect (for development):
http://localhost:8100/login
- Native Redirect (for development):
msauth://login
- Additional Scopes:
email picture profile
Translating that into our configuration object, we now have this:
_10const options: ProviderOptions = {_10 audience: "https://io.ionic.demo.ac",_10 clientId: "yLasZNUGkZ19DGEjTmAITBfGXzqbvd00",_10 discoveryUrl:_10 "https://dev-2uspt-sz.us.auth0.com/.well-known/openid-configuration",_10 logoutUrl: isNative ? "msauth://login" : "http://localhost:8100/login",_10 redirectUri: isNative ? "msauth://login" : "http://localhost:8100/login",_10 scope: "openid offline_access email picture profile",_10};
The web redirect for development is on port 8100
. React uses port 3000
by default, so we will need to make a minor change to our package.json
file as well:
_10"scripts": {_10 "start": "export PORT=8100 && react-scripts start",_10 ..._10},
Note: you can use your own configuration for this tutorial as well. However, we suggest that you start with our configuration, get the application working, and then try your own configuration after that.
Initialization
Before we can use any AuthConnect
functions we need to make sure we have performed the initialization. Add the code to do this after the setting of the options
value in src/providers/AuthProvider.tsx
.
_34import { AuthConnect, ProviderOptions } from "@ionic-enterprise/auth";_34import { isPlatform } from "@ionic/react";_34import { PropsWithChildren, createContext, useState, useEffect } from "react";_34_34const isNative = isPlatform("hybrid");_34_34const options: ProviderOptions = {_34 // see the options setting above_34};_34_34const setupAuthConnect = async (): Promise<void> => {_34 return AuthConnect.setup({_34 platform: isNative ? "capacitor" : "web",_34 logLevel: "DEBUG",_34 ios: { webView: "private" },_34 web: { uiMode: "popup", authFlow: "implicit" },_34 });_34};_34_34export const AuthContext = createContext<{}>({});_34_34export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {_34 const [isSetup, setIsSetup] = useState<boolean>(false);_34_34 useEffect(() => {_34 setupAuthConnect().then(() => setIsSetup(true));_34 }, []);_34_34 return (_34 <AuthContext.Provider value={{}}>_34 {isSetup && children}_34 </AuthContext.Provider>_34 );_34};
This will get Auth Connect ready to use within our application. Notice that this is also where we supply any platform specific Auth Connect options. Right now, logLevel
is set to DEBUG
since this is a demo application. In a production environment, we probably would set it to DEBUG
in development and ERROR
in production.
The isSetup
state variable ensures the setup is complete before we make any further AuthConnect
calls.
The Provider
Auth Connect requires a provider object that specifies details pertaining to communicating with the OIDC service. Auth Connect offers several common providers out of the box: Auth0Provider
, AzureProvider
, CognitoProvider
, OktaProvider
, and OneLoginProvider
. You can also create your own provider, though doing so is beyond the scope of this tutorial.
While they share the same name, providers bundled with Auth Connect are not React Context Providers like the one we're building in this guide. "Providers" from an Auth Connect perspective refers to Authentication Providers.
Since we are using Auth0, we will create an Auth0Provider
:
_11import { AuthConnect, Auth0Provider, ProviderOptions } from '@ionic-enterprise/auth';_11import { isPlatform } from '@ionic/react';_11import { PropsWithChildren, createContext, useState, useEffect } from 'react';_11..._11const provider = new Auth0Provider();_11_11export const AuthContext = createContext<{}>({});_11_11export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {_11 ..._11};
Login and Logout
Login and logout are the two most fundamental operations in the authentication flow.
For the login()
, we need to pass both the provider
and the options
established above. The login()
call resolves an AuthResult
if the operation succeeds. The AuthResult
contains the auth tokens as well as some other information. This object needs to be passed to almost all other Auth Connect
functions. As such, it needs to be saved.
The login()
call rejects with an error if the user cancels the login or if something else prevents the login to complete.
Add the following code to src/providers/AuthProvider.tsx
:
_20..._20export const AuthContext = createContext<{_20 login: () => Promise<void>;_20}>({_20 login: () => { throw new Error("Method not implemented."); }_20});_20_20export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {_20 const [isSetup, setIsSetup] = useState<boolean>(false);_20 const [authResult, setAuthResult] = useState<AuthResult | null>(null);_20_20 ..._20_20 const login = async (): Promise<void> => {_20 const authResult = await AuthConnect.login(provider, options);_20 setAuthResult(authResult);_20 };_20_20 return <AuthContext.Provider value={{ login }}>{isSetup && children}</AuthContext.Provider>;_20};
For the logout operation, we pass the provider
and the authResult
that was returned by the login()
call.
_21..._21export const AuthContext = createContext<{_21 ..._21 logout: () => Promise<void>;_21}>({_21 ..._21 logout: () => { throw new Error("Method not implemented."); },_21});_21_21export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {_21 ..._21_21 const logout = async (): Promise<void> => {_21 if (authResult) {_21 await AuthConnect.logout(provider, authResult);_21 setAuthResult(null);_21 }_21 };_21_21 return <AuthContext.Provider value={{ login, logout }}>{isSetup && children}</AuthContext.Provider>;_21};
To test these new functions, replace the ExploreContainer
with "Login" and "Logout" buttons in the src/pages/Tab1.tsx
file:
_26..._26import { useContext } from "react";_26import { IonButton, IonContent, IonHeader, IonPage, IonTitle, IonToolbar } from '@ionic/react';_26import { AuthContext } from "../providers/AuthProvider";_26import "./Tab1.css";_26_26const Tab1: React.FC = () => {_26 const { login, logout } = useContext(AuthContext);_26_26 return (_26 <IonPage>_26 <IonHeader>_26 ..._26 </IonHeader>_26 <IonContent fullscreen>_26 <IonHeader collapse="condense">_26 ..._26 </IonHeader>_26 <IonButton onClick={login}>Login</IonButton>_26 <IonButton onClick={logout}>Logout</IonButton>_26 </IonContent>_26 </IonPage>_26 );_26};_26_26export default Tab1;
If you are using our Auth0 provider, you can use the following credentials for the test:
- Email Address:
test@ionic.io
- Password:
Ion54321
You should be able to login and logout successfully.
Configure the Native Projects
Build the application for a native device and try the login there as well. You should notice that this does not work on your device.
The problem is that we need to let the native device know which application(s) are allowed to handle navigation to the msauth://
scheme (if you are using our Auth0 Provider). To do this, we need to modify our build.gradle
and Info.plist
files as noted here. If you are using our Auth0 Provider, use msauth
in place of $AUTH_URL_SCHEME
.
Determine Current Auth Status
Right now, the user is shown both the login and logout buttons but you don't really know if the user is logged in or not. Let's change that.
A simple strategy to use is tracking the status using state, updating the value after calling certain Auth Connect API methods. Add code to do this in src/providers/AuthProvider.tsx
. Ignore the extra complexity with the getAuthResult()
function -- we will expand on that as we go.
_36..._36export const AuthContext = createContext<{_36 isAuthenticated: boolean;_36 ..._36}>({_36 isAuthenticated: false,_36 ..._36});_36_36export const AuthProvider: React.FC<PropsWithChildren> = ({ children }) => {_36 ..._36 const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);_36_36 const getAuthResult = async (): Promise<AuthResult | null> => {_36 setIsAuthenticated(!!authResult);_36 return authResult;_36 };_36_36 ..._36_36 const login = async (): Promise<void> => {_36 const authResult = await AuthConnect.login(provider, options);_36 setAuthResult(authResult);_36 setIsAuthenticated(true);_36 };_36_36 const logout = async (): Promise<void> => {_36 if (authResult) {_36 await AuthConnect.logout(provider, authResult);_36 setAuthResult(null);_36 setIsAuthenticated(false);_36 }_36 };_36_36 return <AuthContext.Provider value={{ isAuthenticated, login, logout }}>{isSetup && children}</AuthContext.Provider>;_36};
Use isAuthenticated
in Tab1.tsx
to display only the Login or the Logout button, depending on the current login status. First, update the bindings on the buttons:
_10{_10 !isAuthenticated ? (_10 <IonButton onClick={handleLogin}>Login</IonButton>_10 ) : (_10 <IonButton onClick={handleLogout}>Logout</IonButton>_10 );_10}
Notice the added conditions to display the buttons and the changes to the onClick
event bindings. Integrate the following code into the existing Tab1
component code:
_13const { isAuthenticated, login, logout } = useContext(AuthContext);_13_13const handleLogin = async () => {_13 try {_13 await login();_13 } catch (err) {_13 console.log("Error logging in:", err);_13 }_13};_13_13const handleLogout = async () => {_13 await logout();_13};
Notice the try ... catch
in handleLogin()
. The login()
method will throw an error if the user fails to log in. Production applications should have some kind of handling here, but our sample can get away with simply logging the fact.
At this point, you should see the Login button if you are not logged in and the Logout button if you are.
Get the Tokens
We can now log in and out, but what about getting at the tokens that our OIDC provider gave us? This information is stored as part of the AuthResult
. Auth Connect also includes some methods that allow us to easily look at the contents of the tokens. For example:
_15const getAccessToken = async (): Promise<string | undefined> => {_15 const res = await getAuthResult();_15 return res?.accessToken;_15};_15_15const getUserName = async (): Promise<string | undefined> => {_15 const res = await getAuthResult();_15 if (res) {_15 const data = await AuthConnect.decodeToken<{ name: string }>(_15 TokenType.id,_15 res_15 );_15 return data?.name;_15 }_15};
Note: the format and data stored in the ID token may change based on your provider and configuration. Check the documentation and configuration of your own provider for details.
Add these to src/providers/AuthProvider.tsx
and export them as part of the AuthContext
like we did for the other functions.
You can use these wherever you need to supply a specific token. For example, if you are accessing a backend API that requires you to include a bearer token (and you probably are if you are using Auth Connect), then you can use the getAccessToken()
method to create an HTTP interceptor that adds the token.
We don't need an interceptor for this app, but as a challenge for you, update Tab1.tsx
to show the current user's name when they are logged in. You could also display the access token if you want (though you would never do that in a real app).
Refreshing the Authentication
In a typical OIDC implementation, access tokens are very short lived. In such a case, it is common to use a longer lived refresh token to obtain a new AuthResult
.
Let's add a function to src/providers/AuthProvider.tsx
that does the refresh, and then modify getAuthResult()
to call it when needed.
_24const refreshAuth = async (_24 authResult: AuthResult_24): Promise<AuthResult | null> => {_24 let newAuthResult: AuthResult | null = null;_24_24 if (await AuthConnect.isRefreshTokenAvailable(authResult)) {_24 try {_24 newAuthResult = await AuthConnect.refreshSession(provider, authResult);_24 } catch (err) {_24 console.log("Error refreshing session.", err);_24 }_24 }_24_24 return newAuthResult;_24};_24_24const getAuthResult = async (): Promise<AuthResult | null> => {_24 if (authResult && (await AuthConnect.isAccessTokenExpired(authResult))) {_24 const newAuthResult = await refreshAuth(authResult);_24 setAuthResult(newAuthResult);_24 }_24 setIsAuthenticated(!!authResult);_24 return authResult;_24};
Now anything using getAuthResult()
to get the current auth result will automatically handle a refresh if needed.
Store the Auth Result
Up until this point, we have been storing our AuthResult
in a local state variable in src/providers/AuthProvider.tsx
. This has a couple of disadvantages:
- Our tokens could show up in a stack trace.
- Our tokens do not survive a browser refresh or application restart.
There are several options we could use to store the AuthResult
, but one that handles persistence as well as storing the data in a secure location on native devices is Ionic Identity Vault.
For our application, we will install Identity Vault and use it in "Secure Storage" mode to store the tokens. The first step is to install the product.
_10npm i @ionic-enterprise/identity-vault
Next, we will create a factory that builds either the actual Vault - if we are on a device - or a browser-based "Vault" that is suitable for development if we are in the browser. The following code should go in src/providers/SessionVaultProvider.tsx
:
_10import {_10 BrowserVault,_10 IdentityVaultConfig,_10 Vault,_10} from "@ionic-enterprise/identity-vault";_10import { isPlatform } from "@ionic/react";_10_10const createVault = (config: IdentityVaultConfig): Vault | BrowserVault => {_10 return isPlatform("hybrid") ? new Vault(config) : new BrowserVault(config);_10};
This provides us with a secure Vault on our devices, or a fallback Vault that allows us to keep using our browser-based development flow.
With the factory in place to build our Vault, let's create a Context that will allow us to manage our authentication result. Add the following code to src/providers/SessionVaultProvider.tsx
:
_47..._47const key = 'auth-result';_47const vault = createVault({_47 key: 'io.ionic.gettingstartedacreact',_47 type: VaultType.SecureStorage,_47 deviceSecurityType: DeviceSecurityType.None,_47 lockAfterBackgrounded: 5000,_47 shouldClearVaultAfterTooManyFailedAttempts: true,_47 customPasscodeInvalidUnlockAttempts: 2,_47 unlockVaultOnLoad: false,_47});_47_47export const SessionVaultContext = createContext<{_47 clearSession: () => Promise<void>;_47 getSession: () => Promise<AuthResult | null>;_47 setSession: (value?: AuthResult) => Promise<void>;_47}>({_47 clearSession: () => {_47 throw new Error('Method not implemented.');_47 },_47 getSession: () => {_47 throw new Error('Method not implemented.');_47 },_47 setSession: () => {_47 throw new Error('Method not implemented.');_47 },_47});_47_47export const SessionVaultProvider: React.FC<PropsWithChildren> = ({ children }) => {_47 const clearSession = (): Promise<void> => {_47 return vault.clear();_47 };_47_47 const getSession = (): Promise<AuthResult | null> => {_47 return vault.getValue<AuthResult>(key);_47 };_47_47 const setSession = (value?: AuthResult): Promise<void> => {_47 return vault.setValue(key, value);_47 };_47_47 return (_47 <SessionVaultContext.Provider value={{ clearSession, getSession, setSession }}>_47 {children}_47 </SessionVaultContext.Provider>_47 );_47};
Then, add the provider to App.tsx
. Place the component in between <IonApp>
and <AuthProvider>
, like so:
_10<IonApp>_10 <SessionVaultProvider>_10 <AuthProvider>...</AuthProvider>_10 </SessionVaultProvider>_10</IonApp>
Finally, modify src/providers/AuthProvider.tsx
to use SessionVaultContext
. The goal is to no longer store the auth result in a session variable. Instead, we will use the session vault to store the result and retrieve it from the Vault as needed.
Remove the const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
line of code and replace it with the following:
_10const { clearSession, getSession, setSession } =_10 useContext(SessionVaultContext);
Create a function named saveAuthResult()
. This function will either save the AuthResult
to the Vault, or clear the auth result from the Vault when the session is no longer valid.
_10const saveAuthResult = async (authResult: AuthResult | null): Promise<void> => {_10 if (authResult) {_10 await setSession(authResult);_10 setIsAuthenticated(true);_10 } else {_10 await clearSession();_10 setIsAuthenticated(false);_10 }_10};
Modify getAuthResult()
to obtain the auth result from the Vault:
_10const getAuthResult = async (): Promise<AuthResult | null> => {_10 let authResult = await getSession();_10_10 if (authResult && (await AuthConnect.isAccessTokenExpired(authResult))) {_10 const newAuthResult = await refreshAuth(authResult);_10 saveAuthResult(newAuthResult);_10 }_10 setIsAuthenticated(!!authResult);_10 return authResult;_10};
Finally, modify login()
and logout()
to both to save the results of the operation(s) accordingly:
_12const login = async (): Promise<void> => {_12 const authResult = await AuthConnect.login(provider, options);_12 await saveAuthResult(authResult);_12};_12_12const logout = async (): Promise<void> => {_12 const authResult = await getAuthResult();_12 if (authResult) {_12 await AuthConnect.logout(provider, authResult);_12 await saveAuthResult(null);_12 }_12};
You should now be able to refresh the app and have a persistent session.
Guard the Routes
It's common to have routes in your application that only logged in users could see.
One way this could be achieved is by using the isAuthenticated
state variable to guard the routes. In a production scenario, the route guard component could look something like this:
_16import { useContext, useEffect, useState } from "react";_16import { Redirect, Route, useLocation } from "react-router";_16import { AuthContext } from "./providers/AuthProvider";_16_16export const PrivateRoute = ({ children }: any) => {_16 const { getAccessToken, isAuthenticated } = useContext(AuthContext);_16_16 // Calling `getAccessToken()` will check if the session is valid,_16 // and update `isAuthenticated` accordingly._16 useEffect(() => {_16 getAccessToken();_16 }, [getAccessToken]);_16_16 if (!isAuthenticated) return <Redirect to="/login" />;_16 return children;_16};
<PrivateRoute />
would then wrap protected components like so:
_10<Route path="/user-settings">_10 <PrivateRoute>_10 <UserSettings />_10 </PrivateRoute>_10</Route>
If the current user is not authenticated, when /user-settings
is navigated to, the application will redirect to /login
.