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.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