UnboundID Server SDK

Ping Identity
UnboundID Server SDK Documentation

ExampleScriptedOAuthTokenHandler.groovy

/*
 * 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
 *
 *
 *      Portions Copyright 2012-2024 Ping Identity Corporation
 */
package com.unboundid.directory.sdk.examples.groovy;


import com.unboundid.directory.sdk.http.scripting.ScriptedOAuthTokenHandler;
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.Entry;
import com.unboundid.ldap.sdk.Filter;
import com.unboundid.ldap.sdk.LDAPException;
import com.unboundid.ldap.sdk.ResultCode;
import com.unboundid.ldap.sdk.SearchScope;
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.PutResourceRequest;
import com.unboundid.scim.sdk.SCIMRequest;
import com.unboundid.util.args.ArgumentException;
import com.unboundid.util.args.ArgumentParser;
import com.unboundid.util.args.DNArgument;
import com.unboundid.util.args.FileArgument;

import java.security.GeneralSecurityException;
import java.security.KeyFactory;
import java.security.PublicKey;
import java.security.Signature;
import java.security.spec.X509EncodedKeySpec




/**
 * This class provides a simple implementation of a OAuth Token Handler
 * that validates access tokens from an authorization server encoded in the
 * following format:
 * <pre>
 *   bearer_token :=
 *       [expiration_date][audience][scope][auth_user_id][signature]
 *
 *   expiration_date :=
 *       encoded timestamp representing milliseconds since midnight, January 1,
 *       1970 UTC. (8 bytes)
 *
 *   audience :=
 *       server UUID of the target resource server (16 bytes)
 *
 *   scope :=
 *       an 16-bit (2 byte) mask, starting with the most significant bit:
 *
 *            bit1:     allow GET operations
 *            bit2:     allow SEARCH operations
 *            bit3:     allow POST operations
 *            bit4:     allow PUT operations
 *            bit5:     allow PATCH operations
 *            bit6:     allow DELETE operations
 *            bits7-16: unused
 *
 *    auth_user_id :=
 *        the entryUUID of the authorization entry for which to apply access
 *        controls (16 bytes)
 *
 *    signature :=
 *        an RSA-SHA1 signature of the
 *        [expiration_date][audience][scope][auth_user_id], using the
 *        authorization server's 1024-bit private RSA key (128 bytes)
 * </pre>
 *
 * Note this is a simplistic token encoding and is used only for the purpose of
 * demonstration. Production implementations should be sure to follow the
 * guidelines in the "OAuth 2.0 Threat Model and Security Considerations"
 * (<i>RFC 6819</i>).
 * <p>
 * Also note that the OAuth "scope" is implementation-defined, and thus needs
 * to be evaluated and applied within this extension, whereas standard LDAP
 * access controls will be applied automatically within the Directory Server
 * using the authorization entity (auth_user_id) from the token. This will
 * happen <i>after</i> the access token is validated using this extension.
 * <p>
 *
 * This example takes two configuration arguments:
 * <UL>
 *   <LI>auth-server-public-key-file -- The path to the file containing the
 *       DER-encoded public RSA key from the authorization server. This must be
 *       provided.</LI>
 *   <LI>auth-user-base-dn -- The base DN to use when looking up the DN of the
 *       authorization user specified in the access token.</LI>
 * </UL>
 */
