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