Customizing Single Logout Using Journeys, Pt. 2: Terminating an External Session via REST

This is part 2 of 4 in the series Customizing Single Logout Using Journeys.

Terminating an External Session via REST

There may be instances where your users maintain sessions with ForgeRock Identity Cloud and an external system. If the external application/service follows the SAML 2.0 Spec for Single Logout, you can configure ForgeRock to invalidate the external session alongside a ForgeRock Logout. Unfortunately, many systems have their own bespoke method to session termination.

In cases where the external application/service does not follow the SAML 2.0 spec for Single Logout, but does have a method of logout available via REST API or URL, you can use Scripted Nodes from within a Journey to log a user out of external applications and services.

Ensuring the User Has an Active Session

Since we’ll be pulling attribute data from a user, we’ll want to ensure that this user has an active session.

Create a new Journey entitled Terminate External API Session. In your Journey, connect your Start Node to an Identify Existing User Node with the Identifier and Identity Attribute both set to “userName”. This will check for an existing userName in the session and will load additional attribute information such as the user’s _id, which we’ll use to access their attribute information.


Identify Existing User Details

Sending the Request

In this example, we’ll be mocking out a logout request with https://jsonplaceholder.typicode.com/. The request we’ll need to make includes passing in the user’s external username and sso_token , which we have stored on the user’s identity in ForgeRock Identity Cloud, and will look something like this:

curl -X POST https://jsonplaceholder.typicode.com/posts \
-H "Content-type: application/json" \
-d '{ "username": "foo", "sso_token": "bar"}' 

The response will return a status of 201 if successful.

For the sake of this example, we’ll assume that the username in the external system is the same as the ForgeRock userName, and that the sso_token is stored on frUnindexedString2.

In your Journey, drag and drop in a Scripted Decision Node and create a Script named Send Logout Request, connected to the True output of your Identify Existing User Node. This script will have three outcomes defined: Success, Failure, and Error.

Copy/Paste the following Javascript into your Node:

/*
 Call an external API to terminate a session
 
 In a production instance, store credentials and routes in an ESV for security and reuse.
 
 The scripted decision node needs the following outcomes defined:
 - Success
 - Failure
 - Error
 
 Author: se@forgerock.com
 */

//// CONSTANTS
var BASE_URL = "https://jsonplaceholder.typicode.com";
var USER_SSO_TOKEN = "fr-attr-str2";

var OUTCOMES = {
  SUCCESS: "Success",
  FAILURE: "Failure",
  ERROR: "Error"
}

//// HELPERS
/**
	Calls an external endpoint to terminate a session 
    
    @param {String} username the user's username
    @param {String} ssoToken the user's external ssoToken
    @return {object} the response from the API, or null. Throws an error if not 201
*/
function terminateSession(username, ssoToken) {
  var expectedStatus = 201;
  var options = {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    // If you need to add a bearer token, use something like this: `token: bearerToken,`
    body: {
      "username": username,
      "sso_token": ssoToken
    }
  };

  var requestURL = `${BASE_URL}/posts`;
  var response = httpClient.send(requestURL, options).get();

  if (response.status === expectedStatus) {
    var payload = response.text();
    var jsonResult = JSON.parse(payload);
    return jsonResult;
  } else {
  	throw(`Response is not ${expectedStatus}. Response: ${response.text}`); 
  }
}

//// MAIN
(function() {
  // We wrap our main in a try/catch for the following reasons:
  // - Since we are hitting exernal functions that could throw errors, like httpClient
  // - So that if a user isn't logged in, the sharedState retrieval fails gracefully.
  try {
    var username = nodeState.get("_id");
    var attribute = USER_SSO_TOKEN;
    
    var identity = idRepository.getIdentity(username);
    // If no attribute by this name is found, the result is an empty array: []
    var ssoToken = identity.getAttributeValues(attribute);
    
    // If we found a username and ssotoken
    if (username && ssoToken != '[]') {
      	var response = terminateSession(username, ssoToken);
      	// Store the response in shared state to review later
        // In this script, you could branch paths or perform actions based on the response data.
      	nodeState.putShared('terminationResponse', JSON.stringify(response));
    	outcome = OUTCOMES.SUCCESS;
    } else {
      	nodeState.putShared('terminationResponse', `username: ${username}, ssotoken: ${ssoToken}`);
    	outcome = OUTCOMES.FAILURE;
    }
  } catch(e) {
    logger.error(e);
    outcome = OUTCOMES.ERROR;
  }
}());

How it Works

Let’s break this script down.

Starting at our Main function (the one wrapped in (function(){...}());):

