UnboundID Server SDK

UnboundID Server SDK Documentation

ExampleUnboundIDOAuthTokenHandler.java

/*
 * CDDL HEADER START
 *
 * The contents of this file are subject to the terms of the
 * Common Development and Distribution License, Version 1.0 only
 * (the "License").  You may not use this file except in compliance
 * with the License.
 *
 * You can obtain a copy of the license at
 * docs/licenses/cddl.txt
 * or http://www.opensource.org/licenses/cddl1.php.
 * See the License for the specific language governing permissions
 * and limitations under the License.
 *
 * When distributing Covered Code, include this CDDL HEADER in each
 * file and include the License file at
 * docs/licenses/cddl.txt.  If applicable,
 * add the following below this CDDL HEADER, with the fields enclosed
 * by brackets "[]" replaced with your own identifying information:
 *      Portions Copyright [yyyy] [name of copyright owner]
 *
 * CDDL HEADER END
 *
 *
 *      Copyright 2013 UnboundID Corp.
 */
package com.unboundid.directory.sdk.examples;


import com.unboundid.directory.sdk.common.types.LogSeverity;
import com.unboundid.directory.sdk.http.api.OAuthTokenHandler;
import com.unboundid.directory.sdk.http.config.OAuthTokenHandlerConfig;
import com.unboundid.directory.sdk.http.types.HTTPServerContext;
import com.unboundid.ldap.sdk.DN;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.scim.schema.CoreSchema;
import com.unboundid.scim.sdk.DeleteResourceRequest;
import com.unboundid.scim.sdk.GetResourceRequest;
import com.unboundid.scim.sdk.GetResourcesRequest;
import com.unboundid.scim.sdk.OAuthToken;
import com.unboundid.scim.sdk.OAuthTokenStatus;
import com.unboundid.scim.sdk.PatchResourceRequest;
import com.unboundid.scim.sdk.PostResourceRequest;
import com.unboundid.scim.sdk.PreemptiveAuthInterceptor;
import com.unboundid.scim.sdk.PutResourceRequest;
import com.unboundid.scim.sdk.SCIMRequest;
import com.unboundid.util.StaticUtils;
import com.unboundid.util.args.ArgumentException;
import com.unboundid.util.args.ArgumentParser;
import com.unboundid.util.args.FileArgument;
import com.unboundid.util.args.StringArgument;

import com.unboundid.util.ssl.TrustStoreTrustManager;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.Credentials;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.conn.ssl.TrustStrategy;
import org.apache.http.impl.auth.BasicScheme;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.tsccm.ThreadSafeClientConnManager;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpParams;
import org.apache.wink.client.ClientConfig;
import org.apache.wink.client.ClientResponse;
import org.apache.wink.client.Resource;
import org.apache.wink.client.RestClient;
import org.apache.wink.client.httpclient.ApacheHttpClientConfig;
import org.apache.wink.common.internal.MultivaluedMapImpl;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.MultivaluedMap;
import javax.ws.rs.core.Response;
import java.io.File;
import java.net.URI;
import java.net.URL;
import java.security.GeneralSecurityException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

import static org.apache.http.params.CoreConnectionPNames.SO_REUSEADDR;
import static org.apache.http.params.CoreConnectionPNames.SO_TIMEOUT;


/**
 * This class provides a simple implementation of a OAuth Token Handler
 * that validates access tokens from the UnboundID Identity Broker. This allows
 * the UnboundID Identity Data Store to act as a resource server in an OAuth2
 * deployment. <b>This implementation is provided for sample use only, and is
 * not suitable for real-world deployments.</b>
 * <p>
 * Note that the server running this extension must be registered as an
 * application with the Identity Broker, and the credentials for that
 * application are required as configuration arguments for this extension.
 * <p>
 * Note that the bearer tokens minted by the Identity Broker are opaque and must
 * be validated using the /oauth/validate endpoint on the Identity Broker.
 * Also note that the OAuth "scope" is implementation-defined, and thus needs
 * to be evaluated and applied within this extension. This example uses the
 * following scopes (which must be defined in the Identity Broker):
 * <UL>
 *   <LI>urn:acme:scope:create_user</LI>
 *   <LI>urn:acme:scope:update_user</LI>
 *   <LI>urn:acme:scope:delete_user</LI>
 * </UL>
 * For the sake of simplicity this example does not check the username
 * associated with the token, nor does it care whether the requested operation
 * targets the user's own entry or another user entry. Thus it relies only on
 * the scopes; anyone with the "urn:acme:scope:update_user" scope can modify any
 * user entry, for example. Also any application with a valid token
 * automatically gets read access to all user entries.
 * <p>
 * This example takes four configuration arguments:
 * <UL>
 *   <LI>token-validation-url -- The URL where the Identity Broker token
 *       validation endpoint is listening.</LI>
 *   <LI>client-id -- The client ID to use when validating access tokens
 *       with the Identity Broker.</LI>
 *   <LI>client-secret -- The client secret to use when validating access
 *       tokens with the Identity Broker.</LI>
 *   <LI>trust-store-path -- The path to the trust store file which contains the
 *       certificate trust chain for the Identity Broker.</LI>
 * </UL>
 */
