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
5package org.chromium.chromoting;
6
7import android.annotation.SuppressLint;
8import android.app.Activity;
9import android.content.ActivityNotFoundException;
10import android.content.ComponentName;
11import android.content.Intent;
12import android.content.pm.PackageManager;
13import android.net.Uri;
14import android.text.TextUtils;
15import android.util.Base64;
16import android.util.Log;
17
18import java.io.IOException;
19import java.security.SecureRandom;
20import 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 */
28public 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