/* * 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; 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.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.Base64; import com.unboundid.util.StaticUtils; 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.io.File; import java.io.RandomAccessFile; import java.security.GeneralSecurityException; import java.security.KeyFactory; import java.security.PublicKey; import java.security.Signature; import java.security.spec.X509EncodedKeySpec; import java.text.ParseException; import java.util.Arrays; import java.util.Date; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.UUID; /** * 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 ExampleOAuthTokenHandler extends OAuthTokenHandler { // 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. } /** * Retrieves a human-readable name for this extension. * * @return A human-readable name for this extension. */ @Override() public String getExtensionName() { return "Example 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. " + "It uses a predefined token encoding and requires signed tokens for " + "the sake of ensuring token authenticity." }; } /** * 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); } } /** * 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_PUBLIC_KEY_FILE + "=/config/authServer.der"), "Use the specified file as the authorization server's " + "public key for verifying signed bearer tokens."); 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 token value. Note that b64token is just * an ABNF syntax definition and does not imply any * base64-encoding of the token value. However, this * OAuthTokenHandler example does expect the raw token * value to be base64-encoded, because it uses a * concatenation of raw bytes to make up the token. * @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 { try { //Use a special subclass of OAuthToken which understands base64-encoding. return new Base64OAuthToken(rawTokenValue); } catch (ParseException e) { throw new GeneralSecurityException(StaticUtils.getExceptionMessage(e)); } } /** * 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 { Base64OAuthToken b64Token = (Base64OAuthToken) token; //Extract the first 8 bytes from the raw token byte[] dBytes = new byte[8]; System.arraycopy(b64Token.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]; Base64OAuthToken b64Token = (Base64OAuthToken) token; //Extract the token bytes and the signature bytes from the raw token System.arraycopy(b64Token.getRawTokenBytes(), 0, tokenBytes, 0, tokenBytes.length); System.arraycopy(b64Token.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]; Base64OAuthToken b64Token = (Base64OAuthToken) token; //Extract the serverUUID bytes from the raw token System.arraycopy(b64Token.getRawTokenBytes(), 8, mostSignificantBits, 0, mostSignificantBits.length); System.arraycopy(b64Token.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 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]; Base64OAuthToken b64Token = (Base64OAuthToken) token; //Extract the OAuth scope from the raw token System.arraycopy(b64Token.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]; Base64OAuthToken b64Token = (Base64OAuthToken) token; //Extract the entryUUID bytes from the raw token System.arraycopy(b64Token.getRawTokenBytes(), 26, mostSignificantBits, 0, mostSignificantBits.length); System.arraycopy(b64Token.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. * @throws IllegalArgumentException if the byte array does not contain exactly * 8 bytes. */ 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; } /** * An OAuthToken implementation for working with base64-encoded token values. */ private static class Base64OAuthToken extends OAuthToken { //The base64-decoded token bytes. private byte[] rawTokenBytes; /** * Constructs an OAuth 2.0 bearer token with the given base64-encoded value. * * @param tokenValue The bearer token value. * @throws ParseException if the token value cannot be parsed as a base64 * encoded string. */ Base64OAuthToken(final String tokenValue) throws ParseException { super(tokenValue); rawTokenBytes = Base64.decode(tokenValue); } /** * Gets the raw token bytes. * * @return a byte array. */ final byte[] getRawTokenBytes() { return rawTokenBytes; } } }