mirror of https://github.com/OpenIdentityPlatform/OpenDJ.git

Gaetan Boismal
18.27.2016 c8585baebc9fc35ed12a3321acf47730c967b5d3
OPENDJ-2880 Rest2Ldap as an OAuth2 Resource Server

Rest2Ldap now supports the OAuth2 Authorization standard as a Resource Server.

If an access token is provided in an Authorization header, we try to resolve it
against two types of Authorization Server
* OpenAM /tokeninfo endpoint
* RFC-7662 /token-introspect endpoint (RFC7662TokenResolver class)

These two endpoints must be configured in the json configuration file.

Rest2Ldap can also try to search the access token in the Core Token Service (CTS).
This work is done by the CTSTokenResolver class

For test purpose only, we also have a FileAccessTokenResolver which
resolve access token from local json file.

Once the access token validated, we use token content to extract a user identifier in order to perform the ldap request with proxy authz control.
7 files added
10 files modified
1709 ■■■■■ changed files
opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/opendj-rest2ldap-config.json 107 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/pom.xml 17 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java 141 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Authorizations.java 176 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java 152 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolver.java 115 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java 64 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2Filter.java 4 ●●● patch | view | raw | blame | history
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessTokenResolver.java 135 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthorizationsTestCase.java 131 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java 238 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java 27 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java 39 ●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolverTestCase.java 138 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2FilterTest.java 2 ●●● patch | view | raw | blame | history
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessResolverTestCase.java 124 ●●●●● patch | view | raw | blame | history
opendj-server-legacy/resource/config/http-config.json 99 ●●●●● patch | view | raw | blame | history
opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/opendj-rest2ldap-config.json
@@ -70,7 +70,7 @@
        }
    },
    "authorization": {
    "authorization": {
        // The authorization policies to use. Supported policies are "anonymous", "basic" and "oauth2".
        "policies": [ "basic" ],
@@ -114,7 +114,7 @@
                // Authentication identity template containing a single %s which will be replaced by the authenticating
                // user's name. (i.e: u:%s)
                "authcIdTemplate": "u:%s"
                "authzIdTemplate": "u:%s"
            },
            
            // Bind to the LDAP server using the resulting DN of a search request. 
@@ -134,7 +134,108 @@
                "filterTemplate" : "(&(uid=%s)(objectClass=inetOrgPerson))"
            }
            // TODO: support for HTTP sessions?
        }
        },
        // Use OAuth2 authorization method. If used, LDAP requests will be performed with proxied authorization control.
        // This field is optional.
        "oauth2": {
            // Access tokens associated realm.
            // This attribute is optional and has a string syntax.
            "realm": "myrealm",
            // Defines the list of required scopes required to access the service.
            // This field is required and cannot be empty.
            "requiredScopes": [ "read", "write", "uid" ],
            // Specify the resolver to use to resolve OAuth2 access token.
            // This attribute is required and its value must be one of "openam", "rfc7662", "cts".
            // Note that the JSON object corresponding to this attribute value must be present
            // and well formed in the "oauth2" JSON attribute.
            "resolver": "openam",
            // The default authzIdTemplate demonstrates how an authorization DN may be constructed
            // from the "uid" field in the following example OAuth2 token introspection response:
            // {
            //     "token": "access_token_string",
            //     "uid" : "user.2",
            //     "userName" : [ "user.2" ]
            // }
            // This attribute is required and has a string syntax.
            // It must start with either 'dn:' or 'u:'.
            // Note: For the CTS resolver, the following placeholder "{userName/0}" must be part of the template string,
            //       e.g "authzIdTemplate": "dn:uid={userName/0},ou=People,dc=example,dc=com"
            "authzIdTemplate": "dn:uid={uid},ou=People,dc=example,dc=com",
            // Configures caching of access token introspection results.
            // This attribute is optional, if it is not present, no token caching will be performed.
            "accessTokenCache": {
                // Indicates whether the access token caching should be used.
                // This attribute is optional (default value is false) and must have a boolean syntax.
                "enabled": false,
                // Specifies the maximal caching duration for an access token.
                // Once this delay is over, token will be refreshed from an access token resolver (see "oauth2/resolver")
                // This attribute is optional, its default value is "5 minutes".
                // The duration syntax supports all human readable notations from day ("days", "day", "d")
                // to nanosecond ("nanoseconds", "nanosecond", "nanosec", "nanos", "nano", "ns")
                // Any negative or zero values are incorrect.
                "cacheExpiration": "5 minutes"
            },
            // The OpenAM access token resolver configuration.
            // This attribute must be present if the "oauth2/resolver" is equal to "openam".
            // If "oauth2/resolver" is set to another resolver, this attribute will be ignored.
            "openam": {
                // Defines the OpenAM endpoint URL where the request should be sent.
                // This attribute is required and must have a string syntax.
                "endpointURL": "http://openam.example.com:8080/openam/oauth2/tokeninfo"
            },
            // The RFC-7662 (see https://tools.ietf.org/html/rfc7662) access token resolver configuration.
            // This attribute must be present if the "oauth2/resolver" is equal to "rfc7662".
            // If "oauth2/resolver" is set to another resolver, this attribute will be ignored.
            "rfc7662": {
                // Defines the token introspection endpoint URL where the request should be sent.
                // This attribute is required and must have a string syntax.
                "endpointURL": "http:/example.com/introspect",
                // Token introspect endpoint requires authentication.
                // It should support HTTP basic authorization (a base64-encoded string of clientId:clientSecret)
                // These attributes are mandatory.
                "clientId": "client_id",
                "clientSecret": "client_secret"
            },
            // The CTS access token resolver.
            // This attribute must be present if the "oauth2/resolver" is equal to "cts".
            // If "oauth2/resolver" is set to another resolver, this attribute will be ignored.
            // Note: You can use {userName/0} in authzIdTemplate configuration to access
            //       user id from the default CTS access token content config.
            "cts": {
                // The connection factory to use to access CTS.
                // This value is only used in gateway mode.
                // This attribute must reference a connection factory defined in the "ldapConnectionFactories" section.
                // Default value: "root" (i.e the root connection factory will be used to access the CTS).
                "ldapConnectionFactory": "cts",
                // The access token base DN.
                // This attribute is required and must have a string syntax.
                "baseDN": "ou=famrecords,ou=openam-session,ou=tokens,dc=example,dc=com"
            },
            // ONLY FOR TEST PURPOSE: A File based access token resolver
            // This attribute must be present if the "oauth2/resolver" is equal to "file".
            // If "oauth2/resolver" is set to another resolver, this attribute will be ignored.
            "file": {
                // Directory containing token files.
                // You can test the rest2ldap OAuth2 authorization support by providing some json token files under
                // the directory set in the configuration below.
                // File names must be equal to the token strings.
                // The file content must a JSON object with the following attributes:
                // 'scope', 'expireTime' and all the field(s) needed to resolve the authzIdTemplate.
                "folderPath": "/path/to/test/folder"
            }
        }
    },
opendj-rest2ldap/pom.xml
@@ -34,6 +34,7 @@
            org.forgerock.opendj.*;provide:=true,
            org.forgerock.json.*;provide:=true
        </opendj.osgi.import.additional>
        <openig.version>5.0.0-SNAPSHOT</openig.version>
    </properties>
@@ -54,6 +55,22 @@
        </dependency>
        <dependency>
            <groupId>org.forgerock.commons</groupId>
            <artifactId>forgerock-util</artifactId>
        </dependency>
        <dependency>
            <groupId>org.forgerock.openig</groupId>
            <artifactId>openig-oauth2-resource-server-filter</artifactId>
            <version>${openig.version}</version>
        </dependency>
        <dependency>
            <groupId>org.forgerock.http</groupId>
            <artifactId>chf-client-apache-async</artifactId>
        </dependency>
        <dependency>
            <groupId>org.forgerock</groupId>
            <artifactId>forgerock-build-tools</artifactId>
            <scope>test</scope>
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java
@@ -17,6 +17,7 @@
package org.forgerock.opendj.rest2ldap;
import static org.forgerock.http.util.Json.readJsonLenient;
import static org.forgerock.json.JsonValueFunctions.duration;
import static org.forgerock.json.JsonValueFunctions.enumConstant;
import static org.forgerock.json.JsonValueFunctions.setOf;
import static org.forgerock.opendj.rest2ldap.Rest2LDAP.configureConnectionFactory;
@@ -24,11 +25,17 @@
import static org.forgerock.opendj.rest2ldap.authz.Authorizations.*;
import static org.forgerock.opendj.rest2ldap.authz.ConditionalFilters.*;
import static org.forgerock.opendj.rest2ldap.authz.CredentialExtractors.*;
import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newCtsAccessTokenResolver;
import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newFileAccessTokenResolver;
import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newRfc7662AccessTokenResolver;
import static org.forgerock.util.Reject.checkNotNull;
import static org.forgerock.util.Utils.closeSilently;
import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newConditionalOAuth2ResourceServerFilter;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.ArrayList;
import java.util.HashMap;
@@ -36,15 +43,25 @@
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import org.forgerock.authz.modules.oauth2.AccessTokenInfo;
import org.forgerock.authz.modules.oauth2.AccessTokenException;
import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
import org.forgerock.authz.modules.oauth2.cache.CachingAccessTokenResolver;
import org.forgerock.authz.modules.oauth2.resolver.OpenAmAccessTokenResolver;
import org.forgerock.http.Filter;
import org.forgerock.http.Handler;
import org.forgerock.http.HttpApplication;
import org.forgerock.http.HttpApplicationException;
import org.forgerock.http.filter.Filters;
import org.forgerock.http.handler.Handlers;
import org.forgerock.http.handler.HttpClientHandler;
import org.forgerock.http.io.Buffer;
import org.forgerock.http.protocol.Headers;
import org.forgerock.json.JsonValue;
import org.forgerock.json.JsonValueException;
import org.forgerock.json.resource.RequestHandler;
import org.forgerock.json.resource.Router;
import org.forgerock.json.resource.http.CrestHttp;
@@ -59,7 +76,12 @@
import org.forgerock.util.Factory;
import org.forgerock.util.Function;
import org.forgerock.util.Pair;
import org.forgerock.util.PerItemEvictionStrategyCache;
import org.forgerock.util.annotations.VisibleForTesting;
import org.forgerock.util.promise.NeverThrowsException;
import org.forgerock.util.promise.Promise;
import org.forgerock.util.time.Duration;
import org.forgerock.util.time.TimeService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -68,6 +90,18 @@
    private static final String DEFAULT_ROOT_FACTORY = "root";
    private static final String DEFAULT_BIND_FACTORY = "bind";
    /** Keys for json oauth2 configuration. */
    private static final String RESOLVER_CONFIG_OBJECT = "resolver";
    private static final String REALM = "realm";
    private static final String SCOPES = "requiredScopes";
    private static final String AUTHZID_TEMPLATE = "authzIdTemplate";
    private static final String CACHE_EXPIRATION_DEFAULT = "5 minutes";
    /** Keys for json oauth2 access token cache configuration. */
    private static final String CACHE_CONFIG_OBJECT = "accessTokenCache";
    private static final String CACHE_ENABLED = "enabled";
    private static final String CACHE_EXPIRATION = "cacheExpiration";
    private static final Logger LOG = LoggerFactory.getLogger(Rest2LDAPHttpApplication.class);
    /** URL to the JSON configuration file. */
