Full-Stack Development with Ionic and Nitric: Adding authentication

Ryan Edge
Ryan Edge
Published October 4, 2024

In the previous article, we learned the benefits of using tools like Nitric and Ionic to increase productivity. Then we built a simple full-stack application to display a personalized greeting to a user based on a URL parameter.

This article will extend our application with authentication and further integrate it with the API. The final result will allow users to authenticate with a phone number, associate their name with their identity, and see a personalized greeting based on their identity instead of a URL parameter.

Setting up our Authentication provider

Every application needs a way to authenticate its users so that they can access their data. For ours, we will use Descope, a drag-and-drop customer authentication and identity management solution. Descope is a configurable, no-code solution for authentication, authorization, and identity management. It is not the cheapest solution, but it has a great developer experience and intuitive SDK.

Create Descope account

Before wiring up authentication, we need to create an account on descope.com. This account allows us to leverage the Descope React SDK.

Once authenticated, you will be asked who uses your application. Select the "Consumers" option, which. This option configures the authentication workflows for everyday consumers.

When prompted, select the option to use a "One-time Password" as the authentication method. This option allows users to authenticate using either an email or phone number. When they attempt to log in they will be sent a special code to verify their identity.

Select the option to skip MFA, and you should see a preview of the login screen. Enabling MFA is great for added security, but we will forego that feature for simplicity. The last screen presents previews of the authentication components that will be used in the application.

After finishing onboarding, you will land on the Getting Started page. This page should include a Project ID that will configure authentication. Copy the ID, and add variables for VITE_AUTH_PROVIDER_ID and AUTH_PROVIDER_ID to the .env files in the app and api folders, respectively.

Additionally, to manage users in the API we need to create a Management Key. Once created, add it to the .env file for the app folder as AUTH_MANAGEMENT_KEY.

Configure Descope in the Application

Now we can configure Descope. First, add the SDK to the application by running yarn add @descope/react-sdk in the app folder.

Next, extend the AppProvider by adding the dependency to the top of the file.

javascript
1
2
3
4
5
6
7
8
9
10
11
import { AuthProvider } from "@descope/react-sdk"; Then, replace the AppProviders component with the following code: export default function AppProviders({ children }: PropsWithChildren) { return ( <QueryClientProvider client={queryClient}> <AuthProvider projectId={import.meta.env.VITE_AUTH_PROVIDER_ID}> {children as JSX.Element} </AuthProvider> </QueryClientProvider> ); }

This code adds the AuthProvider component from the Descope SDK to the provider configuration. It uses the import.meta.env.VITE_AUTH_PROVIDER_ID defined in the .env file.

Creating the login page

First, let's create the components for the login page. Inside the pages directory, create a file named login-page.css and add the following code:

css
1
2
3
4
5
6
7
8
ion-content::part(scroll) { padding-top: var(--ion-safe-area-top, 0); padding-left: var(--ion-safe-area-left, 0); } ion-content form { height: 100%; }

This CSS code is designed to style the layout and appearance of the login page, focusing on the content area and the login form. Its purpose is to ensure proper spacing and sizing of elements on the page, accounting for safe areas on different devices. It ensures that the login form provided by Descope will span the device's full screen.

Next, create a file named login-page.tsx and add the following code

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import { Descope } from "@descope/react-sdk"; import { IonContent, IonPage } from "@ionic/react"; import "./login-page.css"; export default function LoginPage() { return ( <IonPage> <IonContent fullscreen className="ion-padding"> <Descope flowId="sign-up-or-in" theme="light" onSuccess={(e) => { console.log(e.detail.user); }} onError={(err) => { console.log("Error!", err); }} style={{ height: "100%" }} /> </IonContent> </IonPage> ); }

This code defines a LoginPage component that renders a login screen using the Descope authentication service. The purpose of this code is to provide users with a way to sign up or sign in to the application.

To achieve its purpose, the code does the following:

  • It imports necessary components from the Ionic framework (IonContent and IonPage) and the Descope authentication service.
  • Inside the LoginPage component, it creates a structure using Ionic components: an IonPage that contains an IonContent. This provides the basic layout for the login page.
  • Within the IonContent, it places the Descope component responsible for handling the authentication process.
  • The Descope component is configured with several props:
    • flowId: Set to "sign-up-or-in", which likely determines the type of authentication flow to use.
    • theme: Set to "light", for styling purposes.
    • onSuccess: A function that logs the user information when authentication is successful.
    • onError: A function that logs any errors that occur during the authentication process.
    • style: Sets the component's height to 100% of its container.

Now that the LoginPage is created let's change the application to route to it if the user is unauthenticated. The Descope SDK provides hooks that allow access to the token that will authenticate users. Let's wire that up now.

Inside the app directory, create a file called router.tsx and add the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import { IonRouterOutlet } from "@ionic/react"; import { Redirect, Route } from "react-router-dom"; import HomePage from "../pages/home/home-page"; import LoginPage from "../pages/login/login-page"; import { useSession } from "@descope/react-sdk"; function AppRouter() { const { isAuthenticated } = useSession(); return ( <IonRouterOutlet> <Route exact path="/dashboard" render={() => { return isAuthenticated ? <HomePage /> : <LoginPage />; }} /> <Route exact path="/"> <Redirect to="/dashboard" /> </Route> </IonRouterOutlet> ); } export default AppRouter;

This code defines a React component to set up the application's navigation structure. It defines two routes: one for the dashboard and another for the root path.

