Home | History | Annotate | Download | only in chromoting
      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