From c8585baebc9fc35ed12a3321acf47730c967b5d3 Mon Sep 17 00:00:00 2001
From: Gaetan Boismal <gaetan.boismal@forgerock.com>
Date: Tue, 24 May 2016 15:45:03 +0000
Subject: [PATCH] OPENDJ-2880 Rest2Ldap as an OAuth2 Resource Server
---
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java | 27
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessResolverTestCase.java | 124 ++++
opendj-rest2ldap/pom.xml | 17
opendj-server-legacy/resource/config/http-config.json | 99 +++
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java | 141 ++++
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java | 152 +++--
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolver.java | 115 +++
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2FilterTest.java | 2
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2Filter.java | 4
opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/opendj-rest2ldap-config.json | 107 +++
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Authorizations.java | 176 +++++
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessTokenResolver.java | 135 ++++
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java | 39
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolverTestCase.java | 138 ++++
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java | 238 ++++++++
opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java | 64 ++
opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthorizationsTestCase.java | 131 ++++
17 files changed, 1,607 insertions(+), 102 deletions(-)
diff --git a/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/opendj-rest2ldap-config.json b/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/opendj-rest2ldap-config.json
index e352407..dc79d68 100644
--- a/opendj-rest2ldap-servlet/src/main/webapp/WEB-INF/classes/opendj-rest2ldap-config.json
+++ b/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"
+ }
+ }
},
diff --git a/opendj-rest2ldap/pom.xml b/opendj-rest2ldap/pom.xml
index 842b3f2..f293562 100644
--- a/opendj-rest2ldap/pom.xml
+++ b/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>
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java
index 6362c6a..b215dd5 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java
+++ b/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) {
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Authorizations.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Authorizations.java
index 96a7d36..56f8c25 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Authorizations.java
+++ b/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.
+ }
}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java
index 6040d0a..589bb5b 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplate.java
+++ b/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;
}
}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolver.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolver.java
new file mode 100644
index 0000000..253726a
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolver.java
@@ -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");
+ }
+ }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java
new file mode 100644
index 0000000..dca7987
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/FileAccessTokenResolver.java
@@ -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));
+ }
+ }
+}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2Filter.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2Filter.java
index db76fb9..b8d3451 100644
--- a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2Filter.java
+++ b/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");
}
diff --git a/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessTokenResolver.java b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessTokenResolver.java
new file mode 100644
index 0000000..4277291
--- /dev/null
+++ b/opendj-rest2ldap/src/main/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessTokenResolver.java
@@ -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);
+ }
+ }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthorizationsTestCase.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthorizationsTestCase.java
new file mode 100644
index 0000000..71b1cea
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/AuthorizationsTestCase.java
@@ -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");
+ }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java
new file mode 100644
index 0000000..72c29b4
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/OAuth2JsonConfigurationTestCase.java
@@ -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();
+ }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java
index f436de2..fa39d16 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/TestUtils.java
+++ b/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.
}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java
index 3d7e099..bfec765 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/AuthzIdTemplateTest.java
+++ b/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;
}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolverTestCase.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolverTestCase.java
new file mode 100644
index 0000000..ee9959f
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/CtsAccessTokenResolverTestCase.java
@@ -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();
+ }
+}
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2FilterTest.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2FilterTest.java
index d0747a2..24383f3 100644
--- a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/ProxiedAuthV2FilterTest.java
+++ b/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() {
diff --git a/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessResolverTestCase.java b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessResolverTestCase.java
new file mode 100644
index 0000000..f4dc1b7
--- /dev/null
+++ b/opendj-rest2ldap/src/test/java/org/forgerock/opendj/rest2ldap/authz/Rfc7662AccessResolverTestCase.java
@@ -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");
+ }
+}
diff --git a/opendj-server-legacy/resource/config/http-config.json b/opendj-server-legacy/resource/config/http-config.json
index 3c0311b..274b65b 100644
--- a/opendj-server-legacy/resource/config/http-config.json
+++ b/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.
--
Gitblit v1.10.0