The code achieves its purpose through the following logic:

  • It imports necessary components and hooks, including IonRouterOutlet from Ionic, routing components from react-router-dom, and page components (HomePage and LoginPage).
  • It uses the useSession hook from the Descope SDK to get the user's authentication status.
  • The AppRouter component configures the IonRouterOutlet component, part of the Ionic framework, for handling route changes.
  • It defines two routes using the Route component:
    • The "/dashboard" route checks if the user is authenticated. If they are, it renders the HomePage component. If not, it shows the LoginPage component.
    • The root path "/" redirects to the "/dashboard" route.

Inside the App.tsx file, import the AppRouter component by adding import AppRouter from "./app/router";. Then swap out the App component with the following code:

javascript
1
2
3
4
5
6
7
8
9
const App: React.FC = () => ( <AppProviders> <IonApp> <IonReactRouter> <AppRouter></AppRouter> </IonReactRouter> </IonApp> </AppProviders> );

Originally, the router was defined in this file. We have replaced the previous implementation for the AppRouter we created.

Run the application by running yarn dev in the app project folder. You should now see a login screen matching the one from Descope.

On successful signup, the application will redirect to collect the user's name. This is the default configuration of the Descope service.

Updating the Home page

Wrapping up the application updates, let's refactor the home page to include a logout button to unauthenticate the user. First, create a file named use-logout.ts in the path src/features/auth/api and add the following code:

javascript
1
2
3
4
5
6
7
8
9
import { useDescope } from "@descope/react-sdk"; export default function useLogout() { const { logout } = useDescope(); return async () => { await logout(); }; }

This code creates an abstraction to logout the user, using the logout function provided by the useDescope hook.

Next, rename Home.tsx to home-page.tsx for consistency. Replace the dependencies with the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
import { IonButton, IonButtons, IonContent, IonHeader, IonPage, IonText, IonTitle, IonToolbar, } from "@ionic/react"; import useLogout from "../../features/auth/api/use-logout"; import { useQuery } from "@tanstack/react-query"; import { useSession } from "@descope/react-sdk";

The imports largely remain the same but with the following changes:

  • Add the useLogout feature to unauthenticate the user,
  • Add useSession to get the authentication token.
  • Add IonButton and IonButtons to display the Logout button.
  • Add IonText to display the greeting.

Replace the HomePage component with the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
export default function HomePage() { const logout = useLogout(); const { data: greeting } = useQueryHello(); return ( <IonPage> <IonHeader collapse="fade"> <IonToolbar> <IonTitle>Home Page</IonTitle> <IonButtons slot="end"> <IonButton onClick={logout}>Sign Out</IonButton> </IonButtons> </IonToolbar> </IonHeader> <IonContent className="ion-padding"> <IonText>{greeting}</IonText> </IonContent> </IonPage> ); }

The changes add an IonButton button to the page header with the text "Sign Out". When clicked, the button unauthenticates the user by using the logout function from the useLogout hook.

Finally, update the useHelloQuery with the following code:

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function useQueryHello() { const { sessionToken, isSessionLoading } = useSession(); return useQuery<string>({ queryKey: [`hello`], queryFn: async () => { const response = await fetch(`/api/hello`, { headers: { Authorization: `Bearer ${sessionToken}` }, }); return await response.text(); }, enabled: !isSessionLoading, }); }

The update makes the following changes:

  • Get the session token and loading state from Descope's useSession hook.
  • Add an Authorization header to the request with the session token.
  • Enable the query only when the session token is loaded.

Upon login, we should see the newly added sign-out button.

Adding authentication to the API

Now that the application is configured, let's integrate Descope with the API.

First, create a file in the API resources folder called auth.ts and add the following code.

javascript
1
2
3
4
5
6
7
8
9
10
11
export default function getAuthClient() { try { return DescopeClient({ projectId: process.env.AUTH_PROVIDER_ID, managementKey: process.env.AUTH_MANAGEMENT_KEY, }); } catch (error) { console.log("failed to initialize: " + error); throw error; } }

This function serves as a way to create and return an authentication client for the application. Its primary purpose is to set up a connection with Descope.

To achieve its purpose, the function follows a simple logic:

  • Call the DescopeClient function with an object with two properties: projectId and managementKey. These values are obtained from the environment variables mentioned earlier.
  • If the client is successfully created, it is immediately returned.
  • If an error occurs during the client creation, it is caught in the catch block. The error is logged to the console, and then re-thrown.

Next, we will extend hello.ts to use the authentication client. Add the following import to the top of the file.

javascript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import { api } from "@nitric/sdk"; Then replace the "hello" get method with the following code: helloApi.get("/hello", async (ctx) => { const authClient = getAuthClient(); const { name } = ctx.req.params; const sessionToken = (ctx.req.headers.authorization as string).split( "Bearer " )[1] as string; const authInfo = await authClient.validateSession(sessionToken); const user = await authClient.management.user.loadByUserId( authInfo.token.sub ); ctx.res.body = `Hello ${user.data?.name}`; return ctx; });

This code reconfigures the GET endpoint at "/hello". When a user makes a request to this endpoint, the code now authenticates the user and responds with a personalized greeting.

To facilitate authentication, we have made the following changes:

  • Get an instance of the AuthClient.
  • Get the session token from the Authorization header.
  • Validate the session token using authClient.validateSession.
  • Get the authenticated user using authClient.management.user.loadByUserId and the information from the session validation.
  • Return the name collected on signup.

Run nitric start from the api directory's root to start the API. Once signed in, you should see the user name added during the signup.

Conclusion

In this article, we extended our application with authentication and integrated it with the API. In the next article, we will extend our application with full-stack functionality for creating, editing, and deleting notes.