public final class ExampleScriptedOAuthTokenHandler
        extends ScriptedOAuthTokenHandler
{
  // The server context for this extension.
  private HTTPServerContext serverContext = null;

  // The Signature object used to verify signed access tokens.
  private Signature signature = null;

  // The serverUUID of this server, which is used to verify the access token's
  // intended audience.
  private UUID serverUUID = null;

  // The base DN to use when looking up the DN of the authorization user
  // specified in the access token.
  private DN authorizationUserBaseDN = null;

  // The name of the argument that will be used for the argument used to specify
  // the file containing the authorization server's public key (in DER format).
  private static final String ARG_NAME_PUBLIC_KEY_FILE =
          "auth-server-public-key-file";

  // The base DN to use when looking up the DN of the authorization user
  // specified in the access token.
  private static final String ARG_NAME_AUTH_USER_BASE_DN =
          "auth-user-base-dn";

  // Constants for bit mask testing with OAuth scope values.
  private static final int ALLOW_GET     = (1 << 31);
  private static final int ALLOW_SEARCH  = (1 << 30);
  private static final int ALLOW_POST    = (1 << 29);
  private static final int ALLOW_PUT     = (1 << 28);
  private static final int ALLOW_PATCH   = (1 << 27);
  private static final int ALLOW_DELETE  = (1 << 26);



  /**
   * 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 ExampleOAuthTokenHandler()
  {
    // No implementation required.
  }



  /**
   * 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 path to the file containing the DER-encoded public RSA key " +
            "from the authorization server. This must be provided.";

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


    description =
            "The base DN to use when looking up the DN of the authorization " +
            "user specified in the access token. This must be provided.";

    final DNArgument baseDNArg = new DNArgument(null,
            ARG_NAME_AUTH_USER_BASE_DN, true, 1, "{base-dn}", description);
    parser.addArgument(baseDNArg);
  }



  /**
   * 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;

    DNArgument baseDNArg =
            (DNArgument) parser.getNamedArgument(ARG_NAME_AUTH_USER_BASE_DN);
    this.authorizationUserBaseDN = baseDNArg.getValue();

    FileArgument publicKeyFileArg =
            (FileArgument) parser.getNamedArgument(ARG_NAME_PUBLIC_KEY_FILE);
    File publicKeyFile = publicKeyFileArg.getValue();

    try
    {
      signature = Signature.getInstance("SHA1withRSA");

      //Read in the public key file
      RandomAccessFile f = new RandomAccessFile(publicKeyFile, "r");
      byte[] publicKeyBytes = new byte[(int)f.length()];
      f.read(publicKeyBytes);
      f.close();

      //Initialize the Signature object with the auth server's public key
      X509EncodedKeySpec spec = new X509EncodedKeySpec(publicKeyBytes);
      KeyFactory keyFactory = KeyFactory.getInstance("RSA");
      PublicKey key = keyFactory.generatePublic(spec);
      signature.initVerify(key);

      //Get this server's serverUUID from the base monitor entry
      Entry entry = serverContext.getInternalRootConnection()
                       .getEntry("cn=monitor", "serverUUID");
      String serverUUIDStr = entry.getAttributeValue("serverUUID");
      serverUUID = UUID.fromString(serverUUIDStr);
    }
    catch (Exception e)
    {
      serverContext.debugCaught(e);
      throw new LDAPException(ResultCode.LOCAL_ERROR, e);
    }
  }



  /**
   * 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 base64TokenValue the base64-encoded bearer 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 base64TokenValue)
          throws GeneralSecurityException
  {
    OAuthToken token = new OAuthToken(base64TokenValue);
    if(token.getRawTokenBytes().length != 170)
    {
      throw new GeneralSecurityException(
          "The access token was not the correct length");
    }
    return token;
  }



  /**
   * 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 java.security.GeneralSecurityException if there is an error
   *         determining the token's expiration date
   */
  @Override
  public boolean isTokenExpired(final OAuthToken token)
          throws GeneralSecurityException
  {
    //Extract the first 8 bytes from the raw token
    byte[] dBytes = new byte[8];
    System.arraycopy(token.getRawTokenBytes(), 0, dBytes, 0, dBytes.length);

    Date expirationDate = new Date(bytesToLong(dBytes));
    Date now = new Date();
    return expirationDate.before(now);
  }



  /**
   * 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
  {
    byte[] tokenBytes = new byte[42];
    byte[] signatureBytes = new byte[128];

    //Extract the token bytes and the signature bytes from the raw token
    System.arraycopy(token.getRawTokenBytes(), 0, tokenBytes,
                      0, tokenBytes.length);
    System.arraycopy(token.getRawTokenBytes(), tokenBytes.length,
                      signatureBytes, 0, signatureBytes.length);

    //Synchronize here to make this code thread-safe, since it may be called
    //simultaneously by multiple threads and is using the shared Signature
    //object
    synchronized (this)
    {
      signature.update(tokenBytes);
      return signature.verify(signatureBytes);
    }
  }



  /**
   * 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
  {
    byte[] mostSignificantBits = new byte[8];
    byte[] leastSignificantBits = new byte[8];

    //Extract the serverUUID bytes from the raw token
    System.arraycopy(token.getRawTokenBytes(), 8, mostSignificantBits, 0,
                      mostSignificantBits.length);
    System.arraycopy(token.getRawTokenBytes(), 16, leastSignificantBits, 0,
                      leastSignificantBits.length);

    UUID targetServerUUID = new UUID(bytesToLong(mostSignificantBits),
                                     bytesToLong(leastSignificantBits));

    return targetServerUUID.equals(serverUUID);
  }



  /**
   * 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 java.security.GeneralSecurityException if there is an error
   *         determining whether the token is valid
   */
  @Override
  public OAuthTokenStatus validateToken(final OAuthToken token,
                                        final SCIMRequest scimRequest)
          throws GeneralSecurityException
  {
    //There are 2 bytes allocated to the scope, but we'll extract them into an
    //array of 4 bytes and convert that to an integer so we can perform bit-wise
    //arithmetic to check the scope values.
    byte[] scopeBytes = new byte[4];

    //Extract the OAuth scope from the raw token
    System.arraycopy(token.getRawTokenBytes(), 24, scopeBytes, 0, 2);

    int scope = 0;
    for (int i = 0; i < scopeBytes.length; i++)
    {
      scope <<= 8;
      scope |= (scopeBytes[i] & 0xFF);
    }

    if (scimRequest instanceof GetResourceRequest)
    {
      return checkScope(scope, ALLOW_GET);
    }
    else if (scimRequest instanceof GetResourcesRequest)
    {
      return checkScope(scope, ALLOW_SEARCH);
    }
    else if (scimRequest instanceof PostResourceRequest)
    {
      return checkScope(scope, ALLOW_POST);
    }
    else if (scimRequest instanceof PutResourceRequest)
    {
      return checkScope(scope, ALLOW_PUT);
    }
    else if (scimRequest instanceof PatchResourceRequest)
    {
      return checkScope(scope, ALLOW_PATCH);
    }
    else if (scimRequest instanceof DeleteResourceRequest)
    {
      return checkScope(scope, ALLOW_DELETE);
    }
    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
  {
    byte[] mostSignificantBits = new byte[8];
    byte[] leastSignificantBits = new byte[8];

    //Extract the entryUUID bytes from the raw token
    System.arraycopy(token.getRawTokenBytes(), 26, mostSignificantBits, 0,
                      mostSignificantBits.length);
    System.arraycopy(token.getRawTokenBytes(), 34, leastSignificantBits, 0,
                      leastSignificantBits.length);

    UUID authzEntryUUID = new UUID(bytesToLong(mostSignificantBits),
        bytesToLong(leastSignificantBits));

    try
    {
      Entry entry = serverContext.getInternalRootConnection().searchForEntry(
        authorizationUserBaseDN.toString(), SearchScope.SUB,
          Filter.createEqualityFilter("entryUUID", authzEntryUUID.toString()));
      if (entry != null)
      {
        return entry.getParsedDN();
      }
      else
      {
        throw new LDAPException(ResultCode.NO_SUCH_OBJECT);
      }
    }
    catch (LDAPException e)
    {
      throw new GeneralSecurityException(
          "Could not find the authorization entry", e);
    }
  }



  /**
   * Helper to verify that the given scope has the privileges specified by the
   * given bit mask.
   *
   * @param scope the OAuth scope bytes from the access token.
   * @param bitMask the scope bytes to check against.
   * @return an {@link OAuthTokenStatus} instance indicating whether the given
   *         token scope allows the access required to perform the requested
   *         operation.
   */
  private OAuthTokenStatus checkScope(final int scope, final int bitMask)
  {
    if ((scope & bitMask) != 0)
    {
      return new OAuthTokenStatus(
              OAuthTokenStatus.ErrorCode.OK);
    }
    else
    {
      return new OAuthTokenStatus(
              OAuthTokenStatus.ErrorCode.INSUFFICIENT_SCOPE);
    }
  }



  /**
   * Helper to expand a compact 8-byte array to a Java long object.
   *
   * @param bytes the byte array to convert.
   * @return a Java long object
   */
  private long bytesToLong(final byte[] bytes) throws IllegalArgumentException
  {
    if (bytes == null || bytes.length != 8)
    {
      throw new IllegalArgumentException(
              "The byte array did not contain exactly 8 bytes");
    }

    long l = 0L;
    for (int i = 0; i < bytes.length; i++)
    {
      l <<= 8;
      l |= (bytes[i] & 0xFF);
    }
    return l;
  }
}