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