The term pass through authentication refers to an identity provider (IDP) delegating authentication to another system, which holds and validates the credentials. Pass through authentication is often a temporary measure with a specific purpose. However, sometimes it is a desirable long-term solution.
This post discusses:
- Overview and Common Scenarios
- Identity Cloud Pass Through Authentication Against Azure AD
- Login to ForgeRock Identity Cloud with Azure AD Credentials
Overview and Common Scenarios
Common scenarios for pass through authentication:
- Migrating from an existing IDP to a new IDP, while maintaining a seamless user experience.
- Adding a new IDP to your enterprise architecture to provide advanced capabilities.
- Promoting one of your applications to become the primary IDP.
Pass through authentication is a proven concept and has been used across different technologies in various settings for years. Think of virtual directories passing authentication requests to a backend system or databases allowing externalized credential validation (often against an LDAP directory) or even the pluggable authentication modules (PAM) on Linux systems allowing password validation against an external directory or database.
With the current wave of cloud migrations, many organizations face the need for pass through authentication as they move to their first cloud-based IDP or migrate from one to another.
Identity Cloud Pass Through Authentication Against Azure AD
This post focuses on the very specific situation, where current credentials exist in Azure Active Directory and ForgeRock Identity Cloud needs to be able to validate and potentially trickle-migrate these credentials.
This solution describes the “trickle migration” use case. Choose trickle migration when you cannot easily migrate credentials due to security constraints on the old and/or the new IDP. This approach has beneficial side effects, like: only migrate users who actually login and reduce utilization and potentially license cost on the side of the new IDP. Furthermore, validate passwords against the policies in the new system and upgrade to the new password storage scheme in Identity Cloud.
Solution Architecture
An authentication journey in ForgeRock Identity Cloud facilitates pass through authentication:
The principle is simple:
- Collect credentials (username and password in this example, but the journey could be combined with social login or MFA).
- Use the username to determine if there is an account with that username already registered in Identity Cloud.
- If yes, authenticate the user locally (in Identity Cloud).
- If no, perform pass though authentication against Azure AD.
- If Azure can validate the credentials, create an account with those credentials in Identity Cloud.
The journey provides as an extra bonus a means to extract additional information about the user from Azure: first and last name, email address, etc. Therefore the trickle migration produces complete accounts and not just stubs. The Authenticate Remotely node in the journey performs a Resource Owner Password Credentials (ROPC) OAuth 2.0 grant flow. Azure wraps additional account information into the access token. The Parse Access Token node extracts the information from the token and the journey then uses it to create the account in Identity Cloud.
Journey
Follow these steps to import the AzureADPassthru journey:
- Download the AzureADPassthru journey and save as
AzureADPassthru.json
. - From a linux or MacOS shell, run the following command to clone the
amtree.sh
GitHub repository:git clone https://github.com/vscheuber/AM-treetool.git
- Run amtree.sh to import the journey with a command line like this:
./amtree.sh -h [TenantURL]/am -u [TenantAdminUser] -p '[Password]' -r /alpha -i -t AzureADPassthru -f AzureADPassthru.json
e.g.:./amtree.sh -h https://openam-volker-dev.forgeblocks.com/am -u volker.scheuber@forgerock.com -p 'Sup3rS3cr3t!' -r /alpha -i -t AzureADPassthru -f AzureADPassthru.json
Hint: Use an admin account without MFA, oramtree.sh
won’t be able to authenticate. -
Make sure you update the AAD Passthru ROPC script with your Azure information.
Scripts
Review the script code behind both nodes below.
AAD Passthru ROPC Script (Authenticate Remotely Node)
/* AAD Passthru ROPC * * Author: volker.scheuber@forgerock.com * * Azure AD pass through authentication using Resource Owner Password Credential flow * * This script needs to be parametrized. It will not work properly as is. * It requires the Platform Username and Platform Password collector nodes * before it can operate. * * The Scripted Decision Node needs the following outcomes defined: * - Valid * - Invalid * - Expired * - Disabled * - Error */ logger.message("AAD Passthru ROPC: start"); if (sharedState.get("username") && transientState.get("password")) { /* * BEGIN SCRIPT CONFIGURATION * * REPLACE WITH YOUR OWN AZURE AD SETTINGS * * AAD_TENANT_ID is your tenant ID: https://docs.microsoft.com/en-us/azure/active-directory/fundamentals/active-directory-how-to-find-tenant * AAD_CLIENT_ID is your registered app ID: https://docs.microsoft.com/en-us/azure/active-directory/develop/quickstart-register-app */ var AAD_TENANT_ID = "76eef484-54f6-4e14-8a18-2d1a8336c0dd"; var AAD_CLIENT_ID = "1dd56f62-3fff-4875-8211-d6653eb1c356"; /* * END SCRIPT CONFIGURATION */ // Azure AD ROPC Configuration var AAD_SCOPE = "profile"; var AAD_RESOURCE = "https://graph.microsoft.com/" var AAD_OAUTH2_TOKEN_URI = "https://login.windows.net/".concat(AAD_TENANT_ID).concat("/oauth2/token"); var request = new org.forgerock.http.protocol.Request(); request.setMethod('POST'); request.setUri(AAD_OAUTH2_TOKEN_URI); request.getHeaders().add("Content-Type", "application/x-www-form-urlencoded"); var params = request.getForm(); params.add("resource", AAD_RESOURCE); params.add("client_id", AAD_CLIENT_ID); params.add("grant_type", "password"); params.add("scope", AAD_SCOPE); params.add("username", sharedState.get("username")); params.add("password", transientState.get("password")); request.getEntity().setString(params.toString()); var response = httpClient.send(request).get(); var result = JSON.parse(response.getEntity().getString()); //logger.message("AAD Passthru ROPC: JSON result: " + JSON.stringify(result)); if (response.getStatus().getCode() === 200) { outcome = "Valid" transientState.put("aadAccessToken", result.access_token); } else { /* Outcomes: * - Valid * - Invalid * - Expired * - Disabled * - Error * * Expected Error Codes: * 50126 - Error validating credentials due to invalid username or password. * 50055 - The password is expired. * 50057 - The user account is disabled. * 50196 - The server terminated an operation because it encountered a client request loop. Please contact your app vendor. */ if (result.error_codes.includes(50126)) { outcome = "Invalid"; } else if (result.error_codes.includes(50055)) { outcome = "Expired"; } else if (result.error_codes.includes(50057)) { outcome = "Disabled"; } else { outcome = "Error"; } logger.message("AAD Passthru ROPC: error = ".concat(result.error)); logger.message("AAD Passthru ROPC: error_description = ".concat(result.error_description)); logger.message("AAD Passthru ROPC: error_codes = ".concat(result.error_codes)); } } else { outcome = "Error"; logger.message("AAD Passthru ROPC: No user or password found in shared state! Use username and password collector nodes before this script to populate shared and transient states!'"); } logger.message("AAD Passthru ROPC: End (outcome=".concat(outcome).concat(")"));
AAD Passthru Parse Access Token Script (Parse Access Token Node)
/* AAD Passthru Parse Access Token * * Author: volker.scheuber@forgerock.com * * Parse Access Token from Azure AD pass through authentication using the * Resource Owner Password Credential flow * * This script does not need to be parametrized. It will work properly as is. * It requires the AAD Passthru ROPC script to run before and finish with the * "Valid" outcome (only outcome that results in an access token). * * The Scripted Decision Node needs the following outcomes defined: * - true * - false */ logger.message("AAD Passthru Parse Access Token: start"); /* * Base64 encode / decode * http://www.webtoolkit.info/ * * Usage: * Base64.encode('some string') * Base64.decode('some encoded string') */ var Base64={_keyStr:"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",encode:function(input){var output="";var chr1,chr2,chr3,enc1,enc2,enc3,enc4;var i=0;input=Base64._utf8_encode(input);while(i<input.length){chr1=input.charCodeAt(i++);chr2=input.charCodeAt(i++);chr3=input.charCodeAt(i++);enc1=chr1>>2;enc2=(chr1&3)<<4|chr2>>4;enc3=(chr2&15)<<2|chr3>>6;enc4=chr3&63;if(isNaN(chr2)){enc3=enc4=64}else if(isNaN(chr3)){enc4=64}output=output+this._keyStr.charAt(enc1)+this._keyStr.charAt(enc2)+this._keyStr.charAt(enc3)+this._keyStr.charAt(enc4)}return output},decode:function(input){var output="";var chr1,chr2,chr3;var enc1,enc2,enc3,enc4;var i=0;input=input.replace(/[^A-Za-z0-9\+\/\=]/g,"");while(i<input.length){enc1=this._keyStr.indexOf(input.charAt(i++));enc2=this._keyStr.indexOf(input.charAt(i++));enc3=this._keyStr.indexOf(input.charAt(i++));enc4=this._keyStr.indexOf(input.charAt(i++));chr1=enc1<<2|enc2>>4;chr2=(enc2&15)<<4|enc3>>2;chr3=(enc3&3)<<6|enc4;output=output+String.fromCharCode(chr1);if(enc3!=64){output=output+String.fromCharCode(chr2)}if(enc4!=64){output=output+String.fromCharCode(chr3)}}output=Base64._utf8_decode(output);return output},_utf8_encode:function(string){string=string.replace(/\r\n/g,"\n");var utftext="";for(var n=0;n<string.length;n++){var c=string.charCodeAt(n);if(c<128){utftext+=String.fromCharCode(c)}else if(c>127&&c<2048){utftext+=String.fromCharCode(c>>6|192);utftext+=String.fromCharCode(c&63|128)}else{utftext+=String.fromCharCode(c>>12|224);utftext+=String.fromCharCode(c>>6&63|128);utftext+=String.fromCharCode(c&63|128)}}return utftext},_utf8_decode:function(utftext){var string="";var i=0;var c=c1=c2=0;while(i<utftext.length){c=utftext.charCodeAt(i);if(c<128){string+=String.fromCharCode(c);i++}else if(c>191&&c<224){c2=utftext.charCodeAt(i+1);string+=String.fromCharCode((c&31)<<6|c2&63);i+=2}else{c2=utftext.charCodeAt(i+1);c3=utftext.charCodeAt(i+2);string+=String.fromCharCode((c&15)<<12|(c2&63)<<6|c3&63);i+=3}}return string}}; outcome = "false"; if (transientState.get("aadAccessToken")) { try { var jsonToken = parseJwt(transientState.get("aadAccessToken").toString()); setSharedObjectAttribute("givenName", jsonToken.given_name); setSharedObjectAttribute("sn", jsonToken.family_name); setSharedObjectAttribute("mail", jsonToken.unique_name); outcome = "true" } catch (e) { logger.message("AAD Passthru Parse Access Token: Exception: ".concat(e)); } } else { outcome = "false"; logger.message("AAD Passthru Parse Access Token: No access token found in transient state! Use ROPC script before this script to populate transient state!'"); } logger.message("AAD Passthru Parse Access Token: End (outcome=".concat(outcome).concat(")")); /* * Parse a non-encrypted JWT token and return its JSON body without header and signature */ function parseJwt (token) { return JSON.parse(Base64.decode("".concat(token).split('.')[1]).replace(/\0/g, '')); } /* * Store attributes in shared state for use with the Create/Patch Object nodes. */ function setSharedObjectAttribute(name, value) { var storage = sharedState.get("objectAttributes"); if (storage && value) { if (storage.put) { storage.put(name, value); } else { storage[name] = value; } } else if (value) { sharedState.put("objectAttributes", JSON.parse("{\""+name+"\":\""+value+"\"}")); } }
Login to ForgeRock Identity Cloud with Azure AD Credentials
After the configuration, it’s time for testing.