OLD | NEW |
| (Empty) |
1 // Copyright (c) 2012 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 /** | |
6 * @fileoverview | |
7 * OAuth2 class that handles retrieval/storage of an OAuth2 token. | |
8 * | |
9 * Uses a content script to trampoline the OAuth redirect page back into the | |
10 * extension context. This works around the lack of native support for | |
11 * chrome-extensions in OAuth2. | |
12 */ | |
13 | |
14 // TODO(jamiewalch): Delete this code once Chromoting is a v2 app and uses the | |
15 // identity API (http://crbug.com/ 134213). | |
16 | |
17 'use strict'; | |
18 | |
19 /** @suppress {duplicate} */ | |
20 var remoting = remoting || {}; | |
21 | |
22 /** @type {remoting.OAuth2} */ | |
23 remoting.oauth2 = null; | |
24 | |
25 | |
26 /** | |
27 * @constructor | |
28 * @extends {remoting.Identity} | |
29 */ | |
30 remoting.OAuth2 = function() { | |
31 }; | |
32 | |
33 // Constants representing keys used for storing persistent state. | |
34 /** @private */ | |
35 remoting.OAuth2.prototype.KEY_REFRESH_TOKEN_ = 'oauth2-refresh-token'; | |
36 /** @private */ | |
37 remoting.OAuth2.prototype.KEY_ACCESS_TOKEN_ = 'oauth2-access-token'; | |
38 /** @private */ | |
39 remoting.OAuth2.prototype.KEY_EMAIL_ = 'remoting-email'; | |
40 /** @private */ | |
41 remoting.OAuth2.prototype.KEY_FULLNAME_ = 'remoting-fullname'; | |
42 | |
43 // Constants for parameters used in retrieving the OAuth2 credentials. | |
44 /** @private */ | |
45 remoting.OAuth2.prototype.SCOPE_ = | |
46 'https://www.googleapis.com/auth/chromoting ' + | |
47 'https://www.googleapis.com/auth/googletalk ' + | |
48 'https://www.googleapis.com/auth/userinfo#email'; | |
49 | |
50 // Configurable URLs/strings. | |
51 /** @private | |
52 * @return {string} OAuth2 redirect URI. | |
53 */ | |
54 remoting.OAuth2.prototype.getRedirectUri_ = function() { | |
55 return remoting.settings.OAUTH2_REDIRECT_URL(); | |
56 }; | |
57 | |
58 /** @private | |
59 * @return {string} API client ID. | |
60 */ | |
61 remoting.OAuth2.prototype.getClientId_ = function() { | |
62 return remoting.settings.OAUTH2_CLIENT_ID; | |
63 }; | |
64 | |
65 /** @private | |
66 * @return {string} API client secret. | |
67 */ | |
68 remoting.OAuth2.prototype.getClientSecret_ = function() { | |
69 return remoting.settings.OAUTH2_CLIENT_SECRET; | |
70 }; | |
71 | |
72 /** @private | |
73 * @return {string} OAuth2 authentication URL. | |
74 */ | |
75 remoting.OAuth2.prototype.getOAuth2AuthEndpoint_ = function() { | |
76 return remoting.settings.OAUTH2_BASE_URL + '/auth'; | |
77 }; | |
78 | |
79 /** @return {boolean} True if the app is already authenticated. */ | |
80 remoting.OAuth2.prototype.isAuthenticated = function() { | |
81 if (this.getRefreshToken()) { | |
82 return true; | |
83 } | |
84 return false; | |
85 }; | |
86 | |
87 /** | |
88 * Remove the cached auth token, if any. | |
89 * | |
90 * @return {!Promise<null>} A promise resolved with the operation completes. | |
91 */ | |
92 remoting.OAuth2.prototype.removeCachedAuthToken = function() { | |
93 window.localStorage.removeItem(this.KEY_EMAIL_); | |
94 window.localStorage.removeItem(this.KEY_FULLNAME_); | |
95 this.clearAccessToken_(); | |
96 this.clearRefreshToken_(); | |
97 return Promise.resolve(null); | |
98 }; | |
99 | |
100 /** | |
101 * Sets the refresh token. | |
102 * | |
103 * @param {string} token The new refresh token. | |
104 * @return {void} Nothing. | |
105 * @private | |
106 */ | |
107 remoting.OAuth2.prototype.setRefreshToken_ = function(token) { | |
108 window.localStorage.setItem(this.KEY_REFRESH_TOKEN_, escape(token)); | |
109 window.localStorage.removeItem(this.KEY_EMAIL_); | |
110 window.localStorage.removeItem(this.KEY_FULLNAME_); | |
111 this.clearAccessToken_(); | |
112 }; | |
113 | |
114 /** | |
115 * @return {?string} The refresh token, if authenticated, or NULL. | |
116 */ | |
117 remoting.OAuth2.prototype.getRefreshToken = function() { | |
118 var value = window.localStorage.getItem(this.KEY_REFRESH_TOKEN_); | |
119 if (typeof value == 'string') { | |
120 return unescape(value); | |
121 } | |
122 return null; | |
123 }; | |
124 | |
125 /** | |
126 * Clears the refresh token. | |
127 * | |
128 * @return {void} Nothing. | |
129 * @private | |
130 */ | |
131 remoting.OAuth2.prototype.clearRefreshToken_ = function() { | |
132 window.localStorage.removeItem(this.KEY_REFRESH_TOKEN_); | |
133 }; | |
134 | |
135 /** | |
136 * @param {string} token The new access token. | |
137 * @param {number} expiration Expiration time in milliseconds since epoch. | |
138 * @return {void} Nothing. | |
139 * @private | |
140 */ | |
141 remoting.OAuth2.prototype.setAccessToken_ = function(token, expiration) { | |
142 // Offset expiration by 120 seconds so that we can guarantee that the token | |
143 // we return will be valid for at least 2 minutes. | |
144 // If the access token is to be useful, this object must make some | |
145 // guarantee as to how long the token will be valid for. | |
146 // The choice of 2 minutes is arbitrary, but that length of time | |
147 // is part of the contract satisfied by callWithToken(). | |
148 // Offset by a further 30 seconds to account for RTT issues. | |
149 var access_token = { | |
150 'token': token, | |
151 'expiration': (expiration - (120 + 30)) * 1000 + Date.now() | |
152 }; | |
153 window.localStorage.setItem(this.KEY_ACCESS_TOKEN_, | |
154 JSON.stringify(access_token)); | |
155 }; | |
156 | |
157 /** | |
158 * Returns the current access token, setting it to a invalid value if none | |
159 * existed before. | |
160 * | |
161 * @private | |
162 * @return {{token: string, expiration: number}} The current access token, or | |
163 * an invalid token if not authenticated. | |
164 */ | |
165 remoting.OAuth2.prototype.getAccessTokenInternal_ = function() { | |
166 if (!window.localStorage.getItem(this.KEY_ACCESS_TOKEN_)) { | |
167 // Always be able to return structured data. | |
168 this.setAccessToken_('', 0); | |
169 } | |
170 var accessToken = window.localStorage.getItem(this.KEY_ACCESS_TOKEN_); | |
171 if (typeof accessToken == 'string') { | |
172 var result = base.jsonParseSafe(accessToken); | |
173 if (result && 'token' in result && 'expiration' in result) { | |
174 return /** @type {{token: string, expiration: number}} */(result); | |
175 } | |
176 } | |
177 console.log('Invalid access token stored.'); | |
178 return {'token': '', 'expiration': 0}; | |
179 }; | |
180 | |
181 /** | |
182 * Returns true if the access token is expired, or otherwise invalid. | |
183 * | |
184 * Will throw if !isAuthenticated(). | |
185 * | |
186 * @return {boolean} True if a new access token is needed. | |
187 * @private | |
188 */ | |
189 remoting.OAuth2.prototype.needsNewAccessToken_ = function() { | |
190 if (!this.isAuthenticated()) { | |
191 throw 'Not Authenticated.'; | |
192 } | |
193 var access_token = this.getAccessTokenInternal_(); | |
194 if (!access_token['token']) { | |
195 return true; | |
196 } | |
197 if (Date.now() > access_token['expiration']) { | |
198 return true; | |
199 } | |
200 return false; | |
201 }; | |
202 | |
203 /** | |
204 * @return {void} Nothing. | |
205 * @private | |
206 */ | |
207 remoting.OAuth2.prototype.clearAccessToken_ = function() { | |
208 window.localStorage.removeItem(this.KEY_ACCESS_TOKEN_); | |
209 }; | |
210 | |
211 /** | |
212 * Update state based on token response from the OAuth2 /token endpoint. | |
213 * | |
214 * @param {function(string):void} onOk Called with the new access token. | |
215 * @param {string} accessToken Access token. | |
216 * @param {number} expiresIn Expiration time for the access token. | |
217 * @return {void} Nothing. | |
218 * @private | |
219 */ | |
220 remoting.OAuth2.prototype.onAccessToken_ = | |
221 function(onOk, accessToken, expiresIn) { | |
222 this.setAccessToken_(accessToken, expiresIn); | |
223 onOk(accessToken); | |
224 }; | |
225 | |
226 /** | |
227 * Update state based on token response from the OAuth2 /token endpoint. | |
228 * | |
229 * @param {function():void} onOk Called after the new tokens are stored. | |
230 * @param {string} refreshToken Refresh token. | |
231 * @param {string} accessToken Access token. | |
232 * @param {number} expiresIn Expiration time for the access token. | |
233 * @return {void} Nothing. | |
234 * @private | |
235 */ | |
236 remoting.OAuth2.prototype.onTokens_ = | |
237 function(onOk, refreshToken, accessToken, expiresIn) { | |
238 this.setAccessToken_(accessToken, expiresIn); | |
239 this.setRefreshToken_(refreshToken); | |
240 onOk(); | |
241 }; | |
242 | |
243 /** | |
244 * Redirect page to get a new OAuth2 authorization code | |
245 * | |
246 * @param {function(?string):void} onDone Completion callback to receive | |
247 * the authorization code, or null on error. | |
248 * @return {void} Nothing. | |
249 */ | |
250 remoting.OAuth2.prototype.getAuthorizationCode = function(onDone) { | |
251 var xsrf_token = base.generateXsrfToken(); | |
252 var GET_CODE_URL = this.getOAuth2AuthEndpoint_() + '?' + | |
253 remoting.Xhr.urlencodeParamHash({ | |
254 'client_id': this.getClientId_(), | |
255 'redirect_uri': this.getRedirectUri_(), | |
256 'scope': this.SCOPE_, | |
257 'state': xsrf_token, | |
258 'response_type': 'code', | |
259 'access_type': 'offline', | |
260 'approval_prompt': 'force' | |
261 }); | |
262 | |
263 /** | |
264 * Processes the results of the oauth flow. | |
265 * | |
266 * @param {Object<string, string>} message Dictionary containing the parsed | |
267 * OAuth redirect URL parameters. | |
268 * @param {function(*)} sendResponse Function to send response. | |
269 */ | |
270 function oauth2MessageListener(message, sender, sendResponse) { | |
271 if ('code' in message && 'state' in message) { | |
272 if (message['state'] == xsrf_token) { | |
273 onDone(message['code']); | |
274 } else { | |
275 console.error('Invalid XSRF token.'); | |
276 onDone(null); | |
277 } | |
278 } else { | |
279 if ('error' in message) { | |
280 console.error( | |
281 'Could not obtain authorization code: ' + message['error']); | |
282 } else { | |
283 // We intentionally don't log the response - since we don't understand | |
284 // it, we can't tell if it has sensitive data. | |
285 console.error('Invalid oauth2 response.'); | |
286 } | |
287 onDone(null); | |
288 } | |
289 chrome.extension.onMessage.removeListener(oauth2MessageListener); | |
290 sendResponse(null); | |
291 } | |
292 chrome.extension.onMessage.addListener(oauth2MessageListener); | |
293 window.open(GET_CODE_URL, '_blank', 'location=yes,toolbar=no,menubar=no'); | |
294 }; | |
295 | |
296 /** | |
297 * Redirect page to get a new OAuth Refresh Token. | |
298 * | |
299 * @param {function():void} onDone Completion callback. | |
300 * @return {void} Nothing. | |
301 */ | |
302 remoting.OAuth2.prototype.doAuthRedirect = function(onDone) { | |
303 /** @type {remoting.OAuth2} */ | |
304 var that = this; | |
305 /** @param {?string} code */ | |
306 var onAuthorizationCode = function(code) { | |
307 if (code) { | |
308 that.exchangeCodeForToken(code, onDone); | |
309 } else { | |
310 onDone(); | |
311 } | |
312 }; | |
313 this.getAuthorizationCode(onAuthorizationCode); | |
314 }; | |
315 | |
316 /** | |
317 * Asynchronously exchanges an authorization code for a refresh token. | |
318 * | |
319 * @param {string} code The OAuth2 authorization code. | |
320 * @param {function():void} onDone Callback to invoke on completion. | |
321 * @return {void} Nothing. | |
322 */ | |
323 remoting.OAuth2.prototype.exchangeCodeForToken = function(code, onDone) { | |
324 /** @param {!remoting.Error} error */ | |
325 var onError = function(error) { | |
326 console.error('Unable to exchange code for token: ' + error.toString()); | |
327 }; | |
328 | |
329 remoting.oauth2Api.exchangeCodeForTokens( | |
330 this.onTokens_.bind(this, onDone), onError, | |
331 this.getClientId_(), this.getClientSecret_(), code, | |
332 this.getRedirectUri_()); | |
333 }; | |
334 | |
335 /** | |
336 * Print a command-line that can be used to register a host on Linux platforms. | |
337 */ | |
338 remoting.OAuth2.prototype.printStartHostCommandLine = function() { | |
339 /** @type {string} */ | |
340 var redirectUri = this.getRedirectUri_(); | |
341 /** @param {?string} code */ | |
342 var onAuthorizationCode = function(code) { | |
343 if (code) { | |
344 console.log('Run the following command to register a host:'); | |
345 console.log( | |
346 '%c/opt/google/chrome-remote-desktop/start-host' + | |
347 ' --code=' + code + | |
348 ' --redirect-url=' + redirectUri + | |
349 ' --name=$HOSTNAME', 'font-weight: bold;'); | |
350 } | |
351 }; | |
352 this.getAuthorizationCode(onAuthorizationCode); | |
353 }; | |
354 | |
355 /** | |
356 * Get an access token, refreshing it first if necessary. The access | |
357 * token will remain valid for at least 2 minutes. | |
358 * | |
359 * @return {!Promise<string>} A promise resolved the an access token or | |
360 * rejected with a remoting.Error. | |
361 */ | |
362 remoting.OAuth2.prototype.getToken = function() { | |
363 /** @const */ | |
364 var that = this; | |
365 | |
366 return new Promise(function(resolve, reject) { | |
367 var refreshToken = that.getRefreshToken(); | |
368 if (refreshToken) { | |
369 if (that.needsNewAccessToken_()) { | |
370 remoting.oauth2Api.refreshAccessToken( | |
371 that.onAccessToken_.bind(that, resolve), reject, | |
372 that.getClientId_(), that.getClientSecret_(), | |
373 refreshToken); | |
374 } else { | |
375 resolve(that.getAccessTokenInternal_()['token']); | |
376 } | |
377 } else { | |
378 reject(new remoting.Error(remoting.Error.Tag.NOT_AUTHENTICATED)); | |
379 } | |
380 }); | |
381 }; | |
382 | |
383 /** | |
384 * Get the user's email address. | |
385 * | |
386 * @return {!Promise<string>} Promise resolved with the user's email | |
387 * address or rejected with a remoting.Error. | |
388 */ | |
389 remoting.OAuth2.prototype.getEmail = function() { | |
390 var cached = window.localStorage.getItem(this.KEY_EMAIL_); | |
391 if (typeof cached == 'string') { | |
392 return Promise.resolve(cached); | |
393 } | |
394 /** @type {remoting.OAuth2} */ | |
395 var that = this; | |
396 | |
397 return new Promise(function(resolve, reject) { | |
398 /** @param {string} email */ | |
399 var onResponse = function(email) { | |
400 window.localStorage.setItem(that.KEY_EMAIL_, email); | |
401 window.localStorage.setItem(that.KEY_FULLNAME_, ''); | |
402 resolve(email); | |
403 }; | |
404 | |
405 that.getToken().then( | |
406 remoting.oauth2Api.getEmail.bind( | |
407 remoting.oauth2Api, onResponse, reject), | |
408 reject); | |
409 }); | |
410 }; | |
411 | |
412 /** | |
413 * Get the user's email address and full name. | |
414 * | |
415 * @return {!Promise<{email: string, name: string}>} Promise | |
416 * resolved with the user's email address and full name, or rejected | |
417 * with a remoting.Error. | |
418 */ | |
419 remoting.OAuth2.prototype.getUserInfo = function() { | |
420 var cachedEmail = window.localStorage.getItem(this.KEY_EMAIL_); | |
421 var cachedName = window.localStorage.getItem(this.KEY_FULLNAME_); | |
422 if (typeof cachedEmail == 'string' && typeof cachedName == 'string') { | |
423 /** | |
424 * The temp variable is needed to work around a compiler bug. | |
425 * @type {{email: string, name: string}} | |
426 */ | |
427 var result = {email: cachedEmail, name: cachedName}; | |
428 return Promise.resolve(result); | |
429 } | |
430 | |
431 /** @type {remoting.OAuth2} */ | |
432 var that = this; | |
433 | |
434 return new Promise(function(resolve, reject) { | |
435 /** | |
436 * @param {string} email | |
437 * @param {string} name | |
438 */ | |
439 var onResponse = function(email, name) { | |
440 window.localStorage.setItem(that.KEY_EMAIL_, email); | |
441 window.localStorage.setItem(that.KEY_FULLNAME_, name); | |
442 resolve({email: email, name: name}); | |
443 }; | |
444 | |
445 that.getToken().then( | |
446 remoting.oauth2Api.getUserInfo.bind( | |
447 remoting.oauth2Api, onResponse, reject), | |
448 reject); | |
449 }); | |
450 }; | |
OLD | NEW |