1 // Copyright 2014 The Chromium Authors. All rights reserved. 2 // Use of this source code is governed by a BSD-style license that can be 3 // found in the LICENSE file. 4 5 package org.chromium.chromoting; 6 7 import android.app.Activity; 8 import android.content.ActivityNotFoundException; 9 import android.content.ComponentName; 10 import android.content.Intent; 11 import android.content.pm.PackageManager; 12 import android.net.Uri; 13 import android.text.TextUtils; 14 import android.util.Base64; 15 import android.util.Log; 16 17 import java.security.SecureRandom; 18 import java.util.ArrayList; 19 20 /** 21 * This class is responsible for fetching a third party token from the user using the OAuth2 22 * implicit flow. It directs the user to a third party login page located at |tokenUrl|. It relies 23 * on the |ThirdPartyTokenFetcher$OAuthRedirectActivity| to intercept the access token from the 24 * redirect at intent://|REDIRECT_URI_PATH|#Intent;...end; upon successful login. 25 */ 26 public class ThirdPartyTokenFetcher { 27 /** Callback for receiving the token. */ 28 public interface Callback { 29 void onTokenFetched(String code, String accessToken); 30 } 31 32 /** The path of the Redirect URI. */ 33 private static final String REDIRECT_URI_PATH = "/oauthredirect/"; 34 35 /** 36 * Request both the authorization code and access token from the server. See 37 * http://tools.ietf.org/html/rfc6749#section-3.1.1. 38 */ 39 private static final String RESPONSE_TYPE = "code token"; 40 41 /** This is used to securely generate an opaque 128 bit for the |mState| variable. */ 42 private static SecureRandom sSecureRandom = new SecureRandom(); 43 44 /** This is used to launch the third party login page in the browser. */ 45 private Activity mContext; 46 47 /** 48 * An opaque value used by the client to maintain state between the request and callback. The 49 * authorization server includes this value when redirecting the user-agent back to the client. 50 * The parameter is used for preventing cross-site request forgery. See 51 * http://tools.ietf.org/html/rfc6749#section-10.12. 52 */ 53 private final String mState; 54 55 private final Callback mCallback; 56 57 /** The list of TokenUrls allowed by the domain. */ 58 private final ArrayList<String> mTokenUrlPatterns; 59 60 private final String mRedirectUriScheme; 61 62 private final String mRedirectUri; 63 64 public ThirdPartyTokenFetcher(Activity context, 65 ArrayList<String> tokenUrlPatterns, 66 Callback callback) { 67 this.mContext = context; 68 this.mState = generateXsrfToken(); 69 this.mCallback = callback; 70 this.mTokenUrlPatterns = tokenUrlPatterns; 71 72 this.mRedirectUriScheme = context.getApplicationContext().getPackageName(); 73 74 // We don't follow the OAuth spec (http://tools.ietf.org/html/rfc6749#section-3.1.2) of the 75 // redirect URI as it is possible for the other applications to intercept the redirect URI. 76 // Instead, we use the intent scheme URI, which can restrict a specific package to handle 77 // the intent. See https://developer.chrome.com/multidevice/android/intents. 78 this.mRedirectUri = "intent://" + REDIRECT_URI_PATH + "#Intent;" + 79 "package=" + mRedirectUriScheme + ";" + 80 "scheme=" + mRedirectUriScheme + ";end;"; 81 } 82 83 /** 84 * @param tokenUrl URL of the third party login page. 85 * @param clientId The client identifier. See http://tools.ietf.org/html/rfc6749#section-2.2. 86 * @param scope The scope of access request. See http://tools.ietf.org/html/rfc6749#section-3.3. 87 */ 88 public void fetchToken(String tokenUrl, String clientId, String scope) { 89 if (!isValidTokenUrl(tokenUrl)) { 90 failFetchToken( 91 "Token URL does not match the domain\'s allowed URL patterns." + 92 " URL: " + tokenUrl + 93 ", patterns: " + TextUtils.join(",", this.mTokenUrlPatterns)); 94 return; 95 } 96 97 Uri uri = buildRequestUri(tokenUrl, clientId, scope); 98 Intent intent = new Intent(Intent.ACTION_VIEW, uri); 99 Log.i("ThirdPartyAuth", "fetchToken() url:" + uri); 100 OAuthRedirectActivity.setEnabled(mContext, true); 101 102 try { 103 mContext.startActivity(intent); 104 } catch (ActivityNotFoundException e) { 105 failFetchToken("No browser is installed to open the third party authentication page."); 106 } 107 } 108 109 private Uri buildRequestUri(String tokenUrl, String clientId, String scope) { 110 Uri.Builder uriBuilder = Uri.parse(tokenUrl).buildUpon(); 111 uriBuilder.appendQueryParameter("redirect_uri", this.mRedirectUri); 112 uriBuilder.appendQueryParameter("scope", scope); 113 uriBuilder.appendQueryParameter("client_id", clientId); 114 uriBuilder.appendQueryParameter("state", mState); 115 uriBuilder.appendQueryParameter("response_type", RESPONSE_TYPE); 116 117 return uriBuilder.build(); 118 } 119 120 /** Verifies the host-supplied URL matches the domain's allowed URL patterns. */ 121 private boolean isValidTokenUrl(String tokenUrl) { 122 for (String pattern : mTokenUrlPatterns) { 123 if (tokenUrl.matches(pattern)) { 124 return true; 125 } 126 } 127 return false; 128 } 129 130 private boolean isValidIntent(Intent intent) { 131 assert intent != null; 132 133 String action = intent.getAction(); 134 135 Uri data = intent.getData(); 136 if (data != null) { 137 return Intent.ACTION_VIEW.equals(action) && 138 this.mRedirectUriScheme.equals(data.getScheme()) && 139 REDIRECT_URI_PATH.equals(data.getPath()); 140 } 141 return false; 142 } 143 144 public boolean handleTokenFetched(Intent intent) { 145 assert intent != null; 146 147 if (!isValidIntent(intent)) { 148 Log.w("ThirdPartyAuth", "Ignoring unmatched intent."); 149 return false; 150 } 151 152 String accessToken = intent.getStringExtra("access_token"); 153 String code = intent.getStringExtra("code"); 154 String state = intent.getStringExtra("state"); 155 156 if (!mState.equals(state)) { 157 failFetchToken("Ignoring redirect with invalid state."); 158 return false; 159 } 160 161 if (code == null || accessToken == null) { 162 failFetchToken("Ignoring redirect with missing code or token."); 163 return false; 164 } 165 166 Log.i("ThirdPartyAuth", "handleTokenFetched()."); 167 mCallback.onTokenFetched(code, accessToken); 168 OAuthRedirectActivity.setEnabled(mContext, false); 169 return true; 170 } 171 172 private void failFetchToken(String errorMessage) { 173 Log.e("ThirdPartyAuth", errorMessage); 174 mCallback.onTokenFetched("", ""); 175 OAuthRedirectActivity.setEnabled(mContext, false); 176 } 177 178 /** Generate a 128 bit URL-safe opaque string to prevent cross site request forgery (XSRF).*/ 179 private static String generateXsrfToken() { 180 byte[] bytes = new byte[16]; 181 sSecureRandom.nextBytes(bytes); 182 // Uses a variant of Base64 to make sure the URL is URL safe: 183 // URL_SAFE replaces - with _ and + with /. 184 // NO_WRAP removes the trailing newline character. 185 // NO_PADDING removes any trailing =. 186 return Base64.encodeToString(bytes, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); 187 } 188 189 /** 190 * In the OAuth2 implicit flow, the browser will be redirected to 191 * intent://|REDIRECT_URI_PATH|#Intent;...end; upon a successful login. OAuthRedirectActivity 192 * uses an intent filter in the manifest to intercept the URL and launch the chromoting app. 193 * 194 * Unfortunately, most browsers on Android, e.g. chrome, reload the URL when a browser 195 * tab is activated. As a result, chromoting is launched unintentionally when the user restarts 196 * chrome or closes other tabs that causes the redirect URL to become the topmost tab. 197 * 198 * To solve the problem, the redirect intent-filter is declared in a separate activity, 199 * |OAuthRedirectActivity| instead of the MainActivity. In this way, we can disable it, 200 * together with its intent filter, by default. |OAuthRedirectActivity| is only enabled when 201 * there is a pending token fetch request. 202 */ 203 public static class OAuthRedirectActivity extends Activity { 204 @Override 205 public void onStart() { 206 super.onStart(); 207 // |OAuthRedirectActivity| runs in its own task, it needs to route the intent back 208 // to Chromoting.java to access the state of the current request. 209 Intent intent = getIntent(); 210 intent.setClass(this, Chromoting.class); 211 startActivity(intent); 212 finishActivity(0); 213 } 214 215 public static void setEnabled(Activity context, boolean enabled) { 216 int enabledState = enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED 217 : PackageManager.COMPONENT_ENABLED_STATE_DEFAULT; 218 ComponentName component = new ComponentName( 219 context.getApplicationContext(), 220 ThirdPartyTokenFetcher.OAuthRedirectActivity.class); 221 context.getPackageManager().setComponentEnabledSetting( 222 component, 223 enabledState, 224 PackageManager.DONT_KILL_APP); 225 } 226 } 227 } 228