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/main/java/org/forgerock/opendj/rest2ldap/Rest2LDAPHttpApplication.java |  141 ++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 138 insertions(+), 3 deletions(-)

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) {

--
Gitblit v1.10.0