IG 2023.6

Extensibility

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 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, 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 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 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.

  1. 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
  2. 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')}"
    }
  3. Go to http://ig.example.com:8080/dispatch, and click log in.

    The HeaderFilter sets Username and Password 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.

  1. 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)
  2. 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.

  3. 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 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, refer to LdapConnectionFactory in DS’s Javadoc.

  1. 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, refer to Install DS for evaluation in Directory Services’s Installation guide.

  2. Add the following script to IG:

    • Linux

    • Windows

    $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.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.

  3. Add the following route to IG:

    • Linux

    • Windows

    $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": "${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.

  4. Go to http://ig.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 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.

  1. Set up and test the example in Password replay from a database.

  2. 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)
  3. 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
        }
    }
  4. 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, calls SqlClient.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.

  5. 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 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 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 as my-decorator.

ExpressionPlugin

An ExpressionPlugin adds a node to the Expression context tree, alongside env (for environment variables), and system (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, the getKey() method returns the name of the node, and the getObject() method returns the unified expression language context object that contains the values needed to resolve the expression. The plugins for env and system 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, under META-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 the src/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 a filter() method, which takes a Context, a Request, and the Handler, which is the next filter or handler to dispatch to. The filter() 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 its handle() method to dispatch the request to the rest of the chain.

Handler

A Handler generates a response for a request.

The Handler interface exposes a handle() 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 a resolve(String) method to do the following:

  • Return the class mapped to a given alias

  • Return null if the given alias is unknown to the resolver

    All 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

  1. Build your IG extension into a .jar file.

  2. Create the directory $HOME/.openig/extra, where $HOME/.openig is the instance directory:

    $ mkdir $HOME/.openig/extra
  3. 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
  4. If the extension has dependencies that are not included in IG, also add them to the directory.

  5. Start IG, as described in Start IG with default settings.

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 Auditing your deployment. For more information about audit event handlers, refer to Audit framework.

Record custom audit events to standard output

Before you start, prepare IG and the sample application as described in the Getting started.

  1. Set up AM as described in Validate access tokens through the introspection endpoint.

  2. 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, and eventName.

    • 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.

  3. 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 named OAuth2AccessTopic. The events conform to the topic schema.

  4. 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.

  5. 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 named OAuth2AccessEvent, with a topic named OAuth2AccessTopic.

    • The audit service publishes the custom audit event to the JsonStdoutAuditEventHandler. A single line per audit event is published to standard output.

  6. Test the setup

    1. 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")
    2. 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.

    3. 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"
      }
Copyright © 2010-2023 ForgeRock, all rights reserved.