// Copyright (c) 2014, SAS Institute Inc., Cary, NC, USA, All Rights Reserved
package com.sas.unravl.auth;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.fasterxml.jackson.databind.node.TextNode;
import com.sas.unravl.ApiCall;
import com.sas.unravl.UnRAVL;
import com.sas.unravl.UnRAVLException;
import com.sas.unravl.UnRAVLRuntime;
import com.sas.unravl.annotations.UnRAVLAuthPlugin;
import com.sas.unravl.assertions.UnRAVLAssertionException;
import com.sas.unravl.util.Json;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.message.BasicHeader;
import org.apache.log4j.Logger;
/**
* An auth plugin which authenticates with OAuth2. This using the user's
* credentials provided by the {@link CredentialsProvider} which by default is
* the {@link NetrcCredentialsProvider}.
* <p>
* This auth element is specified via
*
* <pre>
* "auth" : { "oauth2" : <em>oauth-server-URL</em> }
* "auth" : { "oauth2" : <em>oauth-server-URL</em>, <em>options</em>
* </pre>
*
* where <em>auth-token-URL</em> is a string containing the URL of the
* authorization server token API, such as
*
* <pre>
* { "oauth2" : "http://www.example.com/auth/token" }
* </pre>
*
* <h2>Options</h2> The "oath2" object supports several options to configure the
* OAUth2 authentication. See
* <a href='https://github.com/sassoftware/unravl/blob/master/doc/Authentication.md#oauth2'>
* Authentication: oauth2</a> for complete details.
* <p>
* This auth module may be accessed with either the name <code>"oauth2"</code>,
* <code>"OAuth2"</code>, <code>"oauth"</code>, <code>"OAuth"</code>.
*
* @author DavidBiesack@sas.com
*/
@UnRAVLAuthPlugin({ "oauth", "oauth2", "OAuth", "OAuth2" })
public class OAuth2Auth extends BaseUnRAVLAuth {
private static final String PARAMETER_KEY = "parameter";
private static final String ACCESS_TOKEN = "access_token";
private static final String BIND_ACCESS_TOKEN_KEY = "bindAccessToken";
private static final String DEFAULT_ACCESS_TOKEN_JSON_PATH = "$.access_token";
private static final String DEFAULT_OATH_SCRIPT_RESOURCE = "/com/sas/unravl/auth/get-oauth-token.unravl";
private static final String OAUTH_SCRIPT_KEY = "OAauth2Script";
private String tokenUrl;
private static final Logger logger = Logger.getLogger(OAuth2Auth.class);
private static String ACCESS_TOKEN_JSON_PATH_KEY = "accessTokenJsonPath";
@Override
public void authenticate(UnRAVL script, ObjectNode oauthSpec, ApiCall call)
throws UnRAVLException {
super.authenticate(script, oauthSpec, call);
authenticateAndAddAuthenticationHeader(oauthSpec);
}
void authenticateAndAddAuthenticationHeader(ObjectNode auth)
throws UnRAVLException {
try {
if (getScript().getURI() == null || getScript().getMethod() == null)
throw new UnRAVLException(
"oauth2 auth requires an HTTP method and URI");
// Note: The URI should already be expanded at this point
long start = System.currentTimeMillis();
JsonNode tokenNode = Json.firstFieldValue(getScriptlet());
if (tokenNode == null || !(tokenNode instanceof TextNode))
throw new UnRAVLException(
"oauth2 auth requires an oauth-server-URL.");
tokenUrl = getScript().expand(tokenNode.textValue());
String access_token = getAccessToken(new URI(tokenUrl), auth);
bindAccessTokenInEnv(auth, access_token);
addOptionalQueryParameter(auth, access_token);
long end = System.currentTimeMillis();
logger.trace("oauth2 authentication took " + (end - start) + "ms");
} catch (URISyntaxException e) {
new UnRAVLException(e.getMessage(), e);
} catch (IOException e) {
new UnRAVLException(e.getMessage(), e);
}
}
// bind "access_token" in the environment, or if the oath2 object
// contains a "bindAccessToken" : "varName" option, bind to that var name
// instead.
// for example for
// "bindAccessToken" : "bitlyAccessToken"
// the oauth2 object will bind the variable
// "bitlyAccessToken" in the environment instead of "access_token"
private void bindAccessTokenInEnv(ObjectNode auth, String access_token)
throws UnRAVLException {
// we normally store the token via access_token but the caller
// can override this with their own variable name to bind
String callerKey = stringOption(auth, BIND_ACCESS_TOKEN_KEY,
ACCESS_TOKEN);
getScript().bind(callerKey, access_token);
// TODO: this should be added to the ApiCall; this means
// we need to add headers field to the ApiCall. Ditto for Basic auth
// which also adds
// an Authentication header
getScript().addRequestHeader(
new BasicHeader("Authorization", "Bearer " + access_token));
logger.info("\"oauth2\" auth added 'Authorization: Bearer "
+ access_token + "' header");
}
// Add an access_token={access_token} query parameter to the URI using the
// parameter name.
// Default is to use the name access_token .
// Use "parameter" : "" to change the parameter name.
// Use "parameter" : "" to suppress it.
private void addOptionalQueryParameter(ObjectNode auth, String access_token)
throws UnRAVLException {
// if client specifies a query parameter, we will add it to the request
// URI
JsonNode p = auth.get(PARAMETER_KEY);
String queryParm;
if (p != null && p.isBoolean()) {
if (p.booleanValue())
queryParm = ACCESS_TOKEN;
else
return;
} else {
queryParm = stringOption(auth, PARAMETER_KEY, ACCESS_TOKEN);
}
if (queryParm != null && !"".equals(queryParm)) {
StringBuilder uri = new StringBuilder(getCall().getURI());
String delim = (uri.indexOf("?") == -1) ? "?" : "&";
uri.append(delim).append(queryParm).append("=")
.append(access_token);
getCall().setURI(uri.toString());
logger.info("\"oauth2\" auth added '" + delim + queryParm + "="
+ access_token + "' query parameter");
}
}
/**
* Cache the access_token for this URI's host in the environment, so
* subsequent oauth objects do not have to reauthenticate. The access token
* is stored in the environment as "{userId}.{hostname}.access_token".
*
* @param userId
* the user id
* @param hostname
* the hostname of auth server URI
* @param accessToken
* the access_token string
* @retun the <var>accessToken</var>
*/
private String cacheAccessToken(String userId, String hostname,
String accessToken) {
getScript().bind(accessTokenCacheKey(userId, hostname), accessToken);
return accessToken;
}
private String accessTokenCacheKey(String userId, String hostname) {
return userId + "." + hostname + "." + ACCESS_TOKEN;
}
/**
* Get the access token. If it is part of the credentials for the OAuth2
* host, return that static access token.
* <p>
* If a cached token exists, return it.
* </p>
* <p>
* Otherwise, use UnRAVL to run an REST API POST call to the authentication
* server to get an OAAth access token. The default call uses Basic
* Authentication using with the clientId and clientSecret to authenticate.
* The default request body is a
* <code>application/x-www-form-urlencoded</code> form
* </p>
*
* <pre>
* { "grant_type" : "password",
* "username" : "{userId}",
* "password" : "{password}" }
* </pre>
* <p>
* where <code>userId</code> and <code>password</code> are the user id and
* password associated with the OAth authentication server hostname. The
* client ID, client password, user ID and password are read from the
* {@link CredentialsProvider}, normally the
* {@link NetrcCredentialsProvider}. See that class for the format of the
* <code>.netrc</code> file.
* <p>
* The UnRAVL script is found via the classpath at
* <code>/com/sas/unravl/auth/get-oauth-token.unravl</code> (the default
* resource is in the UnRAVL jar) but this resource path can be changed by
* the text option <code>"OAauth2Script" : "/paths/to/unravl/script"</code>
* in the <code>"oath2"</code> element. The UnRAVL script should invoke the
* POST to the authentication server, then extract and bind the variable
* <code>access_token</code> from the response.
* </p>
*
* @param authTokenURI
* URI of the authorization token server
* @param creds
* credentials associated with the token server
* @return the <code>access_token</code> for the client/user
* @throws UnRAVLException
* if the script execution encounters an error
*/
private String getAccessToken(URI authTokenURI, ObjectNode auth)
throws UnRAVLException, URISyntaxException,
ClientProtocolException, IOException {
String host = authTokenURI.getHost();
String access_token = null;
UnRAVLRuntime runtime = getScript().getRuntime();
CredentialsProvider cp = runtime.getPlugins().getCredentialsProvider();
cp.setRuntime(runtime);
HostCredentials credentials = cp.getHostCredentials(host, auth, false);
if (credentials == null)
throw new UnRAVLAssertionException("No auth credentials for host "
+ host);
if (!(credentials instanceof OAuth2Credentials))
throw new UnRAVLAssertionException(
"Authentication credentials for host lack clientid and secret"
+ host);
OAuth2Credentials creds = (OAuth2Credentials) credentials;
String user = creds.getUserName();
if (creds.getAccessToken() != null) {
access_token = creds.getAccessToken();
return cacheAccessToken(user, host, access_token);
}
String key = accessTokenCacheKey(user, host);
if (runtime.bound(key)) {
access_token = (String) getScript().getRuntime().binding(key);
logger.info(String.format(
"Found cached OAuth2 access_token for user %s and host %s",
user, host));
return cacheAccessToken(user, host, access_token);
}
// See if the access token is cached for this user/host
if (runtime.bound(key)) {
access_token = (String) getCall().getVariable(key);
logger.info("Using cached oauth2 access token: " + access_token);
return cacheAccessToken(user, host, access_token);
}
// Else, use UnRAVL to send a request to the server to generate an
// access_token
String oAuthScriptResourcePath = stringOption(getScriptlet(),
OAUTH_SCRIPT_KEY, DEFAULT_OATH_SCRIPT_RESOURCE);
String accessTokenJsonPath = stringOption(auth,
ACCESS_TOKEN_JSON_PATH_KEY, DEFAULT_ACCESS_TOKEN_JSON_PATH);
// We need a new runtime because we don't want this script to
// be recorded in the calling runtime's history of scripts, or these
// values to affect the calling runtime.
UnRAVLRuntime tokenRuntime = new UnRAVLRuntime(getScript().getRuntime());
tokenRuntime.bind(ACCESS_TOKEN_JSON_PATH_KEY, accessTokenJsonPath);
// @formatter:off
tokenRuntime
// Hmmmm, is it worth defining constants for these keys?
.bind("oath2TokenUrl", authTokenURI.toString())
.bind("clientId", creds.getClientId())
.bind("clientSecret", creds.getClientSecret())
.bind("userId", creds.getUserName())
.bind("password", creds.getPassword())
.bind(ACCESS_TOKEN_JSON_PATH_KEY, accessTokenJsonPath);
// @formatter:on
ObjectMapper mapper = new ObjectMapper();
try (InputStream in = openScriptStream(oAuthScriptResourcePath)) {
ObjectNode accessAuthJson = (ObjectNode) mapper.readTree(in);
UnRAVL oathAccessTokenScript = new UnRAVL(tokenRuntime,
accessAuthJson);
oathAccessTokenScript.run();
access_token = (String) tokenRuntime.binding(ACCESS_TOKEN);
} catch (IOException e) {
throw new UnRAVLException(e.getMessage(), e);
}
return cacheAccessToken(user, host, access_token);
}
private InputStream openScriptStream(String path) throws UnRAVLException {
if (path.startsWith("@")) {
String urlPath = path.substring(1);
try {
return new URL(urlPath).openStream();
} catch (IOException e) {
throw new UnRAVLException("Cannot access oath2 "
+ OAUTH_SCRIPT_KEY + " '" + path + "' as a URL", e);
}
}
// Not a @url; try to load relative to the classpath, or if that fails,
// as a file.
InputStream is = getClass().getResourceAsStream(path);
if (is != null)
return is;
else {
try {
File file = new File(path);
is = new FileInputStream(file);
return is;
} catch (IOException e) {
throw new UnRAVLException("Cannot open oath2 "
+ OAUTH_SCRIPT_KEY + " '" + path
+ "' as via classpath or as a file");
}
}
}
}