Extend
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:
Add .jar files for extensions
IG includes a complete Java application programming interface for extending your deployment with customizations. For more information, refer to Extend IG through the Java API
Create a directory to hold .jar files for IG extensions:
-
Linux
-
Windows
$HOME/.openig/extra
%appdata%\OpenIG\extra
When IG starts up, the JVM loads .jar files in the
extra
directory.
Extend IG through scripts
The following sections describe how to extend IG through scripts:
About scripts
When writing scripts or Java extensions that use the Promise API, avoid the
blocking methods Instead, consider using
|
IG supports the Groovy dynamic scripting language through the use the scriptable objects. For information about scriptable object types, their configuration, and properties, refer to 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, and access responses returned in promise callback methods.
Before trying the scripts in this chapter, install and configure IG as described in the Quick install.
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 information, refer to CaptureDecorator.
Use a reference file script
The following example defines a ScriptableFilter written in Groovy, and stored in the following file:
-
Linux
-
Windows
$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, refer to Configure Scriptable Throttling. For information about using Studio, refer to 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:
-
Linux
-
Windows
$HOME/.openig/scripts/groovy/DispatchHandler.groovy
%appdata%\OpenIG\scripts\groovy\DispatchHandler.groovy
/* * 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:
-
Linux
-
Windows
$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 find(request.uri.path, '^/mylogin')}" }
-
-
Go to http://ig.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 access authentication
HTTP basic access authentication is a simple challenge and response mechanism,
where a server requests credentials from a client, and the client passes them
to the server in an Authorization
header. The credentials are base-64 encoded.
To protect them, use SSL encryption for the connections between the server and
client. For more information, refer to
RFC 2617.
-
Add the following script to IG, to add an
Authorization
header based on a username and password combination:-
Linux
-
Windows
$HOME/.openig/scripts/groovy/BasicAuthFilter.groovy
%appdata%\OpenIG\scripts\groovy\BasicAuthFilter.groovy
/* * 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:
-
Linux
-
Windows
$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, "headers": { "Content-Type": [ "text/plain; charset=UTF-8" ] }, "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://ig.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 SQL queries
The example in this section uses SqlClient, which exposes a JdbcDataSource. Because the JDBC API provides only blocking APIs, using SqlClient to execute long operations can cause deadlocks and/or race issues. Consider updating the example in this section to offload JdbcDataSource calls to another thread. |
This example builds on Password replay 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 Password replay 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:
-
Linux
-
Windows
$HOME/.openig/scripts/groovy/SqlAccessFilter.groovy
%appdata%\OpenIG\scripts\groovy\SqlAccessFilter.groovy
/* * 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:
-
Linux
-
Windows
$HOME/.openig/scripts/groovy/SqlClient.groovy
%appdata%\OpenIG\scripts\groovy\SqlClient.groovy
import groovy.sql.Sql 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:
-
Linux
-
Windows
$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": "${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 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://ig.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 writing scripts or Java extensions that use the Promise API, avoid the
blocking methods Instead, consider using
|
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 information, refer to the reference documentation for the Java class 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 customizations in IG.Remember to provide 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:
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.openig.model.type.service.NoTypeInfo;
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.
*/
@NoTypeInfo
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;
}
}
}
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:
package org.forgerock.openig.doc.examples;
import static java.util.stream.Collectors.toUnmodifiableSet;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import org.forgerock.openig.alias.ClassAliasResolver;
import org.forgerock.openig.heap.Heaplet;
import org.forgerock.openig.heap.Heaplets;
/**
* 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);
}
@Override
public Set<Class<? extends Heaplet>> supportedTypes() {
return ALIASES.values()
.stream()
.map(Heaplets::findHeapletClass)
.filter(Optional::isPresent)
.map(Optional::get)
.collect(toUnmodifiableSet());
}
}
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 customizations in IG
-
Build your IG extension into a .jar file.
-
Create the directory
$HOME/.openig/extra
, where$HOME/.openig
is the instance directory:$ mkdir $HOME/.openig/extra
-
Add the .jar file to the directory. The following example adds
sample-filter.jar
to$HOME/.openig/extra
:$ cp ~/sample-filter/target/sample-filter.jar $HOME/.openig/extra
-
If the extension has dependencies that are not included in IG, also add them to the directory.
-
Start IG, as described in Start and stop IG.
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, refer to Audit the deployment. For more information about audit event handlers, refer to Audit framework.
Before you start, prepare IG and the sample application as described in the Quick install.
-
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:-
Linux
-
Windows
$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:-
Linux
-
Windows
$HOME/.openig/scripts/groovy/OAuth2Access.groovy
%appdata%\OpenIG\scripts\groovy/OAuth2Access.groovy
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, and log a message if there is an error auditService.handleCreate(context, auditEventRequest("OAuth2AccessTopic", auditEvent)) .thenOnException(e -> logger.warn("An error occurred while sending the audit event", e)); // 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:
-
Linux
-
Windows
$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://am.example.com:8088/openam/" } } ], "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; charset=UTF-8" ] }, "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://am.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://ig.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" }
-