/*
* 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 2012-2016 UnboundID Corp.
*/
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 Data Store
* 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;
}
}
|