Summary
An overview of the scripting environment in AM
Updated on 01/11/2021: added OAuth2 Access Token Modification script type
Notes on Scripting in ForgeRock Access Management (AM) 7.0
Scripting in AM extends its authentication, authorization, and federation capabilities. But, it also allows for rapid development for the purpose of demonstration and testing without the need to change and recompile AM's core.
This article aims to complement the currently available and ever-improving official docs, and provide additional insights into evaluating and debugging scripts at runtime.
While developing scripts, also check for solutions in the constantly growing ForgeRock Knowledge Base.
The Scripting API Functionality available for a server-side script will depend on its application and context. All scripts in AM have access to Debug Logging and Accessing HTTP Services.
When you create a script under Realms > Realm Name > Scripts, however, you make choices that will have some additional effect on the functionality available from the script.
Futhermore, the environment in which AM is deployed may affect the configuration and debugging options during script development.
The content of this article is structured as an overview of the scripting environment in AM. It starts with common components and gets into specifics when the script language, script type, or runtime conditions introduce them.
Contents
You can always return to the Contents by selecting the Back to Contents links provided at the beginning of each section in this document.
- Bindings
- Debug Logging
- Accessing HTTP Services
- Language
-
Script Type
- Decision node script for authentication trees (Scripted Decision Node)
- OAuth2 Access Token Modification
- ForgeRock Identity Cloud
- Conclusion
Bindings
Back to Contents
Before you write a single line in your script, some of its context is already defined via bindings. The bindings exist in a script as top-level variables and provide the data available to the script, the objects to interact with, and the placeholders to communicate back to the core AM functionality.
Some of the script templates included in an AM installation (and serving as defaults for the script types) have references to the variables used in the script. Some may even explicitly state what bindings are available; for example, the OIDC Claims Script and OAuth2 Access Token Modification Script templates have a list of bindings in a commented section at the top. Others, however, are not as descriptive and rely on the developer’s knowledge.
You can output all available bindings by using the logger object methods. What you see will depend on the script type. For example, for a Scripted Decision Node script in AM 7.0:
Â
JavaScript
logger.error(Object.keys(this))
s.A.46ae269c-0403-4979-a224-31a67a91e51a: 2020-11-01 11:07:37,549: Thread[ScriptEvaluator-6]: TransactionId[f66fd450-01ce-4652-b3f6-2894e9a0344a-40594]
ERROR: auditEntryDetail,httpClient,requestHeaders,sharedState,logger,requestParameters,context,callbacks,realm,transientState,idRepository
You may encounter some less than useful messages from the scripting engine in the debug output, like the first line displayed above. In further examples in this writing, this “noise” will be mostly omitted.
For another example, the top-level variables present in OAuth2 Access Token Modification Script:
ERROR: httpClient,identity,session,logger,context,scopes,accessToken
You may notice that some bindings are specific to the script type and some are present in both outputs. The httpClient
and logger
objects are universally available for all script types.
In JavaScript, this
represents execution context, and you will see all variables defined in the top-level scope.
You can ignore the context
top-level variable, for it is not a binding, nor is it used in the context of this writing.
In addition, all top-level variables that you declared in your JavaScript will be included in the keys array. To avoid that, you could scope your code in an anonymous Immediately Invoked Function Expression.
For example:
(function () {
// your script
}())
Alternatively, you can filter out known non-bindings. The next example shows how to create an ESLint global comment from the top-level variable names:
filter = ['context', 'var1', 'var2']
logger.error('/* global ' + Object.keys(this).filter(function (e) {return filter.indexOf(e) === -1}).sort().join(', ') + ' */')
You can output the bindings with their respective values:
Object.keys(this).forEach(function (key) {
var value
try {
value = this[key]
} catch (e) {
value = e
}
logger.error(key + ": " + value)
})
In a Scripted Decision Node script, the result will look similar to the following:
ERROR: auditEntryDetail: null
ERROR: httpClient: org.forgerock.openam.scripting.api.http.JavaScriptHttpClient@47b3daf4
ERROR: requestHeaders: {accept=[application/json, text/javascript, */*; q=0.01], accept-api-version=[protocol=1.0,resource=2.1], accept-encoding=[gzip, deflate], accept-language=[en-US], cache-control=[no-cache], connection=[keep-alive], content-length=[1914], content-type=[application/json], cookie=[amlbcookie=01], host=[openam.example.com:8080], origin=[http://openam.example.com:8080], referer=[http://openam.example.com:8080/openam/XUI/], user-agent=[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.80 Safari/537.36], x-nosession=[true], x-password=[anonymous], x-requested-with=[XMLHttpRequest], x-username=[anonymous]}
ERROR: sharedState: {realm=/, authLevel=0, username=user.0}
ERROR: logger: com.sun.identity.shared.debug.Debug@7d6c1ced
ERROR: requestParameters: {authIndexType=[service], authIndexValue=[scripted], realm=[/]}
ERROR: context: javax.script.SimpleScriptContext@7b7b832f
ERROR: callbacks: []
ERROR: realm: /
ERROR: transientState: {}
ERROR: idRepository: org.forgerock.openam.scripting.idrepo.ScriptIdentityRepository@40fa0a75
Instead of logging out each binding separately, you can add new lines to the output. For an OAuth2 Access Token Modification Script example:
var bindings = []
Object.keys(this).forEach(function (key) {
var value
try {
value = this[key]
} catch (e) {
value = e
}
bindings.push(key + ": " + value)
})
logger.error(bindings.join("\n"))
ERROR: httpClient: org.forgerock.http.Client@6940ab1e
[CONTINUED]identity: AMIdentity object: id=user.4,ou=user,ou=am-config
[CONTINUED]session: com.iplanet.sso.providers.dpro.SessionSsoToken@1f9baf32
[CONTINUED]logger: com.sun.identity.shared.debug.Debug@115c52b1
[CONTINUED]bindings: httpClient: org.forgerock.http.Client@6940ab1e,identity: AMIdentity object: id=user.4,ou=user,ou=am-config,session: com.iplanet.sso.providers.dpro.SessionSsoToken@1f9baf32,logger: com.sun.identity.shared.debug.Debug@115c52b1
[CONTINUED]context: InternalError: Access to Java class "javax.script.SimpleScriptContext" is prohibited. (<Unknown source>#9)
[CONTINUED]scopes: [openid, profile]
[CONTINUED]accessToken: nYS7VDGXU7phTSvRdaNmLvTLamU
Â
Groovy
logger.error(binding.variables.toString())
Initially, you may get an error due to the scripting engine security settings, as described in Language > Allowed Java Classes:
ERROR: Script terminated with exception
java.util.concurrent.ExecutionException: javax.script.ScriptException: javax.script.ScriptException: java.lang.SecurityException: Access to Java class "org.codehaus.groovy.jsr223.GroovyScriptEngineImpl$2" is prohibited.
When the reported org.codehaus.groovy.jsr223.GroovyScriptEngineImpl$2
is added to the allowed Java classes, you will also need to add org.forgerock.openam.scripting.ChainedBindings
in order to see the output. For a scripted decision example, you will see an output similar to the following:
ERROR: [auditEntryDetail:null, httpClient:org.forgerock.openam.scripting.api.http.GroovyHttpClient@5e35260, requestParameters:[authIndexType:[service], authIndexValue:[scripted], realm:[/]], idRepository:org.forgerock.openam.scripting.idrepo.ScriptIdentityRepository@9ede4f7, realm:/, logger:com.sun.identity.shared.debug.Debug@7d6c1ced, callbacks:[], requestHeaders:[accept:[application/json, text/javascript, */*; q=0.01], accept-api-version:[protocol=1.0,resource=2.1], accept-encoding:[gzip, deflate], accept-language:[en-US], cache-control:[no-cache], connection:[keep-alive], content-length:[1914], content-type:[application/json], cookie:[amlbcookie=01], host:[openam.example.com:8080], origin:[http://openam.example.com:8080], referer:[http://openam.example.com:8080/openam/XUI/], user-agent:[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.80 Safari/537.36], x-nosession:[true], x-password:[anonymous], x-requested-with:[XMLHttpRequest], x-username:[anonymous]], transientState:[:], sharedState:[realm:/, authLevel:0, username:user.0]]
To make this more readable, you can log out each variable separately:
binding.variables.each { key, value -> logger.error(key + ": " + value)}
ERROR: auditEntryDetail: null
ERROR: idRepository: org.forgerock.openam.scripting.idrepo.ScriptIdentityRepository@27dabf86
ERROR: realm: /
ERROR: logger: com.sun.identity.shared.debug.Debug@7d6c1ced
ERROR: callbacks: []
ERROR: httpClient: org.forgerock.openam.scripting.api.http.GroovyHttpClient@36c87365
ERROR: requestHeaders: [accept:[application/json, text/javascript, */*; q=0.01], accept-api-version:[protocol=1.0,resource=2.1], accept-encoding:[gzip, deflate], accept-language:[en-US], cache-control:[no-cache], connection:[keep-alive], content-length:[1914], content-type:[application/json], cookie:[amlbcookie=01], host:[openam.example.com:8080], origin:[http://openam.example.com:8080], pragma:[no-cache], referer:[http://openam.example.com:8080/openam/XUI/?service=scripted], user-agent:[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.80 Safari/537.36], x-nosession:[true], x-password:[anonymous], x-requested-with:[XMLHttpRequest], x-username:[anonymous]]
ERROR: transientState: [:]
ERROR: sharedState: [realm:/, authLevel:0, username:user.0]
ERROR: requestParameters: [authIndexType:[service], authIndexValue:[scripted], realm:[/], service:[scripted]]
Or, you can add new lines to the output:
def bindings = ""
binding.variables.each {
key, value ->
bindings += key + ": " + value + "\n"
}
logger.error("Bindings: " + bindings)
ERROR: Bindings:
[CONTINUED]auditEntryDetail: null
[CONTINUED]idRepository: org.forgerock.openam.scripting.idrepo.ScriptIdentityRepository@29fdc7f2
[CONTINUED]realm: /
[CONTINUED]logger: com.sun.identity.shared.debug.Debug@7d6c1ced
[CONTINUED]callbacks: []
[CONTINUED]httpClient: org.forgerock.openam.scripting.api.http.GroovyHttpClient@3290ae0d
[CONTINUED]transientState: [:]
[CONTINUED]sharedState: [realm:/, authLevel:0, username:user.0]
[CONTINUED]requestHeaders: [accept:[application/json, text/javascript, */*; q=0.01], accept-api-version:[protocol=1.0,resource=2.1], accept-encoding:[gzip, deflate], accept-language:[en-US], cache-control:[no-cache], connection:[keep-alive], content-length:[2543], content-type:[application/json], cookie:[amlbcookie=01], host:[openam.example.com:8080], origin:[http://openam.example.com:8080], pragma:[no-cache], referer:[http://openam.example.com:8080/openam/XUI/?service=scripted], user-agent:[Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.80 Safari/537.36], x-nosession:[true], x-password:[anonymous], x-requested-with:[XMLHttpRequest], x-username:[anonymous]]
[CONTINUED]requestParameters: [authIndexType:[service], authIndexValue:[scripted], realm:[/], service:[scripted]]
[CONTINUED]
When you know your bindings, you can inspect them individually:
Â
JavaScript or Groovy
logger.error("scopes: " + scopes)
ERROR: scopes: [openid]
Outputting the bindings might not necessarily tell you what the script is expected to produce. For example, the Scripted Decision Node > Outcomes are not declared by default.
In addition, you may benefit from knowing what Java object a binding implements, and what methods associated with this object you may be able to utilize. In order to know what a binding represents, you can use the class
property in Rhino and the getClass()
method in Groovy. For example, in a scripted decision node script, you can check class of the sharedState
object:
Â
JavaScript
logger.error("sharedState class: " + sharedState.class)
ERROR: sharedState class: class java.util.LinkedHashMap
You will have to (temporarily!) remove
java.lang.Class
from the disallowed Java classes, and add it to the allowed classes list for the script type in order to be able to check theclass
property in JavaScript. More details on this are provided in the Language > Allowed Java Classes section.
Â
Groovy
Armed with this knowledge, you can now use some of the java.util.LinkedHashMap methods:
Â
JavaScript or Groovy
logger.error("sharedState contains value: " + sharedState.containsValue("user.0"))
logger.error("transientState contains key: " + transientState.containsKey("password"))
ERROR: sharedState contains value: true
ERROR: transientState contains key: true
Â
Groovy
sharedState.forEach {
key, value ->
logger.error(key + ": " + value)
}
ERROR: realm: /
ERROR: authLevel: 0
ERROR: username: user.0
Other
LinkedHashMap
methods may need to be explicitly allowed in the scripting engine configuration. See the Language > Allowed Java Classes section for details.
Another common encounter in AM scripts is the java.util.HashSet class. You can find some relevant examples in the OAuth2 Access Token Modification > scopes and Scripted Decision Node > idRepository sections of this article.
Debug Logging
Independent of the script type, you can use the Debug Logging and HTTP Services APIs in AM.
AM scripts are stored in configuration data, and there is no well-known way to attach a debugger to an AM script. As an alternative to a proper debugger, you can use the logger
object. As described in Getting Started with Scripting > Debug Logging, methods of the logger
object can be used to capture runtime information from the scripts, and output it in AM logs.
By default, debug logs are saved in files at a location specified in the AM console under CONFIGURE > SERVER DEFAULTS > General > Debugging. In AM’s Maintenance Guide > Debug Logging you can find information on how to control this default functionality.
If your AM stores debug logs in files and you have access to them, you can tail -f
the logs during development. For example:
$ cd ~/openam/var/debug
$ ls
Authentication Federation OtherLogging Radius amUpgrade IdRepo Plugins Session Configuration OAuth2Provider Policy UmaProvider CoreSystem OpenDJ-SDK Push WebServices
Depending on the information to be logged, and on the script application and its type, the logs you are seeking may end up in one of the above categories. But in general, script-related logs could be expected in the OtherLogging
file. For example:
$ tail -f OtherLoggings
. . .
ERROR: Script terminated with exception
java.util.concurrent.ExecutionException: javax.script.ScriptException: Access to Java class "java.lang.Class" is prohibited. (<Unknown source>#51) in <Unknown source> at line number 51 at column number 0
[CONTINUED] at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
. . .
In other environments, the logs data may be sent to the standard output or, as in the case of ForgeRock Identity Cloud (Identity Cloud), exposed via REST. Follow the deployment-specific documentation in order to access AM debugging output. For example:
When you know where to find the logs and how to control the level of the debug output, you can inspect the debug data for possible reasons your script is not working and/or for the information it outputs.
As illustrated in the Bindings chapter, with the logger methods, you can proactively output the script context. You can also output result of an operation, content of an object, a marker, etc., anything that could be converted into a string (explicitly in Groovy or implicitly in JavaScript). For example, you could output the content of the sharedState
binding in the scripted decision context at some point during the authentication process:
Â
JavaScript
logger.error(sharedState)
ERROR: sharedState: {realm=/, authLevel=0, username=user.0, FirstName=Olaf, LastName=Freeman, errorMessage=ReferenceError: "getState" is not defined., clientScriptOutputData={"ip":{"ip":"73.67.228.195"}}, successUrl=http://openam.example.com:8080/openam/XUI/?authIndexType=service&authIndexValue=scripted&test=successUrl#dashboard/}
Â
Groovy
In Groovy, you have to deliberately feed the logger methods with a String, for which purpose you can use toString()
, or you can also concatenate a string and the variable:
logger.error(sharedState.toString())
logger.error("sharedState: " + sharedState)
ERROR: [realm:/, authLevel:0, username:user.0]
ERROR: sharedState: [realm:/, authLevel:0, username:user.0]
Otherwise, you may get an error:
logger.error(sharedState)
ERROR: Script terminated with exception
java.util.concurrent.ExecutionException: javax.script.ScriptException: javax.script.ScriptException: groovy.lang.MissingMethodException: No signature of method: com.sun.identity.shared.debug.Debug.error() is applicable for argument types: (LinkedHashMap) values: [[realm:/, authLevel:0, username:user.0]]
[CONTINUED]Possible solutions: error(java.lang.String), error(java.lang.String, [Ljava.lang.Object;), error(java.lang.String, java.lang.Throwable), grep(), every(), iterator()
[CONTINUED] at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
You can also try and catch and output an error:
Â
JavaScript or Groovy
try {
doSomething()
logger.message("Something is done.")
} catch (e) {
logger.error("Exception occurred: " + e)
}
Â
JavaScript
ERROR: Exception occurred: ReferenceError: "doSomething" is not defined.
Â
Groovy
ERROR: Exception occurred: java.lang.SecurityException: Access to Java class "Script226" is prohibited.
While debugging, you don’t always have to rely on the logs. You can save your error in an available object and carry on with the execution. Then, at some point, you may be able to have the saved content included in the user agent response.
For example, in the scripted decision environment, you can include debugging information in a browser response with the help of a special binding, callbacks
; or, you can preserve it in a custom error message that will be displayed at the end of an unsuccessful authentication. Examples of these approaches could be found in the Scripted Decision Node > Debugging section of this document.
Logs provide a useful context for exceptions and are the main source of debugging information. On the other hand, saving error messages in an available binding and displaying their content on the client side can help you quickly evaluate the scripting functionality, and doing so does not require direct access to the logs nor the efforts for obtaining and filtering them. This may prove useful in environments similar in this regard to ForgeRock Identity Cloud.
Accessing HTTP Services
Accessing HTTP Services provides an example of instantiating the org.forgerock.http.protocol.Request class for preparing an outbound HTTP call from a server-side JavaScript:
Â
JavaScript
var request = new org.forgerock.http.protocol.Request()
In this case, an instance of a class is assigned to a JavaScript variable, but there are other ways of extending server-side scripts with Java, which will be discussed in Language > Scripting Java.
Before sending a request, you can use a number of methods described in the public Java doc to inspect and modify the request object. For example, you can warn the server via the request headers that you are POSTing a JSON content, and/or you can authorize the request with an access token (obtained separately):
Â
JavaScript
var request = new org.forgerock.http.protocol.Request()
var requestBodyJson = {
"param1": "value1",
"param2": "value2"
}
var requestBody = JSON.stringify(requestBodyJson)
request.setMethod("POST")
request.getHeaders().add("Content-Type", "application/json; charset=UTF-8")
request.getHeaders().add("Authorization", "Bearer " + sharedState.get("accessToken")) // 1
request.getEntity().setString(requestBody) // 2
Â
Groovy
The Groovy version will require importing a JSON object to stringify the request body.
import org.forgerock.http.protocol.Request
import groovy.json.JsonOutput
def request = new Request()
def requestBodyJson = [
"param1": "value1",
"param2": "value2"
]
def requestBody = JsonOutput.toJson(requestBodyJson)
request.setMethod("POST")
request.getHeaders().add("Content-Type", "application/json; charset=UTF-8")
request.getHeaders().add("Authorization", "Bearer " + sharedState.get("accessToken")) // 1
request.getEntity().setString(requestBody) // 2
- In this case, the access token is delivered by a special
sharedState
object existing in the context of an authentication tree. - If for some reason you don’t enjoy typing, you can use the setEntity() convenience method instead of calling
setString()
on the request entity:
request.setEntity(requestBody)
Then, you can send the prepared request with the help of the httpClient
object provided as a binding to scripts of all types in AM.
In the following example, we check if the IP derived from the client side (there will be an example of doing so later in this writing) is a healthy one, according to an external resource. The resource will be inquired by making an outbound request with httpClient
and receiving a Response from the remote API:
Â
JavaScript
var failure = true
var ip = JSON.parse(sharedState.get("clientScriptOutputData")).ip // 1
var fr = JavaImporter(
org.forgerock.http.protocol.Request
)
var request = new fr.Request()
request.setUri("https://api.antideo.com/ip/health/" + ip.ip)
request.setMethod("GET")
var response = httpClient.send(request).get()
if (response.getStatus().getCode() === 200) {
var ipHealth = JSON.parse(response.getEntity().getString()).health
failure = !ipHealth || (ipHealth.toxic || ipHealth.proxy || ipHealth.spam)
} else {
failure = true
}
Â
Groovy
The Groovy version will again require explicit JSON support in order to be able to process the response:
import org.forgerock.http.protocol.Request
import groovy.json.JsonSlurper
def jsonSlurper = new JsonSlurper()
def failure = true
def ip = jsonSlurper.parseText(sharedState.get("clientScriptOutputData")).ip // 1
def request = new Request()
request.setUri("https://api.antideo.com/ip/health/" + ip.ip)
request.setMethod("GET")
def response = httpClient.send(request).get()
if (response.getStatus().getCode() == 200) {
def ipHealth = jsonSlurper.parseText(response.getEntity().getString()).health
failure = (ipHealth.toxic || ipHealth.proxy || ipHealth.spam)
} else {
failure = true
}
- This code assumes that something like
'{"ip": {"ip":"65.113.98.10"}}'
is stored under the “clientScriptOutputData” key insharedState
.
Thus, the scripting functionality can be greatly extended with access to external resources of all kinds.
It is worth reminding that httpClient
requests are synchronous and blocking until they are completed. There is currently no apparent way to control the timeout of an individual HTTP request made with the send(Request request) method.
You can, however, specify a timeout for the script execution in the AM console under Configure > Global Services > Scripting > Secondary Configurations > Script Type Name > Secondary Configurations > EngineConfiguration > Server-side Script Timeout. When the script timeout occurs, the script execution will stop, and the procedure the script is part of will fail.
Alternatively, you may choose to allow HTTP requests to timeout, leave the Server-side Script Timeout at its default 0
(which means no timeout), or populate it with a high number, and catch unsuccessful requests. For an illustration, let’s visit the Google website over a port other than 443:
Â
JavaScript or Groovy
var request = new org.forgerock.http.protocol.Request()
request.setUri("https://www.google.com:123") // Timeout the request.
request.setMethod("GET")
try {
var response = httpClient.send(request).get()
} catch (e) {
logger.error("Exception: " + e)
}
if (!response) {
logger.error("No response.")
} else if (response.getStatus().getCode() == 200) {
logger.error("Response: " + response.getEntity().getString())
} else {
logger.error("Response code: " + response.getStatus().getCode())
}
ERROR: Exception: JavaException: java.util.concurrent.ExecutionException: java.lang.RuntimeException: java.net.ConnectException: Timeout connecting to [www.google.com/216.58.217.36:123]
ERROR: No response.
In Groovy, you will need to add
java.util.concurrent.ExecutionException
to the allowed Java classes in order to catch the exception.
Handling HTTP timeouts this way will let you proceed with the flow the script is a part of.
Language
You need to watch your language while writing scripts in AM, for your choice of scripting engine may require different syntax and will affect the runtime environment as well. Server-side scripts in AM 7.0 can be written in Groovy 3.0.x or JavaScript running on Rhino 1.7R4.
You can check your Groovy version with the following:
logger.error("Groovy version: " + GroovySystem.version)
Doing so will require
groovy.lang.GroovySystem
to be added to the list of Allowed Java Classes.While GroovySystem.version reports 3.0.4 in AM 7.0.0, not all of the new functionality seems to be supported at this time.
Scripting Java
The scripting capabilities can be extended with publicly available Java packages.
The way underlying Java is employed in a script is different between the two scripting engines.
Consider examples in the Scripted Decision Node section of this writing. In both engines, you can use a fully qualified class name inline:
Â
JavaScript or Groovy
action = org.forgerock.openam.auth.node.api.Action.goTo("true").putSessionProperty("customKey", "customValue").build()
If you have to reference an object many times, using the fully qualified name can quickly make it crowded and hard to read in the script editor. Groovy follows Java and allows for an import
statement:
Â
Groovy
import org.forgerock.openam.auth.node.api.Action
action = Action.goTo("true").putSessionProperty("customKey", "customValue").build()
Rhino implements its own ways of Scripting Java. In Rhino, a reference to a package, a static method, and sometimes an instance of a class can be assigned to a variable:
Â
JavaScript
var callback = javax.security.auth.callback // Package.
var firstNameCallback = new javax.security.auth.callback.NameCallback("First Name") // Instance.
var goTo = org.forgerock.openam.auth.node.api.Action.goTo // Static method.
var send = org.forgerock.openam.auth.node.api.Action.send // Static method.
var lastNameCallback = new callback.NameCallback("Last Name", "Sure")
if (callbacks.isEmpty()) {
action = send(
firstNameCallback,
lastNameCallback
).build()
} else {
sharedState.put("firstName", callbacks.get(0).getName())
sharedState.put("lastName", callbacks.get(1).getName())
action = goTo("true").build()
}
You can also use JavaImporter Constructor in Rhino, which allows for reusing explicit class or package references by putting them in a namespace. For example:
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
com.sun.identity.authentication.callbacks.HiddenValueCallback,
com.sun.identity.authentication.callbacks.ScriptTextOutputCallback
)
with (fr) {
var script = "var confirmation = confirm('something') \n\
document.getElementById('clientScriptOutputData').value = JSON.stringify({ \n\
confirmation: confirmation \n\
}) \n\
\n\
document.getElementById('loginButton_0').click()"
if (callbacks.isEmpty()) {
action = Action.send(
new HiddenValueCallback("clientScriptOutputData", "false"),
new ScriptTextOutputCallback(script)
).build()
} else {
sharedState.put("clientScriptOutputData", callbacks.get(0).getValue())
}
}
In general, use of the with statement in JavaScript is not recommended due to ambiguity and potential performance and compatibility issues. Instead, you can prefix the desired object name with the namespace variable you assigned the imported content to:
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
javax.security.auth.callback.NameCallback
)
if (callbacks.isEmpty()) {
action = fr.Action.send(
new fr.NameCallback("Enter Your First Name"),
new fr.NameCallback("Enter Your Last Name")
).build();
} else {
sharedState.put("FirstName", callbacks.get(0).getName());
sharedState.put("LastName", callbacks.get(1).getName());
action = fr.Action.goTo("true").build();
}
Another potential benefit of using JavaImporter
could be the more discernible errors it produces.
For example, com.sun.identity.idm.IdUtils is not currently allowed by default in AM 7. If you attempt to call its getIdentity
method with the full path inline, or by assigning either the class or the method reference to a variable, you may receive somewhat misleading errors:
Â
JavaScript
var username = "user.0"
var realm = "/"
var IdUtils = com.sun.identity.idm.IdUtils
var getIdentity = com.sun.identity.idm.IdUtils.getIdentity
try {
var id = com.sun.identity.idm.IdUtils.getIdentity(username, realm)
} catch (e) {
logger.error(e)
}
try {
var id = IdUtils.getIdentity(username, realm)
} catch (e) {
logger.error(e)
}
try {
var id = getIdentity(username, realm)
} catch (e) {
logger.error(e)
}
ERROR: TypeError: Cannot call property getIdentity in object [JavaPackage com.sun.identity.idm.IdUtils]. It is not a function, it is "object".
ERROR: TypeError: Cannot call property getIdentity in object [JavaPackage com.sun.identity.idm.IdUtils]. It is not a function, it is "object".
ERROR: TypeError: getIdentity is not a function, it is object.
With JavaImporter
, the error will immediately indicate the class unavailability; thus, hinting to possible restrictions in the scripting engine configuration:
Â
JavaScript
var fr = JavaImporter(
com.sun.identity.idm.IdUtils
)
try {
var id = fr.IdUtils.getIdentity(username, realm)
} catch (e) {
logger.error(e)
}
TypeError: Cannot call method "getIdentity" of undefined
Similarly, if you try to detect the Rhino version in a script, you’ll need to import the org.mozilla.javascript.Context
class, which is not allowed in AM 7.0.0 by default. Assigning this class to a variable or calling it directly will produce “it is object” errors, and using javaImporter
will show the class as undefined:
Â
JavaScript
try {
var Context = org.mozilla.javascript.Context
var currentContext = Context.getCurrentContext()
var rhinoVersion = currentContext.getImplementationVersion()
logger.error("Rhino Version: " + rhinoVersion)
} catch (e) {
logger.error("Exception: " + e)
}
ERROR: Exception: TypeError: Cannot call property getCurrentContext in object [JavaPackage org.mozilla.javascript.Context]. It is not a function, it is "object".
try {
var rhino = JavaImporter(
org.mozilla.javascript.Context
)
var currentContext = rhino.Context.getCurrentContext()
var rhinoVersion = currentContext.getImplementationVersion()
logger.error("Rhino Version: " + rhinoVersion)
} catch (e) {
logger.error("Exception: " + e)
}
ERROR: Exception: TypeError: Cannot call method "getCurrentContext" of undefined
Of course, by now, you don’t need JavaImporter
to tell you what “is object” might mean in an error. But even after adding org.mozilla.javascript.Context
to the allowed list, you would still get a non-telling error from the variable assignment syntax:
ERROR: Exception: InternalError: Access to Java class "java.lang.Class" is prohibited. (<Unknown source>#9)
At the same time, the JavaImporter
syntax will produce unambiguous:
ERROR: Exception: InternalError: Access to Java class "org.forgerock.openam.scripting.timeouts.ObservedContextFactory$ObservedJavaScriptContext" is prohibited. (<Unknown source>#24)
Allowing
org.forgerock.openam.scripting.timeouts.ObservedContextFactory$ObservedJavaScriptContext
will continue to puzzle adepts of the variable assignment approach:
ERROR: Exception: InternalError: Access to Java class "java.lang.Class" is prohibited. (<Unknown source>#9)
While using JavaImporter
will finally work:
ERROR: Rhino Version: Rhino 1.7 release 4 2012 06 18
For all the above reasons, using JavaImporter
and a namespace variable syntax is recommended for scripting Java in JavaScript in AM 7.
Allowed Java Classes
The selection of a scripting engine also makes difference in how the Scripting Environment Security is applied.
The allowed Java classes are defined in the AM console under Configure > Global Services > Scripting > Secondary Configurations > Script Type > Secondary Configurations > engineConfiguration > Java class whitelist, as described in Global Services > Scripting > Engine Configuration.
If a class is used by a script and is not present in the allowed list, you may encounter an error. If unhandled, the exception in your logs will look similar to the following:
o.f.o.s.ThreadPoolScriptEvaluator: 2020-11-01 09:20:40,525: Thread[http-nio-8080-exec-41]: TransactionId[f66fd450-01ce-4652-b3f6-2894e9a0344a-44339]
ERROR: Script terminated with exception
java.util.concurrent.ExecutionException: javax.script.ScriptException: javax.script.ScriptException: java.lang.SecurityException: Access to Java class "org.apache.groovy.json.internal.LazyMap" is prohibited.
[CONTINUED] at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
[CONTINUED] at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:205)
[CONTINUED] at org.forgerock.openam.scripting.ThreadPoolScriptEvaluator.evaluateScript(ThreadPoolScriptEvaluator.java:89)
[CONTINUED] at org.forgerock.openam.auth.nodes.ScriptedDecisionNode.process(ScriptedDecisionNode.java:197)
[CONTINUED] at org.forgerock.openam.auth.trees.engine.AuthTreeExecutor.process(AuthTreeExecutor.java:143)
. . .
[CONTINUED] at java.base/java.lang.Thread.run(Thread.java:834)
[CONTINUED]Caused by: javax.script.ScriptException: javax.script.ScriptException: java.lang.SecurityException: Access to Java class "org.apache.groovy.json.internal.LazyMap" is prohibited.
[CONTINUED] at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:158)
. . .
[CONTINUED] ... 9 common frames omitted
[CONTINUED]java.util.concurrent.ExecutionException: javax.script.ScriptException: javax.script.ScriptException: java.lang.SecurityException: Access to Java class "org.apache.groovy.json.internal.LazyMap" is prohibited.
at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
at java.base/java.util.concurrent.FutureTask.get(FutureTask.java:205)
at org.forgerock.openam.scripting.ThreadPoolScriptEvaluator.evaluateScript(ThreadPoolScriptEvaluator.java:89)
at org.forgerock.openam.auth.nodes.ScriptedDecisionNode.process(ScriptedDecisionNode.java:197)
at org.forgerock.openam.auth.trees.engine.AuthTreeExecutor.process(AuthTreeExecutor.java:143)
at org.forgerock.openam.auth.trees.engine.AuthTreeExecutor.process(AuthTreeExecutor.java:192)
at org.forgerock.openam.core.rest.authn.trees.AuthTrees.processTree(AuthTrees.java:464)
at org.forgerock.openam.core.rest.authn.trees.AuthTrees.evaluateTreeAndProcessResult(AuthTrees.java:280)
at org.forgerock.openam.core.rest.authn.trees.AuthTrees.invokeTree(AuthTrees.java:272)
at org.forgerock.openam.core.rest.authn.RestAuthenticationHandler.authenticate(RestAuthenticationHandler.java:228)
at org.forgerock.openam.core.rest.authn.http.AuthenticationServiceV1.authenticate(AuthenticationServiceV1.java:157)
at jdk.internal.reflect.GeneratedMethodAccessor258.invoke(Unknown Source)
. . .
at java.base/java.lang.Thread.run(Thread.java:834)
Caused by: javax.script.ScriptException: javax.script.ScriptException: java.lang.SecurityException: Access to Java class "org.apache.groovy.json.internal.LazyMap" is prohibited.
at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:158)
. . .
at org.codehaus.groovy.jsr223.GroovyScriptEngineImpl.eval(GroovyScriptEngineImpl.java:317)
... 9 common frames omitted
This is a slightly shortened version of the error, which in real life takes 296 lines in standard output. Hence, it is very visible in the logs, except the cases where unfiltered content contains many unhandled errors.
The code responsible for the message above may look like the following:
Â
Groovy
import groovy.json.JsonSlurper
def stringifiedJson = '{"key": "value"}'
def jsonSlurper = new JsonSlurper()
def json = jsonSlurper.parseText(stringifiedJson)
While groovy.json.JsonSlurper
is included by default in the allowed Java classes for all script types, you may still need to explicitly add org.apache.groovy.json.internal.LazyMap
to the list in order for the JsonSlurper instance to work.
It should be noted that while Groovy may be indispensible in certain environments, or even the only scripting option, you are encouraged to use JavaScript in AM in places where control over the scripting engine configuration may not be exposed to AM admins, as currently is the case in ForgeRock Identity Cloud.
Out of the box, JavaScript will expose less restricted behavior for some commonly requested functionality, while Groovy scripts may need certain Java classes explicitly allowed. In the example above, the JavaScript equivalent of the code will work without any action taken in the scripting engine configuration:
Â
JavaScript
var stringifiedJson = '{"key": "value"}'
var json = JSON.parse(stringifiedJson)
For another example, you may need to check if a variable is declared in a Groovy script:
Â
Groovy
if (binding.hasVariable("existingSession")) {
existingAuthLevel = existingSession.get("AuthLevel")
} else {
logger.error("Variable existingSession not declared - not a session upgrade.")
}
Doing so will require a Java class to be allowed, org.codehaus.groovy.jsr223.GroovyScriptEngineImpl$2
, which will become evident from an error:
ERROR: Script terminated with exception
java.util.concurrent.ExecutionException: javax.script.ScriptException: javax.script.ScriptException: java.lang.SecurityException: Access to Java class "org.codehaus.groovy.jsr223.GroovyScriptEngineImpl$2" is prohibited.
[CONTINUED] at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
. . .
Caused by: java.lang.SecurityException: Access to Java class "org.codehaus.groovy.jsr223.GroovyScriptEngineImpl$2" is prohibited.
at org.forgerock.openam.scripting.sandbox.GroovySandboxValueFilter.filter(GroovySandboxValueFilter.java:74)
. . .
In JavaScript, `typeof` won't require any additional permissions:
Â
JavaScript
if (typeof existingSession !== "undefined") {
existingAuthLevel = existingSession.get("AuthLevel")
} else {
logger.error("Variable existingSession not declared - not a session upgrade.");
}
Or maybe, you want to list all available bindings in Groovy:
Â
Groovy
logger.error(binding.variables.toString())
The following error will indicate that you also need to allow the org.forgerock.openam.scripting.ChainedBindings
class:
ERROR: Script terminated with exception
java.util.concurrent.ExecutionException: javax.script.ScriptException: javax.script.ScriptException: java.lang.SecurityException: Access to Java class "org.forgerock.openam.scripting.ChainedBindings" is prohibited.
[CONTINUED] at java.base/java.util.concurrent.FutureTask.report(FutureTask.java:122)
. . .
Caused by: java.lang.SecurityException: Access to Java class "org.forgerock.openam.scripting.ChainedBindings" is prohibited.
at org.forgerock.openam.scripting.sandbox.GroovySandboxValueFilter.filter(GroovySandboxValueFilter.java:74)
. . .
The JavaScript equivalent will work by default:
Â
JavaScript
logger.error(Object.keys(this))
And if you try to catch a GroovyRuntimeException, you will need to add the exception class to the allowed list as well. Otherwise, your script may be terminated with an unhandled exception. For example, for this particular try/catch
block below, groovy.lang.MissingPropertyException
will need to be permitted when the authentication session is not an upgrade in the context of a scripted decision node:
Â
JavaScript or Groovy
try {
var existingAuthLevel = existingSession.get("AuthLevel")
} catch (e) {
logger.error(e)
}
Nothing special needs to be done in order for this code to work in JavaScript.
For another example, note the differences in the requirements between the scripting engines in the following code:
Â
JavaScript or Groovy
var username = sharedState.get("username")
var attribute = "mail"
var email
try {
email = idRepository.getAttribute(username, attribute).toArray()[100]
logger.message("User's email: " + email)
} catch(e) {
logger.error("catch: " + e)
}
If “email” is not an attribute in the identity, or there is no member at the requested array index, the all-forgiving JavaScript will proceed with the undefined value, but Groovy will produce an error:
ERROR: catch: java.lang.ArrayIndexOutOfBoundsException: Index 100 out of bounds for length 0
But again, that exception is only handled in Groovy if java.lang.ArrayIndexOutOfBoundsException
is permitted in the scripting engine security settings.
However, sometimes, Groovy may allow for easier interaction with the underlying Java.
There might be cases when you need to additionally import a common Java class in JavaScript. For example, your data could be returned in char[]
, as in the case of javax.security.auth.callback.PasswordCallback.getPassword()
. In order to convert the value into a String, you will need to import the java.lang.String
class.
This particular class is currently allowed by default for all server-side scripts in AM 7, and using it should not require changes in the scripting engine configuration.
An example from scripted decision callbacks:
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
javax.security.auth.callback.PasswordCallback,
java.lang.String // 1
)
if (callbacks.isEmpty()) {
action = fr.Action.send(
new fr.PasswordCallback("password hint", false)
).build()
} else {
transientState.put("password", fr.String(callbacks.get(0).getPassword())) // 1, 2
action = fr.Action.goTo("true").build()
}
- We need this in JavaScript to convert
char[]
returned bygetPassword()
to a String. - Save the stringified value in
transientState
.
Â
For another example, out of the box, you may use getClass()
to find out what Java object a variable implements, as described in Bindings. In JavaScript, the alternative is checking the object’s class
property, and for doing so, you’d need to allow java.lang.Class
, which is explicitly prohibited by default.
Iterating over an object might be easier in Groovy as well. For example, the sharedState
object in a scripted decision represents java.util.LinkedHashMap. Its forEach()
method does not work with the JavaScript syntax, but you could evaluate or process the content of sharedState
dynamically by iterating over the list of its keys:
Â
JavaScript
sharedState.keySet().toArray().forEach(function (key) {
logger.error(key + ": " + sharedState.get(key))
})
On this occasion, in order to make this JavaScript to work, you’d need to add java.util.LinkedHashMap$LinkedKeySet
to the allowed Java classes in your scripting engine configuration. In Groovy, you can omit that step and use the allowed by default forEach()
method:
Groovy
sharedState.forEach {
key, value ->
logger.error(key + ": " + value)
}
More on Rhino
The server-side JavaScript in AM is running on Rhino. In this environment, some things may not work the same way they do in native JavaScript implementations.
Use Function Scope
You might experience unexpected behavior in the top-level scope of an AM script.
One known behavior is that assigning a Java object to a variable in the script’s global context might convert it to a Rhino or JavaScript-specific type. Consider the following example:
var javaString = new java.lang.String()
logger.error("javaString.class: " + javaString.getBytes)
Here, we are trying to check fo presence of the
.getBytes
method and thus, determine whether the variable is assigned a instance of thejava.lang.String
class.Checking directly for the class name would require access to the
java.lang.Class
class, which is explicitly prohibited by default in the AM scripting engine configuration.
If this were literally the content of your scripted decision, and the code is placed in the top-level scope, you will see in the logs that the .getBytes
method is undefined:
ERROR: javaString.class: undefined
If you, however, move the same code into a function, the Java type will be preserved in the variable and you will see the stringified representation of the .getBytes
method:
(function () {
var javaString = new java.lang.String()
logger.error("javaString.class: " + javaString.getBytes)
})
ERROR: javaString.class: function getBytes() {/*\nvoid getBytes(int,int,byte[],int)\nbyte[] getBytes()\nbyte[] getBytes(java.nio.charset.Charset)\nbyte[] getBytes(java.lang.String)\n*/}\n
This is because in the top-level scope, the Java string will be converted into org.mozilla.javascript.ConsString class, which does not have a .getBytes
method.
The take away here is that you should put ALL your code into a function, including the option of wrapping your entire script in an Immediately Invoked Function Expression (IIFE).
This way you will insure consistent and predictable behavior of type coercion in your AM script.
String Comparison
You may also encounter Strict Equality Comparison not working in some cases. For example, a String value stored in requestParameters
or requestHeaders
objects, and values returned by the idRepository.getAttribute()
method represent the java.lang.String
class. Comparing them with a string variable may require converting the value to String, finding a match with the indexOf()
method, or using the Abstract Equality Comparison:
JavaScript
var authIndexType = "service"
logger.error(requestParameters.get("authIndexType").get(0))
// > ERROR: service
logger.error(requestParameters.get("authIndexType").get(0) === authIndexType)
// > ERROR: false
logger.error(String(requestParameters.get("authIndexType").get(0)) === authIndexType)
// > ERROR: true
logger.error(requestParameters.get("authIndexType").get(0).indexOf(authIndexType) !== -1)
// > ERROR: true
logger.error(requestParameters.get("authIndexType").get(0) == authIndexType)
// > ERROR: true
In both JavaScript and Groovy, to convert to a String, you can use toString()
or concatenate a string and a value (in that order).
Generally, however, in JavaScript, it is better to use the String
object in non-constructor context, for it lets you handle Symbol
, null
, and undefined
values all at once. For example:
String(idRepository.getAttribute(username, attribute).toArray()[100])
Script Type
Selecting a script type will define the script’s bindings—the default objects and references in the script’s top-level scope.
In addition, for the server-side scripts, access to the underlying Java classes can be allowed or disallowed differently for the different script types. You can control access to the Java classes in the AM console under Configure > Global Services > Scripting > Secondary Configurations > Script Type > Secondary Configurations > engineConfiguration, as described in Global Services Scripting Configuration.
See The Scripting Environment for additional details on scripting contexts and security settings.
Decision node script for authentication trees (Scripted Decision Node)
Configuration
[Back to Contents](#heading--content)AM serves as an authentication and authorization server, and the recommended authentication flow is using Authentication Trees whenever possible. Augmenting the authentication context, extending it in arbitrary (but controlled) ways without changing AM code is made possible with the scripted decision nodes.
In a scripted decision node configuration, you need to specify a server-side script to be executed, its possible outcomes, and all of the inputs required by the script and the outputs it is required to produce:
The *
(wildcard) variable can be referenced in the script configuration to include all available inputs or outputs without verifying their presence in Shared Tree State—a special object that holds the current authentication state and allows for data exchange between otherwise stateless nodes in the authentication tree.
For more information about Scripted Decision Node configuration, see Authentication Nodes Configuration Reference > Scripted Decision Node.
Outcomes
At the end of a script execution, the script can communicate back to its node by providing an outcome, an action to take, and any additional audit data, by populating the following top-level variables:
-
outcome
, the variable that contains the result of the script execution and matches one of the outcomes specified in the node configuration.
When the node execution completes, tree evaluation will continue along the path that matches the value of the outcome. For example, the expected outcome could be “true” or “false”:
Then, the script can define its outcome by assigning a String value to the outcome
variable. For example:
Â
JavaScript or Groovy
if ( . . . ) {
outcome = "true"
} else {
outcome = "false"
}
Outcomes could be a collection of any other strings; for example: “success”, “failure”, “error”, and “unsure”—if those correspond to respective paths in the authentication tree.
Currently, the Authentication Tree Decision Node Script template contains a comment implying that there could be only two possible outcomes:
JavaScript
/*
- Data made available by nodes that have already executed are available in the sharedState variable.
- The script should set outcome to either "true" or "false".
*/
outcome = "true";
-
action
, the variable that can be assigned an Action Interface object to define the script outcome and/or specify one or more operations to perform. For example:
Â
Â
JavaScript
var goTo = org.forgerock.openam.auth.node.api.Action.goTo
action = goTo("true").build() // The outcome is set to "true".
Â
JavaScript
var goTo = org.forgerock.openam.auth.node.api.Action.goTo
action = goTo("true").putSessionProperty("customKey", "customValue").build() // The outcome is set to "true", and a custom session property will be created and populated.
Â
JavaScript
var goTo = org.forgerock.openam.auth.node.api.Action.goTo
action = goTo("true")
.putSessionProperty("customKey1", "customValue1")
.putSessionProperty("customKey2", "customValue2")
.build() // The outcome is set to "true", and two additional operations are specified.
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action
)
action = fr.Action.goTo("false").withErrorMessage("Friendly error description.").build() // The outcome is set to "false". The error message will be included in the authentication response, and if supported by the UI, the message will be displayed to the end user.
Â
Groovy
import org.forgerock.openam.auth.node.api.Action
action = Action.goTo("true").build() // The outcome is set to "true".
A value set either in outcome
or action
is something the node will expect, recognize, and evaluate to decide on the ultimate outcome, with the action value taking precedence. In the following example, setting outcome
directly won’t have any effect, because the outcome specified in action
will be evaluated and returned first:
Â
JavaScript or Groovy
action = Action.goTo("false").build() // Takes effect.
outcome = "true" // Is not considered.
-
auditEntryDetail
, the placeholder for additional audit information that the node may provide, as described in Scripted Decision Node API Functionality > Adding Audit Information.
Although the variable is defined by default in the script top-level scope, it is not initially populated.
Bindings
The script context is provided via its bindings. The bindings also serve as the information exchange channel between the scripting context and the parent node. In AM 7.0, the following bindings are available in Scripted Decision Node scripts:
-
sharedState
, the object that holds the state of the authentication tree and allows data exchange between the stateless nodes, as described in Storing Values in Shared Tree State. The binding is derived from the TreeContext class’ sharedState field.
A node may expect some inputs and may be expected to save certain outputs in the sharedState
object.You can see what the object contains by logging out its current content:
Â
JavaScript or Groovy
logger.error(sharedState.toString())
ERROR: {realm=/, authLevel=0, username=user.0}
What you see will depend on what the preceding nodes in the tree have already added to sharedState
. In the example above, only the Username Collector node was used thus far, and predictably, it had captured the username. An individual property could then be obtained and/or inspected via the binding’s get(String key)
method:
Â
JavaScript or Groovy
var username = sharedState.get("username")
By using the sharedState.put(String key, Object value)
method, you can store information that could be used later in the authentication session. Because, you may not be ready to make your scripted decision yet, but your script may have obtained something from an external resource (or prepared some information in another manner) that could be used in more than one way by different nodes down the authentication flow.Some of the properties saved in sharedState
may have general purpose. You can, for example, provide a custom error message for an unsuccessful authentication attempt:
Â
JavaScript or Groovy
try {
var username = getState("username")
} catch (e) {
sharedState.put("errorMessage", e.toString())
}
You can store an object in
sharedState
, but for interoperability, you may choose to store its String representation instead. Another example would be saving a stringified JSON.
If supported by the UI, the value stored under the “errorMessage” key will be displayed to the end user instead of the default login failure message when the authentication eventually fails.
In the example above, because the getState
binding is not declared, JavaScript will produce the following message to be displayed on the login screen:
Which is a part of the failed authentication response returned to the user agent:
{"code":401,"reason":"Unauthorized","message":"ReferenceError: \"getState\" is not defined.","detail":{"failureUrl":""}}
Remember, however, that a message provided in Action.goTo("false").withErrorMessage(String message)
will override the “errorMessage” content.
Another example of a universally recognized property would be “successUrl”. For example:
Â
JavaScript or Groovy
sharedState.put("successUrl", "http://openam.example.com:8080/openam/XUI/?authIndexType=service&authIndexValue=scripted&test=successUrl#dashboard/")
Once again, whether the property is actually used, depends on the UI implementation and whether it considers the authentication response:
{"tokenId":"Pk8vDJCVDz1phdK83JlqWnXB2uc.*AAJTSQACMDEAAlNLABxEQlBkdnRiRk1oMjY4dUh3aXdQcDNLSDVRMUk9AAR0eXBlAANDVFMAAlMxAAA.*","successUrl":"http://openam.example.com:8080/openam/XUI/?authIndexType=service&authIndexValue=scripted&test=successUrl#dashboard/","realm":"/"}
-
transientState
, the object for storing sensitive information that must not leave the server unencrypted and may not need to persist between authentication requests during the authentication session.
This means that the data stored in transientState
exists only until the next response is sent to the user, unless the secret data is requested later in the authentication tree, between the responses (in a conventional term: “across a callback boundary”).
sharedState
exists unconditionally during the lifetime of the authentication session and could be returned to the user in an unencrypted JWT in each response during the authentication flow.
Details
If you choose to save the authentication session state in JWT (under Realms > Realm Name > Authentication > Settings > Trees > Authentication session state management scheme), and set CONFIGURE > Global Services > Session > Client-based Sessions > Encryption Algorithm to “NONE”, your authentication state will be included in an encoded but unencrypted form in every (callback) response to the user agent:
{
"state": "valid",
"maxTime": 5,
"maxIdle": 5,
"maxCaching": 3,
"sessionType": "USER",
"lastActivityTime": 1606271524,
"jti": "b05bfee4-cd98-41d4-99d1-6417d073cfc1",
"exp": 1606271824,
"props": {
"treeState": "{\"sharedState\":{\"realm\":\"/\",\"authLevel\":0,\"username\":\"user.0\"},\"secureState\":{},\"currentNodeId\":\"06bc8627-1ff5-44d2-bdc4-7cffeeac7729\",\"sessionProperties\":{},\"sessionHooks\":[],\"webhooks\":[]}",
"AMCtxId": "f66fd450-01ce-4652-b3f6-2894e9a0344a-63080",
"amlbcookie": "01"
}
}
If the secret value is required across requests, it will be “promoted” (that is, moved) into the tree’s secureState
, which is a special object that is always encrypted and is not to be accessed directly. Instead, if they were available in the scripting environment, you could use the TreeContext’s getState(String key) or getTransientState(String key) public methods, which first checks for the key in transientState
and then in secureState
. At the time of this writing, neither of the methods nor a similar functionality is included in the scripting decision node bindings, but something to that effect may be introduced in later iterations of AM.
To retrieve a key from transientState
use its get(String key)
method, and to populate a key use put(String key, V value)
.
For example, to get a password saved in transientState
by the Password Collector node:
Â
JavaScript or Groovy
var password = transientState.get("password")
Or share a value with a node down the authentication tree:
Â
JavaScript or Groovy
transientState.put("sensitiveKey", "sensitiveValue")
-
callbacks
, the placeholder for a collection of form components and/or page elements to be sent back to the authenticating user, as described in Supported Callbacks.
The examples provided in Scripted Decision Node API Functionality > Using Callbacks highlight the general idea: a node, via its script, can send information to and get input from the user and/or retrieve data about the user agent. When the collected data is submitted back to the server-side script, it could be stored in sharedState
or used directly by the script.
You can use interactive callbacks to request input from the user. For example, PasswordCallback could be used in your scripted decision for capturing a secret value:
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action, // 1
javax.security.auth.callback.PasswordCallback, // 2
java.lang.String // 3
)
if (callbacks.isEmpty()) { // 4
action = fr.Action.send(
fr.PasswordCallback("password hint", false) // 5
).build()
} else {
transientState.put("password", fr.String(callbacks.get(0).getPassword())) // 3, 6
action = fr.Action.goTo("true").build()
}
logger.error("transientState: " + transientState)
ERROR: transientState: {password=1077}
Groovy
import org.forgerock.openam.auth.node.api.Action // 1
import javax.security.auth.callback.PasswordCallback // 2
if (callbacks.isEmpty()) { // 4
action = Action.send([
new PasswordCallback("password hint", false) // 5
]).build()
} else {
transientState.put("password", callbacks.get(0).getPassword().toString()) // 6
action = Action.goTo("true").build()
}
logger.error("transientState: " + transientState)
ERROR: transientState: [password:1077]
- Import the API that allows for using the Action Interface and sending callbacks.
- Import the callback class(es).
- We need this in JavaScript to convert
char[]
returned bygetPassword()
to a String. - Check if any callbacks have been already requested by the node; if not, specify one (or multiple callbacks, separated by comma) that will be sent to the user agent.
- When instantiating the callback class, remember to pass in parameters matching its constructor.
- When the form input has been populated and submitted to the server side, get the form value and save it in
transientState
orsharedState
to make it available for the downstream nodes in the tree.If your scripted decision depends on multiple rounds of interaction with the user, you have an option to send the same or different callbacks from the same script until all necessary feedback is collected. For example, let’s keep sending the password callback back to the user if no input has been provided:
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action, // 1
javax.security.auth.callback.PasswordCallback, // 2
java.lang.String // 3
)
function sendCallbacks() {
action = fr.Action.send(
fr.PasswordCallback("password hint", false) // 5
).build()
}
function processCallbacks() {
var password = fr.String(callbacks.get(0).getPassword())
if (password.isEmpty()) { // 7
var count = parseInt(sharedState.get("count")) || 1 // 8
if (count > 4) { // 8
action = fr.Action.goTo("false").withErrorMessage("Something went wrong . . . ").build()
return
}
sharedState.put("count", count + 1)
sendCallbacks()
return
}
transientState.put("password", password) // 6
action = fr.Action.goTo("true").build()
}
if (callbacks.isEmpty()) { // 4
sendCallbacks()
} else {
processCallbacks()
}
Groovy
import org.forgerock.openam.auth.node.api.Action // 1
import javax.security.auth.callback.PasswordCallback // 2
def sendCallbacks = {
action = Action.send(
new PasswordCallback("password hint", false) // 5
).build()
}
def processCallbacks = {
def password = callbacks.get(0).getPassword().toString()
if (password.isEmpty()) { // 7
def count = 1
if (sharedState.get("count")) { // 8
count = sharedState.get("count").toInteger()
}
if (count > 4) { // 8
action = Action.goTo("false").withErrorMessage("Something went wrong . . . ").build()
return
}
sharedState.put("count", count + 1)
sendCallbacks()
return
}
transientState.put("password", password) // 6
action = Action.goTo("true").build()
}
if (callbacks.isEmpty()) { // 4
sendCallbacks()
} else {
processCallbacks()
}
- Resend password callback if no input was provided.
- Terminate the exercise after four unsuccessful tries.Callbacks may also be used to inform the user of something important, or to run arbitrary scripts on the client-side. For example, you may try to obtain the client-side IP (for further analysis) with the help of
ScriptTextOutputCallback
andHiddenValueCallback
:
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
com.sun.identity.authentication.callbacks.HiddenValueCallback,
com.sun.identity.authentication.callbacks.ScriptTextOutputCallback
)
var script = " \n\
var script = document.createElement('script') // A \n\
\n\
script.src = 'https://code.jquery.com/jquery-3.4.1.min.js' // A \n\
script.onload = function (e) { // B \n\
$.getJSON('https://api.ipify.org/?format=json', function (json) { \
document.getElementById('clientScriptOutputData').value = JSON.stringify({ \n\
ip: json \n\
}) // C \n\
}) \
.always(function () { \n\
document.getElementById('loginButton_0').click() // D \n\
}) \n\
} \n\
\n\
document.getElementsByTagName('head')[0].appendChild(script) // A \n\
\n\
setTimeout(function () { // E \n\
document.getElementById('loginButton_0').click() \n\
}, 4000)" // 1
if (callbacks.isEmpty()) {
action = fr.Action.send(
new fr.HiddenValueCallback("clientScriptOutputData", "false"),
new fr.ScriptTextOutputCallback(script)
).build()
} else {
var failure = true
if (callbacks.get(0).getValue() != "clientScriptOutputData") { // 2
sharedState.put("clientScriptOutputData", callbacks.get(0).getValue()) // 3
failure = false
}
if (failure) {
logger.error('Authentication denied.')
action = fr.Action.goTo("false").build()
} else {
logger.message('Authentication allowed.')
action = fr.Action.goTo("true").build()
}
}
Â
Groovy
import org.forgerock.openam.auth.node.api.Action
import com.sun.identity.authentication.callbacks.ScriptTextOutputCallback
import com.sun.identity.authentication.callbacks.HiddenValueCallback
def script = '''
var script = document.createElement('script') // A
script.src = 'https://code.jquery.com/jquery-3.4.1.min.js' // A
script.onload = function (e) { // B
$.getJSON('https://api.ipify.org/?format=json', function (json) {
document.getElementById('clientScriptOutputData').value = JSON.stringify({
ip: json
}) // C
})
.always(function () {
document.getElementById('loginButton_0').click() // D
})
}
document.getElementsByTagName('head')[0].appendChild(script) // A
setTimeout(function () { // E
document.getElementById('loginButton_0').click()
}, 4000)
''' // 1
if (callbacks.isEmpty()) {
action = Action.send([
new HiddenValueCallback("clientScriptOutputData", "false"),
new ScriptTextOutputCallback(script)
]).build()
} else {
def failure = true
if (callbacks.get(0).getValue() != "clientScriptOutputData") { // 2
sharedState.put("clientScriptOutputData", callbacks.get(0).getValue()) // 3
failure = false
}
if (failure) {
logger.error('Authentication denied.')
action = Action.goTo("false").build()
} else {
logger.message('Authentication allowed.')
action = Action.goTo("true").build()
}
}
- The client-side portion can be specified directly in the body of the server-side script.
The client-side scripting environment is defined by the user browser and is not specific to ForgeRock.
You can use your browser console for writing scripts in the user agent, which will allow for some immediate feedback. Then, you can multiline the script by wrapping it with '''
in Groovy and with ;
and/or \n\
in JavaScript.
There may be custom nodes proving amenities for editing the client-side portion of the code. For example: Client Script Auth Tree Node.
The original client-side script in the example above looks like the following:
Â
JavaScript, client-side
script.src = 'https://code.jquery.com/jquery-3.4.1.min.js' // A
script.onload = function (e) { // B
$.getJSON('https://api.ipify.org/?format=json', function (json) {
document.getElementById('clientScriptOutputData').value = JSON.stringify({
ip: json
}) // C
})
.always(function () {
document.getElementById("loginButton_0").click() // D
})
}
document.getElementsByTagName('head')[0].appendChild(script) // A
setTimeout(function () { // E
document.getElementById('loginButton_0').click()
}, 4000)
-
A. Create a script element and add to DOM for loading an external library.
-
B. When the library is loaded, make a request to an external source to obtain the client’s IP information.
-
C. Save the information, received as a JSON object, as a string in the input constructed with
HiddenValueCallback
. -
D. When the HTTP call is complete, submit the form.
-
E. If the HTTP request takes more time than the specified timeout, submit the form after a timeout.
While developing the server-side script, you can further delay or dismiss automatic submission of the form.
Unlike Client-side Authentication scripts used in authentication modules, when the callbacks are sent by a Scripted Decision Node script, the following applies:
-
The form is NOT self-submitting, and setting
autoSubmitDelay
won’t have any effect. -
The input for the client-side data needs to be populated directly (unlike authentication chain modules, where the callback input can be referenced via the
output
object). -
There is no automatically provided
submit()
function.
- Check if the client-side data input has been populated before proceeding with the authentication flow.
- Store the data under an arbitrary named key in the
sharedState
object—to share it with the rest of the tree.As the authentication worries along, the information stored intransientState
andsharedState
can be requested by the other nodes.
For example:
Â
JavaScript
var ip = JSON.parse(sharedState.get("clientScriptOutputData")).ip
Groovy
import groovy.json.JsonSlurper
def jsonSlurper = new JsonSlurper()
def ip = jsonSlurper.parseText(sharedState.get("clientScriptOutputData")).ip
The
groovy.json.JsonSlurper
class is included by default in your AM console under Configure > Global Services > Scripting > Secondary Configurations > AUTHENTICATION TREE DECISION NODE > Secondary Configurations > engineConfiguration > Java class whitelist, but you may need to addorg.apache.groovy.json.internal.LazyMap
to the list as well. Find more information on the subject in Language > Allowed Java Classes of this writing.
Then, you can check the IP data against a list of (dis)allowed locations, save it in the user profile, etc.
At the time of this writing, the API used in the example above was returning something like the following:
{"ip":"65.113.98.10"}
In a scripted decision node script, you can easily try a particular callback before using it in authentication node development, or employ callbacks to display intermediate debugging information as described in Debugging > Callbacks.
-
idRepository
, the object that provides access to the user identity data, as described in Scripted Decision Node API Functionality > Accessing Profile Data.
Attributes available to the idRepository
object will be defined in AM’s Identity Repository setup. You can see them in the AM console under Realms > Realm Name > Identity Stores > Identity Store Name > User Configuration > LDAP User Attributes.
idRepository.getAttribute(String username, String attribute)
returns a java.util.HashSet.
idRepository.setAttribute(String username, String attribute, String[] values)
and
idRepository.addAttribute(String username, String attribute, String value)
will update the corresponding field in the user profile.
A few examples of accessing and manipulating data accessible via idRepository
:
Â
JavaScript
var username = sharedState.get("username")
var attribute = "mail"
idRepository.setAttribute(username, attribute, ["user.0@a.com", "user.0@b.com"]) // Set multiple values; must be an Array.
logger.error(idRepository.getAttribute(username, attribute))
// > ERROR: [user.0@b.com, user.0@a.com]
idRepository.setAttribute(username, attribute, ["user.0@a.com"]) // Set a single value; MUST be an Array.
logger.error(idRepository.getAttribute(username, attribute))
// > ERROR: [user.0@a.com]
Groovy
def username = sharedState.get("username")
def attribute = "mail"
idRepository.setAttribute(username, attribute, ["user.0@a.com", "user.0@b.com"] as String[]) // Set multiple values; cast the List as a String array.
logger.error(idRepository.getAttribute(username, attribute).toString())
// > ERROR: [user.0@b.com, user.0@a.com]
idRepository.setAttribute(username, attribute, "user.0@a.com") // Set a single value; COULD be a String.
logger.error(idRepository.getAttribute(username, attribute).toString())
// > ERROR: [user.0@a.com]
JavaScript or Groovy
var username = sharedState.get("username")
var attribute = "mail"
idRepository.addAttribute(username, attribute, "user.0@c.com") // Add a value as a String.
logger.error(idRepository.getAttribute(username, attribute).toString())
// > ERROR: [user.0@a.com, user.0@c.com]
logger.error(idRepository.getAttribute(username, attribute).iterator().next()) // Get the first value.
// > ERROR: user.0@a.com
logger.error(idRepository.getAttribute(username, attribute).toArray()[1]) // Get a value at the specified index.
// > ERROR: user.0@c.com
logger.error(idRepository.getAttribute(username, "non-existing-attribute").toString())
// > ERROR: []: If no attribute by this name is found, an empty Set is returned.
If you need to check whether an attribute is populated prior to requesting its individual values, you can use the .iterator().hasNext()
method, or convert the returned set toArray()
and check its length:
Â
JavaScript or Groovy
var username = sharedState.get("username")
var attribute = "mail"
var value = idRepository.getAttribute(username, attribute)
logger.error("value: " + value)
// > ERROR: value: [user.0@a.com, user.0@c.com]
if (value.iterator().hasNext()) {
logger.error("Attribute's first value: " + value.iterator().next())
// > ERROR: Attribute's first value: user.0@a.com
}
if (value.toArray().length) {
logger.error("Attribute's last value:" + value.toArray()[value.toArray().length - 1])
// > ERROR: Attribute's last value:user.0@c.com
}
For brevity, and to illustrate interchangeability, the same syntax was used in the last two examples. As noted in Debug Logging, in JavaScript you don’t need to convert a non-string argument to String for the logger methods (although, doing so won’t hurt either), and the following will work:
logger.error(idRepository.getAttribute(username, attribute)) // > ERROR: [user.0@a.com, user.0@c.com]
The value returned by idRepository.getAttribute(String username, String attribute)
is a HashSet
; optionally, you may also be able to employ some of its methods described in the corresponding Java, Rhino, and Groovy docs. For example, you can use size()
in JavaScript and Groovy (and count {}
in Groovy) to check length of the returned value directly, without intermediate conversions:
Â
JavaScript or Groovy
var username = sharedState.get("username")
var attribute = "mail"
var value = idRepository.getAttribute(username, attribute)
logger.error("value size: " + value.size())
// > ERROR: value size: 2
-
realm
, the name of the realm the user is authenticating to.
For example, the Top Level Realm:
Â
JavaScript or Groovy
logger.error(realm)
// > ERROR: /
-
requestHeaders
, the object that provides methods for accessing headers in the login request, as described in Scripted Decision Node API Functionality > Accessing Request Header Data.
-
requestParameters
, the object that contains the authentication request parameters.
For example, you may be able to check which authentication tree was requested to make your scripted decision in:
Â
JavaScript
var service
var authIndexType = requestParameters.get("authIndexType")
if (authIndexType && String(authIndexType.get(0)) === "service") { // 1
service = requestParameters.get("authIndexValue").get(0)
}
- In JavaScript, the values stored in
requestParameters
havetypeof
object and represent thejava.lang.String
class; hence, you need to convert the parameter value to String in order to use Strict Equality Comparison, as described in Language > More on Rhino > String Comparison.
Â
Groovy
def service
def authIndexType = requestParameters.get("authIndexType")
if (authIndexType && authIndexType.get(0) == "service") {
service = requestParameters.get("authIndexValue").get(0)
}
-
existingSession
(session upgrade only), the object containing the existing session information, as described in Scripted Decision Node API Functionality > Accessing Existing Session Data.
In order to determine whether the current request is a session upgrade, you can check if the binding is declared:
Â
JavaScript
var existingAuthLevel
if (typeof existingSession !== "undefined") {
existingAuthLevel = existingSession.get("AuthLevel")
} else {
logger.error("Variable existingSession not declared - not a session upgrade.");
}
logger.error("Existing Auth Level: " + existingAuthLevel)
ERROR: Variable existingSession not declared - not a session upgrade.
ERROR: Existing Auth Level: undefined
Groovy
def existingAuthLevel
if (binding.hasVariable("existingSession")) { // 1
existingAuthLevel = existingSession.get("AuthLevel")
} else {
logger.error("Variable existingSession not declared - not a session upgrade.")
}
logger.error("Existing Auth Level: " + existingAuthLevel)
ERROR: Variable existingSession not declared - not a session upgrade.
ERROR: Existing Auth Level: null
You could also use try/catch
when referencing the existingSession
variable, which has a benefit of the same syntax in both languages, but is probably not the most efficient way to perform the check. For example:
Â
JavaScript or Groovy
var existingAuthLevel
try { // 1
existingAuthLevel = existingSession.get("AuthLevel")
} catch (e) {
logger.error(e.toString())
}
logger.error("Existing Auth Level: " + existingAuthLevel)
JavaScript
ERROR: ReferenceError: "existingSession" is not defined.
ERROR: Existing Auth Level: undefined
Groovy
ERROR: groovy.lang.MissingPropertyException: No such property: existingSession for class: Script262
ERROR: Existing Auth Level: null
- Employing either technique may not work in Groovy with the default scripting engine configuration, and you may need to explicitly allow additional Java classes, which may or may not be an option in your environment. See the Language > Allowed Java Classes and ForgeRock Identity Cloud > Allowed Java Classes sections for details.
The easiest way to test scripts with a reference to existingSession
is probably navigating to the login screen (while being signed in) with the ForceAuth=true
authentication parameter added to the query string.
For example:
http://openam.example.com:8080/openam/XUI/?service=ScriptedTree&ForceAuth=true#login
ERROR: Existing Auth Level: 0
For more information on the session upgrade subject, see Sessions Guide > Session Upgrade.
-
logger
, the object that provides methods for writing debug messages, as described in Getting Started with Scripting > Debug Logging and earlier in this writing.
-
httpClient
, the HTTP client object, as described in Accessing HTTP Services and earlier in this writing.
Debugging
The logger
object is your best debugging friend, but not the only one:
- Callbacks
If you need an immediate feedback without completing the authentication journey, you can display the debugging content with a callback.
For example, you can use javax.security.auth.callback.TextOutputCallback. In a simplest case, you’d display the stringified content of an object:
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
javax.security.auth.callback.TextOutputCallback
)
if (callbacks.isEmpty()) {
action = fr.Action.send(
new fr.TextOutputCallback(
fr.TextOutputCallback.ERROR,
sharedState
)
).build()
} else {
action = fr.Action.goTo("true").build()
}
Groovy
import org.forgerock.openam.auth.node.api.Action
import javax.security.auth.callback.TextOutputCallback
if (callbacks.isEmpty()) {
action = Action.send(
new TextOutputCallback(
TextOutputCallback.ERROR,
sharedState.toString()
)
).build()
} else {
action = Action.goTo("true").build()
}
Or, you can output multiple messages:
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
javax.security.auth.callback.TextOutputCallback
)
var messages = ""
try {
var username = nonExistingBinding("username")
} catch (e) {
messages += e + " | "
}
try {
var username = sharedState.nonExistingMethod("username")
} catch (e) {
messages += e + " | "
}
if (messages.length && callbacks.isEmpty()) {
action = fr.Action.send(
new fr.TextOutputCallback(
fr.TextOutputCallback.ERROR,
messages
)
).build()
} else {
action = fr.Action.goTo("true").build()
}
Groovy
import org.forgerock.openam.auth.node.api.Action
import javax.security.auth.callback.TextOutputCallback
def messages = ""
try {
var username = nonExistingBinding("username")
} catch (e) {
messages += e.toString() + " | "
}
try {
var username = sharedState.nonExistingMethod("username")
} catch (e) {
messages += e.toString() + " | "
}
if (messages.length() && callbacks.isEmpty()) {
action = Action.send(
new TextOutputCallback(
TextOutputCallback.ERROR,
messages
)
).build()
} else {
action = Action.goTo("true").build()
}
When your debugging content grows, and the messages need to be better separated visually, you can have more control over the browser output with com.sun.identity.authentication.callbacks.ScriptTextOutputCallback. For example, you can alert yourself with the debug messages:
Â
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
com.sun.identity.authentication.callbacks.ScriptTextOutputCallback
)
var messages = []
messages.push("sharedState: " + sharedState)
try {
var username = nonExistingBinding("username")
} catch (e) {
messages.push(e)
}
try {
var username = sharedState.nonExistingMethod("username")
} catch (e) {
messages.push(e)
}
if (callbacks.isEmpty()) {
var script = "alert('" + messages.join("\\n\\n") + "')"
action = fr.Action.send(
new fr.ScriptTextOutputCallback(
script
)
).build()
} else {
action = fr.Action.goTo("true").build()
}
Â
Groovy
import org.forgerock.openam.auth.node.api.Action
import com.sun.identity.authentication.callbacks.ScriptTextOutputCallback
var messages = []
messages.push("sharedState: " + sharedState)
try {
var username = nonExistingBinding("username")
} catch (e) {
messages.push(e)
}
try {
var username = sharedState.nonExistingMethod("username")
} catch (e) {
messages.push(e)
}
if (callbacks.isEmpty()) {
var script = "alert('" + messages.join("\\n\\n") + "')"
action = Action.send(
new ScriptTextOutputCallback(
script
)
).build()
} else {
action = fr.Action.goTo("true").build()
}
You could also leverage the browser console:
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
com.sun.identity.authentication.callbacks.ScriptTextOutputCallback
)
var messages = []
messages.push("sharedState: " + sharedState)
try {
var username = nonExistingBinding("username")
} catch (e) {
messages.push(e)
}
try {
var username = sharedState.nonExistingMethod("username")
} catch (e) {
messages.push(e)
}
if (callbacks.isEmpty()) {
var script = "console.log(JSON.parse(JSON.stringify("
script += JSON.stringify(messages)
script += ")))"
action = fr.Action.send(
new fr.ScriptTextOutputCallback(
script
)
).build()
} else {
action = fr.Action.goTo("true").build()
}
Â
Groovy
import org.forgerock.openam.auth.node.api.Action
import com.sun.identity.authentication.callbacks.ScriptTextOutputCallback
import groovy.json.JsonOutput
var messages = []
messages.push("sharedState: " + sharedState)
try {
var username = nonExistingBinding("username")
} catch (e) {
messages.push(e.toString())
}
try {
var username = sharedState.nonExistingMethod("username")
} catch (e) {
messages.push(e.toString())
}
if (callbacks.isEmpty()) {
var script = "console.log(JSON.parse(JSON.stringify("
script += JsonOutput.toJson(messages)
script += ")))"
action = Action.send(
new ScriptTextOutputCallback(
script
)
).build()
} else {
action = fr.Action.goTo("true").build()
}
- Error Message
As noted before, you can use the sharedState “errorMessage” property and the action interface to construct a custom error message, which will be sent to back the user agent, and could be displayed by the UI when your tree execution comes to a negatory end. In this message, you can include debugging information.
The sharedState
object persists during entire authentication session, across scripted decision nodes in the authentication tree. It has a designated key, “errorMessage”, that is respected by the core AM functionality. You can accumulate debugging information under this key:
JavaScript or Groovy
try {
var username = nonExistingBinding("username")
} catch (e) {
if (sharedState.get("errorMessage")) {
sharedState.put("errorMessage", sharedState.get("errorMessage") + " " + e.toString())
} else {
sharedState.put("errorMessage", e.toString())
}
}
try {
var username = sharedState.nonExistingMethod("username")
logger.error('username: ' + username)
} catch (e) {
logger.error('sharedState.get("errorMessage"): ' + sharedState.get("errorMessage"))
if (sharedState.get("errorMessage")) {
sharedState.put("errorMessage", sharedState.get("errorMessage") + " " + e.toString())
} else {
sharedState.put("errorMessage", e.toString())
}
}
If you eventually fail the authentication, taking the tree to the Failure node, the content of the “errorMessage” key will be included in the authentication response sent to the user agent:
{"code":401,"reason":"Unauthorized","message":"ReferenceError: \"nonExistingBinding\" is not defined. TypeError: Cannot find function nonExistingMethod in object {realm=/, authLevel=0, username=user.0, errorMessage=ReferenceError: \"nonExistingBinding\" is not defined.}.","detail":{"failureUrl":""}}
If you need to terminate the tree with a specific message, you can override the one stored in sharedState
using the Action Interface and its withErrorMessage(String message)
method:
Â
JavaScript or Groovy
action = org.forgerock.openam.auth.node.api.Action.goTo("false").withErrorMessage("A terrible error occurred!").build()
Which will again result in the error message being included in the authentication response:
{"code":401,"reason":"Unauthorized","message":"A terrible error occurred!","detail":{"failureUrl":""}}
This can be combined with a try/catch
:
Â
JavaScript or Groovy
var password
try {
password = secrets.getGenericSecret("scripted.node.secret.id").getAsUtf8()
output = "true"
} catch(e) {
action = Action.goTo("false").withErrorMessage(e.toString()).build()
}
The new secrets
binding was introduced in ForgeRock Identity Cloud scripting environment and will become available in the future versions of AM. If you use your code interchangeably and try to access secrets
in AM 7.0, the variable may not be defined, and the above will result in an error message being included in the authentication response. For example, an error constructed in JavaScript:
{"code":401,"reason":"Unauthorized","message":"ReferenceError: \"secrets\" is not defined.","detail":{"failureUrl":""}}
If respected by the UI, this message will be displayed to the end user instead of the default one.
OAuth2 Access Token Modification
You select an OAuth2 Access Token Modification script for all clients in a realm in the AM console under Realms > Realm Name > Services > OAuth2 Provider > Core > OAuth2 Access Token Modification Script. What may not be completely obvious is that currently, all the scripts are shared between the realms as well.
You can verify this by navigating to a script definition and observe changes made in one realm appearing in another. Also, the script ID is going to be the same. For example:
This means that if you want to apply a different access token modification in a (sub)realm, you’ll need to create a separate script of the OAuth2 Access Token Modification type for doing so.
Application of this script type is described in AM 7 > OAuth 2.0 Guide > Modifying the Content of Access Tokens.
Presently, there is additional API functionality to be introduced for the OAuth 2.0 Access Token Modification type, which is described in the early access version of the doc.
Bindings
There are following bindings provided in the OAuth2 Access Token Modification type:
-
accessToken
, an interface to the issued access token information.
Â
The Public API Javadoc links provided in the Guide are important source of additional information. By examining the Access Token interface, you can see methods that you may be able to use in your scripts, including the inherited ones. For example, after setting an access token custom field as the Guide describes, you can get its value by using the getCustomFields()
method:
Â
JavaScript or Groovy
var grantType = accessToken.getGrantType()
var resourceType = "user"
if (grantType == "client_credentials") {
resourceType = "client"
} else if (grantType == "urn:ietf:params:oauth:grant-type:device_code") {
resourceType = "device"
}
accessToken.setField("resourceType", resourceType)
logger.error("access token custom fields: " + accessToken.getCustomFields())
logger.error("access token resource type: " + accessToken.getCustomFields().get("resourceType"))
ERROR: access token custom fields: {resourceType=client}
ERROR: access token resource type: client
Introspection results for the issued access token will look similar to the following:
{
"active": true,
"scope": "profile",
"realm": "/",
"client_id": "node-openid-client",
"user_id": "node-openid-client",
"token_type": "Bearer",
"exp": 1607652206,
"sub": "node-openid-client",
"iss": "http://openam.example.com:8080/openam/oauth2",
"authGrantId": "j_el6hUyQ34n8wsjlFonc9TwNIo",
"auditTrackingId": "121b1cdc-bd42-47ff-987d-bbcb2a3ba7ab-30052",
"resourceType": "client"
}
-
scopes
, the requested scopes in the form of java.util.HashSet.
Examples:
Â
JavaScript or Groovy
logger.error("access token grant type: " + accessToken.getGrantType())
logger.error("scopes: " + scopes)
logger.error("scopes length: " + scopes.size())
logger.error("first scope: " + scopes.toArray()[0])
Possible output:
ERROR: accessToken grant type: authorization_code
ERROR: scopes: [openid, profile]
ERROR: size: 2
ERROR: first scope: openid
ERROR: accessToken grant type: refresh_token
ERROR: scopes: [profile]
ERROR: size: 1
ERROR: first scope: profile
Â
JavaScript
scopes.toArray().forEach(function (scope) {
logger.error(scope)
})
ERROR: openid
ERROR: profile
Â
Groovy
scopes.each {
scope ->
logger.error(scope)
}
ERROR: openid
ERROR: profile
-
identity
, a reference to the authorization subject provided as an instance of the com.sun.identity.idm.AMIdentity class.
You can get individual attributes from the subject’s identity and use them in your script. The values for each attribute are returned as a java.util.HashSet:
Â
JavaScript or Groovy
logger.error("identity mail: " + identity.getAttribute("mail"))
ERROR: identity mail: [user.0@a.com, user.0@c.com]
If you have access to the scripting engine configuration and can allow the com.iplanet.am.sdk.AMHashMap
class, getting all identity attributes is an option:
Â
JavaScript or Groovy
logger.error("identity: " + identity.getAttributes())
ERROR: identity: [modifyTimestamp:[20201210015027Z], _username:[user.0], inetuserstatus:[Active], givenName:[User], createTimestamp:[20201014213634Z], iplanet-am-user-success-url:[https://mail.google.com, https://google.com], uid:[user.0], iplanet-am-user-auth-config:[[Empty]], userPassword:[{PBKDF2-HMAC-SHA256}10:dsvp8tdJ/2NdyehyfwC03x9LYrLbAuvFb+t+saBmwWKJ75CLtA7IyY2x/Y02xdSh], employeeNumber:[0], _id:[user.0], sn:[0], telephoneNumber:[999-999-9999], dn:[uid=user.0,ou=people,ou=identities], cn:[User 0], mail:[user.0@a.com, user.0@c.com], objectClass:[top, inetuser, kbaInfoContainer, person, inetOrgPerson, organizationalPerson, iplanet-am-user-service]]
Presenting a Map in a more readable format in JavaScript will require another Java class to be allowed, com.sun.identity.common.CaseInsensitiveHashSet
. Then, you will be able to loop over the identity object key set:
Â
JavaScript
var identityAttributesLog = ["Identity Attributes:"]
identity.getAttributes().keySet().toArray().forEach(function (key) {
identityAttributesLog.push(key + ": " + identity.getAttribute(key))
})
logger.error(identityAttributesLog.join("\n"))
ERROR: Identity Attributes:
[CONTINUED]modifyTimestamp: [20201210015027Z]
[CONTINUED]_username: [user.0]
[CONTINUED]inetuserstatus: [Active]
[CONTINUED]givenName: [User]
[CONTINUED]createTimestamp: [20201014213634Z]
[CONTINUED]iplanet-am-user-success-url: [https://mail.google.com, https://google.com]
[CONTINUED]uid: [user.0]
[CONTINUED]iplanet-am-user-auth-config: [[Empty]]
[CONTINUED]userPassword: [{PBKDF2-HMAC-SHA256}10:dsvp8tdJ/2NdyehyfwC03x9LYrLbAuvFb+t+saBmwWKJ75CLtA7IyY2x/Y02xdSh]
[CONTINUED]employeeNumber: [0]
[CONTINUED]_id: [user.0]
[CONTINUED]sn: [0]
[CONTINUED]telephoneNumber: [999-999-9999]
[CONTINUED]dn: [uid=user.0,ou=people,ou=identities]
[CONTINUED]cn: [User 0]
[CONTINUED]mail: [user.0@a.com, user.0@c.com]
[CONTINUED]objectClass: [top, inetuser, kbaInfoContainer, person, inetOrgPerson, organizationalPerson, iplanet-am-user-service]
Â
Groovy
var identityAttributesLog = "Identity Attributes:\n"
identity.getAttributes().each {
key, value ->
identityAttributesLog += key + ": " + value + "\n"
}
logger.error(identityAttributesLog)
ERROR: Identity Attributes:
[CONTINUED]modifyTimestamp: [20201210015027Z]
[CONTINUED]_username: [user.0]
[CONTINUED]inetuserstatus: [Active]
[CONTINUED]givenName: [User]
[CONTINUED]createTimestamp: [20201014213634Z]
[CONTINUED]iplanet-am-user-success-url: [https://mail.google.com, https://google.com]
[CONTINUED]uid: [user.0]
[CONTINUED]iplanet-am-user-auth-config: [[Empty]]
[CONTINUED]userPassword: [{PBKDF2-HMAC-SHA256}10:dsvp8tdJ/2NdyehyfwC03x9LYrLbAuvFb+t+saBmwWKJ75CLtA7IyY2x/Y02xdSh]
[CONTINUED]employeeNumber: [0]
[CONTINUED]_id: [user.0]
[CONTINUED]sn: [0]
[CONTINUED]telephoneNumber: [999-999-9999]
[CONTINUED]dn: [uid=user.0,ou=people,ou=identities]
[CONTINUED]cn: [User 0]
[CONTINUED]mail: [user.0@a.com, user.0@c.com]
[CONTINUED]objectClass: [top, inetuser, kbaInfoContainer, person, inetOrgPerson, organizationalPerson, iplanet-am-user-service]
[CONTINUED]
The identity content will depend on the authorization subject. Thus, different individual attributes could be requested depending on the authorization grant, and the same attributes could be populated differently. For example:
Â
JavaScript or Groovy
logger.error("grant type: " + accessToken.getGrantType())
logger.error("identity mail: " + identity.getAttribute("mail"))
logger.error("identity userpassword: " + identity.getAttribute("userpassword"))
logger.error("identity com.forgerock.openam.oauth2provider.clientType: " + identity.getAttribute("com.forgerock.openam.oauth2provider.clientType"))
ERROR: grant type: refresh_token
ERROR: identity mail: [user.0@a.com, user.0@c.com]
ERROR: identity userpassword: [{PBKDF2-HMAC-SHA256}10:dsvp8tdJ/2NdyehyfwC03x9LYrLbAuvFb+t+saBmwWKJ75CLtA7IyY2x/Y02xdSh]
ERROR: identity com.forgerock.openam.oauth2provider.clientType: []
ERROR: grant type: client_credentials
ERROR: identity mail: []
ERROR: identity userpassword: [password]
ERROR: identity com.forgerock.openam.oauth2provider.clientType: [Confidential]
-
logger
, the object that provides methods for writing debug messages, as described in Getting Started with Scripting > Debug Logging and earlier in this writing.
-
httpClient
, the HTTP client object, as described in Accessing HTTP Services and earlier in this writing. -
session
(only if session cookie is present), a reference to the end user session.
While the session
variable is always defined, it is not assigned any value if there is no session cookie attached to the request. Typically, this is the case if a non-interactive authorization grant is used—such as Refresh Token, Client Credentials, or Resource Owner Password Credentials. At the same time, currently, an OAuth2 Access Token Modification script is selected on the realm level, and is shared among all OAuth 2.0 client applications in the realm. The clients may authorize themselves using different grants. Therefore, referencing the user session in a script via the session
binding may not be a valid approach in all cases. To handle this situation you can add a condition, for example:
Â
JavaScript or Groovy
if (session) {
logger.error("AuthLevel: " + session.getProperty("AuthLevel"))
} else {
logger.error("No session")
}
For another example, after checking for session information availability, you could set a custom claim with a value from a custom session property:
if (session && session.getProperty("customKey")) {
accessToken.setField("customClaim", session.getProperty("customKey"))
} else {
logger.error("No session")
}
Then, the access token resulting from an interactive authorization grant will contain the custom claim field:
"access_token": {
"active": true,
"scope": "openid profile",
"realm": "/",
"client_id": "node-openid-client",
"user_id": "user.0",
"token_type": "Bearer",
"exp": 1607652206,
"sub": "user.0",
"iss": "http://openam.example.com:8080/openam/oauth2",
"auth_level": 0,
"authGrantId": "_WQ-GVqB6OZWb8sYnWT7d5R9TFg",
"auditTrackingId": "121b1cdc-bd42-47ff-987d-bbcb2a3ba7ab-1692",
"customClaim": "customValue"
}
OAuth2 Access Token Modification script does not currently change the refresh token content, nor do custom claims based on dynamic data persist automatically. This means that if you use a non-interactive authorization grant to renew access tokens with no session cookie attached to the authorization request, you would need to save the dynamically obtained custom claim information in a persistent scope; for example, in the user profile during authentication (as described in Scripted Decision Node > Bindings > idRepository). Then, you will be able to pull the saved info from the user identity:
accessToken.setField("customClaim", identity.getAttribute("customKey"))
ForgeRock Identity Cloud (Identity Cloud)
Due to its cloud based, multi-tenant nature, the Identity Cloud environment introduces its own specifics to the scripting provisions in AM 7.
Debug Logging
Identity Cloud Docs > Tenants > View Audit Logs outlines general idea on how logs produced in Identity Cloud could be viewed over its REST API.
At the time of this writing, the list of available log sources consists of the following:
$ export ORIGIN=https://your-tenant-host.forgeblocks.com
$ export API_KEY_ID=your-api-key-id
$ export API_KEY_SECRET=your-api-key-secret
$ curl -X GET \
-H "x-api-key: $API_KEY_ID" \
-H "x-api-secret: $API_KEY_SECRET" \
"$your_tenant_ORIGIN/monitoring/logs/sources"
{"result":["am-access","am-activity","am-authentication","am-config","am-core","am-everything","ctsstore","ctsstore-access","ctsstore-config-audit","ctsstore-upgrade","idm-access","idm-activity","idm-authentication","idm-config","idm-core","idm-everything","idm-sync","userstore","userstore-access","userstore-config-audit","userstore-ldif-importer","userstore-upgrade"],"resultCount":22,"pagedResultsCookie":null,"totalPagedResultsPolicy":"NONE","totalPagedResults":1,"remainingPagedResults":0}
After you obtained the list of sources, select one that is the closest to what you are seeking. Currently, am-core
is the best source for getting logs produced by AM scripts, but this may change in the future. For example, a designated script-specific category may be introduced.
As shown in Identity Cloud docs, the logs come in a form of JSON, with each log containing the “payload” key populated with a String or an Object. An example of two logs:
{
"result": [
{
"payload": "10.40.68.18 - - [06/Nov/2020:23:20:42 +0000] \"GET /am/isAlive.jsp HTTP/1.0\" 200 112 1ms\n",
"timestamp": "2020-11-06T23:20:44.095224402Z",
"type": "text/plain"
},
{
"payload": {
"context": "default",
"level": "ERROR",
"logger": "scripts.AUTHENTICATION_TREE_DECISION_NODE.bc0c6654-b10e-44d1-9ea3-712940fbea67",
"mdc": {
"transactionId": "372127e5-7d3b-4379-8db8-2213e2a3337a-1010"
},
"message": "sharedState: {realm=/alpha, authLevel=0, username=user.0}",
"thread": "ScriptEvaluator-5",
"timestamp": "2020-11-06T23:20:49.222Z",
"transactionId": "372127e5-7d3b-4379-8db8-2213e2a3337a-1010"
},
"timestamp": "2020-11-06T23:20:49.222889214Z",
"type": "application/json"
},
],
"resultCount": "<integer>",
"pagedResultsCookie": "<string>",
"totalPagedResultsPolicy": "<string>",
"totalPagedResults": "<integer>",
"remainingPagedResults": "<integer>"
}
You can tail logs from the selected source, and employ a script to automate the process of requesting, filtering, and outputting the logged content.
This Identity Cloud logging tool for Node.js can be used to print out the logs as stringified JSON in the terminal. Its core module can be shared between different scripts customized for particular tenant and source. For example:
$ node tail.am-core.js
. . .
"10.138.0.42 - - [13/Jan/2021:20:01:40 +0000] \"GET /am/isAlive.jsp HTTP/1.1\" 200 112 1ms\n"
"10.40.49.236 - - [13/Jan/2021:20:01:40 +0000] \"GET /am/isAlive.jsp HTTP/1.0\" 200 112 0ms\n"
{"context":"default","level":"WARN","logger":"com.sun.identity.idm.IdUtils","mdc":{"transactionId":"0d3c7dac-d4e8-4cdd-b651-f5ff6659113d-566"},"message":"Error searching for user identity IdUtils.getIdentity: No user found for idm-provisioning","thread":"http-nio-8080-exec-4","timestamp":"2021-01-13T20:01:50.336Z","transactionId":"0d3c7dac-d4e8-4cdd-b651-f5ff6659113d-566"}
{"context":"default","level":"ERROR","logger":"scripts.OAUTH2_ACCESS_TOKEN_MODIFICATION.d22f9a0c-426a-4466-b95e-d0f125b0d5fa","mdc":{"transactionId":"0d3c7dac-d4e8-4cdd-b651-f5ff6659113d-566"},"message":"OAuth2 Access Token Modification Script","thread":"ScriptEvaluator-1","timestamp":"2021-01-13T20:01:50.339Z","transactionId":"0d3c7dac-d4e8-4cdd-b651-f5ff6659113d-566"}
. . .
The output produced by the script may be further processed with command-line tools of your choice.
For example, you can filter the output and change its presentation with jq. The following command will filter the logs content by presence of the “exception” key, or by checking if the nested “logger” property is populated with a script reference; then, it will limit the presentation to “logger”, “message”, “timestamp”, and “exception” keys:
$ node tail.am-core.js | jq '. | select(objects) | select(has("exception") or (.logger | test("scripts."))) | {logger: .logger, message: .message, timestamp: .timestamp, exception: .exception}'
. . .
{
"logger": "scripts.AUTHENTICATION_TREE_DECISION_NODE.bbf4feef-2bfe-46b7-824f-f632f7de426f",
"message": "value: [userName:user.0]",
"timestamp": "2021-01-14T00:07:38.809Z",
"exception": null
}
{
"logger": "org.forgerock.openam.core.rest.authn.trees.AuthTrees",
"message": "Exception in processing the tree",
"timestamp": "2021-01-14T00:07:38.815Z",
"exception": "org.forgerock.openam.auth.node.api.NodeProcessException: Script must set 'outcome' to a string.\n\tat org.forgerock.openam.auth.nodes.ScriptedDecisionNode.process(ScriptedDecisionNode.java:237)\n\t . . . "
}
. . .
The filter:
select( . . . )
has("exeption")
or
(.logger | test("scripts."))
The presentation:
| {logger: .logger, message: .message, timestamp: .timestamp, exception: .exception}
Or, you may only be interested in exceptions produced by a particular logger—a script, for example:
$ node tail.am-core.js | jq '. | select(objects) | select(has("exception") and (.logger | test("org.forgerock.openam.scripting.")) or (.logger | test("scripts."))) | {logger: .logger, message: .message, timestamp: .timestamp, exception: .exception}'
Notice the filter change:
select( . . . )
has("exception") and (.logger | test("org.forgerock.openam.scripting."))
or
(.logger | test("scripts."))
And so on.
If you modify the scripts to allow for non-JSON data, or use
jq
in a different environment where JSON output is not guaranteed, you may want to limit the tool input to JSON only. For example, in ForgeRock DevOps (ForgeOps), you could tail an AM pod scripting logs with the following:kubectl logs --follow am-78684784c4-j2ngm | jq -R 'fromjson? | select(objects) | select(has("exception") and (.logger | test("org.forgerock.openam.scripting.")) or (.logger | test("scripts."))) | {logger: .logger, message: .message, timestamp: .timestamp, exception: .exception}'
Alternatively, you can modify the scripts themselves for tailoring the logs data prior to printing it out.
It is easy to do by modifying the default function that processes and outputs the content received from the tail endpoint, and by providing your custom version as an argument when loading the module. This flexibility is demonstrated in the examples included in the repository.
Yet another option is making changes in the main module, tail.js. This way, commonly used logs processing techniques could be shared between different tenant and source-specific callers (although the same could be achieved by reusing a custom function discussed in the previous paragraph). Changing the main module has been implemented in the following repository, which also maintains a list of the Identity Cloud log categories that could be used for filtering out some unwanted log “noise”: GitHub - vscheuber/fidc-debug-tools: ForgeRock Identity Cloud Debug Tools
The Node.js JavaScript approach referenced above was inspired by a Ruby script, courtesy of Beau Croteau and Volker Scheuber:
Ruby
# Specify the full base URL of the FIDC service.
host="https://your-tenant.forgeblocks.com"
# Specify the log API key and secret
api_key_id="aaa2...219"
api_key_secret="56ce...1ada1"
# Available sources are listed below. Uncomment the source you want to use. For development and debugging use "am-core" and "idm-core" respectively:
# source="am-access"
# source="am-activity"
# source="am-authentication"
# source="am-config"
source="am-core"
# source="am-everything"
# source="ctsstore"
# source="ctsstore-access"
# source="ctsstore-config-audit"
# source="ctsstore-upgrade"
# source="idm-access"
# source="idm-activity"
# source="idm-authentication"
# source="idm-config"
# source="idm-core"
# source="idm-everything"
# source="idm-sync"
# source="userstore"
# source="userstore-access"
# source="userstore-config-audit"
# source="userstore-ldif-importer"
# source="userstore-upgrade"
require 'pp'
require 'json'
prc=""
while(true) do
o=`curl -s --get --header 'x-api-key: #{api_key_id}' #{prc} --header 'x-api-secret: #{api_key_secret}' --data 'source=#{source}' "#{host}/monitoring/logs/tail"`
obj=JSON.parse(o)
obj["result"].each{|r|
pp r["payload"]
}
prc="--data '_pagedResultsCookie=#{obj["pagedResultsCookie"]}'"
sleep 10
end
To prepare the output content for the tool, print the payload and use to_json to make it a stringified JSON:
# pp r["payload"]
print r["payload"].to_json
Â
Unfortunately, without filtering, the current log sources in Identity Cloud output overwhelming amount of data with only some of it providing meaningful feedback for debugging purposes. Hopefully, more specific log categories will become supported in the near future so that no additional programming skills will be required for developing scripts against the identity cloud environment.
In addition, the response from the Identity Cloud monitoring endpoint is often far from immediate.
As an alternative, to receive a quick feedback from your authentication journey, you can use debugging techniques outlined in details for the scripted decision type:
- Displaying debugging information with the help of callbacks
- Including debugging information in the custom error message
For example, to show an object content on the client side in a scripted decision, you can use javax.security.auth.callback.TextOutputCallback:
JavaScript
var fr = JavaImporter(
org.forgerock.openam.auth.node.api.Action,
javax.security.auth.callback.TextOutputCallback
)
var messages = "Debugger"
messages = messages.concat(" | sharedState: ", sharedState.toString())
if (callbacks.isEmpty()) {
action = fr.Action.send(
new fr.TextOutputCallback(
fr.TextOutputCallback.ERROR,
messages
)
).build()
} else {
action = fr.Action.goTo("true").build()
}
Allowed Java Classes
Despite the fact that some of the AM default scripts are shipped in Groovy, the use of Groovy is not supported and therefore, discouraged in Identity Cloud.
Making changes to the scripting Engine Configuration is not an option in Identity Cloud at this time. Which means you cannot change class-name patterns allowed to be invoked by the script types.
While this may be less of a prominent issue in the JavaScript environment, some basic functionality in Groovy cannot be enabled as a result.
For example, the OAuth2 Access Token Modification Script
default script template comes in Groovy with the following code:
Groovy
/*
. . .
def result = new JsonSlurper().parseText(response.entity.string)
. . .
*/
Which causes no issues while commented out, but if uncommented it currently results in:
"Access to Java class \"org.apache.groovy.json.internal.LazyMap\" is prohibited."
Every reference to allowed and disallowed Java classes in this article applies here, with the additional detail that at the moment, you will not be able to change the default scripting configuration. This means, for example, that in JavaScript, you will not be able to check what Java class an object represents (by inspecting the class
property, as described in Bindings). Similarly, in JavaScript, you cannot currently iterate over the content of sharedState
and other HashMap objects by getting a list of their keys, as shown in the Language > Allowed Java Classes examples. At the same time, since Groovy is not supported in Identity Cloud, you might not be willing to invest too much effort in developing Groovy scripts. Some of this issues could be resolved in the future with changes in the Identity Cloud scripting engine configuration and/or in how it is controlled.
Accessing Profile Data
An Identity Cloud tenant is deployed in platform mode with an identity repository shared between AM and ForgeRock Identity Management (IDM).
The Identity Store configuration in AM is not exposed in Identity Cloud; hence, it may not be obvious that the user search attribute is not uid
. This means that, in the scripted decision context, you cannot pass username into methods of the idRepository
object. Instead, you need to identify users with their IDM object ID, which corresponds to the _id
attribute value.
In an environment integrated with IDM, as in the case of Identity Cloud, you can utilize Identify Existing User Node for looking up a user by an attribute, according to the Identity Object
you had chosen for your authentication journey.
For example, you can place Identify Existing User
after the Username Collector node, and look up the user with their username
checked against the IDM’s userName
attribute:
Doing so will save the _id
property in the sharedState
object (if the user is found), and let you use its value as the user identifier in the idRepository
methods:
Â
JavaScript or Groovy
logger.error("sharedState: " + sharedState)
var username = sharedState.get("_id")
var attribute = "mail"
logger.error(idRepository.getAttribute(username, attribute).toString())
If you use jq
to filter and format stringified JSON from the logs, as described in ForgeRock Identity Cloud > Debug Logging, the output will look similar to the following:
$ node tail.am-core.js | jq '. | select(objects) | select(has("exception") or (.logger | test("scripts."))) | {logger: .logger, message: .message, timestamp: .timestamp, exception: .exception}'
{
"logger": "scripts.AUTHENTICATION_TREE_DECISION_NODE.bbf4feef-2bfe-46b7-824f-f632f7de426f",
"message": "sharedState: {realm=/alpha, authLevel=0, username=user.0, _id=d7eed43d-ab2c-40be-874d-92571aa17107}",
"timestamp": "2020-11-29T19:34:39.882Z",
"exception": null
}
{
"logger": "scripts.AUTHENTICATION_TREE_DECISION_NODE.bbf4feef-2bfe-46b7-824f-f632f7de426f",
"message": "[user.0@e.com]",
"timestamp": "2020-11-29T19:34:39.884Z",
"exception": null
}
Â
Adding an Identifier
to the Identify Existing User
configuration will put objectAttributes
property into the sharedState
object, and populate it with the specified attribute, which may be required by other IDM nodes in platform mode:
{
"logger": "scripts.AUTHENTICATION_TREE_DECISION_NODE.bbf4feef-2bfe-46b7-824f-f632f7de426f",
"message": "sharedState: {realm=/alpha, authLevel=0, username=user.0, _id=d7eed43d-ab2c-40be-874d-92571aa17107, objectAttributes={userName=user.0}}",
"timestamp": "2020-11-29T20:00:56.252Z",
"exception": null
}
The attribute value by which the Identify Existing User
node finds the user can come from another interactive node such as Attribute Collector
. For example, you can identify the user by their email:
In this case, Identify Attribute
in the Identify Existing User
node is set to mail
:
If the user is found, their _id
will be added to the shared state:
{
"logger": "scripts.AUTHENTICATION_TREE_DECISION_NODE.42f7ebf7-1a71-4ec8-8984-c91bc0f7c3fd",
"message": "sharedState: {realm=/alpha, authLevel=0, objectAttributes={mail=user.0@e.com, userName=user.0}, _id=d7eed43d-ab2c-40be-874d-92571aa17107, username=user.0}",
"timestamp": "2021-01-19T08:05:10.835Z",
"exception": null
}
You can also specify user identifier programmatically.
For example, consider scenario where your user ID comes as an authentication request parameter, and the corresponding identity field is a custom attribute:
JavaScript in Scripted Decision Username
var idParameter = requestParameters.get("id")
if (idParameter && !idParameter.isEmpty()) {
sharedState.put("username", idParameter.get(0))
}
outcome = "true"
Â
JavaScript in Scripted Decision Debugger
logger.error("sharedState: " + sharedState)
outcome = "true"
When you request this authentication journey with the correct id
parameter, and use it to populate the “username” key in sharedState
, the Identify Existing User node will be able to find the corresponding identity record:
https://openam-dx-kl02.forgeblocks.com/am/XUI/?realm=/alpha&service=ScriptedIdentifyUser&id=5f31ccc762cb7e2033b6626eab066b23015dc012#/
{
"logger": "scripts.AUTHENTICATION_TREE_DECISION_NODE.42f7ebf7-1a71-4ec8-8984-c91bc0f7c3fd",
"message": "sharedState: {realm=/alpha, authLevel=0, username=user.0, _id=d7eed43d-ab2c-40be-874d-92571aa17107, objectAttributes={userName=user.0}}",
"timestamp": "2020-12-20T03:34:43.842Z",
"exception": null
}
Another consequence of the Identity Store configuration not being exposed in the AM console is that you cannot verify which attributes in the identity store are accessible from the scripts. In addition, attribute naming in AM and IDM is inconsistent.
While the IDM property names are exposed in the Admin UIs, consult the Identity Cloud Docs > Developers > User Profile Properties and Attributes Reference tables for the corresponding attribute names you can use in AM scripts.
You can see IDM attributes for a realm in the Platform Admin under:
Native Consoles > Identity Management > CONFIGURE > Managed Objects > MANAGED OBJECT
Identities > Manage > Realm Name - Users > userName > Details
Identities > Manage > Realm Name - Users > userName > Raw JSON
For example, to get frIndexedString1
value, labeled as Generic Indexed String 1
in the UI, in an OAuth2 Access Token Modification script, you would refer to the corresponding AM attribute as fr-attr-istr1
:
Â
JavaScript or Groovy
if (identity.getAttribute("fr-attr-istr1").toArray().length) {
logger.error("frIndexedString1: " + identity.getAttribute("fr-attr-istr1").toArray()[0]);
}
{
"logger": "scripts.OAUTH2_ACCESS_TOKEN_MODIFICATION.d22f9a0c-426a-4466-b95e-d0f125b0d5fa",
"message": "frIndexedString1: test",
"timestamp": "2020-12-01T20:08:00.468Z",
"exception": null
}
Extended Functionality
There is an additional binding introduced in Identity Cloud Scripted Decision Node scripts for secure use of secrets:
-
secrets
, credentials to be used in a script, but specified outside of the script itself, as currently described in the early access Scripted Decision Node API Functionality > Accessing Credentials and Secrets.
Conclusion
We went over some common scripting scenarios in AM 7. While not being a definitive guide, this writing extends the currently available official docs, and hopefully provides a developer with sufficient framework to start extending AM functionality with scripts.