Ping SDKs

Step 4. Implement authentication using the Ping SDK

Now that we have our environment and servers setup, let’s jump into the application! Within your IDE of choice, navigate to the reactjs-todo/client directory. This directory is where you will spend the rest of your time.

First, open up the index.js file, import the Config module from the Ping SDK for JavaScript and call the setAsync() method on this object:

/reactjs-todo/client/index.js
+ import { Config } from '@forgerock/javascript-sdk';
  import React from 'react';
  import { createRoot } from 'react-dom/client';

  import Router from './router';
  import { WELLKNOWN_URL, APP_URL, JOURNEY_LOGIN, WEB_OAUTH_CLIENT } from './constants';
  import { AppContext, useGlobalStateMgmt } from './global-state';

  import './styles/index.scss';

+ const urlParams = new URLSearchParams(window.location.search);
+ const journeyParam = urlParams.get('journey');
+
+ await Config.setAsync();

  /**
  * Initialize the React application
  * This is an IIFE (Immediately Invoked Function Expression),
  * so it calls itself.
  */
  (async function initAndHydrate() {

@@ collapsed @@

The use of setAsync() should always be the first SDK method called and is frequently done at the application’s top-level file. To configure the SDK to communicate with the journeys, OAuth clients, and realms of the appropriate server, pass a configuration object with the appropriate values.

The configuration object you will use in this instance will pull most of its values out of the .env variables previously setup, which are mapped to constants within our constants.js file.

Here’s an example config for an PingOne Advanced Identity Cloud tenant:

/reactjs-todo/client/index.js
  import { Config } from '@forgerock/javascript-sdk';
  import React from 'react';
  import { createRoot } from 'react-dom/client';

  import Router from './router';
  import { WELLKNOWN_URL, APP_URL, JOURNEY_LOGIN, WEB_OAUTH_CLIENT } from './constants';
  import { AppContext, useGlobalStateMgmt } from './global-state';

  import './styles/index.scss';

  const urlParams = new URLSearchParams(window.location.search);
  const journeyParam = urlParams.get('journey');

  await Config.setAsync(
+   {
+     clientId: WEB_OAUTH_CLIENT,
+     redirectUri: `${window.location.origin}/callback`,
+     scope: 'openid profile email address',
+     serverConfig: {
+       wellknown: WELLKNOWN_URL,
+       timeout: 3000,
+     },
+     tree: `${journeyParam || JOURNEY_LOGIN}`,
+   }
  );

@@ collapsed @@

Go back to your browser and refresh the home page. There should be no change to what’s rendered, and no errors in the console. Now that the app is configured to your server, let’s wire up the simple login page.

Building the login page

First, let’s review how the application renders the home page:

index.js > router.js > views/home.js > inline code + components (components/)

For the login page, the same pattern applies except it has less code within the view file:

index.js > router.js > views/login.js > components/journey/form.js

In the top-right of the home page, click Sign In to open the login page.

You should see a "loading" spinner and message that’s persistent since it doesn’t have the callbacks from your server that are needed to render the form. Obtaining these callbacks is the first task.

Login page with spinner
Figure 1. Screenshot of the todo app’s login page with spinner.

Since most of the action is taking place in reactjs-todo/client/components/journey/form.js, open it and add the FRAuth module from the Ping SDK for JavaScript:

reactjs-todo/client/components/journey/form.js
+ import { FRAuth } from '@forgerock/javascript-sdk';
import React from 'react';

import Loading from '../utilities/loading';

@@ collapsed @@

FRAuth is the first object used as it provides the necessary methods for authenticating a user by using an authentication journey or tree. Use the start() method of FRAuth as it returns data we need for rendering the form.

You will need to add new imports. Add useContext and useState from the React package.

You’ll use the useState() method for managing the data received from the server, and the useEffect is needed due to the FRAuth.start() method resulting in a network request.

reactjs-todo/client/components/journey/form.js
  import { FRAuth } from '@forgerock/javascript-sdk';
- import React from 'react';
+ import React, { useEffect, useState } from 'react';

  import Loading from '../utilities/loading';

  export default function Form() {
+   const [step, setStep] = useState(null);

+   useEffect(() => {
+     async function getStep() {
+       try {
+         const initialStep = await FRAuth.start();
+         console.log(initialStep);
+         setStep(initialStep);
+       } catch (err) {
+         console.error(`Error: request for initial step; ${err}`);
+       }
+     }
+     getStep();
+   }, []);
    return <Loading message="Checking your session ..." />;
  }

We are passing an empty array as the second argument into useEffect. This instructs the useEffect to only run once after the component mounts.

To learn more, refer to What an Effect with empty dependencies means in the React Developer Documentation.

This code prints the response to starting the journey to the debug console in the browser. This response contains the first step of the journey and its callbacks. These callbacks are the instructions for what needs to be rendered to the user to collect their input.

Console showing first step of the journey and the `callbacks` returned from the server.
Figure 2. Screenshot of browser console showing first step of the journey and the callbacks returned from the server.

Below is a summary of what you’ll do to get the form to react to the new callback data:

  1. Import the needed form-input components

  2. Create a function to map received callbacks to the appropriate component

  3. Use the components to render the appropriate UI for each callback in the response from the server

First, import the Alert, AppContext, Password, Text, and Unknown components.

reactjs-todo/client/components/journey/form.js
  import { FRAuth } from '@forgerock/javascript-sdk';
  import React, { useEffect, useState } from 'react';

+ import Alert from './alert';
+ import Password from './password';
+ import Text from './text';
+ import Unknown from './unknown';
  import Loading from '../utilities/loading';

@@ collapsed @@

Next, within the Form function body, create the function that maps these imported components to their appropriate callback.

reactjs-todo/client/components/journey/form.js
@@ collapsed @@

  export default function Form() {
    const [step, setStep] = useState(null);

@@ collapsed @@

+   function mapCallbacksToComponents(cb, idx) {
+     const name = cb?.payload?.input?.[0].name;
+     switch (cb.getType()) {
+       case 'NameCallback':
+         return <Text callback={cb} inputName={name} key='username' />;
+       case 'PasswordCallback':
+         return <Password callback={cb} inputName={name} key='password' />;
+       default:
+         // If current callback is not supported, render a warning message
+         return <Unknown callback={cb} key={`unknown-${idx}`} />;
+     }
+   }
    return <Loading message="Checking your session ..." />;
  }

Finally, check for the presence of the step.callbacks, and if they exist, map over them with the function from above. Replace the single return of <Loading message="Checking your session ..." /> with the following:

reactjs-todo/client/components/journey/form.js
@@ collapsed @@

+   if (!step) {
      return <Loading message='Checking your session ...' />;
+   } else if (step.callbacks?.length) {
+     return (
+       <form className='cstm_form'>
+         {step.callbacks.map(mapCallbacksToComponents)}
+         <button className='btn btn-primary w-100' type='submit'>
+           Sign In
+         </button>
+       </form>
+     );
+   } else {
+     return <Alert message={step.payload.message} />;
*   }
  }

Refresh the page, and you should now have a dynamic form that reacts to the callbacks returned from our initial call to ForgeRock.

Login page form
Figure 3. Screenshot of login page with rendered form

Handling the login form submission

Since a form that can’t submit anything isn’t very useful, we’ll now handle the submission of the user input values to ForgeRock. First, let’s edit the current form element, <form className="cstm_form">, and add an onSubmit handler with a simple, inline function.

reactjs-todo/client/components/journey/form.js
@@ collapsed @@

- <form className='cstm_form'>
+ <form
+   className="cstm_form"
+   onSubmit={(event) => {
+     event.preventDefault();
+     async function getStep() {
+       try {
+         const nextStep = await FRAuth.next(step);
+         console.log(nextStep);
+         setStep(nextStep);
+       } catch (err) {
+         console.error(`Error: form submission; ${err}`);
+       }
+     }
+     getStep();
+   }}
+ >

Refresh the login page and use the test user to login. You will get a mostly blank login page if the user’s credentials are valid and the journey completes. You can verify this by going to the Network panel within the developer tools and inspect the last /authenticate request. It should have a tokenId and successUrl property.

Successful request without handling render
Figure 4. Screenshot of empty login form & network request showing success data

You may ask, "How did the user’s input values get added to the step object?" Let’s take a look at the component for rendering the username input. Open up the Text component: components/journey/text.js. Notice how special methods are being used on the callback object. These are provided as convenience methods by the SDK for getting and setting data.

reactjs-todo/client/components/journey/text.js
@@ collapsed @@

  export default function Text({ callback, inputName }) {
    const [state] = useContext(AppContext);
    const existingValue = callback.getInputValue();

    const textInputLabel = callback.getPrompt();
    function setValue(event) {
      callback.setInputValue(event.target.value);
    }

    return (
      <div className={`cstm_form-floating form-floating mb-3`}>
        <input
          className={`cstm_form-control form-control ${validationClass} bg-transparent ${state.theme.textClass} ${state.theme.borderClass}`}
          defaultValue={existingValue}
          id={inputName}
          name={inputName}
          onChange={setValue}
          placeholder={textInputLabel}
        />
        <label htmlFor={inputName}>{textInputLabel}</label>
      </div>
    );
  }

The two important items to focus on are the callback.getInputValue() and the callback.setInputValue(). The getInputValue retrieves any existing value that may be provided by ForgeRock, and the setInputValue sets the user’s input on the callback while they are typing (i.e. onChange). Since the callback is passed from the Form to the components by "reference" (not by "value"), any mutation of the callback object within the Text (or Password) component is also contained within the step object in Form.

You may think, "That’s not very idiomatic React! Shared, mutable state is bad." And, yes, you are correct, but we are taking advantage of this to keep everything simple (and this guide from being too long), so I hope you can excuse the pattern.

Each callback type has its own collection of methods for getting and setting data in addition to a base set of generic callback methods. These methods are added to the callback prototype by the SDK automatically. For more information about these callback methods, see our API documentation, or the source code in GitHub, for more details.

Now that the form is rendering and submitting, add conditions to the Form component for handling the success and error response from ForgeRock. This condition handles the success result of the authentication journey.

reactjs-todo/client/components/journey/form.js
@@ collapsed @@

  if (!step) {
    return <Loading message='Checking your session ...' />;
+ } else if (step.type === 'LoginSuccess') {
+   return <Alert message="Success! You're logged in." type='success' />;
  } else if (step.callbacks?.length) {

@@ collapsed @@

Once you handle the success and error condition, return to the browser and remove all cookies created from any previous logins. Refresh the page and login with your test user created in the Setup section above. You should see a "Success!" alert message. Congratulations, you are now able to authenticate users!

Login page with successful authentication
Figure 5. Screenshot of login page with success alert