blob: e4f5e81b172eee9137c9bb74ac9465dbc395f78d [file] [log] [blame]
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00001# Copyright 2015 The Chromium Authors. All rights reserved.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +00002# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00005"""Google OAuth2 related functions."""
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +00006
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00007import BaseHTTPServer
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +00008import collections
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00009import datetime
10import functools
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000011import hashlib
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000012import json
13import logging
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000014import optparse
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000015import os
16import socket
17import sys
18import threading
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080019import time
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000020import urllib
21import urlparse
22import webbrowser
23
24from third_party import httplib2
25from third_party.oauth2client import client
26from third_party.oauth2client import multistore_file
27
28
29# depot_tools/.
30DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
31
32
33# Google OAuth2 clients always have a secret, even if the client is an installed
34# application/utility such as this. Of course, in such cases the "secret" is
35# actually publicly known; security depends entirely on the secrecy of refresh
36# tokens, which effectively become bearer tokens. An attacker can impersonate
37# service's identity in OAuth2 flow. But that's generally fine as long as a list
38# of allowed redirect_uri's associated with client_id is limited to 'localhost'
39# or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process
40# running on user's machine to successfully complete the flow and grab refresh
41# token. When you have a malicious code running on your machine, you're screwed
42# anyway.
43# This particular set is managed by API Console project "chrome-infra-auth".
44OAUTH_CLIENT_ID = (
45 '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com')
46OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp'
47
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070048# This is what most GAE apps require for authentication.
49OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
50# Gerrit and Git on *.googlesource.com require this scope.
51OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
52# Deprecated. Use OAUTH_SCOPE_EMAIL instead.
53OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000054
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +000055# Additional OAuth scopes.
56ADDITIONAL_SCOPES = {
57 'code.google.com': 'https://www.googleapis.com/auth/projecthosting',
58}
59
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +000060# Path to a file with cached OAuth2 credentials used by default relative to the
61# home dir (see _get_token_cache_path). It should be a safe location accessible
62# only to a current user: knowing content of this file is roughly equivalent to
63# knowing account password. Single file can hold multiple independent tokens
64# identified by token_cache_key (see Authenticator).
65OAUTH_TOKENS_CACHE = '.depot_tools_oauth2_tokens'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000066
67
68# Authentication configuration extracted from command line options.
69# See doc string for 'make_auth_config' for meaning of fields.
70AuthConfig = collections.namedtuple('AuthConfig', [
71 'use_oauth2', # deprecated, will be always True
72 'save_cookies', # deprecated, will be removed
73 'use_local_webserver',
74 'webserver_port',
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000075 'refresh_token_json',
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000076])
77
78
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000079# OAuth access token with its expiration time (UTC datetime or None if unknown).
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070080class AccessToken(collections.namedtuple('AccessToken', [
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000081 'token',
82 'expires_at',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070083 ])):
84
85 def needs_refresh(self, now=None):
86 """True if this AccessToken should be refreshed."""
87 if self.expires_at is not None:
88 now = now or datetime.datetime.utcnow()
89 # Allow 5 min of clock skew between client and backend.
90 now += datetime.timedelta(seconds=300)
91 return now >= self.expires_at
92 # Token without expiration time never expires.
93 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000094
95
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000096# Refresh token passed via --auth-refresh-token-json.
97RefreshToken = collections.namedtuple('RefreshToken', [
98 'client_id',
99 'client_secret',
100 'refresh_token',
101])
102
103
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000104class AuthenticationError(Exception):
105 """Raised on errors related to authentication."""
106
107
108class LoginRequiredError(AuthenticationError):
109 """Interaction with the user is required to authenticate."""
110
111 def __init__(self, token_cache_key):
112 # HACK(vadimsh): It is assumed here that the token cache key is a hostname.
113 msg = (
114 'You are not logged in. Please login first by running:\n'
115 ' depot-tools-auth login %s' % token_cache_key)
116 super(LoginRequiredError, self).__init__(msg)
117
118
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800119class LuciContextAuthError(Exception):
120 """Raised on errors related to unsuccessful attempts to load LUCI_CONTEXT"""
121
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700122 def __init__(self, msg, exc=None):
123 if exc is None:
124 logging.error(msg)
125 else:
126 logging.exception(msg)
127 msg = '%s: %s' % (msg, exc)
128 super(LuciContextAuthError, self).__init__(msg)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800129
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700130
131def has_luci_context_local_auth():
132 """Returns whether LUCI_CONTEXT should be used for ambient authentication.
133 """
134 try:
135 params = _get_luci_context_local_auth_params(os.environ)
136 except LuciContextAuthError:
137 return False
138 if params is None:
139 return False
140 return bool(params.default_account_id)
141
142
143def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800144 """Returns a valid AccessToken from the local LUCI context auth server.
145
146 Adapted from
147 https://chromium.googlesource.com/infra/luci/luci-py/+/master/client/libs/luci_context/luci_context.py
148 See the link above for more details.
149
150 Returns:
151 AccessToken if LUCI_CONTEXT is present and attempt to load it is successful.
152 None if LUCI_CONTEXT is absent.
153
154 Raises:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700155 LuciContextAuthError if LUCI_CONTEXT is present, but there was a failure
156 obtaining its access token.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800157 """
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700158 params = _get_luci_context_local_auth_params(os.environ)
159 if params is None:
160 return None
161 return _get_luci_context_access_token(
162 params, datetime.datetime.utcnow(), scopes)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800163
164
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700165_LuciContextLocalAuthParams = collections.namedtuple(
166 '_LuciContextLocalAuthParams', [
167 'default_account_id',
168 'secret',
169 'rpc_port',
170])
171
172
173def _get_luci_context_local_auth_params(env):
174 """Returns local auth parameters if local auth is configured else None.
175
176 Raises LuciContextAuthError on unexpected failures.
177 """
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800178 ctx_path = env.get('LUCI_CONTEXT')
179 if not ctx_path:
180 return None
181 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800182 try:
183 loaded = _load_luci_context(ctx_path)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700184 except (OSError, IOError, ValueError) as e:
185 raise LuciContextAuthError('Failed to open, read or decode LUCI_CONTEXT', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800186 try:
187 local_auth = loaded.get('local_auth')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700188 except AttributeError as e:
189 raise LuciContextAuthError('LUCI_CONTEXT not in proper format', e)
190 if local_auth is None:
191 logging.debug('LUCI_CONTEXT configured w/o local auth')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800192 return None
193 try:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700194 return _LuciContextLocalAuthParams(
195 default_account_id=local_auth.get('default_account_id'),
196 secret=local_auth.get('secret'),
197 rpc_port=int(local_auth.get('rpc_port')))
198 except (AttributeError, ValueError) as e:
199 raise LuciContextAuthError('local_auth config malformed', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800200
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700201
202def _load_luci_context(ctx_path):
203 # Kept separate for test mocking.
204 with open(ctx_path) as f:
205 return json.load(f)
206
207
208def _get_luci_context_access_token(params, now, scopes=OAUTH_SCOPE_EMAIL):
209 # No account, local_auth shouldn't be used.
210 if not params.default_account_id:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800211 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700212 if not params.secret:
213 raise LuciContextAuthError('local_auth: no secret')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800214
215 logging.debug('local_auth: requesting an access token for account "%s"',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700216 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800217 http = httplib2.Http()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700218 host = '127.0.0.1:%d' % params.rpc_port
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800219 resp, content = http.request(
220 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
221 method='POST',
222 body=json.dumps({
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700223 'account_id': params.default_account_id,
224 'scopes': scopes.split(' '),
225 'secret': params.secret,
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800226 }),
227 headers={'Content-Type': 'application/json'})
228 if resp.status != 200:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700229 raise LuciContextAuthError(
230 'local_auth: Failed to grab access token from '
231 'LUCI context server with status %d: %r' % (resp.status, content))
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800232 try:
233 token = json.loads(content)
234 error_code = token.get('error_code')
235 error_message = token.get('error_message')
236 access_token = token.get('access_token')
237 expiry = token.get('expiry')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700238 except (AttributeError, ValueError) as e:
239 raise LuciContextAuthError('Unexpected access token response format', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800240 if error_code:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700241 raise LuciContextAuthError(
242 'Error %d in retrieving access token: %s', error_code, error_message)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800243 if not access_token:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700244 raise LuciContextAuthError(
245 'No access token returned from LUCI context server')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800246 expiry_dt = None
247 if expiry:
248 try:
249 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800250 logging.debug(
251 'local_auth: got an access token for '
252 'account "%s" that expires in %d sec',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700253 params.default_account_id, (expiry_dt - now).total_seconds())
254 except (TypeError, ValueError) as e:
255 raise LuciContextAuthError('Invalid expiry in returned token', e)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800256 else:
257 logging.debug(
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700258 'local auth: got an access token for account "%s" that does not expire',
259 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800260 access_token = AccessToken(access_token, expiry_dt)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700261 if access_token.needs_refresh(now=now):
262 raise LuciContextAuthError('Received access token is already expired')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800263 return access_token
264
265
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000266def make_auth_config(
267 use_oauth2=None,
268 save_cookies=None,
269 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000270 webserver_port=None,
271 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000272 """Returns new instance of AuthConfig.
273
274 If some config option is None, it will be set to a reasonable default value.
275 This function also acts as an authoritative place for default values of
276 corresponding command line options.
277 """
278 default = lambda val, d: val if val is not None else d
279 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000280 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000281 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000282 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000283 default(webserver_port, 8090),
284 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000285
286
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000287def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000288 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000289 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000290 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000291 parser.add_option_group(parser.auth_group)
292
293 # OAuth2 vs password switch.
294 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000295 parser.auth_group.add_option(
296 '--oauth2',
297 action='store_true',
298 dest='use_oauth2',
299 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000300 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000301 parser.auth_group.add_option(
302 '--no-oauth2',
303 action='store_false',
304 dest='use_oauth2',
305 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000306 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
307
308 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000309 parser.auth_group.add_option(
310 '--no-cookies',
311 action='store_false',
312 dest='save_cookies',
313 default=default_config.save_cookies,
314 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000315
316 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000317 parser.auth_group.add_option(
318 '--auth-no-local-webserver',
319 action='store_false',
320 dest='use_local_webserver',
321 default=default_config.use_local_webserver,
322 help='Do not run a local web server when performing OAuth2 login flow.')
323 parser.auth_group.add_option(
324 '--auth-host-port',
325 type=int,
326 default=default_config.webserver_port,
327 help='Port a local web server should listen on. Used only if '
328 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000329 parser.auth_group.add_option(
330 '--auth-refresh-token-json',
331 default=default_config.refresh_token_json,
332 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000333
334
335def extract_auth_config_from_options(options):
336 """Given OptionParser parsed options, extracts AuthConfig from it.
337
338 OptionParser should be populated with auth options by 'add_auth_options'.
339 """
340 return make_auth_config(
341 use_oauth2=options.use_oauth2,
342 save_cookies=False if options.use_oauth2 else options.save_cookies,
343 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000344 webserver_port=options.auth_host_port,
345 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000346
347
348def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000349 """AuthConfig -> list of strings with command line options.
350
351 Omits options that are set to default values.
352 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000353 if not auth_config:
354 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000355 defaults = make_auth_config()
356 opts = []
357 if auth_config.use_oauth2 != defaults.use_oauth2:
358 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
359 if auth_config.save_cookies != auth_config.save_cookies:
360 if not auth_config.save_cookies:
361 opts.append('--no-cookies')
362 if auth_config.use_local_webserver != defaults.use_local_webserver:
363 if not auth_config.use_local_webserver:
364 opts.append('--auth-no-local-webserver')
365 if auth_config.webserver_port != defaults.webserver_port:
366 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000367 if auth_config.refresh_token_json != defaults.refresh_token_json:
368 opts.extend([
369 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000370 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000371
372
373def get_authenticator_for_host(hostname, config):
374 """Returns Authenticator instance to access given host.
375
376 Args:
377 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
378 a cache key for token cache.
379 config: AuthConfig instance.
380
381 Returns:
382 Authenticator object.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800383
384 Raises:
385 AuthenticationError if hostname is invalid.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000386 """
387 hostname = hostname.lower().rstrip('/')
388 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
389 if '://' not in hostname:
390 hostname = 'https://' + hostname
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700391 # TODO(tandrii): this is horrible.
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000392 scopes = OAUTH_SCOPES
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000393 parsed = urlparse.urlparse(hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000394 if parsed.netloc in ADDITIONAL_SCOPES:
395 scopes = "%s %s" % (scopes, ADDITIONAL_SCOPES[parsed.netloc])
396
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000397 if parsed.path or parsed.params or parsed.query or parsed.fragment:
398 raise AuthenticationError(
399 'Expecting a hostname or root host URL, got %s instead' % hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000400 return Authenticator(parsed.netloc, config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000401
402
403class Authenticator(object):
404 """Object that knows how to refresh access tokens when needed.
405
406 Args:
407 token_cache_key: string key of a section of the token cache file to use
408 to keep the tokens. See hostname_to_token_cache_key.
409 config: AuthConfig object that holds authentication configuration.
410 """
411
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000412 def __init__(self, token_cache_key, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000413 assert isinstance(config, AuthConfig)
414 assert config.use_oauth2
415 self._access_token = None
416 self._config = config
417 self._lock = threading.Lock()
418 self._token_cache_key = token_cache_key
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000419 self._external_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000420 self._scopes = scopes
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000421 if config.refresh_token_json:
422 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000423 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000424
425 def login(self):
426 """Performs interactive login flow if necessary.
427
428 Raises:
429 AuthenticationError on error or if interrupted.
430 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000431 if self._external_token:
432 raise AuthenticationError(
433 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000434 return self.get_access_token(
435 force_refresh=True, allow_user_interaction=True)
436
437 def logout(self):
438 """Revokes the refresh token and deletes it from the cache.
439
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000440 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000441 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000442 with self._lock:
443 self._access_token = None
444 storage = self._get_storage()
445 credentials = storage.get()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000446 had_creds = bool(credentials)
447 if credentials and credentials.refresh_token and credentials.revoke_uri:
448 try:
449 credentials.revoke(httplib2.Http())
450 except client.TokenRevokeError as e:
451 logging.warning('Failed to revoke refresh token: %s', e)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000452 storage.delete()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000453 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000454
455 def has_cached_credentials(self):
456 """Returns True if long term credentials (refresh token) are in cache.
457
458 Doesn't make network calls.
459
460 If returns False, get_access_token() later will ask for interactive login by
461 raising LoginRequiredError.
462
463 If returns True, most probably get_access_token() won't ask for interactive
464 login, though it is not guaranteed, since cached token can be already
465 revoked and there's no way to figure this out without actually trying to use
466 it.
467 """
468 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000469 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000470
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800471 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
472 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000473 """Returns AccessToken, refreshing it if necessary.
474
475 Args:
476 force_refresh: forcefully refresh access token even if it is not expired.
477 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800478 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000479
480 Raises:
481 AuthenticationError on error or if authentication flow was interrupted.
482 LoginRequiredError if user interaction is required, but
483 allow_user_interaction is False.
484 """
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800485 def get_loc_auth_tkn():
486 exi = sys.exc_info()
487 if not use_local_auth:
488 logging.error('Failed to create access token')
489 raise
490 try:
491 self._access_token = get_luci_context_access_token()
492 if not self._access_token:
493 logging.error('Failed to create access token')
494 raise
495 return self._access_token
496 except LuciContextAuthError:
497 logging.exception('Failed to use local auth')
498 raise exi[0], exi[1], exi[2]
499
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000500 with self._lock:
501 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000502 logging.debug('Forcing access token refresh')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800503 try:
504 self._access_token = self._create_access_token(allow_user_interaction)
505 return self._access_token
506 except LoginRequiredError:
507 return get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000508
509 # Load from on-disk cache on a first access.
510 if not self._access_token:
511 self._access_token = self._load_access_token()
512
513 # Refresh if expired or missing.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700514 if not self._access_token or self._access_toke.needs_refresh():
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000515 # Maybe some other process already updated it, reload from the cache.
516 self._access_token = self._load_access_token()
517 # Nope, still expired, need to run the refresh flow.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700518 if not self._access_token or self._access_token.needs_refresh():
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800519 try:
520 self._access_token = self._create_access_token(
521 allow_user_interaction)
522 except LoginRequiredError:
523 get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000524
525 return self._access_token
526
527 def get_token_info(self):
528 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
529 access_token = self.get_access_token()
530 resp, content = httplib2.Http().request(
531 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
532 urllib.urlencode({'access_token': access_token.token})))
533 if resp.status == 200:
534 return json.loads(content)
535 raise AuthenticationError('Failed to fetch the token info: %r' % content)
536
537 def authorize(self, http):
538 """Monkey patches authentication logic of httplib2.Http instance.
539
540 The modified http.request method will add authentication headers to each
541 request and will refresh access_tokens when a 401 is received on a
542 request.
543
544 Args:
545 http: An instance of httplib2.Http.
546
547 Returns:
548 A modified instance of http that was passed in.
549 """
550 # Adapted from oauth2client.OAuth2Credentials.authorize.
551
552 request_orig = http.request
553
554 @functools.wraps(request_orig)
555 def new_request(
556 uri, method='GET', body=None, headers=None,
557 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
558 connection_type=None):
559 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000560 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000561 resp, content = request_orig(
562 uri, method, body, headers, redirections, connection_type)
563 if resp.status in client.REFRESH_STATUS_CODES:
564 logging.info('Refreshing due to a %s', resp.status)
565 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000566 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000567 return request_orig(
568 uri, method, body, headers, redirections, connection_type)
569 else:
570 return (resp, content)
571
572 http.request = new_request
573 return http
574
575 ## Private methods.
576
577 def _get_storage(self):
578 """Returns oauth2client.Storage with cached tokens."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000579 # Do not mix cache keys for different externally provided tokens.
580 if self._external_token:
581 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
582 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
583 else:
584 cache_key = self._token_cache_key
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000585 path = _get_token_cache_path()
586 logging.debug('Using token storage %r (cache key %r)', path, cache_key)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000587 return multistore_file.get_credential_storage_custom_string_key(
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000588 path, cache_key)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000589
590 def _get_cached_credentials(self):
591 """Returns oauth2client.Credentials loaded from storage."""
592 storage = self._get_storage()
593 credentials = storage.get()
594
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000595 if not credentials:
596 logging.debug('No cached token')
597 else:
598 _log_credentials_info('cached token', credentials)
599
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000600 # Is using --auth-refresh-token-json?
601 if self._external_token:
602 # Cached credentials are valid and match external token -> use them. It is
603 # important to reuse credentials from the storage because they contain
604 # cached access token.
605 valid = (
606 credentials and not credentials.invalid and
607 credentials.refresh_token == self._external_token.refresh_token and
608 credentials.client_id == self._external_token.client_id and
609 credentials.client_secret == self._external_token.client_secret)
610 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000611 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000612 return credentials
613 # Construct new credentials from externally provided refresh token,
614 # associate them with cache storage (so that access_token will be placed
615 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000616 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000617 credentials = client.OAuth2Credentials(
618 access_token=None,
619 client_id=self._external_token.client_id,
620 client_secret=self._external_token.client_secret,
621 refresh_token=self._external_token.refresh_token,
622 token_expiry=None,
623 token_uri='https://accounts.google.com/o/oauth2/token',
624 user_agent=None,
625 revoke_uri=None)
626 credentials.set_store(storage)
627 storage.put(credentials)
628 return credentials
629
630 # Not using external refresh token -> return whatever is cached.
631 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000632
633 def _load_access_token(self):
634 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000635 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000636 creds = self._get_cached_credentials()
637 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000638 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000639 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000640 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000641
642 def _create_access_token(self, allow_user_interaction=False):
643 """Mints and caches a new access token, launching OAuth2 dance if necessary.
644
645 Uses cached refresh token, if present. In that case user interaction is not
646 required and function will finish quietly. Otherwise it will launch 3-legged
647 OAuth2 flow, that needs user interaction.
648
649 Args:
650 allow_user_interaction: if True, allow interaction with the user (e.g.
651 reading standard input, or launching a browser).
652
653 Returns:
654 AccessToken.
655
656 Raises:
657 AuthenticationError on error or if authentication flow was interrupted.
658 LoginRequiredError if user interaction is required, but
659 allow_user_interaction is False.
660 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000661 logging.debug(
662 'Making new access token (allow_user_interaction=%r)',
663 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000664 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000665
666 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000667 refreshed = False
668 if credentials and not credentials.invalid:
669 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000670 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000671 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000672 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000673 refreshed = True
674 except client.Error as err:
675 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000676 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000677 'Attempting a full authentication flow.', err)
678
679 # Refresh token is missing or invalid, go through the full flow.
680 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000681 # Can't refresh externally provided token.
682 if self._external_token:
683 raise AuthenticationError(
684 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000685 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000686 logging.debug('Requesting user to login')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000687 raise LoginRequiredError(self._token_cache_key)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000688 logging.debug('Launching OAuth browser flow')
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000689 credentials = _run_oauth_dance(self._config, self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000690 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000691
692 logging.info(
693 'OAuth access_token refreshed. Expires in %s.',
694 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000695 storage = self._get_storage()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000696 credentials.set_store(storage)
697 storage.put(credentials)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000698 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000699
700
701## Private functions.
702
703
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000704def _get_token_cache_path():
705 # On non Win just use HOME.
706 if sys.platform != 'win32':
707 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
708 # Prefer USERPROFILE over HOME, since HOME is overridden in
709 # git-..._bin/cmd/git.cmd to point to depot_tools. depot-tools-auth.py script
710 # (and all other scripts) doesn't use this override and thus uses another
711 # value for HOME. git.cmd doesn't touch USERPROFILE though, and usually
712 # USERPROFILE == HOME on Windows.
713 if 'USERPROFILE' in os.environ:
714 return os.path.join(os.environ['USERPROFILE'], OAUTH_TOKENS_CACHE)
715 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
716
717
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000718def _is_headless():
719 """True if machine doesn't seem to have a display."""
720 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
721
722
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000723def _read_refresh_token_json(path):
724 """Returns RefreshToken by reading it from the JSON file."""
725 try:
726 with open(path, 'r') as f:
727 data = json.load(f)
728 return RefreshToken(
729 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
730 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
731 refresh_token=str(data['refresh_token']))
732 except (IOError, ValueError) as e:
733 raise AuthenticationError(
734 'Failed to read refresh token from %s: %s' % (path, e))
735 except KeyError as e:
736 raise AuthenticationError(
737 'Failed to read refresh token from %s: missing key %s' % (path, e))
738
739
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000740def _log_credentials_info(title, credentials):
741 """Dumps (non sensitive) part of client.Credentials object to debug log."""
742 if credentials:
743 logging.debug('%s info: %r', title, {
744 'access_token_expired': credentials.access_token_expired,
745 'has_access_token': bool(credentials.access_token),
746 'invalid': credentials.invalid,
747 'utcnow': datetime.datetime.utcnow(),
748 'token_expiry': credentials.token_expiry,
749 })
750
751
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000752def _run_oauth_dance(config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000753 """Perform full 3-legged OAuth2 flow with the browser.
754
755 Returns:
756 oauth2client.Credentials.
757
758 Raises:
759 AuthenticationError on errors.
760 """
761 flow = client.OAuth2WebServerFlow(
762 OAUTH_CLIENT_ID,
763 OAUTH_CLIENT_SECRET,
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000764 scopes,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000765 approval_prompt='force')
766
767 use_local_webserver = config.use_local_webserver
768 port = config.webserver_port
769 if config.use_local_webserver:
770 success = False
771 try:
772 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
773 except socket.error:
774 pass
775 else:
776 success = True
777 use_local_webserver = success
778 if not success:
779 print(
780 'Failed to start a local webserver listening on port %d.\n'
781 'Please check your firewall settings and locally running programs that '
782 'may be blocking or using those ports.\n\n'
783 'Falling back to --auth-no-local-webserver and continuing with '
784 'authentication.\n' % port)
785
786 if use_local_webserver:
787 oauth_callback = 'http://localhost:%s/' % port
788 else:
789 oauth_callback = client.OOB_CALLBACK_URN
790 flow.redirect_uri = oauth_callback
791 authorize_url = flow.step1_get_authorize_url()
792
793 if use_local_webserver:
794 webbrowser.open(authorize_url, new=1, autoraise=True)
795 print(
796 'Your browser has been opened to visit:\n\n'
797 ' %s\n\n'
798 'If your browser is on a different machine then exit and re-run this '
799 'application with the command-line parameter\n\n'
800 ' --auth-no-local-webserver\n' % authorize_url)
801 else:
802 print(
803 'Go to the following link in your browser:\n\n'
804 ' %s\n' % authorize_url)
805
806 try:
807 code = None
808 if use_local_webserver:
809 httpd.handle_request()
810 if 'error' in httpd.query_params:
811 raise AuthenticationError(
812 'Authentication request was rejected: %s' %
813 httpd.query_params['error'])
814 if 'code' not in httpd.query_params:
815 raise AuthenticationError(
816 'Failed to find "code" in the query parameters of the redirect.\n'
817 'Try running with --auth-no-local-webserver.')
818 code = httpd.query_params['code']
819 else:
820 code = raw_input('Enter verification code: ').strip()
821 except KeyboardInterrupt:
822 raise AuthenticationError('Authentication was canceled.')
823
824 try:
825 return flow.step2_exchange(code)
826 except client.FlowExchangeError as e:
827 raise AuthenticationError('Authentication has failed: %s' % e)
828
829
830class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
831 """A server to handle OAuth 2.0 redirects back to localhost.
832
833 Waits for a single request and parses the query parameters
834 into query_params and then stops serving.
835 """
836 query_params = {}
837
838
839class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
840 """A handler for OAuth 2.0 redirects back to localhost.
841
842 Waits for a single request and parses the query parameters
843 into the servers query_params and then stops serving.
844 """
845
846 def do_GET(self):
847 """Handle a GET request.
848
849 Parses the query parameters and prints a message
850 if the flow has completed. Note that we can't detect
851 if an error occurred.
852 """
853 self.send_response(200)
854 self.send_header('Content-type', 'text/html')
855 self.end_headers()
856 query = self.path.split('?', 1)[-1]
857 query = dict(urlparse.parse_qsl(query))
858 self.server.query_params = query
859 self.wfile.write('<html><head><title>Authentication Status</title></head>')
860 self.wfile.write('<body><p>The authentication flow has completed.</p>')
861 self.wfile.write('</body></html>')
862
863 def log_message(self, _format, *args):
864 """Do not log messages to stdout while running as command line program."""