@@ -77,8 +111,19 @@
    protected final Schema schema;
    private final Map<String, ConnectionFactory> connectionFactories = new HashMap<>();
    /** Used for token caching. */
    private ScheduledExecutorService executorService;
    private enum Policy { BASIC, ANONYMOUS }
    /** Define the method which should be used to resolve an OAuth2 access token. */
    private enum OAuth2ResolverType {
        RFC7662,
        OPENAM,
        CTS,
        FILE
    }
    @VisibleForTesting
    enum Policy { OAUTH2, BASIC, ANONYMOUS }
    private enum BindStrategy { SIMPLE, SEARCH, SASL_PLAIN }
@@ -107,6 +152,7 @@
    public final Handler start() throws HttpApplicationException {
        try {
            final JsonValue configuration = readJson(configurationUrl);
            executorService = Executors.newSingleThreadScheduledExecutor();
            configureConnectionFactories(configuration.get("ldapConnectionFactories"));
            return Handlers.chainOf(
                    CrestHttp.newHttpHandler(configureRest2Ldap(configuration)),
@@ -155,11 +201,18 @@
            closeSilently(factory);
        }
        connectionFactories.clear();
        if (executorService != null) {
            executorService.shutdown();
            executorService = null;
        }
    }
    private Filter buildAuthorizationFilter(final JsonValue config) {
    private Filter buildAuthorizationFilter(final JsonValue config) throws HttpApplicationException {
        final Set<Policy> policies = config.get("policies").as(setOf(enumConstant(Policy.class)));
        final List<ConditionalFilter> filters = new ArrayList<>(policies.size());
        if (policies.contains(Policy.OAUTH2)) {
            filters.add(buildOAuth2Filter(config.get("oauth2")));
        }
        if (policies.contains(Policy.BASIC)) {
            filters.add(buildBasicFilter(config.get("basic")));
        }
@@ -169,6 +222,88 @@
        return newAuthorizationFilter(filters);
    }
    @VisibleForTesting
    ConditionalFilter buildOAuth2Filter(final JsonValue config) throws HttpApplicationException {
        final String realm = config.get(REALM).defaultTo("no_realm").asString();
        final Set<String> scopes = config.get(SCOPES).required().asSet(String.class);
        final AccessTokenResolver resolver =
                createCachedTokenResolverIfNeeded(config, parseUnderlyingResolver(config));
        final ConditionalFilter oAuth2Filter = newConditionalOAuth2ResourceServerFilter(
                realm, scopes, resolver, config.get(AUTHZID_TEMPLATE).required().asString());
        return newConditionalFilter(
                Filters.chainOf(oAuth2Filter.getFilter(),
                                newProxyAuthzFilter(getConnectionFactory(DEFAULT_ROOT_FACTORY))),
                oAuth2Filter.getCondition());
    }
    @VisibleForTesting
    AccessTokenResolver createCachedTokenResolverIfNeeded(
            final JsonValue config, final AccessTokenResolver resolver) {
        final JsonValue cacheConfig = config.get(CACHE_CONFIG_OBJECT);
        if (cacheConfig.isNull() || !cacheConfig.get(CACHE_ENABLED).defaultTo(Boolean.FALSE).asBoolean()) {
            return resolver;
        }
        final Duration expiration = parseCacheExpiration(
                cacheConfig.get(CACHE_EXPIRATION).defaultTo(CACHE_EXPIRATION_DEFAULT));
        final PerItemEvictionStrategyCache<String, Promise<AccessTokenInfo, AccessTokenException>> cache =
                new PerItemEvictionStrategyCache<>(executorService, expiration);
        cache.setMaxTimeout(expiration);
        return new CachingAccessTokenResolver(TimeService.SYSTEM, resolver, cache);
    }
    @VisibleForTesting
    AccessTokenResolver parseUnderlyingResolver(final JsonValue configuration) throws HttpApplicationException {
        final JsonValue resolver = configuration.get(RESOLVER_CONFIG_OBJECT).required();
        switch (resolver.as(enumConstant(OAuth2ResolverType.class))) {
        case RFC7662:
            return parseRfc7662Resolver(configuration);
        case OPENAM:
            return new OpenAmAccessTokenResolver(new HttpClientHandler(),
                                                 TimeService.SYSTEM,
                                                 configuration.get("openam").get("endpointURL").required().asString());
        case CTS:
            final JsonValue cts = configuration.get("cts").required();
            return newCtsAccessTokenResolver(
                getConnectionFactory(cts.get("ldapConnectionFactory").defaultTo(DEFAULT_ROOT_FACTORY).asString()),
                                     cts.get("baseDN").required().asString());
        case FILE:
            return newFileAccessTokenResolver(configuration.get("file").get("folderPath").required().asString());
        default:
            throw new JsonValueException(resolver, "is not a supported access token resolver");
        }
    }
    private AccessTokenResolver parseRfc7662Resolver(final JsonValue configuration) throws HttpApplicationException {
        final JsonValue rfc7662 = configuration.get("rfc7662").required();
        final String introspectionEndPointURL = rfc7662.get("endpointURL").required().asString();
        try {
            return newRfc7662AccessTokenResolver(new HttpClientHandler(),
                                                 new URI(introspectionEndPointURL),
                                                 rfc7662.get("clientId").required().asString(),
                                                 rfc7662.get("clientSecret").required().asString());
        } catch (final URISyntaxException e) {
            throw new IllegalArgumentException("The token introspection endpoint '"
                    + introspectionEndPointURL + "' URL has an invalid syntax: " + e.getLocalizedMessage(), e);
        }
    }
    private Duration parseCacheExpiration(final JsonValue expirationJson) {
        try {
            final Duration expiration = expirationJson.as(duration());
            if (expiration.isZero() || expiration.isUnlimited()) {
                throw new JsonValueException(expirationJson, "The cache expiration duration cannot be "
                        + (expiration.isZero() ? "zero" : "unlimited."));
            }
            return expiration;
        } catch (final Exception e) {
            throw new JsonValueException(expirationJson,
                      "Malformed duration value '" + expirationJson.toString() + "' for cache expiration. "
                    + "The duration syntax supports all human readable notations from day ('days'', 'day'', 'd'') "
                    + "to nanosecond ('nanoseconds', 'nanosecond', 'nanosec', 'nanos', 'nano', 'ns')");
        }
    }
    /**
     * Creates a new {@link Filter} in charge of injecting {@link AuthenticatedConnectionContext}.
     *
@@ -263,7 +398,7 @@
    private AuthenticationStrategy buildSASLBindStrategy(JsonValue config) {
        return newSASLPlainStrategy(
                getConnectionFactory(config.get("ldapConnectionFactory").defaultTo(DEFAULT_BIND_FACTORY).asString()),
                schema, config.get("authcIdTemplate").defaultTo("u:%s").asString());
                schema, config.get(AUTHZID_TEMPLATE).defaultTo("u:%s").asString());
    }
    private AuthenticationStrategy buildSearchThenBindStrategy(JsonValue config) {
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Authorizations.java
@@ -17,12 +17,27 @@
import static org.forgerock.opendj.rest2ldap.authz.ConditionalFilters.asConditionalFilter;
import static org.forgerock.opendj.rest2ldap.authz.ConditionalFilters.newConditionalFilter;
import static org.forgerock.util.promise.Promises.newResultPromise;
import java.net.URI;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.forgerock.authz.modules.oauth2.AccessTokenInfo;
import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
import org.forgerock.authz.modules.oauth2.OAuth2Context;
import org.forgerock.authz.modules.oauth2.ResourceAccess;
import org.forgerock.authz.modules.oauth2.ResourceServerFilter;
import org.forgerock.http.Filter;
import org.forgerock.http.Handler;
import org.forgerock.http.filter.Filters;
import org.forgerock.http.protocol.Headers;
import org.forgerock.http.protocol.Request;
import org.forgerock.http.protocol.Response;
import org.forgerock.http.protocol.ResponseException;
import org.forgerock.http.protocol.Status;
import org.forgerock.opendj.ldap.Connection;
import org.forgerock.opendj.ldap.ConnectionFactory;
import org.forgerock.opendj.ldap.controls.ProxiedAuthV2RequestControl;
@@ -33,15 +48,15 @@
import org.forgerock.services.context.SecurityContext;
import org.forgerock.util.Function;
import org.forgerock.util.Pair;
import org.forgerock.util.Reject;
import org.forgerock.util.promise.NeverThrowsException;
import org.forgerock.util.promise.Promise;
import org.forgerock.util.time.TimeService;
/**
 * Factory methods to create {@link Filter} performing authentication and authorizations.
 */
/** Factory methods to create {@link Filter} performing authentication and authorizations. */
public final class Authorizations {
    private Authorizations() {
    }
    private static final String OAUTH2_AUTHORIZATION_HEADER = "Authorization";
    /**
     * Creates a new {@link Filter} in charge of injecting an {@link AuthenticatedConnectionContext}. This
@@ -113,4 +128,155 @@
    public static Filter newProxyAuthorizationFilter(ConnectionFactory connectionFactory) {
        return new ProxiedAuthV2Filter(connectionFactory);
    }
    /**
     * Creates a new {@link AccessTokenResolver} as defined in the RFC-7662.
     * <p>
     * @see <a href="https://tools.ietf.org/html/rfc7662">RFC-7662</a>
     *
     * @param httpClient
     *          Http client handler used to perform the request
     * @param introspectionEndPointURL
     *          Introspect endpoint URL to use to resolve the access token.
     * @param clientAppId
     *          Client application id to use in HTTP Basic authentication header.
     * @param clientAppSecret
     *          Client application secret to use in HTTP Basic authentication header.
     * @return A new {@link AccessTokenResolver} instance.
     */
    public static AccessTokenResolver newRfc7662AccessTokenResolver(final Handler httpClient,
                                                                    final URI introspectionEndPointURL,
                                                                    final String clientAppId,
                                                                    final String clientAppSecret) {
        return new Rfc7662AccessTokenResolver(httpClient, introspectionEndPointURL, clientAppId, clientAppSecret);
    }
    /**
     * Creates a new CTS access token resolver.
     *
     * @param connectionFactory
     *          The {@link ConnectionFactory} to use to perform search against the CTS.
     * @param ctsBaseDNTemplate
     *          The base DN template to use to resolve the access token DN.
     * @return A new CTS access token resolver.
     */
    public static AccessTokenResolver newCtsAccessTokenResolver(final ConnectionFactory connectionFactory,
                                                                final String ctsBaseDNTemplate) {
        return new CtsAccessTokenResolver(connectionFactory, ctsBaseDNTemplate);
    }
    /**
     * Creates a new file access token resolver which should only be used for test purpose.
     *
     * @param tokenFolder
     *          The folder where the access token to resolve must be stored.
     * @return A new file access token resolver which should only be used for test purpose.
     */
    public static AccessTokenResolver newFileAccessTokenResolver(final String tokenFolder) {
        return new FileAccessTokenResolver(tokenFolder);
    }
    /**
     * Creates a new OAuth2 authorization filter configured with provided parameters.
     *
     * @param realm
     *          The realm to displays in error responses.
     * @param scopes
     *          Scopes that an access token must have to be access a resource.
     * @param resolver
     *          The {@link AccessTokenResolver} to use to resolve an access token.
     * @param authzIdTemplate
     *          Authorization ID template.
     * @return A new OAuth2 authorization filter configured with provided parameters.
     */
    public static Filter newOAuth2ResourceServerFilter(final String realm,
                                                                  final Set<String> scopes,
                                                                  final AccessTokenResolver resolver,
                                                                  final String authzIdTemplate) {
        return createResourceServerFilter(realm, scopes, resolver, authzIdTemplate);
    }
    /**
     * Creates a new optional OAuth2 authorization filter configured with provided parameters.
     * <p>
     * This filter will be used only if an OAuth2 Authorization header is present in the incoming request.
     *
     * @param realm
     *          The realm to displays in error responses.
     * @param scopes
     *          Scopes that an access token must have to be access a resource.
     * @param resolver
     *          The {@link AccessTokenResolver} to use to resolve an access token.
     * @param authzIdTemplate
     *          Authorization ID template.
     * @return A new OAuth2 authorization filter configured with provided parameters.
     */
    public static ConditionalFilter newConditionalOAuth2ResourceServerFilter(final String realm,
                                                                             final Set<String> scopes,
                                                                             final AccessTokenResolver resolver,
                                                                             final String authzIdTemplate) {
        return new ConditionalFilter() {
            @Override
            public Filter getFilter() {
                return createResourceServerFilter(realm, scopes, resolver, authzIdTemplate);
            }
            @Override
            public Condition getCondition() {
                return new Condition() {
                    @Override
                    public boolean canApplyFilter(final Context context, final Request request) {
                        return request.getHeaders().containsKey(OAUTH2_AUTHORIZATION_HEADER);
                    }
                };
            }
        };
    }
    private static Filter createResourceServerFilter(final String realm,
                                                     final Set<String> scopes,
                                                     final AccessTokenResolver resolver,
                                                     final String authzIdTemplate) {
        Reject.ifTrue(realm == null || realm.isEmpty(), "realm must not be empty");
        Reject.ifNull(resolver, "Access token resolver must not be null");
        Reject.ifTrue(scopes == null || scopes.isEmpty(), "scopes set can not be empty");
        Reject.ifTrue(authzIdTemplate == null || authzIdTemplate.isEmpty(), "Authz id template must not be empty");
        final ResourceAccess scopesProvider = new ResourceAccess() {
            @Override
            public Set<String> getRequiredScopes(final Context context, final Request request)
                    throws ResponseException {
                return scopes;
            }
        };
        return Filters.chainOf(new ResourceServerFilter(resolver, TimeService.SYSTEM, scopesProvider, realm),
                               createSecurityContextInjectionFilter(authzIdTemplate));
    }
    private static Filter createSecurityContextInjectionFilter(final String authzIdTemplate) {
        final AuthzIdTemplate template = new AuthzIdTemplate(authzIdTemplate);
        return new Filter() {
            @Override
            public Promise<Response, NeverThrowsException> filter(final Context context,
                                                                  final Request request,
                                                                  final Handler next) {
                final AccessTokenInfo token = context.asContext(OAuth2Context.class).getAccessToken();
                final Map<String, Object> authz = new HashMap<>(1);
                try {
                    authz.put(template.getSecurityContextID(), template.formatAsAuthzId(token.asJsonValue()));
                } catch (final IllegalArgumentException e) {
                    return newResultPromise(
                            new Response().setStatus(Status.FORBIDDEN).setCause(e).setEntity("Invalid configuration"));
                }
                final Context securityContext = new SecurityContext(context, token.getToken(), authz);
                return next.handle(securityContext, request);
            }
        };
    }
    private Authorizations() {
        // Prevent instantiation.
    }
}
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java
@@ -15,15 +15,19 @@
 */
package org.forgerock.opendj.rest2ldap.authz;
import static org.forgerock.util.Utils.joinAsString;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.forgerock.json.JsonPointer;
import org.forgerock.json.JsonValue;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.schema.Schema;
import org.forgerock.services.context.SecurityContext;
/**
 * An authorization ID template used for mapping security context principals to
@@ -32,56 +36,77 @@
 * <code>u:{uid}@{realm}.example.com</code>.
 */
final class AuthzIdTemplate {
    private static interface Impl {
        String formatAsAuthzId(AuthzIdTemplate t, Object[] templateVariables, Schema schema);
    private interface Impl {
        String formatAsAuthzId(AuthzIdTemplate t, Object[] templateVariables);
    }
    private static final Impl DN_IMPL = new Impl() {
        @Override
        public String formatAsAuthzId(final AuthzIdTemplate t, final Object[] templateVariables,
                final Schema schema) {
            final String authzId = String.format(Locale.ENGLISH, t.formatString, templateVariables);
            try {
                // Validate the DN.
                DN.valueOf(authzId.substring(3), schema);
            } catch (final IllegalArgumentException e) {
                throw new IllegalArgumentException(
                        "The request could not be authorized because the required security principal "
                        + "was not a valid LDAP DN");
            }
            return authzId;
        }
    };
    private static final Pattern DN_PATTERN = Pattern.compile("^dn:\\{[^}]+\\}$");
    private static final Impl DN_TEMPLATE_IMPL = new Impl() {
        @Override
        public String formatAsAuthzId(final AuthzIdTemplate t, final Object[] templateVariables,
                final Schema schema) {
            return "dn:" + DN.format(t.dnFormatString, schema, templateVariables);
        public String formatAsAuthzId(final AuthzIdTemplate t, final Object[] templateVariables) {
            // We're not interested in matching and place-holder attribute types can be tolerated,
            // so we can just use the core schema.
            return DN.format(t.formatString, Schema.getCoreSchema(), templateVariables).toString();
        }
    };
    private static final Pattern KEY_RE = Pattern.compile("\\{([^}]+)\\}");
    private static final Impl UID_TEMPLATE_IMPL = new Impl() {
        @Override
        public String formatAsAuthzId(final AuthzIdTemplate t, final Object[] templateVariables,
                final Schema schema) {
        public String formatAsAuthzId(final AuthzIdTemplate t, final Object[] templateVariables) {
            return String.format(Locale.ENGLISH, t.formatString, templateVariables);
        }
    };
    private final String dnFormatString;
    private static final Pattern TEMPLATE_KEY_RE = Pattern.compile("\\{([^}]+)\\}");
    private enum TemplateType {
        DN ("dn:", SecurityContext.AUTHZID_DN, DN_TEMPLATE_IMPL),
        UID ("u:", SecurityContext.AUTHZID_ID, UID_TEMPLATE_IMPL);
        private final String key;
        private final String securityContextId;
        private final Impl impl;
        TemplateType(final String key, final String securityContextId, final Impl impl) {
            this.key = key;
            this.securityContextId = securityContextId;
            this.impl = impl;
        }
        private String getSecurityContextId() {
            return securityContextId;
        }
        private Impl getImpl() {
            return impl;
        }
        private static TemplateType parseTemplateType(final String template) {
            for (final TemplateType type : TemplateType.values()) {
                if (template.startsWith(type.key)) {
                    return type;
                }
            }
            throw new IllegalArgumentException("Invalid authorization ID template: '" + template + "'. Templates must "
                       + "start with one of the following elements: " + joinAsString(",", getSupportedStartKeys()));
        }
        private static List<String> getSupportedStartKeys() {
            final List<String> startKeys = new ArrayList<>();
            for (final TemplateType type : TemplateType.values()) {
                startKeys.add(type.key);
            }
            return startKeys;
        }
        private String removeTemplateKey(final String formattedString) {
            return formattedString.substring(key.length()).trim();
        }
    }
    private final TemplateType type;
    private final String formatString;
    private final List<String> keys = new ArrayList<>();
    private final Impl pimpl;
    private final String template;
    /**
@@ -92,29 +117,22 @@
     * @throws IllegalArgumentException
     *             if template doesn't start with "u:" or "dn:"
     */
    public AuthzIdTemplate(final String template) {
        if (!template.startsWith("u:") && !template.startsWith("dn:")) {
            throw new IllegalArgumentException("Invalid authorization ID template: " + template);
        }
    AuthzIdTemplate(final String template) {
        this.type = TemplateType.parseTemplateType(template);
        this.formatString = formatTemplate(template);
        this.template = template;
    }
    private String formatTemplate(final String template) {
        // Parse the template keys and replace them with %s for formatting.
        final Matcher matcher = KEY_RE.matcher(template);
        final Matcher matcher = TEMPLATE_KEY_RE.matcher(template);
        final StringBuffer buffer = new StringBuffer(template.length());
        while (matcher.find()) {
            matcher.appendReplacement(buffer, "%s");
            keys.add(matcher.group(1));
        }
        matcher.appendTail(buffer);
        this.formatString = buffer.toString();
        this.template = template;
        if (template.startsWith("dn:")) {
            this.pimpl = DN_PATTERN.matcher(template).matches() ? DN_IMPL : DN_TEMPLATE_IMPL;
            this.dnFormatString = formatString.substring(3);
        } else {
            this.pimpl = UID_TEMPLATE_IMPL;
            this.dnFormatString = null;
        }
        return type.removeTemplateKey(buffer.toString());
    }
    @Override
@@ -122,41 +140,45 @@
        return template;
    }
    String getSecurityContextID() {
        return this.type.getSecurityContextId();
    }
    /**
     * Return the template with all the variable replaced.
     *
     * @param principals
     *            Value to use to replace the variables.
     * @param schema
     *            Schema to perform validation.
     * @return The template with all the variable replaced.
     */
    public String formatAsAuthzId(final Map<String, Object> principals, final Schema schema) {
    String formatAsAuthzId(final JsonValue principals) {
        final String[] templateVariables = getPrincipalsForFormatting(principals);
        return pimpl.formatAsAuthzId(this, templateVariables, schema);
        return type.getImpl().formatAsAuthzId(this, templateVariables);
    }
    private String[] getPrincipalsForFormatting(final Map<String, Object> principals) {
    private String[] getPrincipalsForFormatting(final JsonValue principals) {
        final String[] values = new String[keys.size()];
        for (int i = 0; i < values.length; i++) {
            final String key = keys.get(i);
            final Object value = principals.get(key);
            if (isJSONPrimitive(value)) {
                values[i] = String.valueOf(value);
            } else if (value != null) {
                throw new IllegalArgumentException(String.format(
                        "The request could not be authorized because the required "
                                + "security principal '%s' had an invalid data type", key));
            } else {
            final JsonValue value = principals.get(new JsonPointer(key));
            if (value == null) {
                throw new IllegalArgumentException(String.format(
                        "The request could not be authorized because the required "
                                + "security principal '%s' could not be determined", key));
            }
            final Object object = value.getObject();
            if (!isJSONPrimitive(object)) {
                throw new IllegalArgumentException(String.format(
                        "The request could not be authorized because the required "
                                + "security principal '%s' had an invalid data type", key));
            }
            values[i] = String.valueOf(object);
        }
        return values;
    }
    static boolean isJSONPrimitive(final Object value) {
    private boolean isJSONPrimitive(final Object value) {
        return value instanceof String || value instanceof Boolean || value instanceof Number;
    }
}
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolver.java
New file
@@ -0,0 +1,115 @@
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2016 ForgeRock AS.
 */
package org.forgerock.opendj.rest2ldap.authz;
import static org.forgerock.opendj.ldap.requests.Requests.newSingleEntrySearchRequest;
import static org.forgerock.opendj.rest2ldap.authz.Utils.close;
import static org.forgerock.util.Reject.checkNotNull;
import java.io.IOException;
import java.util.concurrent.atomic.AtomicReference;
import org.forgerock.authz.modules.oauth2.AccessTokenInfo;
import org.forgerock.authz.modules.oauth2.AccessTokenException;
import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
import org.forgerock.http.util.Json;
import org.forgerock.json.JsonValue;
import org.forgerock.opendj.ldap.Connection;
import org.forgerock.opendj.ldap.ConnectionFactory;
import org.forgerock.opendj.ldap.DN;
import org.forgerock.opendj.ldap.Filter;
import org.forgerock.opendj.ldap.LdapException;
import org.forgerock.opendj.ldap.SearchScope;
import org.forgerock.opendj.ldap.responses.SearchResultEntry;
import org.forgerock.services.context.Context;
import org.forgerock.util.AsyncFunction;
import org.forgerock.util.Function;
import org.forgerock.util.promise.Promise;
/**
 * This class resolves an access token in order to get the {@link AccessTokenInfo}
 * by performing a request to an OpenDJ server.
 */
final class CtsAccessTokenResolver implements AccessTokenResolver {
    private static final Filter FR_CORE_TOKEN_OC_FILTER = Filter.equality("objectclass", "frCoreToken");
    private final ConnectionFactory connectionFactory;
    private final DN ctsBaseDN;
    CtsAccessTokenResolver(final ConnectionFactory connectionFactory, final String ctsBaseDN) {
        this.connectionFactory = checkNotNull(connectionFactory, "connectionFactory cannot be null");
        this.ctsBaseDN = DN.valueOf(checkNotNull(ctsBaseDN, "ctsBaseDN cannot be null"));
    }
    @Override
    public Promise<AccessTokenInfo, AccessTokenException> resolve(final Context context, final String token) {
        final AtomicReference<Connection> connectionHolder = new AtomicReference<>();
        return connectionFactory
            .getConnectionAsync()
            .thenAsync(new AsyncFunction<Connection, SearchResultEntry, LdapException>() {
                @Override
                public Promise<SearchResultEntry, LdapException> apply(final Connection connection)
                        throws LdapException {
                    connectionHolder.set(connection);
                    return connection.searchSingleEntryAsync(newSingleEntrySearchRequest(
                            ctsBaseDN.child("coreTokenId", token),
                            SearchScope.BASE_OBJECT, FR_CORE_TOKEN_OC_FILTER, "coreTokenObject"));
                }
            }).then(new Function<SearchResultEntry, AccessTokenInfo, AccessTokenException>() {
                @Override
                public AccessTokenInfo apply(final SearchResultEntry entry) throws AccessTokenException {
                    final JsonValue accessToken = parseJson(
                            entry.getAttribute("coreTokenObject").firstValueAsString(), token);
                    final String tokenName = getRequiredFirstValue(accessToken.get("tokenName"));
                    if (!tokenName.equals("access_token")) {
                        throw new AccessTokenException(
                                "The token '" + token + "' must be an access token, but it is a \"" + tokenName + "\"");
                    }
                    return new AccessTokenInfo(accessToken, token,
                            accessToken.get("scope").required().asSet(String.class),
                            Long.parseLong(getRequiredFirstValue(accessToken.get("expireTime"))));
                }
            }, new Function<LdapException, AccessTokenInfo, AccessTokenException>() {
                @Override
                public AccessTokenInfo apply(final LdapException e) throws AccessTokenException {
                    throw new AccessTokenException("Unable to find the token '" + token + "' in the CTS because: "
                            + e.getMessage(), e);
                }
            }).thenCatchRuntimeException(new Function<RuntimeException, AccessTokenInfo, AccessTokenException>() {
                @Override
                public AccessTokenInfo apply(final RuntimeException e) throws AccessTokenException {
                    throw new AccessTokenException("Unable to resolve access token '" + token
                            + "' due to the following reason: " + e.getMessage(), e);
                }
            }).thenFinally(close(connectionHolder));
    }
    private String getRequiredFirstValue(final JsonValue list) {
        return list.required().asList(String.class).get(0);
    }
    private JsonValue parseJson(final String accessTokenJson, final String token) throws AccessTokenException {
        try {
            return new JsonValue(Json.readJson(accessTokenJson));
        } catch (final IOException e) {
            throw new AccessTokenException("Json of token '" + token + "' is malformed");
        }
    }
}
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java
New file
@@ -0,0 +1,64 @@
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2016 ForgeRock AS.
 */
package org.forgerock.opendj.rest2ldap.authz;
import static org.forgerock.util.Reject.checkNotNull;
import static org.forgerock.util.promise.Promises.newExceptionPromise;
import static org.forgerock.util.promise.Promises.newResultPromise;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import org.forgerock.authz.modules.oauth2.AccessTokenInfo;
import org.forgerock.authz.modules.oauth2.AccessTokenException;
import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
import org.forgerock.http.util.Json;
import org.forgerock.json.JsonValue;
import org.forgerock.json.JsonValueException;
import org.forgerock.services.context.Context;
import org.forgerock.util.promise.Promise;
/** A file access token resolver which should only be used for test purpose.*/
final class FileAccessTokenResolver implements AccessTokenResolver {
    private final String folderPath;
    FileAccessTokenResolver(final String folderPath) {
        this.folderPath = checkNotNull(folderPath);
    }
    @Override
    public Promise<AccessTokenInfo, AccessTokenException> resolve(final Context context, final String token) {
        final JsonValue accessToken;
        try (final InputStream stream = new FileInputStream(new File(folderPath, token))) {
            accessToken = new JsonValue(Json.readJsonLenient(stream));
        } catch (final IOException e) {
            return newExceptionPromise(new AccessTokenException("Unable to find token file '" + token + "'", e));
        }
        try {
            final AccessTokenInfo result = new AccessTokenInfo(accessToken, token,
                    accessToken.get("scope").required().asSet(String.class),
                    accessToken.get("expireTime").required().asLong());
            return newResultPromise(result);
        } catch (final JsonValueException e) {
            return newExceptionPromise(
                    new AccessTokenException("Malformed token file '" + token + "': '" + e.getMessage() + "'.", e));
        }
    }
}
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2Filter.java
@@ -71,12 +71,10 @@
     *
     * @param connectionFactory
     *            Factory used to get the {@link Connection}
     * @param authzIdProvider
     *            Function in charge of providing the authzid to use for the ProxiedAuth control
     * @throws NullPointerException
     *             If a parameter is null
     */
    public ProxiedAuthV2Filter(final ConnectionFactory connectionFactory) {
    ProxiedAuthV2Filter(final ConnectionFactory connectionFactory) {
        this.connectionFactory = checkNotNull(connectionFactory, "connectionFactory cannot be null");
    }
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessTokenResolver.java
New file
@@ -0,0 +1,135 @@
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2016 ForgeRock AS.
 */
package org.forgerock.opendj.rest2ldap.authz;
import static java.util.concurrent.TimeUnit.SECONDS;
import static org.forgerock.util.Reject.checkNotNull;
import java.io.IOException;
import java.net.URI;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import org.forgerock.authz.modules.oauth2.AccessTokenInfo;
import org.forgerock.authz.modules.oauth2.AccessTokenException;
import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
import org.forgerock.http.Handler;
import org.forgerock.http.protocol.Responses;
import org.forgerock.http.protocol.Entity;
import org.forgerock.http.protocol.Form;
import org.forgerock.http.protocol.Headers;
import org.forgerock.http.protocol.Request;
import org.forgerock.http.protocol.Response;
import org.forgerock.http.protocol.Status;
import org.forgerock.json.JsonValue;
import org.forgerock.json.JsonValueException;
import org.forgerock.services.context.Context;
import org.forgerock.util.Function;
import org.forgerock.util.encode.Base64;
import org.forgerock.util.promise.Promise;
/**
 * An {@link AccessTokenResolver} which is RFC 7662 compliant.
 * <p>
 * @see <a href="https://tools.ietf.org/html/rfc7662">RFC-7662</a>
 */
final class Rfc7662AccessTokenResolver implements AccessTokenResolver {
    /** RFC 7662 defined fields for token introspection request. */
    private static final String RFC_7662_FORM_TOKEN_FIELD = "token";
    private static final String RFC_7662_FORM_TOKEN_TYPE_HINT_FIELD = "token_type_hint";
    private static final String RFC_7662_FORM_TOKEN_TYPE_HINT_ACCESS_TOKEN = "access_token";
    /** RFC 7662 defined fields for token introspection response. */
    private static final String RFC_7662_RESPONSE_SCOPE_FIELD = "scope";
    private static final String RFC_7662_RESPONSE_EXPIRE_TIME_FIELD = "exp";
    /** Rest2Ldap fields name for the RFC 7662 access token resolver. */
    private static final String RFC_7662_RESPONSE_ACTIVE_FIELD = "active";
    private final Handler httpClient;
    private final URI introspectionEndPointURI;
    private final String clientAppId;
    private final String clientAppSecret;
    Rfc7662AccessTokenResolver(final Handler httpClient,
                               final URI introspectionEndPointURI,
                               final String clientAppId,
                               final String clientAppSecret) {
        this.httpClient = checkNotNull(httpClient);
        this.introspectionEndPointURI = checkNotNull(introspectionEndPointURI);
        this.clientAppId = checkNotNull(clientAppId);
        this.clientAppSecret = checkNotNull(clientAppSecret);
    }
    @Override
    public Promise<AccessTokenInfo, AccessTokenException> resolve(final Context context, final String token) {
        final Request request = new Request().setUri(introspectionEndPointURI);
        final Headers headers = request.getHeaders();
        headers.put("Accept", "application/json");
        headers.put("Authorization", "Basic " + Base64.encode((clientAppId + ":" + clientAppSecret).getBytes()));
        final Form form = new Form();
        form.add(RFC_7662_FORM_TOKEN_FIELD, token);
        form.add(RFC_7662_FORM_TOKEN_TYPE_HINT_FIELD, RFC_7662_FORM_TOKEN_TYPE_HINT_ACCESS_TOKEN);
        form.toRequestEntity(request);
        return httpClient.handle(context, request)
                         .then(buildAccessToken(token),
                               Responses.<AccessTokenInfo, AccessTokenException> noopExceptionFunction());
    }
    private Function<Response, AccessTokenInfo, AccessTokenException> buildAccessToken(final String tokenSent) {
        return new Function<Response, AccessTokenInfo, AccessTokenException>() {
            @Override
            public AccessTokenInfo apply(final Response response) throws AccessTokenException {
                final Status status = response.getStatus();
                if (!Status.OK.equals(status)) {
                    throw new AccessTokenException(
                            "Authorization server returned an error: " + status, response.getCause());
                }
                try (final Entity entity = response.getEntity()) {
                    final JsonValue jsonResponse = asJson(entity);
                    if (!jsonResponse.get(RFC_7662_RESPONSE_ACTIVE_FIELD).defaultTo(Boolean.FALSE).asBoolean()) {
                        throw new AccessTokenException(
                                "Access token returned by authorization server is not currently active");
                    }
                    return buildAccessTokenFromJson(jsonResponse, tokenSent);
                } catch (final JsonValueException e) {
                    throw new AccessTokenException("Invalid or malformed access token: " + e.getMessage(), e);
                }
            }
        };
    }
    private AccessTokenInfo buildAccessTokenFromJson(final JsonValue jsonToken, final String token) {
        final Set<String> tokenScopes = new HashSet<>(Arrays.asList(
                jsonToken.get(RFC_7662_RESPONSE_SCOPE_FIELD).required().asString().trim().split(" +")));
        final Long expiresAt = jsonToken.get(RFC_7662_RESPONSE_EXPIRE_TIME_FIELD).required().asLong();
        return new AccessTokenInfo(jsonToken, token, tokenScopes, SECONDS.toMillis(expiresAt));
    }
    private JsonValue asJson(final Entity entity) throws AccessTokenException {
        try {
            return new JsonValue(entity.getJson());
        } catch (final IOException e) {
            // Do not use Entity.toString(), we probably don't want to fully output the content here
            throw new AccessTokenException("Cannot read response content as JSON", e);
        }
    }
}
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthorizationsTestCase.java
New file
@@ -0,0 +1,131 @@
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2016 ForgeRock AS.
 */
package org.forgerock.opendj.rest2ldap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import org.forgerock.authz.modules.oauth2.AccessTokenInfo;
import org.forgerock.authz.modules.oauth2.AccessTokenException;
import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
import org.forgerock.http.Handler;
import org.forgerock.http.protocol.Request;
import org.forgerock.json.JsonValue;
import org.forgerock.opendj.rest2ldap.authz.Authorizations;
import org.forgerock.opendj.rest2ldap.authz.ConditionalFilters;
import org.forgerock.services.context.AttributesContext;
import org.forgerock.services.context.Context;
import org.forgerock.services.context.RootContext;
import org.forgerock.services.context.SecurityContext;
import org.forgerock.testng.ForgeRockTestCase;
import org.forgerock.util.promise.Promises;
import org.forgerock.util.time.TimeService;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@Test
@SuppressWarnings("javadoc")
public final class AuthorizationsTestCase extends ForgeRockTestCase {
    private static final String TEST_TOKEN = "2YotnFZFEjr1zCsicMWpAA";
    private Context context;
    @Mock
    private AccessTokenResolver resolver;
    @Mock
    private Handler next;
    @Captor
    private ArgumentCaptor<Request> requestCapture;
    @Captor
    private ArgumentCaptor<Context> contextCapture;
    @BeforeMethod
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
    }
    @DataProvider
    private Object[][] basicUseCaseData() {
        return new Object[][] {
            { "dn:uid={user/id},dc=com", "uid=bjensen,dc=com", SecurityContext.AUTHZID_DN },
            { "u:{user/id}", "bjensen", SecurityContext.AUTHZID_ID }
        };
    }
    @Test(dataProvider = "basicUseCaseData")
    public void testBasicUseCase(final String template,
                                 final String resolvedTemplate,
                                 final String securityContextKey) throws Exception {
        newContextChain();
        prepareResolverResponse();
        final ConditionalFilters.ConditionalFilter filter = Authorizations.newConditionalOAuth2ResourceServerFilter(
                "realm",
                new HashSet<>(Arrays.asList("read", "write", "dolphin")),
                resolver,
                template
        );
        final Request request = new Request();
        request.getHeaders().add("Authorization", "Bearer " + TEST_TOKEN);
        filter.getFilter().filter(context, request, next);
        verify(next).handle(contextCapture.capture(), requestCapture.capture());
        assertThat(requestCapture.getValue()).isEqualTo(request);
        final Context responseContext = contextCapture.getValue();
        assertThat(responseContext.asContext(AttributesContext.class).getAttributes().get("test")).isEqualTo("value");
        final String resolvedDn = responseContext.asContext(SecurityContext.class).getAuthorization()
                                                                                  .get(securityContextKey)
                                                                                  .toString();
        assertThat(resolvedDn).isEqualTo(resolvedTemplate);
    }
    private void prepareResolverResponse() {
        final Map<String, Object> jsonResponse = new HashMap<>();
        final Set<String> scopes = new HashSet<>(Arrays.asList("read", "write", "dolphin"));
        jsonResponse.put("access_token", TEST_TOKEN);
        jsonResponse.put("expires_in", 120000);
        jsonResponse.put("scope", scopes);
        jsonResponse.put("user", Collections.singletonMap("id", "bjensen"));
        final AccessTokenInfo token = new AccessTokenInfo(
                new JsonValue(jsonResponse), TEST_TOKEN, scopes, TimeService.SYSTEM.now() + 120000);
        when(resolver.resolve(eq(context), eq(TEST_TOKEN)))
                .thenReturn(Promises.<AccessTokenInfo, AccessTokenException> newResultPromise(token));
    }
    private void newContextChain() {
        context = new AttributesContext(new RootContext());
        context.asContext(AttributesContext.class).getAttributes().put("test", "value");
    }
}
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java
New file
@@ -0,0 +1,238 @@
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2016 ForgeRock AS.
 */
package org.forgerock.opendj.rest2ldap;
import static org.assertj.core.api.Assertions.assertThat;
import static org.forgerock.opendj.rest2ldap.TestUtils.parseJson;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
import org.forgerock.json.JsonValueException;
import org.forgerock.opendj.ldap.ConnectionFactory;
import org.forgerock.testng.ForgeRockTestCase;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.mockito.Spy;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@Test
@SuppressWarnings("javadoc")
public class OAuth2JsonConfigurationTestCase extends ForgeRockTestCase {
    @Mock
    private AccessTokenResolver resolver;
    @Spy
    private Rest2LDAPHttpApplication fakeApp;
    @BeforeMethod
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        fakeApp = spy(Rest2LDAPHttpApplication.class);
    }
    @DataProvider
    public Object[][] invalidOAuth2Configurations() {
        // @Checkstyle:off
        return new Object[][] {
                // Missing 'realm'
                {
                        "{}",
                },
                // Missing 'authzIdTemplate'
                {
                        "{'realm': 'example.com'}",
                },
                // Missing 'requiredScopes'
                {
                        "{'realm': 'example.com', "
                                + "'authzIdTemplate': 'dn: ou={/user/id},dc=example,dc=com'}",
                },
                // Missing 'resolver'
                {
                        "{'realm': 'example.com',"
                                + "'authzIdTemplate': 'dn: ou={/user/id},dc=example,dc=com',"
                                + "'requiredScopes': ['read', 'write', 'dolphin']}",
                },
                // Missing 'openam/endpointURL'
                {
                        "{'realm': 'example.com',"
                                + "'authzIdTemplate': 'dn: ou={/user/id},dc=example,dc=com',"
                                + "'requiredScopes': ['read', 'write', 'dolphin'],"
                                + "'resolver': 'openam',"
                                + "'openam': {}}",
                },
                // Invalid 'authzIdTemplate' content
                {
                        "{'realm': 'example.com',"
                                + "'authzIdTemplate': 'userName: ou={/user/id},dc=example,dc=com',"
                                + "'requiredScopes': ['read', 'write', 'dolphin'],"
                                + "'resolver': 'openam',"
                                + "'openam': {'endpointURL': 'http://www.example.com/token-info'},"
                                + "'accessTokenCache': {'enabled': true, 'cacheExpiration': '42'}}",
                },
                // Invalid 'accessTokenCache/expiration' duration
                {
                        "{'realm': 'example.com',"
                                + "'authzIdTemplate': 'dn: ou={/user/id},dc=example,dc=com',"
                                + "'requiredScopes': ['read', 'write', 'dolphin'],"
                                + "'resolver': 'openam',"
                                + "'openam': {'endpointURL': 'http://www.example.com/token-info'},"
                                + "'accessTokenCache': {'enabled': true, 'cacheExpiration': '42'}}",
                }
        };
        // @Checkstyle:on
    }
    @Test(dataProvider = "invalidOAuth2Configurations", expectedExceptions = JsonValueException.class)
    public void testInvalidOauth2Configurations(final String rawJson) throws Exception {
        fakeApp.buildOAuth2Filter(parseJson(rawJson));
    }
    @Test(expectedExceptions = IllegalArgumentException.class,
          expectedExceptionsMessageRegExp = ".*scopes set can not be empty.*")
    public void testOAuth2FilterWithEmptyScopes() throws Exception {
        final String config =
            "{'realm': 'example.com',"
                    + "'authzIdTemplate': 'dn: ou={/user/id},dc=example,dc=com',"
                    + "'requiredScopes': [],"
                    + "'resolver': 'openam',"
                    + "'openam': {'endpointURL': 'http://www.example.com/token-info'}}";
        fakeApp.buildOAuth2Filter(parseJson(config));
    }
    @DataProvider
    public Object[][] invalidResolverConfigurations() {
        // @Checkstyle:off
        return new Object[][] {
            {
                    "{}",
            },
            {
                    "{'resolver': 'rfc7662',"
                            + "'rfc7662': {}}",
            },
            {
                    "{'resolver': 'rfc7662',"
                            + "'rfc7662': { 'endpointURL': 'http:/example.com/introspect'}}",
            },
            {
                    "{'resolver': 'rfc7662',"
                            + "'rfc7662': { 'endpointURL': 'http:/example.com/introspect',"
                            + "               'clientId': 'client_app_id'}}",
            },
            {
                    "{'resolver': 'openam',"
                            + "'openam': {}}",
            },
            {
                    "{'resolver': 'cts',"
                            + "'cts': {}}",
            },
            {
                    "{'resolver': 'file',"
                            + "'file': {}}",
            }
        };
        // @Checkstyle:on
    }
    @Test(dataProvider = "invalidResolverConfigurations", expectedExceptions = JsonValueException.class)
    public void testInvalidResolverConfigurations(final String rawJson) throws Exception {
        fakeApp.parseUnderlyingResolver(parseJson(rawJson));
    }
    @DataProvider
    public Object[][] invalidCacheResolverConfigurations() {
        // @Checkstyle:off
        return new Object[][] {
                {
                        "{'accessTokenCache': {"
                                + "'enabled': true,"
                                + "'cacheExpiration': '0 minutes'}}",
                },
                {
                        "{'accessTokenCache': {"
                                + "'enabled': true,"
                                + "'cacheExpiration': 'lorem ipsum'}}",
                }
        };
        // @Checkstyle:on
    }
    @Test(dataProvider = "invalidCacheResolverConfigurations", expectedExceptions = JsonValueException.class)
    public void testInvalidCacheResolverConfigurations(final String rawJson) throws Exception {
        fakeApp.createCachedTokenResolverIfNeeded(parseJson(rawJson), resolver);
    }
    @DataProvider
    public Object[][] ingnoredCacheResolverConfigurations() {
        // @Checkstyle:off
        return new Object[][] {
                {
                        "{}"
                },
                {
                        "{'accessTokenCache': {"
                                + "'enabled': false,"
                                + "'cacheExpiration': '5 minutes'}}"
                }
        };
        // @Checkstyle:on
    }
    @Test(dataProvider = "ingnoredCacheResolverConfigurations")
    public void testNoCacheFallbackOnResolver(final String rawJson) throws Exception {
        assertThat(fakeApp.createCachedTokenResolverIfNeeded(parseJson(rawJson), resolver)).isEqualTo(resolver);
    }
    @DataProvider
    public Object[][] validResolverConfigurations() {
        // @Checkstyle:off
        return new Object[][] {
                {
                        "{'resolver': 'rfc7662',"
                                + "'rfc7662': { 'endpointURL': 'http:/example.com/introspect',"
                                + "               'clientId': 'client_app_id',"
                                + "               'clientSecret': 'client_app_secret'}}"
                },
                {
                        "{'resolver': 'openam',"
                                + "'openam': { 'endpointURL': 'http:/example.com/tokeninfo'}}"
                },
                {
                        "{'resolver': 'cts',"
                                + "'cts': { 'baseDN': 'coreTokenId={token},dc=com' }}"
                },
                {
                        "{'resolver': 'file',"
                                + "'file': { 'folderPath': '/path/to/test/folder'}}"
                }
        };
        // @Checkstyle:on
    }
    @Test(dataProvider = "validResolverConfigurations")
    public void testValidResolverConfiguration(final String rawJson) throws Exception {
        when(fakeApp.getConnectionFactory(eq("root"))).thenReturn(mock(ConnectionFactory.class));
        assertThat(fakeApp.parseUnderlyingResolver(parseJson(rawJson))).isNotNull();
    }
}
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java
@@ -11,15 +11,18 @@
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions copyright [year] [name of copyright owner]".
 *
 * Copyright 2013-2015 ForgeRock AS.
 * Copyright 2013-2016 ForgeRock AS.
 */
package org.forgerock.opendj.rest2ldap;
import static org.forgerock.json.JsonValue.json;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import java.util.List;
import org.forgerock.http.util.Json;
import org.forgerock.json.JsonPointer;
import org.forgerock.json.JsonValue;
import org.forgerock.json.resource.ResourceResponse;
@@ -89,6 +92,28 @@
        return result;
    }
    /**
     * Return {@link JsonValue} corresponding to the provided json blob.
     *
     * @param jsonStr
     *          JSON blob.
     * @return A {@link JsonValue} corresponding to the provided json blob.
     */
    public static JsonValue parseJson(final String jsonStr) throws IOException {
        return new JsonValue(Json.readJsonLenient(new StringReader(toValidJson(jsonStr))));
    }
    /**
     * Allows usage of single quote character in json string used in unit tests.
     *
     * @param jsonStr
     *          The json string to convert to valid json.
     * @return A Json compliant string.
     */
    public static String toValidJson(final String jsonStr) {
        return jsonStr.replace("'", "\"");
    }
    private TestUtils() {
        // Prevent instantiation.
    }
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java
@@ -20,7 +20,7 @@
import java.util.LinkedHashMap;
import java.util.Map;
import org.forgerock.opendj.ldap.schema.Schema;
import org.forgerock.json.JsonValue;
import org.forgerock.testng.ForgeRockTestCase;
import org.testng.annotations.DataProvider;
import org.testng.annotations.Test;
@@ -35,46 +35,47 @@
    @DataProvider
    public Object[][] templateData() {
        // @formatter:off
        // [template as set in json configuration file]
        // [excepted result after placeholders resolution]
        // [Values to use to resolve the placeholders]
        return new Object[][] {
            {
                "dn:uid={uid},ou={realm},dc=example,dc=com",
                "dn:uid=test.user,ou=acme,dc=example,dc=com",
                "uid=test.user,ou=acme,dc=example,dc=com",
                map("uid", "test.user", "realm", "acme")
            },
            {
                // Should perform DN quoting.
                "dn:uid={uid},ou={realm},dc=example,dc=com",
                "dn:uid=test.user,ou=test\\+cn=quoting,dc=example,dc=com",
                "uid=test.user,ou=test\\+cn=quoting,dc=example,dc=com",
                map("uid", "test.user", "realm", "test+cn=quoting")
            },
            {
                // Should not perform DN quoting.
                "dn:{dn}",
                "dn:uid=test.user,ou=acme,dc=example,dc=com",
                map("dn", "uid=test.user,ou=acme,dc=example,dc=com")
            },
            {
                "u:{uid}@{realm}.example.com",
                "u:test.user@acme.example.com",
                "test.user@acme.example.com",
                map("uid", "test.user", "realm", "acme")
            },
            {
                // Should not perform any DN quoting.
                "u:{uid}@{realm}.example.com",
                "u:test.user@test+cn=quoting.example.com",
                "test.user@test+cn=quoting.example.com",
                map("uid", "test.user", "realm", "test+cn=quoting")
            },
            {
                // Should resolve boolean and numbers
                "u:{uid}.{numericid}.{testboolean}@{realm}.example.com",
                "test.42.true@test.example.com",
                map("uid", "test", "numericid", 42, "testboolean", true, "realm", "test")
            }
        };
        // @formatter:on
    }
    @Test(dataProvider = "templateData")
    public void testTemplates(final String template, final String expected,
            Map<String, Object> principals) throws Exception {
        assertThat(
                new AuthzIdTemplate(template)
                        .formatAsAuthzId(principals, Schema.getDefaultSchema()))
    public void testTemplates(final String template, final String expected, Map<String, Object> principals)
            throws Exception {
        assertThat(new AuthzIdTemplate(template).formatAsAuthzId(new JsonValue(principals)))
                .isEqualTo(expected);
    }
@@ -103,7 +104,7 @@
    @Test(dataProvider = "invalidTemplateData", expectedExceptions = IllegalArgumentException.class)
    public void testInvalidTemplateData(final String template, Map<String, Object> principals)
            throws Exception {
        new AuthzIdTemplate(template).formatAsAuthzId(principals, Schema.getDefaultSchema());
        new AuthzIdTemplate(template).formatAsAuthzId(new JsonValue(principals));
    }
    @DataProvider
@@ -122,10 +123,10 @@
        new AuthzIdTemplate(template);
    }
    private Map<String, Object> map(String... keyValues) {
    private Map<String, Object> map(Object... keyValues) {
        Map<String, Object> map = new LinkedHashMap<>();
        for (int i = 0; i < keyValues.length; i += 2) {
            map.put(keyValues[i], keyValues[i + 1]);
            map.put(keyValues[i].toString(), keyValues[i + 1]);
        }
        return map;
    }
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolverTestCase.java
New file
@@ -0,0 +1,138 @@
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2016 ForgeRock AS.
 */
package org.forgerock.opendj.rest2ldap.authz;
import static org.assertj.core.api.Assertions.assertThat;
import static org.forgerock.opendj.ldap.LdapException.newLdapException;
import static org.forgerock.opendj.ldap.spi.LdapPromises.newSuccessfulLdapPromise;
import static org.forgerock.opendj.rest2ldap.TestUtils.toValidJson;
import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newCtsAccessTokenResolver;
import static org.mockito.Matchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
import java.util.concurrent.ExecutionException;
import org.forgerock.authz.modules.oauth2.AccessTokenInfo;
import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
import org.forgerock.json.JsonPointer;
import org.forgerock.opendj.ldap.Attribute;
import org.forgerock.opendj.ldap.Connection;
import org.forgerock.opendj.ldap.ConnectionFactory;
import org.forgerock.opendj.ldap.LdapException;
import org.forgerock.opendj.ldap.ResultCode;
import org.forgerock.opendj.ldap.SearchScope;
import org.forgerock.opendj.ldap.requests.SearchRequest;
import org.forgerock.opendj.ldap.responses.SearchResultEntry;
import org.forgerock.opendj.ldap.spi.LdapPromises;
import org.forgerock.services.context.Context;
import org.forgerock.testng.ForgeRockTestCase;
import org.forgerock.util.promise.Promises;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
@Test
@SuppressWarnings("javadoc")
public class CtsAccessTokenResolverTestCase extends ForgeRockTestCase {
    @Mock
    private ConnectionFactory connectionFactory;
    @Mock
    private Connection connection;
    @Captor
    private ArgumentCaptor<SearchRequest> requestCapture;
    private final Context context = null;
    private AccessTokenResolver resolver;
    @BeforeMethod
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        resolver = newCtsAccessTokenResolver(connectionFactory, "dc=cts,dc=example,dc=com");
    }
    @Test
    public void basicUseCase() throws Exception {
        final SearchResultEntry entry = mock(SearchResultEntry.class);
        final Attribute attribute = mock(Attribute.class);
        when(entry.getAttribute("coreTokenObject")).thenReturn(attribute);
        when(attribute.firstValueAsString()).thenReturn(toValidJson(
                  "{ 'tokenName': ['access_token'],"
                + "'scope': ['read', 'write', 'dolphin'],"
                + "'expireTime': ['1461057000'],"
                + "'an info': 'info value',"
                + "'user': {'id': 'bjensen'}}"));
        when(connection.searchSingleEntryAsync(requestCapture.capture()))
                .thenReturn(newSuccessfulLdapPromise(entry));
        when(connectionFactory.getConnectionAsync()).thenReturn(
                Promises.<Connection, LdapException> newResultPromise(connection));
        final String testToken = "test-token";
        final AccessTokenInfo accessToken = resolver.resolve(context, testToken).get();
        final SearchRequest request = requestCapture.getValue();
        assertThat(request.getName().toString()).isEqualTo("coreTokenId=test-token,dc=cts,dc=example,dc=com");
        assertThat(request.getScope()).isEqualTo(SearchScope.BASE_OBJECT);
        assertThat(request.getAttributes()).containsExactly("coreTokenObject");
        assertThat(accessToken.getExpiresAt()).isEqualTo(1461057000);
        assertThat(accessToken.getScopes()).containsOnly("dolphin", "read", "write");
        assertThat(accessToken.getInfo().get("an info")).isEqualTo("info value");
        assertThat(accessToken.asJsonValue().get(new JsonPointer("/user/id")).asString()).isEqualTo("bjensen");
    }
    @Test(expectedExceptions = ExecutionException.class,
          expectedExceptionsMessageRegExp = ".*Unable to find the token 'test-token' in the CTS because:.*")
    public void testConnectionFactoryError() throws Exception {
        when(connectionFactory.getConnectionAsync()).thenReturn(
                Promises.<Connection, LdapException> newExceptionPromise(
                        newLdapException(ResultCode.INSUFFICIENT_ACCESS_RIGHTS)));
        resolver.resolve(context, "test-token").get();
    }
    @Test(expectedExceptions = ExecutionException.class,
            expectedExceptionsMessageRegExp = ".*Unable to find the token 'test-token' in the CTS because:.*")
    public void testConnectionSearchError() throws Exception {
        when(connectionFactory.getConnectionAsync()).thenReturn(
                Promises.<Connection, LdapException> newResultPromise(connection));
        when(connection.searchSingleEntryAsync(any(SearchRequest.class))).thenReturn(
                LdapPromises.<SearchResultEntry, LdapException> newFailedLdapPromise(
                        newLdapException(ResultCode.INSUFFICIENT_ACCESS_RIGHTS)));
        resolver.resolve(context, "test-token").get();
    }
    @Test(expectedExceptions = ExecutionException.class, expectedExceptionsMessageRegExp =
                  ".*The token 'test-token' must be an access token, but it is a \"refresh_token\"")
    public void testInvalidTokenType() throws Exception {
        final SearchResultEntry entry = mock(SearchResultEntry.class);
        final Attribute attribute = mock(Attribute.class);
        when(entry.getAttribute("coreTokenObject")).thenReturn(attribute);
        when(attribute.firstValueAsString()).thenReturn(toValidJson(
                "{ 'tokenName': ['refresh_token'],"
                + "'expireTime': ['1461057000']}"));
        when(connectionFactory.getConnectionAsync()).thenReturn(
                Promises.<Connection, LdapException> newResultPromise(connection));
        when(connection.searchSingleEntryAsync(any(SearchRequest.class))).thenReturn(newSuccessfulLdapPromise(entry));
        resolver.resolve(context, "test-token").get();
    }
}
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2FilterTest.java
@@ -78,7 +78,7 @@
    }
    @Test
    public void testConnectionIsUsingProxiedAuthControlOnRequests() throws LdapException {
    public void testConnectionIsUsingProxiedAuthControlOnRequests() throws Exception {
        final ConnectionFactory connectionFactory = mock(ConnectionFactory.class);
        when(connectionFactory.getConnectionAsync())
                .thenReturn(Promises.<Connection, LdapException> newResultPromise(new CheckConnection() {
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessResolverTestCase.java
New file
@@ -0,0 +1,124 @@
/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2016 ForgeRock AS.
 */
package org.forgerock.opendj.rest2ldap.authz;
import static org.assertj.core.api.Assertions.assertThat;
import static org.forgerock.opendj.rest2ldap.authz.Authorizations.newRfc7662AccessTokenResolver;
import static org.mockito.Matchers.any;
import static org.mockito.Matchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.net.URI;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import org.forgerock.authz.modules.oauth2.AccessTokenInfo;
import org.forgerock.authz.modules.oauth2.AccessTokenException;
import org.forgerock.authz.modules.oauth2.AccessTokenResolver;
import org.forgerock.http.Handler;
import org.forgerock.http.MutableUri;
import org.forgerock.http.protocol.Request;
import org.forgerock.http.protocol.Response;
import org.forgerock.http.protocol.Status;
import org.forgerock.services.context.AttributesContext;
import org.forgerock.services.context.Context;
import org.forgerock.services.context.RootContext;
import org.forgerock.testng.ForgeRockTestCase;
import org.forgerock.util.encode.Base64;
import org.forgerock.util.promise.Promise;
import org.mockito.ArgumentCaptor;
import org.mockito.Captor;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.testng.annotations.BeforeMethod;
import org.testng.annotations.Test;
@Test
@SuppressWarnings("javadoc")
public final class Rfc7662AccessResolverTestCase extends ForgeRockTestCase {
    @Mock
    private Handler client;
    @Captor
    private ArgumentCaptor<Request> requestCapture;
    private AccessTokenResolver resolver;
    private Context context;
    @BeforeMethod
    public void setUp() throws Exception {
        MockitoAnnotations.initMocks(this);
        resolver = newRfc7662AccessTokenResolver(
                client, new URI("http://www.example.com/introspect"), "client_id", "client_secret");
        context = new AttributesContext(new RootContext());
    }
    @Test
    public void testBasicUseCase() throws Exception {
        final AccessTokenInfo token = createFakeTokenResponse(
                "active-access-token", true, "  read    write dolphin ", 1461057).get();
        assertThat(token.getExpiresAt()).isEqualTo(1461057000);
        assertThat(token.getScopes()).containsOnly("dolphin", "read", "write");
        assertThat(token.getInfo().get("an info")).isEqualTo("info value");
    }
    @Test(expectedExceptions = ExecutionException.class,
          expectedExceptionsMessageRegExp =
                  ".*AccessTokenException.*Access token returned by authorization server is not currently active.*")
    public void testInactiveTokenInRefused() throws Exception {
        createFakeTokenResponse("inactive-access-token", false, " read  write dolphin ", 1461057).get();
    }
    @Test(expectedExceptions = ExecutionException.class,
          expectedExceptionsMessageRegExp = ".*AccessTokenException.*Authorization server returned an error:.*")
    public void testErrorResponse() throws Exception {
        when(client.handle(eq(context), any(Request.class))).thenReturn(
                Response.newResponsePromise(new Response().setStatus(Status.UNAUTHORIZED)));
        resolver.resolve(context, "fake-access-token").get();
    }
    private Promise<AccessTokenInfo, AccessTokenException> createFakeTokenResponse(
            final String token, final boolean active, final String scopes, final long expiresAt) throws Exception {
        final Map<String, Object> jsonResponse = new HashMap<>();
        jsonResponse.put("active", active);
        jsonResponse.put("scope", scopes);
        jsonResponse.put("exp", expiresAt);
        jsonResponse.put("an info", "info value");
        when(client.handle(eq(context), any(Request.class))).thenReturn(
                Response.newResponsePromise(new Response().setStatus(Status.OK).setEntity(jsonResponse)));
        final Promise<AccessTokenInfo, AccessTokenException> promise = resolver.resolve(context, token);
        ensureRequestIsCorrect(token);
        return promise;
    }
    private void ensureRequestIsCorrect(final String token) throws Exception {
        verify(client).handle(eq(context), requestCapture.capture());
        final Request request = requestCapture.getValue();
        assertThat(request.getMethod()).isEqualTo("POST");
        assertThat(request.getUri()).isEqualTo(new MutableUri("http://www.example.com/introspect"));
        assertThat(request.getForm().get("token").get(0)).isEqualTo(token);
        assertThat(request.getForm().get("token_type_hint").get(0)).isEqualTo("access_token");
        assertThat(request.getHeaders().get("Accept").getFirstValue()).isEqualTo("application/json");
        final String credentials = request.getHeaders().getFirst("Authorization").substring("basic".length() + 1);
        assertThat(new String(Base64.decode(credentials)).split(":")).containsExactly("client_id", "client_secret");
    }
}
opendj-server-legacy/resource/config/http-config.json
@@ -40,7 +40,7 @@
                
                // Authentication identity template containing a single %s which will be replaced by the authenticating
                // user's name. (i.e: u:%s)
                "authcIdTemplate": "u:%s"
                "authzIdTemplate": "u:%s"
            },
            
            // Bind to the LDAP server using the resulting DN of a search request. 
@@ -60,7 +60,102 @@
                "filterTemplate" : "(&(uid=%s)(objectClass=inetOrgPerson))"
            }
            // TODO: support for HTTP sessions?
        }
        },
        // Use OAuth2 authorization method. If used, LDAP requests will be performed with proxied authorization control.
        // This field is optional.
        "oauth2": {
            // Access tokens associated realm.
            // This attribute is optional and has a string syntax.
            "realm": "myrealm",
            // Defines the list of required scopes required to access the service.
            // This field is required and cannot be empty.
            "requiredScopes": [ "read", "write", "uid" ],
            // Specify the resolver to use to resolve OAuth2 access token.
            // This attribute is required and its value must be one of "openam", "rfc7662", "cts".
            // Note that the JSON object corresponding to this attribute value must be present
            // and well formed in the "oauth2" JSON attribute.
            "resolver": "openam",
            // The default authzIdTemplate demonstrates how an authorization DN may be constructed
            // from the "uid" field in the following example OAuth2 token introspection response:
            // {
            //     "token": "access_token_string",
            //     "uid" : "user.2",
            //     "userName" : [ "user.2" ]
            // }
            // This attribute is required and has a string syntax.
            // It must start with either 'dn:' or 'u:'.
            // Note: For the CTS resolver, the following placeholder "{userName/0}" must be part of the template string,
            //       e.g "authzIdTemplate": "dn:uid={userName/0},ou=People,dc=example,dc=com"
            "authzIdTemplate": "dn:uid={uid},ou=People,dc=example,dc=com",
            // Configures caching of access token introspection results.
            // This attribute is optional, if it is not present, no token caching will be performed.
            "accessTokenCache": {
                // Indicates whether the access token caching should be used.
                // This attribute is optional (default value is false) and must have a boolean syntax.
                "enabled": false,
                // Specifies the maximal caching duration for an access token.
                // Once this delay is over, token will be refreshed from an access token resolver (see "oauth2/resolver")
                // This attribute is optional, its default value is "5 minutes".
                // The duration syntax supports all human readable notations from day ("days", "day", "d")
                // to nanosecond ("nanoseconds", "nanosecond", "nanosec", "nanos", "nano", "ns")
                // Any negative or zero values are incorrect.
                "cacheExpiration": "5 minutes"
            },
            // The OpenAM access token resolver configuration.
            // This attribute must be present if the "oauth2/resolver" is equal to "openam".
            // If "oauth2/resolver" is set to another resolver, this attribute will be ignored.
            "openam": {
                // Defines the OpenAM endpoint URL where the request should be sent.
                // This attribute is required and must have a string syntax.
                "endpointURL": "http://openam.example.com:8080/openam/oauth2/tokeninfo"
            },
            // The RFC-7662 (see https://tools.ietf.org/html/rfc7662) access token resolver configuration.
            // This attribute must be present if the "oauth2/resolver" is equal to "rfc7662".
            // If "oauth2/resolver" is set to another resolver, this attribute will be ignored.
            "rfc7662": {
                // Defines the token introspection endpoint URL where the request should be sent.
                // This attribute is required and must have a string syntax.
                "endpointURL": "http:/example.com/introspect",
                // Token introspect endpoint requires authentication.
                // It should support HTTP basic authorization (a base64-encoded string of clientId:clientSecret)
                // These attributes are mandatory.
                "clientId": "client_id",
                "clientSecret": "client_secret"
            },
            // The CTS access token resolver.
            // This attribute must be present if the "oauth2/resolver" is equal to "cts".
            // If "oauth2/resolver" is set to another resolver, this attribute will be ignored.
            // Note: You can use {userName/0} in authzIdTemplate configuration to access
            //       user id from the default CTS access token content config.
            "cts": {
                // The access token base DN.
                // This attribute is required and must have a string syntax.
                "baseDN": "ou=famrecords,ou=openam-session,ou=tokens,dc=example,dc=com"
            },
            // ONLY FOR TEST PURPOSE: A File based access token resolver
            // This attribute must be present if the "oauth2/resolver" is equal to "file".
            // If "oauth2/resolver" is set to another resolver, this attribute will be ignored.
            "file": {
                // Directory containing token files.
                // You can test the rest2ldap OAuth2 authorization support by providing some json token files under
                // the directory set in the configuration below.
                // File names must be equal to the token strings.
                // The file content must a JSON object with the following attributes:
                // 'scope', 'expireTime' and all the field(s) needed to resolve the authzIdTemplate.
                "folderPath": "/path/to/test/folder"
            }
        }
    },
    // The REST APIs and their LDAP attribute mappings.