Extending IG Through Scripts
The following sections describe how to extend IG through scripts:
About Scripting
Important
When you are writing scripts or Java extensions, never use a Promise
blocking method, such as get()
, getOrThrow()
, or getOrThrowUninterruptibly()
, to obtain the response.
A promise represents the result of an asynchronous operation. Therefore, using a blocking method to wait for the result can cause deadlocks and/or race issues.
IG supports the Groovy dynamic scripting language through the use the scriptable objects. For information about scriptable object types, their configuration, and properties, see Scripts.
Scriptable objects are configured by the script's Internet media type, and either a source script included in the JSON configuration, or a file script that IG reads from a file. The configuration can optionally supply arguments to the script.
IG provides global variables to scripts at runtime, and provides access to Groovy's built-in functionality. Scripts can access the request and the context, store variables across executions, write messages to logs, make requests to a web service or to an LDAP directory service, and access responses returned in promise callback methods.
Before trying the scripts in this chapter, install and configure IG as described in Getting Started Guide.
When developing and debugging your scripts, consider configuring a capture decorator to log requests, responses, and context data in JSON form. You can then turn off capturing when you move to production. For details, see "CaptureDecorator".
Using a Reference File Script
The following example defines a ScriptableFilter written in Groovy, and stored in the following file:
$HOME/.openig/scripts/groovy/SimpleFormLogin.groovy
%appdata%\OpenIG\scripts\groovy\SimpleFormLogin.groovy
{ "name": "SimpleFormLogin", "type": "ScriptableFilter", "config": { "type": "application/x-groovy", "file": "SimpleFormLogin.groovy" } }
Relative paths in the file field depend on how IG is installed. If IG is installed in an application server, then paths for Groovy scripts are relative to $HOME/.openig/scripts/groovy
(or %appdata%\OpenIG\scripts\groovy
).
The base location $HOME/.openig/scripts/groovy
(or %appdata%\OpenIG\scripts\groovy
) is on the classpath when the scripts are executed. If some Groovy scripts are not in the default package, but instead have their own package names, they belong in the directory corresponding to their package name. For example, a script in package com.example.groovy
belongs in $HOME/.openig/scripts/groovy/com/example/groovy/
(or %appdata%\OpenIG\scripts\groovy\com\example\groovy\
).
Scripting in Studio
You can use Studio to configure a ScriptableFilter or ScriptableThrottlingPolicy, or use scripts to configure scopes in OAuth2ResourceServerFilter.
During configuration, you can enter the script directly into the object, or you can use a stored reference script. Note the following points about creating and using reference scripts:
When you enter a script directly into an object, the script is added to the list of reference scripts.
You can use a reference script in multiple objects in a route, but if you edit a reference script, all objects that use it are updated with the change.
If you delete an object that uses a script, or remove the object from the chain, the script that it references remains in the list of scripts.
If a reference script is used in an object, you can't rename or delete the script.
For an example of creating a ScriptableThrottlingPolicy in Studio, see "Configuring Scriptable Throttling". For information about using Studio, see "Adding Configuration to a Route".
Scripting Dispatch
To route requests when the conditions are complicated, use a ScriptableHandler
instead of a DispatchHandler
as described in "DispatchHandler".
Add the following script to IG:
$HOME/.openig/scripts/groovy/DispatchHandler.groovy
%appdata%\OpenIG\scripts\groovy\DispatchHandler.groovy
/* * Copyright 2014-2020 ForgeRock AS. All Rights Reserved * * Use of this code requires a commercial software license with ForgeRock AS. * or with one of its affiliates. All use shall be exclusively subject * to such license between the licensee and ForgeRock AS. */ /* * This simplistic dispatcher matches the path part of the HTTP request. * If the path is /mylogin, it checks Username and Password headers, * accepting bjensen:H1falutin, and returning HTTP 403 Forbidden to others. * Otherwise it returns HTTP 401 Unauthorized. */ // Rather than return a Promise of a response from an external source, // this script returns the response itself. response = new Response(Status.OK); switch (request.uri.path) { case "/mylogin": if (request.headers.Username.values[0] == "bjensen" && request.headers.Password.values[0] == "H1falutin") { response.status = Status.OK response.entity = "<html><p>Welcome back, Babs!</p></html>" } else { response.status = Status.FORBIDDEN response.entity = "<html><p>Authorization required</p></html>" } break default: response.status = Status.UNAUTHORIZED response.entity = "<html><p>Please <a href='./mylogin'>log in</a>.</p></html>" break } // Return the locally created response, no need to wrap it into a Promise return response
Add the following route to IG, to set up headers required by the script when the user logs in:
$HOME/.openig/config/routes/98-dispatch.json
%appdata%\OpenIG\config\routes\98-dispatch.json
{ "heap": [ { "name": "DispatchHandler", "type": "DispatchHandler", "config": { "bindings": [{ "condition": "${matches(request.uri.path, '/mylogin')}", "handler": { "type": "Chain", "config": { "filters": [ { "type": "HeaderFilter", "config": { "messageType": "REQUEST", "add": { "Username": [ "bjensen" ], "Password": [ "H1falutin" ] } } } ], "handler": "Dispatcher" } } }, { "handler": "Dispatcher", "condition": "${matches(request.uri.path, '/dispatch')}" } ] } }, { "name": "Dispatcher", "type": "ScriptableHandler", "config": { "type": "application/x-groovy", "file": "DispatchHandler.groovy" } } ], "handler": "DispatchHandler", "condition": "${matches(request.uri.path, '^/dispatch') or matches(request.uri.path, '^/mylogin')}" }
Go to http://openig.example.com:8080/dispatch, and click
log in
.The HeaderFilter sets
Username
andPassword
headers in the request, and passes the request to the script. The script responds,Welcome back, Babs!
Scripting HTTP Basic Authentication
HTTP Basic authentication calls for the user agent such as a browser to send a user name and password to the server in an Authorization
header. HTTP Basic authentication relies on an encrypted connection to protect the user name and password credentials, which are base64-encoded in the Authorization
header, not encrypted.
Add the following script to IG, to add an
Authorization
header based on a username and password combination:$HOME/.openig/scripts/groovy/BasicAuthFilter.groovy
%appdata%\OpenIG\scripts\groovy\BasicAuthFilter.groovy
/* * Copyright 2014-2020 ForgeRock AS. All Rights Reserved * * Use of this code requires a commercial software license with ForgeRock AS. * or with one of its affiliates. All use shall be exclusively subject * to such license between the licensee and ForgeRock AS. */ /* * Perform basic authentication with the user name and password * that are supplied using a configuration like the following: * * { * "name": "BasicAuth", * "type": "ScriptableFilter", * "config": { * "type": "application/x-groovy", * "file": "BasicAuthFilter.groovy", * "args": { * "username": "bjensen", * "password": "H1falutin" * } * } * } */ def userPass = username + ":" + password def base64UserPass = userPass.getBytes().encodeBase64() request.headers.add("Authorization", "Basic ${base64UserPass}" as String) // Credentials are only base64-encoded, not encrypted: Set scheme to HTTPS. /* * When connecting over HTTPS, by default the client tries to trust the server. * If the server has no certificate * or has a self-signed certificate unknown to the client, * then the most likely result is an SSLPeerUnverifiedException. * * To avoid an SSLPeerUnverifiedException, * set up HTTPS correctly on the server. * Either use a server certificate signed by a well-known CA, * or set up the gateway to trust the server certificate. */ request.uri.scheme = "https" // Calls the next Handler and returns a Promise of the Response. // The Response can be handled with asynchronous Promise callbacks. next.handle(context, request)
Add the following route to IG, to set up headers required by the script when the user logs in:
$HOME/.openig/config/routes/09-basic.json
%appdata%\OpenIG\config\routes\09-basic.json
{ "handler": { "type": "Chain", "config": { "filters": [ { "type": "ScriptableFilter", "config": { "type": "application/x-groovy", "file": "BasicAuthFilter.groovy", "args": { "username": "bjensen", "password": "H1falutin" } }, "capture": "filtered_request" } ], "handler": { "type": "StaticResponseHandler", "config": { "status": 200, "reason": "OK", "headers": { "Content-Type": [ "text/plain" ] }, "entity": "Hello bjensen!" } } } }, "condition": "${matches(request.uri.path, '^/basic')}" }
When the request path matches
/basic
, the route calls the Chain, which runs the ScriptableFilter. The capture setting captures the request as updated by the ScriptableFilter. Finally, IG returns a static page.Go to http://openig.example.com:8080/basic.
The captured request in the console log shows that the scheme is now HTTPS, and that the
Authorization
header is set for HTTP Basic:GET https://app.example.com:8081/basic HTTP/1.1 ... Authorization: Basic Ymp...aW4=
Scripting Authentication to LDAP-Enabled Servers
Many organizations use an LDAP directory service, such as ForgeRock Directory Services (DS), to store user profiles and authentication credentials. This section describes how to authenticate to DS by using a script and a ScriptableFilter.
DS is secure by default, so connections between IG and DS must be configured for TLS. For convenience, this example uses a TrustAllManager to blindly accept any certificate presented by DS. In a production environment, use a TrustManager that is configured to accept only the appropriate certificates.
If the LDAP connection in your deployment is not secured with TLS, you can remove SSL options from the example script, and remove the TrustAllManager from the example route.
For more information about attributes and types for interacting with LDAP, see AttributeParser in DS's Javadoc. The ConnectionFactory heartbeat is enabled by default. For information about how to disable it, see LdapConnectionFactory in DS's Javadoc.
Install an LDAP directory server, such as DS, and then generate or import some sample users who can authenticate over LDAP. For information about setting up DS and importing sample data, see Install DS for Evaluation in DS's Installation Guide.
Add the following script to IG:
$HOME/.openig/scripts/groovy/LdapsAuthFilter.groovy
%appdata%\OpenIG\scripts\groovy\LdapsAuthFilter.groovy
import org.forgerock.opendj.ldap.* import org.forgerock.opendj.security.SslOptions; /* Perform LDAP authentication based on user credentials from a form, * connecting to an LDAPS enabled server. * * If LDAP authentication succeeds, then return a promise to handle the response. * If there is a failure, produce an error response and return it. */ username = request.form?.username[0] password = request.form?.password[0] // Update port number to match the LDAPS port of your directory service. host = ldapHost ?: "localhost" port = ldapPort ?: 1636 // Include options for SSL. // In this example, the keyManager is not set (no mTLS enabled), and both // the trustManager and the LDAP secure protocol are specified from the // script arguments (see 'trustManager' and 'protocols' arguments). // In a development environment (when there is no TLS), the SslOptions can be removed completely. ldapOptions = ldap.defaultOptions(context) SslOptions sslOptions = SslOptions.newSslOptions(null, trustManager) .enabledProtocols(protocols); ldapOptions = ldapOptions.set(CommonLdapOptions.SSL_OPTIONS, sslOptions); // Include SSL options in the LDAP connection client = ldap.connect(host, port as Integer, ldapOptions) try { // Assume the username is an exact match of either // the user ID, the email address, or the user's full name. filter = "(|(uid=%s)(mail=%s)(cn=%s))" user = client.searchSingleEntry( "ou=people,dc=example,dc=com", ldap.scope.sub, ldap.filter(filter, username, username, username)) client.bind(user.name as String, password?.toCharArray()) // Authentication succeeded. // Set a header (or whatever else you want to do here). request.headers.add("Ldap-User-Dn", user.name.toString()) // Most LDAP attributes are multi-valued. // When you read multi-valued attributes, use the parse() method, // with an AttributeParser method // that specifies the type of object to return. attributes.cn = user.cn?.parse().asSetOfString() // When you write attribute values, set them directly. user.description = "New description set by my script" // Here is how you might read a single value of a multi-valued attribute: attributes.description = user.description?.parse().asString() // Call the next handler. This returns when the request has been handled. return next.handle(context, request) } catch (AuthenticationException e) { // LDAP authentication failed, so fail the response with // HTTP status code 403 Forbidden. response = new Response(Status.FORBIDDEN) response.headers['Content-Type'] = "text/html; charset=utf-8" response.entity = "<html><p>Authentication failed: " + e.message + "</p></html>" } catch (Exception e) { // Something other than authentication failed on the server side, // so fail the response with HTTP 500 Internal Server Error. response = new Response(Status.INTERNAL_SERVER_ERROR) response.headers['Content-Type'] = "text/html; charset=utf-8" response.entity = "<html><p>Server error: " + e.message + "</p></html>" } finally { client.close() } // Return the locally created response, no need to wrap it into a Promise return response
Information about the script is given in the script comments. If necessary, adjust the script to match your DS installation.
Add the following route to IG:
$HOME/.openig/config/routes/10-ldap.json
%appdata%\OpenIG\config\routes\10-ldap.json
{ "heap": [ { "name": "DsTrustManager", "type": "TrustAllManager" } ], "handler": { "type": "Chain", "config": { "filters": [ { "type": "ScriptableFilter", "config": { "args": { "ldapHost": "localhost", "ldapPort": 1636, "protocols": "TLSv1.3", "trustManager": "${heap['DsTrustManager']}" }, "type": "application/x-groovy", "file": "LdapsAuthFilter.groovy" } } ], "handler": { "type": "ScriptableHandler", "config": { "type": "application/x-groovy", "source": [ "dn = request.headers['Ldap-User-Dn'].values[0]", "entity = '<html><body><p>Ldap-User-Dn: ' + dn + '</p></body></html>'", "", "response = new Response(Status.OK)", "response.entity = entity", "return response" ] } } } }, "condition": "${matches(request.uri.path, '^/ldap')}" }
Notice the following features of the route:
The route matches requests to
/ldap
.The ScriptableFilter calls
LdapsAuthFilter.groovy
to authenticate the user over a secure LDAP connection, using the username and password provided in the request.The script uses TrustAllManager to blindly accept any certificate presented by DS.
The script receives a connection to the DS server, using TLS options. Using the credentials in the request, the script tries to perform an LDAP bind operation. If the bind succeeds (the credentials are accepted by the LDAP server), the request continues to the ScriptableHandler. Otherwise, the request stops with an error.
The ScriptableHandler returns the user DN.
Go to http://openig.example.com:8080/ldap?username=abarnes&password=chevron to specify credentials for the sample user
abarnes
.The script returns the user DN:
Ldap-User-Dn: uid=abarnes,ou=People,dc=example,dc=com
Scripting SQL Queries
This example builds on "Logging In With Credentials From a Database" to use scripts to look up credentials in a database, set the credentials in headers, and set the scheme in HTTPS to protect the request.
Set up and test the example in "Logging In With Credentials From a Database".
Add the following script to IG, to look up user credentials in the database, by email address, and set the credentials in the request headers for the next handler:
$HOME/.openig/scripts/groovy/SqlAccessFilter.groovy
%appdata%\OpenIG\scripts\groovy\SqlAccessFilter.groovy
/* * Copyright 2014-2020 ForgeRock AS. All Rights Reserved * * Use of this code requires a commercial software license with ForgeRock AS. * or with one of its affiliates. All use shall be exclusively subject * to such license between the licensee and ForgeRock AS. */ /* * Look up user credentials in a relational database * based on the user's email address provided in the request form data, * and set the credentials in the request headers for the next handler. */ def client = new SqlClient(dataSource) def credentials = client.getCredentials(request.form?.mail[0]) request.headers.add("Username", credentials.Username) request.headers.add("Password", credentials.Password) // The credentials are not protected in the headers, so use HTTPS. request.uri.scheme = "https" // Calls the next Handler and returns a Promise of the Response. // The Response can be handled with asynchronous Promise callbacks. next.handle(context, request)
Add the following script to IG to access the database, and get credentials:
$HOME/.openig/scripts/groovy/SqlClient.groovy
%appdata%\OpenIG\scripts\groovy\SqlClient.groovy
/* * Copyright 2014-2020 ForgeRock AS. All Rights Reserved * * Use of this code requires a commercial software license with ForgeRock AS. * or with one of its affiliates. All use shall be exclusively subject * to such license between the licensee and ForgeRock AS. */ import groovy.sql.Sql import javax.naming.InitialContext import javax.sql.DataSource /** * Access a database with a well-known structure, * in particular to get credentials given an email address. */ class SqlClient { // DataSource supplied as constructor parameter. def sql SqlClient(DataSource dataSource) { if (dataSource == null) { throw new IllegalArgumentException("DataSource is null") } this.sql = new Sql(dataSource) } // The expected table is laid out like the following. // Table USERS // ---------------------------------------- // | USERNAME | PASSWORD | EMAIL |...| // ---------------------------------------- // | <username>| <passwd> | <mail@...>|...| // ---------------------------------------- String tableName = "USERS" String usernameColumn = "USERNAME" String passwordColumn = "PASSWORD" String mailColumn = "EMAIL" /** * Get the Username and Password given an email address. * * @param mail Email address used to look up the credentials * @return Username and Password from the database */ def getCredentials(mail) { def credentials = [:] def query = "SELECT " + usernameColumn + ", " + passwordColumn + " FROM " + tableName + " WHERE " + mailColumn + "='$mail';" sql.eachRow(query) { credentials.put("Username", it."$usernameColumn") credentials.put("Password", it."$passwordColumn") } return credentials } }
Add the following route to IG to set up headers required by the scripts when the user logs in:
$HOME/.openig/config/routes/11-db.json
%appdata%\OpenIG\config\routes\11-db.json
{ "heap": [ { "name": "SystemAndEnvSecretStore-1", "type": "SystemAndEnvSecretStore" }, { "name": "JdbcDataSource-1", "type": "JdbcDataSource", "config": { "driverClassName": "org.h2.Driver", "jdbcUrl": "jdbc:h2:tcp://localhost/~/test", "username": "sa", "passwordSecretId": "database.password", "secretsProvider": "SystemAndEnvSecretStore-1" } } ], "handler": { "type": "Chain", "config": { "filters": [ { "type": "ScriptableFilter", "config": { "args": { "dataSource": "${heap['JdbcDataSource-1']}" }, "type": "application/x-groovy", "file": "SqlAccessFilter.groovy" } }, { "type": "StaticRequestFilter", "config": { "method": "POST", "uri": "http://app.example.com:8081/login", "form": { "username": [ "${request.headers['Username'][0]}" ], "password": [ "${request.headers['Password'][0]}" ] } } } ], "handler": "ReverseProxyHandler" } }, "condition": "${matches(request.uri.path, '^/db')}" }
Notice the following features of the route:
The route matches requests to
/db
.The JdbcDataSource in the heap sets up the connection to the database.
The ScriptableFilter calls
SqlAccessFilter.groovy
to look up credentials over SQL.SqlAccessFilter.groovy
, in turn, callsSqlClient.groovy
to access the database to get the credentials.The StaticRequestFilter uses the credentials to build a login request.
Although the script sets the scheme to HTTPS, for convenience in this example, the StaticRequestFilter resets the URI to HTTP.
To test the setup, go to a URL with a query string parameter that specifies an email address in the database, such as http://openig.example.com:8080/db?mail=george@example.com.
The sample application profile page for George is displayed.