Adding Address Auto-Completion to Your ForgeRock Registration Journeys

Postal address auto-complete is one of those features that you often see on registration forms or when filling in shipping details. They’re usually so simple and inconspicuous that you don’t even notice them, until you land on form without them, and wonder why they didn’t go the tiny extra mile to make your life that little bit easier.

Introduction

There are many address validation and auto-completion services publicly available, some free, and some commercial. In this article, we look at how to add Google’s flavor to a ForgeRock user registration journey. Note that these instructions are for ForgeRock Identity Cloud and in ForgeRock on-prem products. The end result should be an address field that can be dropped into any authentication or registration journey that auto-completes the full postal address as the user begins to enter the first few characters:

We’ll also combine this with some of the out-of-the-box magic that lets the ForgeRock Identity Platform determine the user’s real geographic location, so that we can restrict the search radius of the address auto-completion service.

Steps

The first step is to get an API key for the Google Maps service. The easiest way to do this is to head to Google guide here and follow the instructions. For security reasons, Google recommends restricting the HTTP referrers (sites which can use the API key) and scope (API features the key can be used for), but it’s not strictly necessary for testing.

Once you have your Google API key, log in to your ForgeRock admin UI and either create a new journey or select an existing one, then create a new scripted authentication node called something appropriate; for example, “Get Address”, and then configure the script section with the following snippet of JavaScript (adding your Google API key in the first line):

GOOGLE_API_KEY="<your Google API key";


var fr = JavaImporter(
    org.forgerock.openam.auth.node.api,
    javax.security.auth.callback.NameCallback,
    com.sun.identity.authentication.callbacks.ScriptTextOutputCallback,
    com.sun.identity.authentication.callbacks.HiddenValueCallback
);

if (sharedState.containsKey("forgeRock.device.profile") && sharedState.get("forgeRock.device.profile").containsKey("location")) {

    var thisDevice = sharedState.get("forgeRock.device.profile");
    var lon = thisDevice.get("location").get("longitude");
    var lat = thisDevice.get("location").get("latitude");

    var script = String("var place; function addr() { \n" +
                        "    var results = place.getPlace().address_components; console.log(results); \n" +
                        "    var resp = {}; \n" +
                        "    for (var i=0; i < results.length; i++) { \n" +
                        "        var types = results[i].types; \n" +
                        "        for (var j=0; j<types.length; j++) { \n" +
                        "            if (types[j] == 'street_number') resp['street_number'] = results[i].long_name; \n" +
                        "            if (types[j] == 'route') resp['route'] = results[i].long_name; \n" +
                        "            if ((types[j] == 'locality') || (types[j] == 'postal_town')) resp['locality'] = results[i].long_name; \n" +
                        "            if (types[j] == 'postal_code') resp['postal_code'] = results[i].long_name; \n" +
                        "            if (types[j] == 'country') resp['country'] = results[i].long_name; \n" +
                        "        } \n" +
                        "    } \n" +
                        "    document.getElementById('clientScriptOutputData').value=JSON.stringify(resp); \n" +
                        "} \n" +
                        "const center = { lat: " + lat + ", lng: " + lon + " }; \n" +
                        "const defaultBounds = {north: center.lat + 0.1,south: center.lat - 0.1,east: center.lng + 0.1,west: center.lng - 0.1}; \n" +
                        "const options = { bounds: defaultBounds, fields: ['address_components', 'geometry', 'icon', 'name'], origin: center, strictBounds: true, types: ['address'] }; \n" +
                        "var script = document.createElement('script'); \n" +
                        "script.src = 'https://maps.googleapis.com/maps/api/js?key=" + GOOGLE_API_KEY + "&libraries=places'; \n" +
                        "script.onload = function() { var inputs = document.querySelectorAll(\"input[data-vv-as='Address']\");  place = new google.maps.places.Autocomplete(inputs[0], options); place.addListener('place_changed', addr);} \n" +
                        "document.body.appendChild(script); \n" +
                        "if (!document.body.querySelector('button[type=submit]')) { \n" +
                        "   var b = document.createElement('button'); \n " +
                        "   b.id = 'selfieButton' \n" +
                        "   b.onclick = function() { document.getElementById('loginButton_0').click(); \n" +
                        "       document.getElementById('selfieButton').remove(); }; \n" +
                        "   b.classList.add(\"btn\", \"btn-block\", \"mt-3\", \"btn-primary\"); \n" +
                        "   b.innerHTML = \"Next\"; \n" +
                        "   document.getElementById('wrapper').appendChild(b); \n" +
                        "}");

    with (fr) {
        if (callbacks.isEmpty()) {
            action = Action.send(new NameCallback("Address"),
                                 new HiddenValueCallback("clientScriptOutputData", "false"),
                                 new ScriptTextOutputCallback(script)).build();
        } else {
            var objectAttributes = sharedState.get("objectAttributes");
            if (objectAttributes == null) objectAttributes = new java.util.LinkedHashMap();
            var address = JSON.parse(String(callbacks.get(1).getValue()));
            sharedState.put("address", address);
            objectAttributes.put('postalCode', address.postal_code);
            objectAttributes.put('postalAddress', address.street_number + " " + address.route);
            objectAttributes.put('city', address.locality);
            objectAttributes.put('country', address.country);
            sharedState = sharedState.put("objectAttributes", objectAttributes);
            action = Action.goTo("true").build();
        }
    }
} else {
    with (fr) {
        if (callbacks.isEmpty()) {
            action = Action.send(new NameCallback("<Location Unavailable>")).build();
        } else {
            action = Action.goTo("true").build();
        }
    }
}

The script does a few things: it creates a new input field called “Address” (a NameCallback in ForgeRock terms) and uses a ScriptTextOutputCallback to load the Google API with some configuration. This step also links the Google API to the new address field, and finally, adds some logic to capture the full address when the user has selected one. Ultimately, the full address will be passed back to the journey’s sharedState so that an appropriately set up journey can create or update the user’s profile.

Once that scripted node has been added to your journey, be sure to also add a Device Profile Collector node and configure it to Collect Device Location. This is needed to limit the range of addresses that the Google Maps API will return. In theory, the script can be adapted to work without this, but it won’t be quite as user-friendly or responsive.

Finally, complete the registration or login journey using your new node. Here’s an example user registration journey incorporating the “Address AutoFill” node:


Enjoy!


Related Knowlege Base Articles

FAQ: Journeys in Identity Cloud