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.
1234567891011import { 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:
12345678ion-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
12345678910111213141516171819202122232425import { 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
andIonPage
) and theDescope
authentication service. - Inside the
LoginPage
component, it creates a structure using Ionic components: anIonPage
that contains an IonContent. This provides the basic layout for the login page. - Within the
IonContent
, it places theDescope
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:
1234567891011121314151617181920212223242526272829import { 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 fromreact-router-dom
, and page components (HomePage
andLoginPage
). - It uses the
useSession
hook from the Descope SDK to get the user's authentication status. - The
AppRouter
component configures theIonRouterOutlet
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 theHomePage
component. If not, it shows theLoginPage
component. - The root path
"/"
redirects to the"/dashboard"
route.
- The
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:
123456789const 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:
123456789import { 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:
12345678910111213import { 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
andIonButtons
to display the Logout button. - Add
IonText
to display the greeting.
Replace the HomePage
component with the following code:
1234567891011121314151617181920export 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:
1234567891011121314function 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.
1234567891011export 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
andmanagementKey
. 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.
1234567891011121314151617181920import { 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.