var username = nodeState.get("_id");
var attribute = USER_SSO_TOKEN;
    
var identity = idRepository.getIdentity(username);
// If no attribute by this name is found, the result is an empty array: []
var ssoToken = identity.getAttributeValues(attribute);

We first gather the ssoToken attribute from the user using the idRepository script binding which takes in the _id we collected with the Identify Existing User Node.

// If we found a username and ssotoken
if (username && ssoToken != '[]') {
var response = terminateSession(username, ssoToken);
      	// Store the response in shared state to review later
// In this script, you could branch paths or perform actions based on the response data.
      	nodeState.putShared('terminationResponse', JSON.stringify(response));
    	outcome = OUTCOMES.SUCCESS;
} else {
      	nodeState.putShared('terminationResponse', `username: ${username}, ssotoken: ${ssoToken}`);
    	outcome = OUTCOMES.FAILURE;
}

We then check to see if the username and the ssoToken exist on the user, and if they do, we call the terminateSession function which calls the API. Let’s look at that next.

/**
	Calls an external endpoint to terminate a session 
    
    @param {String} username the user's username
    @param {String} ssoToken the user's external ssoToken
    @return {object} the response from the API, or null. Throws an error if not 201
*/
function terminateSession(username, ssoToken) {
  var expectedStatus = 201;
  var options = {
    method: "POST",
    headers: {
      "Content-Type": "application/json"
    },
    // If you need to add a bearer token, use something like this: `token: bearerToken,`
    body: {
      "username": username,
      "sso_token": ssoToken
    }
  };

  var requestURL = `${BASE_URL}/posts`;
  var response = httpClient.send(requestURL, options).get();

  if (response.status === expectedStatus) {
    var payload = response.text();
    var jsonResult = JSON.parse(payload);
    return jsonResult;
  } else {
  	throw(`Response is not ${expectedStatus}. Response: ${response.text}`); 
  }
}

In terminateSession, we construct an httpClient request that POSTs to our API endpoint. This request includes a request body and will return a JSON-formatted response. In this example, we return the response - but in your version you could do things like parse the response, check for a specific code, or branch into different actions (such as calling more requests, changing state, managing user data, etc) based on its outcome. With APIs, the possibilities are endless!

Testing

To test this script, we’ll add a message on both success and failure that 1) tells us if the API was called successfully, and 2) shows us what data was received.

First, put a Message Node wired to the False output of the Identify Existing User Node and the Failure output of your Scripted Decision Node. This can be whatever message you’d like - the example uses the message “No User Found”. Wire the output of your Message Node to the Failure Node.

Secondly, put a Message Node on the Error output of your Scripted Decision Node that informs you if an error occurred. In a production system, you’ll likely want to handle this error by returning to another Journey, page, or failure outcome but in this case we’ll display the message “An Error Occurred. Please view the logs.” Wire the output of your Message Node to the Failure Node.

Finally, add a Configuration Provider Node to the Success output with its True output going to the Success Node. It will use the Node Type “Message Node” and the following transformation script:

/*
	Displays the response from the external API request from "terminationResponse" stored in state.
*/

var terminationResponse = nodeState.get("terminationResponse");

config = {
  "message": {
    "en": `Termination Response: ${terminationResponse ? terminationResponse : 'No response data found'}`
  },
  "messageYes": {"en": "Continue"},
  "messageNo": {"en": "Cancel"},
}

Your Journey should look something like this:


Terminating a Session Journey

In this example, we’re expecting that the user has some session information stored on their user profile. Create a test user in ForgeRock Identity Cloud (we’re using the username test) and add a recognizable string to the frUnindexedString2 attribute (by default, this attribute is labeled as Generic Unindexed String 2 in the Identity Cloud Console). The user’s profile should look something like this:


Test User Data

Open an incognito (or separate browser) window, log in as your test user, and then paste in the URL of your new Journey. You should reach your Message Node displaying the API response from the request you made.


The Termination Response in Shared State

Now, hit “Continue” to reach the user’s dashboard, log out the user, and then go back to your Journey. You should hit the Message Node indicating that no user was found.


No User Found

Summary

With this How-To you have:

  1. Called an external API and retrieved its response
  2. Stored response data in State
  3. Branched your Journey based on an API call

The full Journey, including the testing output, can be downloaded here:

Terminate Session with API Call Journey

This is part of a 4-part series on Creating Custom Single Logout Using Journeys. To continue this series, head to Part 3: Invalidating the User’s ForgeRock Session to learn how to terminate a user’s ForgeRock session while still inside a Journey.


Further reading

2 Likes