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