public final class ExampleUnboundIDOAuthTokenHandler extends OAuthTokenHandler
{
  // The server context for this extension.
  private HTTPServerContext serverContext = null;

  // The name of the argument that will be used to specify the URL of the OAuth2
  // service on the Identity Broker.
  private static final String ARG_NAME_TOKEN_VALIDATION_URL =
          "token-validation-url";

  // The name of the argument that will be used to specify the client-id to use
  // when validating access tokens with the Identity Broker.
  private static final String ARG_NAME_CLIENT_ID =
          "client-id";

  // The name of the argument that will be used to specify the client-secret to
  // use when validating access tokens with the Identity Broker.
  private static final String ARG_NAME_CLIENT_SECRET =
          "client-secret";

  // The name of the argument that will be used to specify the path to the trust
  // store.
  private static final String ARG_NAME_TRUST_STORE_PATH =
          "trust-store-path";

  // The URL of the token validation endpoint on the Identity Broker.
  private URI tokenValidationURL;

  // The client-id to use when validating access tokens with the Identity
  // Broker.
  private String clientID;

  // The client-secret to use when validating access tokens with the Identity
  // Broker.
  private String clientSecret;

  // The path to the trust store for this server.
  private File trustStorePath;

  // The RestClient to use for making requests to the token validation endpoint.
  private RestClient restClient;


  /**
   * Creates a new instance of this OAuth Token handler.  All token
   * handler implementations must include a default constructor, but any
   * initialization should generally be performed in the
   * {@code initializeTokenHandler()} method.
   */
  public ExampleUnboundIDOAuthTokenHandler()
  {
    // No implementation required.
  }



  /**
   * Retrieves a human-readable name for this extension.
   *
   * @return  A human-readable name for this extension.
   */
  @Override()
  public String getExtensionName()
  {
    return "Example UnboundID OAuth Token Handler";
  }



  /**
   * Retrieves a human-readable description for this extension.  Each element
   * of the array that is returned will be considered a separate paragraph in
   * generated documentation.
   *
   * @return  A human-readable description for this extension, or {@code null}
   *          or an empty array if no description should be available.
   */
  @Override()
  public String[] getExtensionDescription()
  {
    return new String[]
    {
      "This OAuth Token Handler serves as an example that may be used " +
      "to demonstrate the process for validating OAuth bearer tokens " +
      "with the UnboundID Identity Broker."
    };
  }



  /**
   * Updates the provided argument parser to define any configuration arguments
   * which may be used by this token handler.  The argument parser may also
   * be updated to define relationships between arguments (e.g., to specify
   * required, exclusive, or dependent argument sets).
   *
   * @param  parser  The argument parser to be updated with the configuration
   *                 arguments which may be used by this token handler.
   *
   * @throws  ArgumentException  If a problem is encountered while updating the
   *                             provided argument parser.
   */
  @Override
  public void defineConfigArguments(final ArgumentParser parser)
          throws ArgumentException
  {
    String description =
            "The URL of the token validation endpoint on the Identity " +
            "Broker. This must be provided.";

    final StringArgument tokenValidationURLArg = new StringArgument(null,
            ARG_NAME_TOKEN_VALIDATION_URL, true, 1, "{url}", description);
    parser.addArgument(tokenValidationURLArg);

    description =
            "The client-id to use when validating access tokens with the " +
            "Identity Broker. This must be provided.";

    final StringArgument clientIDArg = new StringArgument(null,
            ARG_NAME_CLIENT_ID, true, 1, "{client-id}", description);
    parser.addArgument(clientIDArg);

    description =
            "The client-secret to use when validating access tokens with " +
            "the Identity Broker. This must be provided.";

    final StringArgument clientSecretArg = new StringArgument(null,
            ARG_NAME_CLIENT_SECRET, true, 1, "{client-secret}", description);
    parser.addArgument(clientSecretArg);

    description = "The path to the truststore for the local server.";
    List<File> defaultValues = new ArrayList<File>(1);
    defaultValues.add(new File("config/truststore"));

    final FileArgument trustStoreFileArg = new FileArgument(null,
            ARG_NAME_TRUST_STORE_PATH, true, 1, "{file-path}", description,
            true, true, true, false, defaultValues);
    parser.addArgument(trustStoreFileArg);
  }



