OAuth2.java
/*
* Copyright (c) 2014-present, salesforce.com, inc.
* All rights reserved.
* Redistribution and use of this software in source and binary forms, with or
* without modification, are permitted provided that the following conditions
* are met:
* - Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
* - Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
* - Neither the name of salesforce.com, inc. nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission of salesforce.com, inc.
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
* AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
* IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
* ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
* LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
* CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
* SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
* INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
* CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*/
package com.salesforce.androidsdk.auth;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import com.salesforce.androidsdk.app.SalesforceSDKManager;
import com.salesforce.androidsdk.rest.RestResponse;
import org.json.JSONObject;
import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import okhttp3.FormBody;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* Helper methods for common OAuth2 requests.
*
* The typical OAuth2 flow is;
*
* <ol>
* <li> The authorization flow is started by presenting the web-based
* authorization screen to the user. This will prompt him/her to login and to
* authorize our application. The result will be a callback with an
* authorization code, or an error.</li>
*
* <li> Use the authorization code from (a) with the token end point, to get
* access and refresh tokens, as well as other metadata.</li>
*
* <li> Use the access token from (b) to call the identity service, which will let
* us find out the user's username.</li>
*
* <li> Store the username, and refresh and access tokens somewhere safe (like the
* AccountManager).</li>
*
* <li> If the access token becomes invalid, use the refresh token to get another
* access token.</li>
*
* <li> If the refresh token becomes invalid, go back to the beginning.</li>
* </ol>
*
*/
public class OAuth2 {
// Misc constants: strings appearing in requests or responses
private static final String ACCESS_TOKEN = "access_token";
private static final String CLIENT_ID = "client_id";
private static final String CLIENT_SECRET = "client_secret";
private static final String ERROR = "error";
private static final String ERROR_DESCRIPTION = "error_description";
private static final String FORMAT = "format";
private static final String GRANT_TYPE = "grant_type";
private static final String ID = "id";
private static final String INSTANCE_URL = "instance_url";
private static final String JSON = "json";
private static final String MOBILE_POLICY = "mobile_policy";
private static final String PIN_LENGTH = "pin_length";
private static final String REFRESH_TOKEN = "refresh_token";
private static final String RESPONSE_TYPE = "response_type";
private static final String SCOPE = "scope";
private static final String REDIRECT_URI = "redirect_uri";
private static final String SCREEN_LOCK = "screen_lock";
private static final String TOKEN = "token";
private static final String USERNAME = "username";
private static final String EMAIL = "email";
private static final String FIRST_NAME = "first_name";
private static final String LAST_NAME = "last_name";
private static final String DISPLAY_NAME = "display_name";
private static final String PHOTOS = "photos";
private static final String PICTURE = "picture";
private static final String THUMBNAIL = "thumbnail";
private static final String CODE = "code";
private static final String ACTIVATED_CLIENT_CODE = "activated_client_code";
private static final String CUSTOM_ATTRIBUTES = "custom_attributes";
private static final String CUSTOM_PERMISSIONS = "custom_permissions";
private static final String SFDC_COMMUNITY_ID = "sfdc_community_id";
private static final String SFDC_COMMUNITY_URL = "sfdc_community_url";
private static final String AND = "&";
private static final String EQUAL = "=";
private static final String TOUCH = "touch";
private static final String FRONTDOOR = "/secur/frontdoor.jsp?";
private static final String SID = "sid";
private static final String RETURL = "retURL";
private static final String AUTHORIZATION_CODE = "authorization_code";
private static final String AUTHORIZATION = "Authorization";
private static final String BEARER = "Bearer ";
private static final String ASSERTION = "assertion";
private static final String JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer";
private static final String TAG = "OAuth2";
// Login paths
private static final String OAUTH_AUTH_PATH = "/services/oauth2/authorize?display=";
private static final String OAUTH_TOKEN_PATH = "/services/oauth2/token";
private static final String OAUTH_REVOKE_PATH = "/services/oauth2/revoke?token=";
public static final String EMPTY_STRING = "";
/**
* Build the URL to the authorization web page for this login server.
* You need not provide refresh_token, as it is provided automatically.
*
* @param loginServer
* the base protocol and server to use (e.g.
* https://login.salesforce.com)
* @param clientId
* OAuth client ID
* @param callbackUrl
* OAuth callback url
* @param scopes A list of OAuth scopes to request (eg {"visualforce","api"}). If null, the default OAuth scope is provided.
* @return A URL to start the OAuth flow in a web browser/view.
*
* @see <a href="https://help.salesforce.com/apex/HTViewHelpDoc?language=en&id=remoteaccess_oauth_scopes.htm">RemoteAccess OAuth Scopes</a>
*
*/
public static URI getAuthorizationUrl(URI loginServer, String clientId,
String callbackUrl, String[] scopes) {
return getAuthorizationUrl(loginServer, clientId, callbackUrl, scopes, null, null);
}
/**
* Build the URL to the authorization web page for this login server.
* @param loginServer
* @param clientId
* @param callbackUrl
* @param scopes
* @param clientSecret
* @return
*/
public static URI getAuthorizationUrl(URI loginServer, String clientId,
String callbackUrl, String[] scopes, String clientSecret) {
return getAuthorizationUrl(loginServer, clientId, callbackUrl, scopes, clientSecret, null);
}
/**
* Build the URL to the authorization web page for this login server.
* @param loginServer
* @param clientId
* @param callbackUrl
* @param scopes
* @param clientSecret
* @param displayType
* @return
*/
public static URI getAuthorizationUrl(URI loginServer, String clientId,
String callbackUrl, String[] scopes, String clientSecret, String displayType) {
return getAuthorizationUrl(loginServer, clientId,
callbackUrl, scopes, clientSecret, displayType,null);
}
/**
* Build the URL to the authorization web page for this login server.
* @param loginServer
* @param clientId
* @param callbackUrl
* @param scopes
* @param clientSecret
* @param displayType
* @param addlParams
* @return
*/
public static URI getAuthorizationUrl(URI loginServer, String clientId,
String callbackUrl, String[] scopes, String clientSecret, String displayType,
Map<String,String> addlParams) {
final StringBuilder sb = new StringBuilder(loginServer.toString());
sb.append(OAUTH_AUTH_PATH).append(displayType == null ? TOUCH : displayType);
sb.append(AND).append(RESPONSE_TYPE).append(EQUAL).append(clientSecret == null ? TOKEN : ACTIVATED_CLIENT_CODE);
sb.append(AND).append(CLIENT_ID).append(EQUAL).append(Uri.encode(clientId));
if (scopes != null && scopes.length > 0)
sb.append(AND).append(SCOPE).append(EQUAL).append(Uri.encode(computeScopeParameter(scopes)));
sb.append(AND).append(REDIRECT_URI).append(EQUAL).append(callbackUrl);
if(addlParams !=null && addlParams.size() > 0) {
for(Map.Entry<String,String> entry : addlParams.entrySet()) {
String value = entry.getValue()==null?EMPTY_STRING :entry.getValue();
sb.append(AND).append(entry.getKey()).append(EQUAL).append(Uri.encode(value));
}
}
return URI.create(sb.toString());
}
/**
* Build the URL to the authorization web page for this login server.
* @param loginServer
* @param clientId
* @param callbackUrl
* @param scopes
* @param clientSecret
* @param displayType
* @param accessToken
* @param instanceURL
* @return
*/
public static URI getAuthorizationUrl(URI loginServer, String clientId,
String callbackUrl, String[] scopes, String clientSecret,
String displayType, String accessToken, String instanceURL) {
return getAuthorizationUrl(loginServer, clientId, callbackUrl, scopes, clientSecret,
displayType, accessToken, instanceURL,null);
}
/**
* Build the URL to the authorization web page for this login server.
* @param loginServer
* @param clientId
* @param callbackUrl
* @param scopes
* @param clientSecret
* @param displayType
* @param accessToken
* @param instanceURL
* @param addlParams
* @return
*/
public static URI getAuthorizationUrl(URI loginServer, String clientId,
String callbackUrl, String[] scopes, String clientSecret,
String displayType, String accessToken, String instanceURL,
Map<String,String> addlParams) {
if(accessToken == null || instanceURL == null) {
return getAuthorizationUrl(loginServer, clientId, callbackUrl, scopes, clientSecret, displayType, addlParams);
}
final StringBuilder sb = new StringBuilder(instanceURL);
sb.append(FRONTDOOR);
sb.append(SID).append(EQUAL).append(accessToken);
sb.append(AND).append(RETURL).append(EQUAL).append(Uri.encode(getAuthorizationUrl(loginServer,clientId,callbackUrl,
scopes, clientSecret, displayType).toString()));
if(addlParams != null && addlParams.size() > 0) {
for(Map.Entry<String,String> entry : addlParams.entrySet()) {
String value = entry.getValue()==null? EMPTY_STRING :entry.getValue();
sb.append(AND).append(entry.getKey()).append(EQUAL).append(Uri.encode(value));
}
}
return URI.create(sb.toString());
}
private static String computeScopeParameter(String[] scopes) {
final List<String> scopesList = Arrays.asList(scopes == null ? new String[]{} : scopes);
Set<String> scopesSet = new TreeSet<String>(scopesList); // sorted set to make tests easier
scopesSet.add(REFRESH_TOKEN);
return TextUtils.join(" ", scopesSet.toArray(new String[]{}));
}
/**
* Get a new auth token using the refresh token.
*
* @param httpAccessor
* @param loginServer
* @param clientId
* @param refreshToken
* @return
* @throws OAuthFailedException
* @throws IOException
*/
public static TokenEndpointResponse refreshAuthToken(
HttpAccess httpAccessor, URI loginServer, String clientId,
String refreshToken) throws OAuthFailedException, IOException {
return refreshAuthToken(httpAccessor, loginServer, clientId, refreshToken, null);
}
/**
* Get a new auth token using the refresh token.
*
* @param httpAccessor
* @param loginServer
* @param clientId
* @param refreshToken
* @param clientSecret
* @return
* @throws OAuthFailedException
* @throws IOException
*/
public static TokenEndpointResponse refreshAuthToken(
HttpAccess httpAccessor, URI loginServer, String clientId,
String refreshToken, String clientSecret) throws OAuthFailedException, IOException {
return refreshAuthToken(httpAccessor, loginServer, clientId, refreshToken, clientSecret,null);
}
/**
* Get a new auth token using the refresh token.
*
* @param httpAccessor
* @param loginServer
* @param clientId
* @param refreshToken
* @param clientSecret
* @param addlParams
* @return
* @throws OAuthFailedException
* @throws IOException
*/
public static TokenEndpointResponse refreshAuthToken(
HttpAccess httpAccessor, URI loginServer, String clientId,
String refreshToken, String clientSecret,Map<String,String> addlParams) throws OAuthFailedException, IOException {
FormBody.Builder formBodyBuilder = makeTokenEndpointParams(REFRESH_TOKEN,
clientId, clientSecret, addlParams);
formBodyBuilder.add(REFRESH_TOKEN, refreshToken);
formBodyBuilder.add(FORMAT, JSON);
TokenEndpointResponse tr = makeTokenEndpointRequest(httpAccessor, loginServer, formBodyBuilder);
return tr;
}
/**
* Revokes the existing refresh token.
*
* @param httpAccessor
* @param loginServer
* @param refreshToken
* @throws OAuthFailedException
* @throws IOException
*/
public static void revokeRefreshToken(HttpAccess httpAccessor, URI loginServer, String refreshToken) {
final StringBuilder sb = new StringBuilder(loginServer.toString());
sb.append(OAUTH_REVOKE_PATH);
sb.append(Uri.encode(refreshToken));
Request request = new Request.Builder()
.url(sb.toString())
.get()
.build();
try {
httpAccessor.getOkHttpClient().newCall(request).execute();
} catch (IOException e) {
Log.w(TAG, e);
}
}
/** @returns a TokenEndointResponse from the give authorization code, this is typically the first step after
* receiving an authorization code from the oAuth authorization UI flow.
* In addition, this will also call the Identity service to fetch & populate the username field.
*
* @param loginServerUrl the protocol & host (e.g. https://login.salesforce.com) that the authCode was generated from
* @param clientSecret the client secret if there is one (e.g. for IP/IC bypass)
* @param authCode the authorization code issued by the oauth authorization flow.
*
* @throws IOException
* @throws URISyntaxException
* @throws OAuthFailedException
*
*/
public static TokenEndpointResponse swapAuthCodeForTokens(HttpAccess httpAccessor, URI loginServerUrl, String clientSecret,
String authCode, String clientId, String callbackUrl) throws IOException, URISyntaxException, OAuthFailedException {
// call the token endpoint, and swap our authorization code for a refresh & access tokens.
FormBody.Builder formBodyBuilder = makeTokenEndpointParams(AUTHORIZATION_CODE, clientId, clientSecret);
formBodyBuilder.add(REDIRECT_URI, callbackUrl);
TokenEndpointResponse tr = makeTokenEndpointRequest(httpAccessor, loginServerUrl, formBodyBuilder);
return tr;
}
/** @returns a TokenEndointResponse from the give JWT, this is typically the first step after
* receiving a JWT from email link.
* In addition, this will also call the Identity service to fetch & populate the username field.
*
* @param loginServerUrl the protocol & host (e.g. https://login.salesforce.com) that the authCode was generated from
* @param jwt the jwt issued by the oauth authorization flow.
*
* @throws IOException
* @throws URISyntaxException
* @throws OAuthFailedException
*
*/
public static TokenEndpointResponse swapJWTForTokens(HttpAccess httpAccessor, URI loginServerUrl,
String jwt) throws IOException, URISyntaxException, OAuthFailedException {
// call the token endpoint, and swap jwt for an access tokens.
FormBody.Builder formBodyBuilder = new FormBody.Builder()
.add(GRANT_TYPE, JWT_BEARER)
.add(ASSERTION, jwt);
return makeTokenEndpointRequest(httpAccessor, loginServerUrl, formBodyBuilder);
}
/**
* Call the identity service to determine the username of the user and the mobile policy, given
* their identity service ID and an access token.
*
* @param httpAccessor
* @param identityServiceIdUrl
* @param authToken
* @return
* @throws IOException
* @throws URISyntaxException
*/
public static final IdServiceResponse callIdentityService(
HttpAccess httpAccessor, String identityServiceIdUrl,
String authToken) throws IOException, URISyntaxException {
Request.Builder builder = new Request.Builder()
.url(identityServiceIdUrl)
.get();
addAuthorizationHeader(builder, authToken);
Request request = builder.build();
Response response = httpAccessor.getOkHttpClient().newCall(request).execute();
return new IdServiceResponse(response);
}
/**
* Add authorization header to request builder
* @param builder
* @param authToken
*/
public static final Request.Builder addAuthorizationHeader(Request.Builder builder, String authToken) {
return builder.header(AUTHORIZATION, BEARER + authToken);
}
/**
* @param httpAccessor
* @param loginServer
* @param formBodyBuilder
* @return
* @throws OAuthFailedException
* @throws IOException
*/
private static TokenEndpointResponse makeTokenEndpointRequest(
HttpAccess httpAccessor, URI loginServer, FormBody.Builder formBodyBuilder)
throws OAuthFailedException, IOException {
final String refreshPath = loginServer.toString() + OAUTH_TOKEN_PATH;
final RequestBody body = formBodyBuilder.build();
Request request = new Request.Builder()
.url(refreshPath)
.post(body)
.build();
Response response = httpAccessor.getOkHttpClient().newCall(request).execute();
if (response.isSuccessful()) {
return new TokenEndpointResponse(response);
} else {
throw new OAuthFailedException(new TokenErrorResponse(response), response.code());
}
}
/**
* @param grantType
* @param clientId
* @return
*/
private static FormBody.Builder makeTokenEndpointParams(
String grantType, String clientId, String clientSecret) {
return makeTokenEndpointParams(grantType, clientId, clientSecret, null);
}
/**
*
* @param grantType
* @param clientId
* @param clientSecret
* @param addlParams
* @return
*/
private static FormBody.Builder makeTokenEndpointParams(
String grantType, String clientId, String clientSecret, Map<String,String> addlParams) {
FormBody.Builder builder = new FormBody.Builder()
.add(GRANT_TYPE, grantType)
.add(CLIENT_ID, clientId);
if (clientSecret != null) {
builder.add(CLIENT_SECRET, clientSecret);
}
if(addlParams != null ) {
for(Map.Entry<String,String> entry : addlParams.entrySet()) {
builder.add(entry.getKey(),entry.getValue());
}
}
return builder;
}
/**
* Exception thrown when refresh fails.
*/
public static class OAuthFailedException extends Exception {
OAuthFailedException(TokenErrorResponse err, int httpStatusCode) {
super(err.toString());
this.response = err;
this.httpStatusCode = httpStatusCode;
}
final TokenErrorResponse response;
final int httpStatusCode;
boolean isRefreshTokenInvalid() {
return httpStatusCode == HttpURLConnection.HTTP_UNAUTHORIZED
|| httpStatusCode == HttpURLConnection.HTTP_FORBIDDEN
|| httpStatusCode == HttpURLConnection.HTTP_BAD_REQUEST;
}
/**
* Returns token error response.
*
* @return Token error response.
*/
public TokenErrorResponse getTokenErrorResponse() {
return response;
}
/**
* Returns HTTP status code.
*
* @return HTTP status code.
*/
public int getHttpStatusCode() {
return httpStatusCode;
}
private static final long serialVersionUID = 1L;
}
/**************************************************************************************************
*
* Helper classes to parse responses
*
**************************************************************************************************/
/**
* Helper class to parse an identity service response.
*/
public static class IdServiceResponse {
public String username;
public String email;
public String firstName;
public String lastName;
public String displayName;
public String pictureUrl;
public String thumbnailUrl;
public int pinLength = -1;
public int screenLockTimeout = -1;
public JSONObject customAttributes;
public JSONObject customPermissions;
public IdServiceResponse(Response response) {
try {
final JSONObject parsedResponse = (new RestResponse(response)).asJSONObject();
username = parsedResponse.getString(USERNAME);
email = parsedResponse.getString(EMAIL);
firstName = parsedResponse.getString(FIRST_NAME);
lastName = parsedResponse.getString(LAST_NAME);
displayName = parsedResponse.getString(DISPLAY_NAME);
final JSONObject photos = parsedResponse.getJSONObject(PHOTOS);
if (photos != null) {
pictureUrl = photos.getString(PICTURE);
thumbnailUrl = photos.getString(THUMBNAIL);
}
customAttributes = parsedResponse.optJSONObject(CUSTOM_ATTRIBUTES);
customPermissions = parsedResponse.optJSONObject(CUSTOM_PERMISSIONS);
if (parsedResponse.has(MOBILE_POLICY)) {
pinLength = parsedResponse.getJSONObject(MOBILE_POLICY).getInt(PIN_LENGTH);
screenLockTimeout = parsedResponse.getJSONObject(MOBILE_POLICY).getInt(SCREEN_LOCK);
}
} catch (Exception e) {
Log.w(TAG, e);
}
}
}
/**
* Helper class to parse a token refresh error response.
*/
public static class TokenErrorResponse {
public String error;
public String errorDescription;
public TokenErrorResponse(Response response) {
try {
final JSONObject parsedResponse = (new RestResponse(response)).asJSONObject();
error = parsedResponse.getString(ERROR);
errorDescription = parsedResponse
.getString(ERROR_DESCRIPTION);
} catch (Exception e) {
Log.w(TAG, e);
}
}
@Override
public String toString() {
return error + ":" + errorDescription;
}
}
/**
* Helper class to parse a token refresh response.
*/
public static class TokenEndpointResponse {
public String authToken;
public String refreshToken;
public String instanceUrl;
public String idUrl;
public String idUrlWithInstance;
public String orgId;
public String userId;
public String code;
public String communityId;
public String communityUrl;
public Map<String, String> additionalOauthValues;
/**
* Constructor used during login flow
* @param callbackUrlParams
*/
public TokenEndpointResponse(Map<String, String> callbackUrlParams) {
try {
authToken = callbackUrlParams.get(ACCESS_TOKEN);
refreshToken = callbackUrlParams.get(REFRESH_TOKEN);
instanceUrl = callbackUrlParams.get(INSTANCE_URL);
idUrl = callbackUrlParams.get(ID);
code = callbackUrlParams.get(CODE);
computeOtherFields();
communityId = callbackUrlParams.get(SFDC_COMMUNITY_ID);
communityUrl = callbackUrlParams.get(SFDC_COMMUNITY_URL);
final SalesforceSDKManager sdkManager = SalesforceSDKManager.getInstance();
if (sdkManager != null) {
final List<String> additionalOauthKeys = sdkManager.getAdditionalOauthKeys();
if (additionalOauthKeys != null && !additionalOauthKeys.isEmpty()) {
additionalOauthValues = new HashMap<>();
for (final String key : additionalOauthKeys) {
if (!TextUtils.isEmpty(key)) {
additionalOauthValues.put(key, callbackUrlParams.get(key));
}
}
}
}
} catch (Exception e) {
Log.w(TAG, e);
}
}
/**
* Constructor used during refresh flow
* @param response
*/
public TokenEndpointResponse(Response response) {
try {
final JSONObject parsedResponse = (new RestResponse(response)).asJSONObject();
authToken = parsedResponse.getString(ACCESS_TOKEN);
instanceUrl = parsedResponse.getString(INSTANCE_URL);
idUrl = parsedResponse.getString(ID);
computeOtherFields();
if (parsedResponse.has(REFRESH_TOKEN)) {
refreshToken = parsedResponse.getString(REFRESH_TOKEN);
}
if (parsedResponse.has(SFDC_COMMUNITY_ID)) {
communityId = parsedResponse.getString(SFDC_COMMUNITY_ID);
}
if (parsedResponse.has(SFDC_COMMUNITY_URL)) {
communityUrl = parsedResponse.getString(SFDC_COMMUNITY_URL);
}
final SalesforceSDKManager sdkManager = SalesforceSDKManager.getInstance();
if (sdkManager != null) {
final List<String> additionalOauthKeys = sdkManager.getAdditionalOauthKeys();
if (additionalOauthKeys != null && !additionalOauthKeys.isEmpty()) {
additionalOauthValues = new HashMap<>();
for (final String key : additionalOauthKeys) {
if (!TextUtils.isEmpty(key)) {
final String value = parsedResponse.optString(key, null);
if (value != null) {
additionalOauthValues.put(key, value);
}
}
}
}
}
} catch (Exception e) {
Log.w(TAG, e);
}
}
private void computeOtherFields() throws URISyntaxException {
idUrlWithInstance = idUrl.replace(new URI(idUrl).getHost(), new URI(instanceUrl).getHost());
String[] idUrlFragments = idUrl.split("/");
userId = idUrlFragments[idUrlFragments.length - 1];
orgId = idUrlFragments[idUrlFragments.length - 2];
}
}
}