summaryrefslogtreecommitdiffstats
path: root/remoting/android/java/src/org/chromium/chromoting/ThirdPartyTokenFetcher.java
blob: 67e4551c71dc4aa0f3c38a5c914f9c8958eefb5e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
// Copyright 2014 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chromoting;

import android.annotation.SuppressLint;
import android.app.Activity;
import android.content.ActivityNotFoundException;
import android.content.ComponentName;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Base64;

import org.chromium.base.Log;
import org.chromium.base.SecureRandomInitializer;

import java.io.IOException;
import java.security.SecureRandom;
import java.util.ArrayList;

/**
 * This class is responsible for fetching a third party token from the user using the OAuth2
 * implicit flow.  It directs the user to a third party login page located at |tokenUrl|.  It relies
 * on the |ThirdPartyTokenFetcher$OAuthRedirectActivity| to intercept the access token from the
 * redirect at intent://|REDIRECT_URI_PATH|#Intent;...end; upon successful login.
 */
public class ThirdPartyTokenFetcher {
    /** Callback for receiving the token. */
    public interface Callback {
        void onTokenFetched(String code, String accessToken);
    }

    private static final String TAG = "Chromoting";

    /** The path of the Redirect URI. */
    private static final String REDIRECT_URI_PATH = "/oauthredirect/";

    /**
     * Request both the authorization code and access token from the server.  See
     * http://tools.ietf.org/html/rfc6749#section-3.1.1.
     */
    private static final String RESPONSE_TYPE = "code token";

    /** This is used to securely generate an opaque 128 bit for the |mState| variable. */
    @SuppressLint("TrulyRandom")
    private static SecureRandom sSecureRandom;

    // TODO(lambroslambrou): Refactor this class to only initialize a PRNG when ThirdPartyAuth is
    // actually used.
    static {
        sSecureRandom = new SecureRandom();
        try {
            SecureRandomInitializer.initialize(sSecureRandom);
        } catch (IOException e) {
            throw new RuntimeException("Failed to initialize PRNG: " + e);
        }
    }

    /** This is used to launch the third party login page in the browser. */
    private Activity mContext;

    /**
     * An opaque value used by the client to maintain state between the request and callback.  The
     * authorization server includes this value when redirecting the user-agent back to the client.
     * The parameter is used for preventing cross-site request forgery. See
     * http://tools.ietf.org/html/rfc6749#section-10.12.
     */
    private final String mState;

    private final Callback mCallback;

    /** The list of TokenUrls allowed by the domain. */
    private final ArrayList<String> mTokenUrlPatterns;

    private final String mRedirectUriScheme;

    private final String mRedirectUri;

    public ThirdPartyTokenFetcher(Activity context,
                                  ArrayList<String> tokenUrlPatterns,
                                  Callback callback) {
        this.mContext = context;
        this.mState = generateXsrfToken();
        this.mCallback = callback;
        this.mTokenUrlPatterns = tokenUrlPatterns;

        this.mRedirectUriScheme = context.getApplicationContext().getPackageName();

        // We don't follow the OAuth spec (http://tools.ietf.org/html/rfc6749#section-3.1.2) of the
        // redirect URI as it is possible for the other applications to intercept the redirect URI.
        // Instead, we use the intent scheme URI, which can restrict a specific package to handle
        // the intent.  See https://developer.chrome.com/multidevice/android/intents.
        this.mRedirectUri = "intent://" + REDIRECT_URI_PATH + "#Intent;"
                + "package=" + mRedirectUriScheme + ";"
                + "scheme=" + mRedirectUriScheme + ";end;";
    }

    /**
     * @param tokenUrl URL of the third party login page.
     * @param clientId The client identifier. See http://tools.ietf.org/html/rfc6749#section-2.2.
     * @param scope The scope of access request. See http://tools.ietf.org/html/rfc6749#section-3.3.
     */
    public void fetchToken(String tokenUrl, String clientId, String scope) {
        if (!isValidTokenUrl(tokenUrl)) {
            failFetchToken("Token URL does not match the domain\'s allowed URL patterns."
                    + " URL: " + tokenUrl
                    + ", patterns: " + TextUtils.join(",", this.mTokenUrlPatterns));
            return;
        }

        Uri uri = buildRequestUri(tokenUrl, clientId, scope);
        Intent intent = new Intent(Intent.ACTION_VIEW, uri);
        OAuthRedirectActivity.setEnabled(mContext, true);

        try {
            mContext.startActivity(intent);
        } catch (ActivityNotFoundException e) {
            failFetchToken("No browser is installed to open the third party authentication page.");
        }
    }

