AM Scripted Decision Node (Groovy) - Dynamic Choice Collector List

As part of my AM service journey, I wanted to display the user a dynamic choice collector list containing their phone numbers from their billing account contact methods. They may have one phone number OR they may have 10 phone numbers. Once they’ve selected one of the choices I will provide them with different MFA options (BySMS, ByPhoneCall). Just wondering if this is possible?

2 Likes

Hi @jfarrell,

I think you’ll find the answer in this blog post: AM Choice Collector Dynamic List

Regards
Patrick

1 Like

hi @jfarrell
You may also use the configuration provider node :point_right: Configuration Provider node :: ForgeRock Identity Cloud Docs
Regards,
Steph.

1 Like

I’m still struggling to see how I can do this!

If in the “queryBillingAcct” scripted node I have a groovy map phoneNumbers that looks like this:

[phoneNumber:5555555555, phoneNumberType:Mobile]
[phoneNumber:6666666666, phoneNumberType:Mobile]
[phoneNumber:8888888888, phoneNumberType:Home]
[phoneNumber:9999999999, phoneNumberType:Home]

How would i then present the “phoneNumber” in a Dynamic Choice Collector?

Hi @jfarrell,

First, with an OOTB Choice Collector, the options dictate the node outcomes. The auth tree structure is static in nature (e.g you can’t really create outcomes for all of the possible phone numbers… and wire them to the next nodes…) - so a Configuration Provider node would probably not make it if this was possible.

