Extend IG
To achieve complex server interactions or intensive data transformations that you can’t currently achieve with scripts or existing handlers, filters, or expressions, extend IG through scripting and customization. The following sections describe how to extend IG:
Extend IG Through Scripts
The following sections describe how to extend IG through scripts:
About Scripts
When you are writing scripts or Java extensions, never use a 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 the Getting Started.
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.
Use 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\
).
Scripts 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 Configure Scriptable Throttling. For information about using Studio, see Adding Configuration to a Route.
Script 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-2022 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 returning 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": "${find(request.uri.path, '/mylogin')}", "handler": { "type": "Chain", "config": { "filters": [ { "type": "HeaderFilter", "config": { "messageType": "REQUEST", "add": { "Username": [ "bjensen" ], "Password": [ "H1falutin" ] } } } ], "handler": "Dispatcher" } } }, { "handler": "Dispatcher", "condition": "${find(request.uri.path, '/dispatch')}" } ] } }, { "name": "Dispatcher", "type": "ScriptableHandler", "config": { "type": "application/x-groovy", "file": "DispatchHandler.groovy" } } ], "handler": "DispatchHandler", "condition": "${find(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!
Script 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:
{ "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": "${find(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=
Script 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 ForgeRock Directory Services, 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 Directory Services’s Installation Guide.
-
Add the following script to IG:
$HOME/.openig/scripts/groovy/LdapsAuthFilter.groovy
appdata\OpenIG\scripts\groovy\LdapsAuthFilter.groovy
/* * Copyright 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 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.queryParams?.username[0] password = request.queryParams?.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:
{ "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": "${find(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
Script SQL Queries
This example builds on Log 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 Log 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.queryParams?.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:
{ "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": "${find(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 callsSqlAccessFilter.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 george@example.com" class="bare">http://openig.example.com:8080/db?mail=george@example.com.
The sample application profile page for the user is displayed.
Extend IG Through the Java API
When you are writing scripts or Java extensions, never use a 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 includes a complete Java application programming interface to allow you to customize IG to perform complex server interactions or intensive data transformations that you cannot achieve with scripts or the existing handlers, filters, and expressions described in Expressions. The following sections describe how to extend IG through the Java API:
Key Extension Points
Interface Stability: Evolving, as defined in ForgeRock Product Stability Labels.
The following interfaces are available:
- Decorator
-
A
Decorator
adds new behavior to another object without changing the base type of the object.When suggesting custom
Decorator
names, know that IG reserves all field names that use only alphanumeric characters. To avoid clashes, use dots or dashes in your field names, such asmy-decorator
. - ExpressionPlugin
-
An
ExpressionPlugin
adds a node to theExpression
context tree, alongsideenv
(for environment variables), andsystem
(for system properties). For example, the expression${system['user.home']}
yields the home directory of the user running the application server for IG.In your
ExpressionPlugin
, thegetKey()
method returns the name of the node, and thegetObject()
method returns the unified expression language context object that contains the values needed to resolve the expression. The plugins forenv
andsystem
return Map objects, for example.When you add your own
ExpressionPlugin
, you must make it discoverable within your custom library. You do this by adding a services file named after the plugin interface, where the file contains the fully qualified class name of your plugin, underMETA-INF/services/org.forgerock.openig.el.ExpressionPlugin
in the .jar file for your customizations. When you have more than one plugin, add one fully qualified class name per line. For details, see the reference documentation for the Java class link:https://docs.oracle.com/en/java/javase/11/docs/api/java .base/java/util/ServiceLoader.html[ServiceLoader]. If you build your project using Maven, then you can add this under thesrc/main/resources
directory. Add custom libraries, as described in Embed the Customization in IG.Be sure to provide some documentation for IG administrators on how your plugin extends expressions.
- Filter
-
A
Filter
serves to process a request before handing it off to the next element in the chain, in a similar way to an interceptor programming model.The
Filter
interface exposes afilter()
method, which takes a Context, a Request, and the Handler, which is the next filter or handler to dispatch to. Thefilter()
method returns a Promise that provides access to the Response with methods for dealing with both success and failure conditions.A filter can elect not to pass the request to the next filter or handler, and instead handle the request itself. It can achieve this by merely avoiding a call to
next.handle(context, request)
, creating its own response object and returning that in the promise. The filter is also at liberty to replace a response with another of its own. A filter can exist in more than one chain, therefore should make no assumptions or correlations using the chain it is supplied. The only valid use of a chain by a filter is to call itshandle()
method to dispatch the request to the rest of the chain. - Handler
-
A
Handler
generates a response for a request.The
Handler
interface exposes ahandle()
method, which takes a Context, and a Request. It processes the request and returns a Promise that provides access to the link:../_attachments/apidocs/org/forgerock/http/protocol/Response .html[Response] with methods for dealing with both success and failure conditions. A handler can elect to dispatch the request to another handler or chain. - ClassAliasResolver
-
A
ClassAliasResolver
makes it possible to replace a fully qualified class name with a short name (an alias) in an object declaration’s type.The
ClassAliasResolver
interface exposes aresolve(String)
method to do the following:-
Return the class mapped to a given alias
-
Return
null
if the given alias is unknown to the resolverAll resolvers available to IG are asked until the first non-null value is returned or until all resolvers have been contacted.
The order of resolvers is nondeterministic. To prevent conflicts, don’t use the same alias for different types.
-
Implement a Customized Sample Filter
The SampleFilter
class implements the Filter
interface to set a header in
the incoming request and in the outgoing response.
In the following example, the sample filter adds an arbitrary header:
/*
* 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.
*/
// --- JCite ---
package org.forgerock.openig.doc.examples;
import org.forgerock.http.Filter;
import org.forgerock.http.Handler;
import org.forgerock.http.protocol.Request;
import org.forgerock.http.protocol.Response;
import org.forgerock.openig.heap.GenericHeaplet;
import org.forgerock.openig.heap.HeapException;
import org.forgerock.services.context.Context;
import org.forgerock.util.promise.NeverThrowsException;
import org.forgerock.util.promise.Promise;
/**
* Filter to set a header in the incoming request and in the outgoing response.
*/
public class SampleFilter implements Filter {
/** Header name. */
String name;
/** Header value. */
String value;
/**
* Set a header in the incoming request and in the outgoing response.
* A configuration example looks something like the following.
*
* <pre>
* {
* "name": "SampleFilter",
* "type": "SampleFilter",
* "config": {
* "name": "X-Greeting",
* "value": "Hello world"
* }
* }
* </pre>
*
* @param context Execution context.
* @param request HTTP Request.
* @param next Next filter or handler in the chain.
* @return A {@code Promise} representing the response to be returned to the client.
*/
@Override
public Promise<Response, NeverThrowsException> filter(final Context context,
final Request request,
final Handler next) {
// Set header in the request.
request.getHeaders().put(name, value);
// Pass to the next filter or handler in the chain.
return next.handle(context, request)
// When it has been successfully executed, execute the following callback
.thenOnResult(response -> {
// Set header in the response.
response.getHeaders().put(name, value);
});
}
/**
* Create and initialize the filter, based on the configuration.
* The filter object is stored in the heap.
*/
public static class Heaplet extends GenericHeaplet {
/**
* Create the filter object in the heap,
* setting the header name and value for the filter,
* based on the configuration.
*
* @return The filter object.
* @throws HeapException Failed to create the object.
*/
@Override
public Object create() throws HeapException {
SampleFilter filter = new SampleFilter();
filter.name = config.get("name").as(evaluatedWithHeapProperties()).required().asString();
filter.value = config.get("value").as(evaluatedWithHeapProperties()).required().asString();
return filter;
}
}
}
// --- JCite ---
The corresponding filter configuration is similar to this:
{
"name": "SampleFilter",
"type": "org.forgerock.openig.doc.examples.SampleFilter",
"config": {
"name": "X-Greeting",
"value": "Hello world"
}
}
Note how type
is configured with the fully qualified class name for
SampleFilter
. To simplify the configuration, implement a class alias
resolver, as described in
Implement a Class Alias Resolver.
Implement a Class Alias Resolver
To simplify the configuration of a customized object, implement a
ClassAliasResolver
to allow the use of short names instead of fully qualified
class names.
In the following example, a ClassAliasResolver
is created for the
SampleFilter
class:
/*
* 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.
*/
// --- JCite ---
package org.forgerock.openig.doc.examples;
import java.util.HashMap;
import java.util.Map;
import org.forgerock.openig.alias.ClassAliasResolver;
/**
* Allow use of short name aliases in configuration object types.
*
* This allows a configuration with {@code "type": "SampleFilter"}
* instead of {@code "type": "org.forgerock.openig.doc.examples.SampleFilter"}.
*/
public class SampleClassAliasResolver implements ClassAliasResolver {
private static final Map<String, Class<?>> ALIASES =
new HashMap<>();
static {
ALIASES.put("SampleFilter", SampleFilter.class);
}
/**
* Get the class for a short name alias.
*
* @param alias Short name alias.
* @return The class, or null if the alias is not defined.
*/
@Override
public Class<?> resolve(final String alias) {
return ALIASES.get(alias);
}
}
// --- JCite ---
With this ClassAliasResolver
, the filter configuration in
Implement a Customized Sample Filter can use the alias instead of the fully qualified class name,
as follows:
{
"name": "SampleFilter",
"type": "SampleFilter",
"config": {
"name": "X-Greeting",
"value": "Hello world"
}
}
To create a customized ClassAliasResolver
, add a services file with the
following characteristics:
-
Name the file after the class resolver interface.
-
Store the file under
META-INF/services/org.forgerock.openig.alias.ClassAliasResolver
, in the customization .jar file.If you build your project using Maven, you can add the file under the
src/main/resources
directory. -
In your ClassAliasResolver file, add a line for the fully qualified class name of your resolver as follows:
org.forgerock.openig.doc.examples.SampleClassAliasResolver
If you have more than one resolver in your .jar file, add one line for each fully qualified class name.
Configure the Heap Object for the Customization
Objects are added to the heap and supplied with configuration artifacts at
initialization time. To be integrated with the configuration, a class must have
an accompanying implementation of the
Heaplet interface.
The easiest and most common way of exposing the heaplet is to extend the
GenericHeaplet
class in a nested class of the class you want to create and initialize,
overriding the heaplet’s create()
method.
Within the create()
method, you can access the object’s configuration through
the config
field.
Embed the Customization in IG
After building your customizations into a .jar file, add it to the configuration as follows:
-
For IG installed in standalone mode, create the directory
$HOME/.openig/extra
, where$HOME/.openig
is the instance directory, and add the .jar file to the directory. -
For IG installed in web container mode, include the .jar file in the IG .war file, as follows:
-
Unpack
IG-7.1.2.war
-
Include the .jar library in
WEB-INF/lib
-
Create a new .war file
The following example adds the .jar file
sample-filter
tocustom.war
: -
$ mkdir root && cd root
$ jar -xf ~/Downloads/IG-7.1.2.war
$ cp ~/Documents/sample-filter/target/sample-filter-1.0.0-SNAPSHOT.jar WEB-INF/lib
$ jar -cf ../custom.war *
Deploy custom.war
in the same way as you deploy IG-7.1.2.war
.
Record Custom Audit Events
This section describes how to record a custom audit event to standard
output. The example is based on the example in
Validate Access_Tokens Through the Introspection Endpoint, adding an audit event for the
custom topic OAuth2AccessTopic
.
To record custom audit events to other outputs, adapt the route in the following procedure to use another audit event handler.
For information about how to configure supported audit event handlers, and exclude sensitive data from log files, see Auditing Your Deployment. For more information about audit event handlers, see Audit Framework.
Before you start, prepare IG and the sample application as described in the Getting Started.
-
Set up AM as described in Validate Access_Tokens Through the Introspection Endpoint.
-
Define the schema of an event topic called
OAuth2AccessTopic
by adding the following route to IG:$HOME/.openig/audit-schemas/OAuth2AccessTopic.json
appdata\OpenIG\OpenIG\audit-schemas/OAuth2AccessTopic.json
{ "schema": { "$schema": "http://json-schema.org/draft-04/schema#", "id": "OAuth2Access", "type": "object", "properties": { "_id": { "type": "string" }, "timestamp": { "type": "string" }, "transactionId": { "type": "string" }, "eventName": { "type": "string" }, "accessToken": { "type": "object", "properties": { "scopes": { "type": "array", "items": { "type": "string" } }, "expiresAt": "number", "sub": "string" }, "required": [ "scopes" ] }, "resource": { "type": "object", "properties": { "path": { "type": "string" }, "method": { "type": "string" } } } } }, "filterPolicies": { "field": { "includeIf": [ "/_id", "/timestamp", "/eventName", "/transactionId", "/accessToken", "/resource" ] } }, "required": [ "_id", "timestamp", "transactionId", "eventName" ] }
Notice that the schema includes the following fields:
-
Mandatory fields
_id
,timestamp
,transactionId
, andeventName
. -
accessToken
, to include the access_token scopes, expiry time, and the subject. -
resource
, to include the path and method. -
filterPolicies
, to specify additional event fields to include in the logs.
-
-
Define a script to generate audit events on the topic named
OAuth2AccessTopic
, by adding the following file to the IG configuration as:$HOME/.openig/scripts/groovy/OAuth2Access.groovy
appdata\OpenIG\scripts\groovy/OAuth2Access.groovy
/* * Copyright 2020-2021 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 static org.forgerock.json.resource.Requests.newCreateRequest; import static org.forgerock.json.resource.ResourcePath.resourcePath; // Helper functions def String transactionId() { return contexts.transactionId.transactionId.value; } def JsonValue auditEvent(String eventName) { return json(object(field('eventName', eventName), field('transactionId', transactionId()), field('timestamp', clock.instant().toEpochMilli()))); } def auditEventRequest(String topicName, JsonValue auditEvent) { return newCreateRequest(resourcePath("/" + topicName), auditEvent); } def accessTokenInfo() { def accessTokenInfo = contexts.oauth2.accessToken; return object(field('scopes', accessTokenInfo.scopes as List), field('expiresAt', accessTokenInfo.expiresAt), field('subname', accessTokenInfo.info.subname)); } def resourceEvent() { return object(field('path', request.uri.path), field('method', request.method)); } // -------------------------------------------- // Build the event JsonValue auditEvent = auditEvent('OAuth2AccessEvent') .add('accessToken', accessTokenInfo()) .add('resource', resourceEvent()); // Send the event auditService.handleCreate(context, auditEventRequest("OAuth2AccessTopic", auditEvent)); // Continue onto the next filter return next.handle(context, request)
The script generates audit events named
OAuth2AccessEvent
, on a topic namedOAuth2AccessTopic
. The events conform to the topic schema. -
Set an environment variable for the IG agent password, and then restart IG:
$ export AGENT_SECRET_ID='cGFzc3dvcmQ='
The password is retrieved by a SystemAndEnvSecretStore, and must be base64-encoded.
-
Add the following route to IG:
$HOME/.openig/config/routes/30-custom.json
appdata\OpenIG\config\routes\30-custom.json
{ "name": "30-custom", "baseURI": "http://app.example.com:8081", "condition": "${find(request.uri.path, '^/rs-introspect-audit')}", "heap": [ { "name": "AuditService-1", "type": "AuditService", "config": { "config": {}, "eventHandlers": [ { "class": "org.forgerock.audit.handlers.json.stdout.JsonStdoutAuditEventHandler", "config": { "name": "jsonstdout", "elasticsearchCompatible": false, "topics": [ "OAuth2AccessTopic" ] } } ] } }, { "name": "SystemAndEnvSecretStore-1", "type": "SystemAndEnvSecretStore" }, { "name": "AmService-1", "type": "AmService", "config": { "agent": { "username": "ig_agent", "passwordSecretId": "agent.secret.id" }, "secretsProvider": "SystemAndEnvSecretStore-1", "url": "http://openam.example.com:8088/openam/", "version": "7.1" } } ], "handler": { "type": "Chain", "config": { "filters": [ { "name": "OAuth2ResourceServerFilter-1", "type": "OAuth2ResourceServerFilter", "config": { "scopes": [ "mail", "employeenumber" ], "requireHttps": false, "realm": "OpenIG", "accessTokenResolver": { "name": "token-resolver-1", "type": "TokenIntrospectionAccessTokenResolver", "config": { "amService": "AmService-1", "providerHandler": { "type": "Chain", "config": { "filters": [ { "type": "HttpBasicAuthenticationClientFilter", "config": { "username": "ig_agent", "passwordSecretId": "agent.secret.id", "secretsProvider": "SystemAndEnvSecretStore-1" } } ], "handler": "ForgeRockClientHandler" } } } } } }, { "type": "ScriptableFilter", "config": { "type": "application/x-groovy", "file": "OAuth2Access.groovy", "args": { "auditService": "${heap['AuditService-1']}", "clock": "${heap['Clock']}" } } } ], "handler": { "type": "StaticResponseHandler", "config": { "status": 200, "headers": { "Content-Type": [ "text/html" ] }, "entity": "<html><body><h2>Decoded access_token: ${contexts.oauth2.accessToken.info}</h2></body></html>" } } } } }
Notice the following features of the route:
-
The route matches requests to
/rs-introspect-audit
. -
The
accessTokenResolver
uses the token introspection endpoint to validate the access_token. -
The HttpBasicAuthenticationClientFilter adds the credentials to the outgoing token introspection request.
-
The ScriptableFilter uses the Groovy script
OAuth2Access.groovy
to generate audit events namedOAuth2AccessEvent
, with a topic namedOAuth2AccessTopic
. -
The audit service publishes the custom audit event to the JsonStdoutAuditEventHandler. A single line per audit event is published to standard output.
-
-
Test the setup
-
In a terminal window, use a
curl
command similar to the following to retrieve an access_token:$ mytoken=$(curl -s \ --user "client-application:password" \ --data "grant_type=password&username=demo&password=Ch4ng31t&scope=mail%20employeenumber" \ http://openam.example.com:8088/openam/oauth2/access_token | jq -r ".access_token")
-
Access the route, with the access_token returned in the previous step:
$ curl -v http://openig.example.com:8080/rs-introspect-audit --header "Authorization: Bearer ${mytoken}"
Information about the decoded access_token is returned.
-
Search the standard output for an audit message like the following example, that includes an audit event on the topic
OAuth2AccessTopic
:{ "_id": "fa2...-14", "timestamp": 155...541, "eventName": "OAuth2AccessEvent", "transactionId": "fa2...-13", "accessToken": { "scopes": ["employeenumber", "mail"], "expiresAt": 155...000, "subname": "demo" }, "resource": { "path": "/rs-introspect-audit", "method": "GET" }, "source": "audit", "topic": "OAuth2AccessTopic", "level": "INFO" }
-