    private Uri buildRequestUri(String tokenUrl, String clientId, String scope) {
        Uri.Builder uriBuilder = Uri.parse(tokenUrl).buildUpon();
        uriBuilder.appendQueryParameter("redirect_uri", this.mRedirectUri);
        uriBuilder.appendQueryParameter("scope", scope);
        uriBuilder.appendQueryParameter("client_id", clientId);
        uriBuilder.appendQueryParameter("state", mState);
        uriBuilder.appendQueryParameter("response_type", RESPONSE_TYPE);

        return uriBuilder.build();
    }

    /** Verifies the host-supplied URL matches the domain's allowed URL patterns. */
    private boolean isValidTokenUrl(String tokenUrl) {
        for (String pattern : mTokenUrlPatterns) {
            if (tokenUrl.matches(pattern)) {
                return true;
            }
        }
        return false;
    }

    private boolean isValidIntent(Intent intent) {
        assert intent != null;

        String action = intent.getAction();

        Uri data = intent.getData();
        if (data != null) {
            return Intent.ACTION_VIEW.equals(action)
                    && this.mRedirectUriScheme.equals(data.getScheme())
                    && REDIRECT_URI_PATH.equals(data.getPath());
        }
        return false;
    }

    public boolean handleTokenFetched(Intent intent) {
        assert intent != null;

        if (!isValidIntent(intent)) {
            Log.w(TAG, "Ignoring unmatched intent.");
            return false;
        }

        String accessToken = intent.getStringExtra("access_token");
        String code = intent.getStringExtra("code");
        String state = intent.getStringExtra("state");

        if (!mState.equals(state)) {
            failFetchToken("Ignoring redirect with invalid state.");
            return false;
        }

        if (code == null || accessToken == null) {
            failFetchToken("Ignoring redirect with missing code or token.");
            return false;
        }

        mCallback.onTokenFetched(code, accessToken);
        OAuthRedirectActivity.setEnabled(mContext, false);
        return true;
    }

    private void failFetchToken(String errorMessage) {
        Log.e(TAG, "failFetchToken(): %s", errorMessage);
        mCallback.onTokenFetched("", "");
        OAuthRedirectActivity.setEnabled(mContext, false);
    }

    /** Generate a 128 bit URL-safe opaque string to prevent cross site request forgery (XSRF).*/
    private static String generateXsrfToken() {
        byte[] bytes = new byte[16];
        sSecureRandom.nextBytes(bytes);
        // Uses a variant of Base64 to make sure the URL is URL safe:
        // URL_SAFE replaces - with _ and + with /.
        // NO_WRAP removes the trailing newline character.
        // NO_PADDING removes any trailing =.
        return Base64.encodeToString(bytes, Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING);
    }

    /**
     * In the OAuth2 implicit flow, the browser will be redirected to
     * intent://|REDIRECT_URI_PATH|#Intent;...end; upon a successful login. OAuthRedirectActivity
     * uses an intent filter in the manifest to intercept the URL and launch the chromoting app.
     *
     * Unfortunately, most browsers on Android, e.g. chrome, reload the URL when a browser
     * tab is activated.  As a result, chromoting is launched unintentionally when the user restarts
     * chrome or closes other tabs that causes the redirect URL to become the topmost tab.
     *
     * To solve the problem, the redirect intent-filter is declared in a separate activity,
     * |OAuthRedirectActivity| instead of the MainActivity.  In this way, we can disable it,
     * together with its intent filter, by default. |OAuthRedirectActivity| is only enabled when
     * there is a pending token fetch request.
     */
    public static class OAuthRedirectActivity extends Activity {
        @Override
        public void onStart() {
            super.onStart();
            // |OAuthRedirectActivity| runs in its own task, it needs to route the intent back
            // to Chromoting.java to access the state of the current request.
            Intent intent = getIntent();
            intent.setClass(this, Chromoting.class);
            startActivity(intent);
            finishActivity(0);
        }

        public static void setEnabled(Activity context, boolean enabled) {
            int enabledState = enabled ? PackageManager.COMPONENT_ENABLED_STATE_ENABLED
                                       : PackageManager.COMPONENT_ENABLED_STATE_DEFAULT;
            ComponentName component = new ComponentName(
                    context.getApplicationContext(),
                    ThirdPartyTokenFetcher.OAuthRedirectActivity.class);
            context.getPackageManager().setComponentEnabledSetting(
                    component,
                    enabledState,
                    PackageManager.DONT_KILL_APP);
        }
    }
}