  /**
   * Initializes this OAuth Token Handler.
   *
   * @param  serverContext  A handle to the server context for the server in
   *                        which this extension is running.
   * @param  config         The general configuration for this OAuth Token
   *                        Handler.
   * @param  parser         The argument parser which has been initialized from
   *                        the configuration for this token handler.
   *
   * @throws LDAPException  If a problem occurs while initializing this token
   *                        handler.
   */
  @Override()
  public void initializeTokenHandler(
                   final HTTPServerContext serverContext,
                   final OAuthTokenHandlerConfig config,
                   final ArgumentParser parser)
         throws LDAPException
  {
    this.serverContext = serverContext;

    StringArgument tokenValidationURLArg =
           (StringArgument) parser.getNamedArgument(
                   ARG_NAME_TOKEN_VALIDATION_URL);

    try
    {
      // First make sure the token validation URL validates as a URL.
      final URL url = new URL(tokenValidationURLArg.getValue());

      // Then convert it to a URI for use by the Wink REST client.
      tokenValidationURL = url.toURI();
    }
    catch (Exception e)
    {
      throw new LDAPException(ResultCode.LOCAL_ERROR,
              StaticUtils.getExceptionMessage(e));
    }

    StringArgument clientIDArg =
          (StringArgument) parser.getNamedArgument(ARG_NAME_CLIENT_ID);
    clientID = clientIDArg.getValue();

    StringArgument clientSecretArg =
          (StringArgument) parser.getNamedArgument(ARG_NAME_CLIENT_SECRET);
    clientSecret = clientSecretArg.getValue();

    FileArgument trustStoreFileArg =
          (FileArgument) parser.getNamedArgument(ARG_NAME_TRUST_STORE_PATH);
    trustStorePath = trustStoreFileArg.getValue();

    if (!trustStorePath.isAbsolute())
    {
      trustStorePath =
           new File(serverContext.getServerRoot(), trustStorePath.getPath());
    }

    final TrustStoreTrustManager trustStoreTrustManager =
      new TrustStoreTrustManager(trustStorePath, null, "JKS", true);

    //Configure the Wink REST client for SSL using the specified trust store.
    try
    {
      final SSLSocketFactory sslSocketFactory = new SSLSocketFactory(
        new TrustStrategy()
        {
          @Override
          public boolean isTrusted(final X509Certificate[] x509Certificates,
                                   final String s)
            throws CertificateException
          {
            trustStoreTrustManager.checkServerTrusted(x509Certificates, s);
            return true;
          }
        },
        SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
      final Scheme httpsScheme = new Scheme("https", 443, sslSocketFactory);
      final SchemeRegistry schemeRegistry = new SchemeRegistry();
      schemeRegistry.register(httpsScheme);

      final HttpParams params = new BasicHttpParams();
      DefaultHttpClient.setDefaultHttpParams(params);
      params.setBooleanParameter(SO_REUSEADDR, true);
      params.setIntParameter(SO_TIMEOUT, 60000);
      final ThreadSafeClientConnManager mgr =
              new ThreadSafeClientConnManager(schemeRegistry);
      mgr.setDefaultMaxPerRoute(20);
      mgr.setMaxTotal(200);
      DefaultHttpClient httpClient = new DefaultHttpClient(mgr, params);

      final Credentials credentials =
            new UsernamePasswordCredentials(clientID, clientSecret);
      httpClient.getCredentialsProvider().setCredentials(new AuthScope(
            tokenValidationURL.getHost(), tokenValidationURL.getPort()),
              credentials);
      httpClient.addRequestInterceptor(
            new PreemptiveAuthInterceptor(new BasicScheme(), credentials), 0);

      ClientConfig clientConfig = new ApacheHttpClientConfig(httpClient);
      restClient = new RestClient(clientConfig);
    }
    catch (Exception e)
    {
      throw new LDAPException(ResultCode.LOCAL_ERROR,
              StaticUtils.getExceptionMessage(e));
    }
  }



  /**
   * Retrieves a map containing examples of configurations that may be used for
   * this extension.  The map key should be a list of sample arguments, and the
   * corresponding value should be a description of the behavior that will be
   * exhibited by the extension when used with that configuration.
   *
   * @return  A map containing examples of configurations that may be used for
   *          this extension.  It may be {@code null} or empty if there should
   *          not be any example argument sets.
   */
  @Override()
  public Map<List<String>,String> getExamplesArgumentSets()
  {
    final LinkedHashMap<List<String>,String> exampleArgs =
         new LinkedHashMap<List<String>,String>(1);

    exampleArgs.put(Arrays.asList(
             ARG_NAME_TOKEN_VALIDATION_URL + "=https://acme.com/oauth/validate",
             ARG_NAME_CLIENT_ID + "=acb941f0-76d5-11e2-bcfd-0800200c9a66",
             ARG_NAME_CLIENT_SECRET + "=2Uslw61xaP"),
         "Use the specified OAuth2 service URL, client-id, and client-secret " +
         "to validate access tokens. The default truststore arguments will " +
         "be used.");

    return exampleArgs;
  }



  /**
   * Creates an {@link OAuthToken} instance from the incoming token value.
   * <p>
   * Implementers may choose to return a subclass of {@link OAuthToken} in order
   * to provide convenience methods for interacting with the token. This can be
   * helpful because the returned {@link OAuthToken} is passed to all of the
   * other methods in this class.
   *
   * @param rawTokenValue the b64token value. Note that b64token is just an ABNF
   *                      syntax definition and does not imply any
   *                      base64-encoding of the token value.
   * @return a {@link OAuthToken} instance. This must not be {@code null}.
   * @throws GeneralSecurityException if there is an error decoding the token
   */
  @Override
  public OAuthToken decodeOAuthToken(final String rawTokenValue)
          throws GeneralSecurityException
  {
    return new OAuthToken(rawTokenValue);
  }



  /**
   * Determines whether the given token is expired.
   *
   * @param token the OAuth 2.0 bearer token.
   * @return {@code true} if the token is already expired, {@code false} if not.
   * @throws GeneralSecurityException if there is an error determining the
   *         token's expiration date
   */
  @Override
  public boolean isTokenExpired(final OAuthToken token)
          throws GeneralSecurityException
  {
    // Defer to the validateToken() method.
    return false;
  }



  /**
   * Determines whether the incoming token is authentic (i.e. that it came from
   * a trusted authorization server and not an attacker). Implementers are
   * encouraged to use signed tokens and use this method to verify the
   * signature, but other methods such as symmetric key encryption (using a
   * shared secret) can be used as well.
   *
   * @param token the OAuth 2.0 bearer token.
   * @return {@code true} if the bearer token can be verified as authentic and
   *         originating from a trusted source, {@code false} if not.
   * @throws java.security.GeneralSecurityException
   *          if there is an error determining whether the token is authentic
   */
  @Override
  public boolean isTokenAuthentic(final OAuthToken token)
          throws GeneralSecurityException
  {
    // Defer to the validateToken() method.
    return true;
  }



  /**
   * Determines whether the incoming token is targeted for this server. This
   * allows the implementation to reject the token early in the validation
   * process if it can see that the intended recipient was not this server.
   *
   * @param token the OAuth 2.0 bearer token.
   * @return {@code true} if the bearer token identifies this server as the
   *         intended recipient, {@code false} if not.
   * @throws java.security.GeneralSecurityException
   *          if there is an error determining whether the token is for this
   *          server
   */
  @Override
  public boolean isTokenForThisServer(final OAuthToken token)
          throws GeneralSecurityException
  {
    // Defer to the validateToken() method.
    return true;
  }



  /**
   * Determines whether the incoming token is valid for the given request. This
   * method should verify that the token is legitimate and grants access to the
   * requested resource specified in the {@link SCIMRequest}. This typically
   * involves checking the token scope and any other attributes granted by the
   * authorization grant. Implementations may need to call back to the
   * authorization server to verify that the token is still valid and has not
   * been revoked.
   *
   * @param token the OAuth 2.0 bearer token.
   * @param scimRequest the {@link SCIMRequest} that we are validating.
   * @return an {@link OAuthTokenStatus} object which indicates whether the
   *         bearer token is valid and grants access to the target resource.
   *         This must not be {@code null}.
   * @throws GeneralSecurityException if there is an error determining whether
   *         the token is valid
   */
  @Override
  public OAuthTokenStatus validateToken(final OAuthToken token,
                                        final SCIMRequest scimRequest)
          throws GeneralSecurityException
  {
    final Resource tokenValidationEndpoint =
            restClient.resource(tokenValidationURL);
    tokenValidationEndpoint.contentType(
            MediaType.APPLICATION_FORM_URLENCODED_TYPE);
    tokenValidationEndpoint.accept(MediaType.APPLICATION_JSON_TYPE);
    final MultivaluedMap<String, String> formData =
            new MultivaluedMapImpl<String, String>();
    formData.putSingle("token", token.getTokenValue());

    ClientResponse validationResponse = null;
    try
    {
      validationResponse = tokenValidationEndpoint.post(formData);
    }
    catch (Exception e)
    {
      serverContext.debugCaught(e);
      serverContext.logMessage(LogSeverity.SEVERE_ERROR,
              StaticUtils.getExceptionMessage(e));
      return new OAuthTokenStatus(OAuthTokenStatus.ErrorCode.INVALID_TOKEN);
    }

    if (validationResponse.getStatusCode() !=
            Response.Status.OK.getStatusCode())
    {
      return new OAuthTokenStatus(OAuthTokenStatus.ErrorCode.INVALID_TOKEN);
    }

    //This token handler only allows access to SCIM "User" resources. If the
    //requested resource is anything else, deny the request.
    if (!scimRequest.getResourceDescriptor().equals(CoreSchema.USER_DESCRIPTOR))
    {
      return getOAuthTokenStatus(false);
    }

    boolean hasCreateScope = false;
    boolean hasUpdateScope = false;
    boolean hasDeleteScope = false;

    try
    {
      JSONObject jsonObject = new JSONObject(
              validationResponse.getEntity(String.class));
      JSONArray scopes = jsonObject.getJSONArray("scope");

      for (int i = 0; i < scopes.length(); i++)
      {
        String scope = scopes.getString(i);
        if (scope.equalsIgnoreCase("urn:acme:scope:create_user"))
        {
          hasCreateScope = true;
        }
        else if(scope.equalsIgnoreCase("urn:acme:scope:update_user"))
        {
          hasUpdateScope = true;
        }
        else if(scope.equalsIgnoreCase("urn:acme:scope:delete_user"))
        {
          hasDeleteScope = true;
        }
      }
    }
    catch (JSONException e)
    {
      serverContext.debugCaught(e);
      return new OAuthTokenStatus(OAuthTokenStatus.ErrorCode.INVALID_TOKEN);
    }

    if ((scimRequest instanceof GetResourceRequest) ||
            (scimRequest instanceof GetResourcesRequest))
    {
      //No special scope needed for read access.
      return getOAuthTokenStatus(true);
    }
    else if (scimRequest instanceof PostResourceRequest)
    {
      return getOAuthTokenStatus(hasCreateScope);
    }
    else if ((scimRequest instanceof PutResourceRequest) ||
                (scimRequest instanceof PatchResourceRequest))
    {
      return getOAuthTokenStatus(hasUpdateScope);
    }
    else if (scimRequest instanceof DeleteResourceRequest)
    {
      return getOAuthTokenStatus(hasDeleteScope);
    }
    else
    {
      return new OAuthTokenStatus(OAuthTokenStatus.ErrorCode.INVALID_TOKEN,
              "Could not determine request type");
    }
  }



  /**
   * Extracts the DN of the authorization entry (for which to apply access
   * controls) from the incoming token.
   * <p/>
   * This may require performing an LDAP search in order to find the DN that
   * matches a certain attribute value contained in the token.
   *
   * @param token the OAuth 2.0 bearer token.
   * @return the authorization DN to use. This must not return {@code null}.
   * @throws java.security.GeneralSecurityException
   *          if there is an error determining the authorization user DN
   */
  @Override
  public DN getAuthzDN(final OAuthToken token) throws GeneralSecurityException
  {
    //Return a root account here as the authorization DN, because we do not want
    //the Identity Data Store to apply additional access controls when
    //performing the operation; the scopes are all we need to check.
    try
    {
      return new DN("cn=Directory Manager");
    }
    catch(LDAPException e)
    {
      serverContext.debugCaught(e);
      return null;
    }
  }



  /**
   * Helper to verify that the given scope has the privileges specified by the
   * given bit mask.
   *
   * @param hasScope whether the user has been granted the necessary scope.
   * @return an {@link OAuthTokenStatus} instance indicating whether the given
   *         scope allows the access required to perform the requested
   *         operation.
   */
  private OAuthTokenStatus getOAuthTokenStatus(final boolean hasScope)
  {
    if (hasScope)
    {
      return new OAuthTokenStatus(
              OAuthTokenStatus.ErrorCode.OK);
    }
    else
    {
      return new OAuthTokenStatus(
              OAuthTokenStatus.ErrorCode.INSUFFICIENT_SCOPE);
    }
  }
}