So taking this blog AM Choice Collector Dynamic List as a basis, the scripted decision node task is to transform the phone map to an options array like [ “home: 5555555555”, "Mobile: 6666666666, … ] that is rendered to the user via the ChoiceCallback, and then after user selection, store the outcome (a phone number) in shared state, to be used by the next node…

Regards
Patrick

1 Like

I simplified my Scripted Decision Node quite a bit and now have an array stored in sharedState as follows:

DEBUG: newSharedState {realm=/example, authLevel=0, pageNodeCallbacks={0=0, 1=1}, username=testuser, preferredlanguage=en, phoneNumberList=[4444444444, 5555555555, 8888888888, 9999999999]}

Now as you mentioned the task now is to render the phoneNumberList array options to the user via the ChoiceCallback and allow the user to select which phone number. Once the selection is stored in sharedState I can use that variable in the subsequent nodes.

I created a Scripted Decision Node as follows:

import org.forgerock.openam.auth.node.api.Action
import javax.security.auth.callback.ChoiceCallback

// Retrieve the phone numbers array from sharedState
def phoneNumbersArray = sharedState.get(“phoneNumberList”)

if (callbacks.isEmpty()) {
Action.send(
new ChoiceCallback(
“Please select a phone number:”,
phoneNumbersArray as String[],
0,
false
)
).build()
} else {
def index = callbacks.get(0).getSelectedIndexes()[0]
def selectedPhoneNumber = phoneNumbersArray[index]
sharedState.put(“selectedPhoneNumber”, selectedPhoneNumber)
Action.goTo(“true”).build()
}

But when I use this Scripted Decision Node it doesn’t do anything. There are no errors either!

Try :

var fr = JavaImporter(
    org.forgerock.openam.auth.node.api.Action,
    javax.security.auth.callback.ChoiceCallback
)

...


fr.Action.send(
        new fr.ChoiceCallback(
...

fr.Action.goTo(option).build();

Regards
Patrick

Here is the updated Scripted Decision Node in javascript:

// Import Classes
var fr = JavaImporter(
    org.forgerock.openam.auth.node.api.Action,
    javax.security.auth.callback.ChoiceCallback
)

logger.error("2DEBUG phoneListChoiceCallback Scripted Decision Node - BEGIN");
var phoneNumbersArray = sharedState.get("phoneNumberList");
logger.error("2DEBUG phoneNumbersArray: " + phoneNumbersArray);
var phoneNumbersStringArray = [];

// Initialize an empty array and convert each element to a string and store them
for (var i = 0; i < phoneNumbersArray.length; i++) {
    phoneNumbersStringArray[i] = String(phoneNumbersArray[i]);
}
logger.error("2DEBUG phoneNumbersStringArray: " + phoneNumbersStringArray);
             
if (callbacks.isEmpty()) {
    logger.error("2DEBUG inside callbacksisEmpty");
    var choiceCallback = new fr.ChoiceCallback(
        "Please select a phone number:",
        phoneNumbersStringArray,
        0,
        false
    );
    logger.error("2DEBUG choiceCallback created: " + choiceCallback);
    logger.error("2DEBUG choiceCallback prompt: " + choiceCallback.getPrompt());
    
    // Convert the choices array to a string using a JavaScript loop
    var choicesString = "";
    for (var i = 0; i < choiceCallback.getChoices().length; i++) {
        if (i > 0) {
            choicesString += ", ";
        }
        choicesString += choiceCallback.getChoices()[i];
    }
    logger.error("2DEBUG choiceCallback options: " + choicesString);
    
    fr.Action.send(choiceCallback).build();
    logger.error("2DEBUG choiceCallback sent");
    logger.error("2DEBUG outcome is false");
    outcome = "false";
} else {
    logger.error("2DEBUG inside else statement");
    var index = callbacks.get(0).getSelectedIndexes()[0];
    var selectedPhoneNumber = phoneNumbersStringArray[index];
    sharedState.put("selectedPhoneNumber", selectedPhoneNumber);
    logger.error("2DEBUG selectedPhoneNumber - " + selectedPhoneNumber);
    fr.Action.goTo("true").build();
    logger.error("2DEBUG phoneListChoiceCallback Scripted Decision Node - END");
    outcome = "true";
}

The debugging of the code above looks fine, but nothing is presented to the client in the browser:

ERROR: 2DEBUG phoneListChoiceCallback Scripted Decision Node - BEGIN
ERROR: 2DEBUG phoneNumbersArray: [4444444444, 5555555555, 8888888888, 9999999999]
ERROR: 2DEBUG phoneNumbersStringArray: 4444444444, 5555555555, 8888888888, 9999999999
ERROR: 2DEBUG inside callbacksisEmpty
ERROR: 2DEBUG choiceCallback created: javax.security.auth.callback.ChoiceCallback@373aaef7
ERROR: 2DEBUG choiceCallback prompt: Please select a phone number:
ERROR: 2DEBUG choiceCallback options: 4444444444, 5555555555, 8888888888, 9999999999
ERROR: 2DEBUG choiceCallback sent
ERROR: 2DEBUG outcome is false

Although the client browser just hangs when it gets to this point and just kills the session. You do get an “Unknown error. Please contact your Administrator” (500 error). However, the Authentication and OtherLogging logs aren’t showing any errors otherwise.

If callbacks are empty, then the last instruction in this code branch should be sending the callback, e.g: return fr.Action.send(choiceCallback).build();. Here the script just return “false”, that’s not a callback.

Once the user has received the form, filled it and submitted, the script is resumed at the other branch (e.g callback is not empty). Then response is processed, and based on the result, outcome is determined - the outcomes are the outputs of the nodes (those small rounded shapes attached to the right of the node) which are linked to the next nodes in the tree. So if all Ok, then return fr.Action.gotTo("true").build else, return fr.Action.goTo("false").build().

And if outcomes are “true” and “false” then these must be configured for the scripted decision node -check this: Scripted decision node API :: ForgeRock Identity Cloud Docs

There are some examples here (look at the legacy ones): Scripted decision node API :: ForgeRock Identity Cloud Docs

Regards
Patrick

1 Like

This seems to work:

var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
javax.security.auth.callback.ChoiceCallback
);

var phoneNumbersArray = [“4444444444”,“5555555555”,“8888888888”,“9999999999”];

if (callbacks.isEmpty()) {
action = fr.Action.send(
new fr.ChoiceCallback(“Please select a phone number”,phoneNumbersArray,0,false)).build()
} else {
var index = callbacks.get(0).getSelectedIndexes()[0]
var selectedPhoneNumber = phoneNumbersArray[index]
sharedState.put(“selectedPhoneNumber”, selectedPhoneNumber)
action = fr.Action.goTo(“true”).build()
}

Now I just need to change the the script to read the array from sharedState and I should be okay!

Screenshots:
image

image

1 Like

Thought I’d put a final update here for a working example. Let’s say you have an authentication journey (tree) where you would like to send a OTP code via SMS to one of the customers available phone numbers that you have pulled from their identity.

  • Let’s assume that list of phone numbers is gathered in a Scripted Decision Node and the result is saved into SharedState as follows:

sharedState.put(“phoneNumberList”, phoneNumberList.toArray())

  • Now on the subsequent Scripted Decision Node you want to present that list of phone numbers in a “callback” using the “ChoiceCallback”. Here is a working example in groovy:
// Classes To Import
import org.forgerock.openam.auth.node.api.Action
import javax.security.auth.callback.ChoiceCallback

// Define Some Variables
def logID = "phoneListChoiceCallbackGroovy SCRIPTED NODE"
def phoneNumberList = nodeState.get("phoneNumberList")
def prompt = "These phone numbers were found for 2FA validation"
def defaultChoice = 0
def multipleSelectionsAllowed = false

// set default outcome
outcome = "false"

// Check if phoneNumberList is not null
if (phoneNumberList != null) {
    // Manually convert phoneNumberList to a Groovy list
    def groovyPhoneNumberList = phoneNumberList.collect { phoneNumber ->
        // Remove the additional quotes around the values
        phoneNumber.toString().replaceAll(/^"|"$/, "")
    }

    // Check if there are no callbacks (i.e., it's the initial request)
    if (callbacks.isEmpty()) {
        // If no callbacks, create a ChoiceCallback for user selection
        action = Action.send(
                new ChoiceCallback(prompt, groovyPhoneNumberList as String[], defaultChoice, multipleSelectionsAllowed)).build()
    } else {
        // If callbacks exist, user has made a selection
        def index = callbacks[0].getSelectedIndexes()[0]
        def selectedPhoneNumber = groovyPhoneNumberList[index]

        // Add a null check before using the selected phone number
        if (selectedPhoneNumber != null) {
            // Store the selected phone number in shared state
            nodeState.putShared("phone", selectedPhoneNumber)
        } else {
            // Log an error if the selected phone number is null
            logger.error(logID + " - Selected phone number is null")
        }
        // Set the action to go to the next step in the flow
        action = Action.goTo("true").build()
    }
} else {
    // Log an error if phoneNumberList is null
    logger.error(logID + " - Error: No valid choices found")
}
1 Like