blob: a2ee60e45962c020ce5874abd0bb7df420ab3d9f [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:
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700135 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700136 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 Shyshkalovb3c44412018-04-19 14:27:19 -0700158 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700159 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
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700173def _cache_thread_safe(f):
174 """Decorator caching result of nullary function in thread-safe way."""
175 lock = threading.Lock()
176 cache = []
177
178 @functools.wraps(f)
179 def caching_wrapper():
180 if not cache:
181 with lock:
182 if not cache:
183 cache.append(f())
184 return cache[0]
185
186 # Allow easy way to clear cache, particularly useful in tests.
187 caching_wrapper.clear_cache = lambda: cache.pop() if cache else None
188 return caching_wrapper
189
190
191@_cache_thread_safe
192def _get_luci_context_local_auth_params():
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700193 """Returns local auth parameters if local auth is configured else None.
194
195 Raises LuciContextAuthError on unexpected failures.
196 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700197 ctx_path = os.environ.get('LUCI_CONTEXT')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800198 if not ctx_path:
199 return None
200 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800201 try:
202 loaded = _load_luci_context(ctx_path)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700203 except (OSError, IOError, ValueError) as e:
204 raise LuciContextAuthError('Failed to open, read or decode LUCI_CONTEXT', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800205 try:
206 local_auth = loaded.get('local_auth')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700207 except AttributeError as e:
208 raise LuciContextAuthError('LUCI_CONTEXT not in proper format', e)
209 if local_auth is None:
210 logging.debug('LUCI_CONTEXT configured w/o local auth')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800211 return None
212 try:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700213 return _LuciContextLocalAuthParams(
214 default_account_id=local_auth.get('default_account_id'),
215 secret=local_auth.get('secret'),
216 rpc_port=int(local_auth.get('rpc_port')))
217 except (AttributeError, ValueError) as e:
218 raise LuciContextAuthError('local_auth config malformed', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800219
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700220
221def _load_luci_context(ctx_path):
222 # Kept separate for test mocking.
223 with open(ctx_path) as f:
224 return json.load(f)
225
226
227def _get_luci_context_access_token(params, now, scopes=OAUTH_SCOPE_EMAIL):
228 # No account, local_auth shouldn't be used.
229 if not params.default_account_id:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800230 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700231 if not params.secret:
232 raise LuciContextAuthError('local_auth: no secret')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800233
234 logging.debug('local_auth: requesting an access token for account "%s"',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700235 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800236 http = httplib2.Http()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700237 host = '127.0.0.1:%d' % params.rpc_port
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800238 resp, content = http.request(
239 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
240 method='POST',
241 body=json.dumps({
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700242 'account_id': params.default_account_id,
243 'scopes': scopes.split(' '),
244 'secret': params.secret,
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800245 }),
246 headers={'Content-Type': 'application/json'})
247 if resp.status != 200:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700248 raise LuciContextAuthError(
249 'local_auth: Failed to grab access token from '
250 'LUCI context server with status %d: %r' % (resp.status, content))
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800251 try:
252 token = json.loads(content)
253 error_code = token.get('error_code')
254 error_message = token.get('error_message')
255 access_token = token.get('access_token')
256 expiry = token.get('expiry')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700257 except (AttributeError, ValueError) as e:
258 raise LuciContextAuthError('Unexpected access token response format', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800259 if error_code:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700260 raise LuciContextAuthError(
261 'Error %d in retrieving access token: %s', error_code, error_message)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800262 if not access_token:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700263 raise LuciContextAuthError(
264 'No access token returned from LUCI context server')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800265 expiry_dt = None
266 if expiry:
267 try:
268 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800269 logging.debug(
270 'local_auth: got an access token for '
271 'account "%s" that expires in %d sec',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700272 params.default_account_id, (expiry_dt - now).total_seconds())
273 except (TypeError, ValueError) as e:
274 raise LuciContextAuthError('Invalid expiry in returned token', e)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800275 else:
276 logging.debug(
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700277 'local auth: got an access token for account "%s" that does not expire',
278 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800279 access_token = AccessToken(access_token, expiry_dt)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700280 if access_token.needs_refresh(now=now):
281 raise LuciContextAuthError('Received access token is already expired')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800282 return access_token
283
284
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000285def make_auth_config(
286 use_oauth2=None,
287 save_cookies=None,
288 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000289 webserver_port=None,
290 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000291 """Returns new instance of AuthConfig.
292
293 If some config option is None, it will be set to a reasonable default value.
294 This function also acts as an authoritative place for default values of
295 corresponding command line options.
296 """
297 default = lambda val, d: val if val is not None else d
298 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000299 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000300 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000301 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000302 default(webserver_port, 8090),
303 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000304
305
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000306def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000307 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000308 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000309 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000310 parser.add_option_group(parser.auth_group)
311
312 # OAuth2 vs password switch.
313 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000314 parser.auth_group.add_option(
315 '--oauth2',
316 action='store_true',
317 dest='use_oauth2',
318 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000319 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000320 parser.auth_group.add_option(
321 '--no-oauth2',
322 action='store_false',
323 dest='use_oauth2',
324 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000325 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
326
327 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000328 parser.auth_group.add_option(
329 '--no-cookies',
330 action='store_false',
331 dest='save_cookies',
332 default=default_config.save_cookies,
333 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000334
335 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000336 parser.auth_group.add_option(
337 '--auth-no-local-webserver',
338 action='store_false',
339 dest='use_local_webserver',
340 default=default_config.use_local_webserver,
341 help='Do not run a local web server when performing OAuth2 login flow.')
342 parser.auth_group.add_option(
343 '--auth-host-port',
344 type=int,
345 default=default_config.webserver_port,
346 help='Port a local web server should listen on. Used only if '
347 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000348 parser.auth_group.add_option(
349 '--auth-refresh-token-json',
350 default=default_config.refresh_token_json,
351 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000352
353
354def extract_auth_config_from_options(options):
355 """Given OptionParser parsed options, extracts AuthConfig from it.
356
357 OptionParser should be populated with auth options by 'add_auth_options'.
358 """
359 return make_auth_config(
360 use_oauth2=options.use_oauth2,
361 save_cookies=False if options.use_oauth2 else options.save_cookies,
362 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000363 webserver_port=options.auth_host_port,
364 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000365
366
367def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000368 """AuthConfig -> list of strings with command line options.
369
370 Omits options that are set to default values.
371 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000372 if not auth_config:
373 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000374 defaults = make_auth_config()
375 opts = []
376 if auth_config.use_oauth2 != defaults.use_oauth2:
377 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
378 if auth_config.save_cookies != auth_config.save_cookies:
379 if not auth_config.save_cookies:
380 opts.append('--no-cookies')
381 if auth_config.use_local_webserver != defaults.use_local_webserver:
382 if not auth_config.use_local_webserver:
383 opts.append('--auth-no-local-webserver')
384 if auth_config.webserver_port != defaults.webserver_port:
385 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000386 if auth_config.refresh_token_json != defaults.refresh_token_json:
387 opts.extend([
388 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000389 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000390
391
392def get_authenticator_for_host(hostname, config):
393 """Returns Authenticator instance to access given host.
394
395 Args:
396 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
397 a cache key for token cache.
398 config: AuthConfig instance.
399
400 Returns:
401 Authenticator object.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800402
403 Raises:
404 AuthenticationError if hostname is invalid.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000405 """
406 hostname = hostname.lower().rstrip('/')
407 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
408 if '://' not in hostname:
409 hostname = 'https://' + hostname
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700410 # TODO(tandrii): this is horrible.
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000411 scopes = OAUTH_SCOPES
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000412 parsed = urlparse.urlparse(hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000413 if parsed.netloc in ADDITIONAL_SCOPES:
414 scopes = "%s %s" % (scopes, ADDITIONAL_SCOPES[parsed.netloc])
415
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000416 if parsed.path or parsed.params or parsed.query or parsed.fragment:
417 raise AuthenticationError(
418 'Expecting a hostname or root host URL, got %s instead' % hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000419 return Authenticator(parsed.netloc, config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000420
421
422class Authenticator(object):
423 """Object that knows how to refresh access tokens when needed.
424
425 Args:
426 token_cache_key: string key of a section of the token cache file to use
427 to keep the tokens. See hostname_to_token_cache_key.
428 config: AuthConfig object that holds authentication configuration.
429 """
430
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000431 def __init__(self, token_cache_key, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000432 assert isinstance(config, AuthConfig)
433 assert config.use_oauth2
434 self._access_token = None
435 self._config = config
436 self._lock = threading.Lock()
437 self._token_cache_key = token_cache_key
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000438 self._external_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000439 self._scopes = scopes
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000440 if config.refresh_token_json:
441 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000442 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000443
444 def login(self):
445 """Performs interactive login flow if necessary.
446
447 Raises:
448 AuthenticationError on error or if interrupted.
449 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000450 if self._external_token:
451 raise AuthenticationError(
452 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000453 return self.get_access_token(
454 force_refresh=True, allow_user_interaction=True)
455
456 def logout(self):
457 """Revokes the refresh token and deletes it from the cache.
458
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000459 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000460 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000461 with self._lock:
462 self._access_token = None
463 storage = self._get_storage()
464 credentials = storage.get()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000465 had_creds = bool(credentials)
466 if credentials and credentials.refresh_token and credentials.revoke_uri:
467 try:
468 credentials.revoke(httplib2.Http())
469 except client.TokenRevokeError as e:
470 logging.warning('Failed to revoke refresh token: %s', e)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000471 storage.delete()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000472 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000473
474 def has_cached_credentials(self):
475 """Returns True if long term credentials (refresh token) are in cache.
476
477 Doesn't make network calls.
478
479 If returns False, get_access_token() later will ask for interactive login by
480 raising LoginRequiredError.
481
482 If returns True, most probably get_access_token() won't ask for interactive
483 login, though it is not guaranteed, since cached token can be already
484 revoked and there's no way to figure this out without actually trying to use
485 it.
486 """
487 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000488 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000489
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800490 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
491 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000492 """Returns AccessToken, refreshing it if necessary.
493
494 Args:
495 force_refresh: forcefully refresh access token even if it is not expired.
496 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800497 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000498
499 Raises:
500 AuthenticationError on error or if authentication flow was interrupted.
501 LoginRequiredError if user interaction is required, but
502 allow_user_interaction is False.
503 """
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800504 def get_loc_auth_tkn():
505 exi = sys.exc_info()
506 if not use_local_auth:
507 logging.error('Failed to create access token')
508 raise
509 try:
510 self._access_token = get_luci_context_access_token()
511 if not self._access_token:
512 logging.error('Failed to create access token')
513 raise
514 return self._access_token
515 except LuciContextAuthError:
516 logging.exception('Failed to use local auth')
517 raise exi[0], exi[1], exi[2]
518
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000519 with self._lock:
520 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000521 logging.debug('Forcing access token refresh')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800522 try:
523 self._access_token = self._create_access_token(allow_user_interaction)
524 return self._access_token
525 except LoginRequiredError:
526 return get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000527
528 # Load from on-disk cache on a first access.
529 if not self._access_token:
530 self._access_token = self._load_access_token()
531
532 # Refresh if expired or missing.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700533 if not self._access_token or self._access_toke.needs_refresh():
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000534 # Maybe some other process already updated it, reload from the cache.
535 self._access_token = self._load_access_token()
536 # Nope, still expired, need to run the refresh flow.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700537 if not self._access_token or self._access_token.needs_refresh():
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800538 try:
539 self._access_token = self._create_access_token(
540 allow_user_interaction)
541 except LoginRequiredError:
542 get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000543
544 return self._access_token
545
546 def get_token_info(self):
547 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
548 access_token = self.get_access_token()
549 resp, content = httplib2.Http().request(
550 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
551 urllib.urlencode({'access_token': access_token.token})))
552 if resp.status == 200:
553 return json.loads(content)
554 raise AuthenticationError('Failed to fetch the token info: %r' % content)
555
556 def authorize(self, http):
557 """Monkey patches authentication logic of httplib2.Http instance.
558
559 The modified http.request method will add authentication headers to each
560 request and will refresh access_tokens when a 401 is received on a
561 request.
562
563 Args:
564 http: An instance of httplib2.Http.
565
566 Returns:
567 A modified instance of http that was passed in.
568 """
569 # Adapted from oauth2client.OAuth2Credentials.authorize.
570
571 request_orig = http.request
572
573 @functools.wraps(request_orig)
574 def new_request(
575 uri, method='GET', body=None, headers=None,
576 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
577 connection_type=None):
578 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000579 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000580 resp, content = request_orig(
581 uri, method, body, headers, redirections, connection_type)
582 if resp.status in client.REFRESH_STATUS_CODES:
583 logging.info('Refreshing due to a %s', resp.status)
584 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000585 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000586 return request_orig(
587 uri, method, body, headers, redirections, connection_type)
588 else:
589 return (resp, content)
590
591 http.request = new_request
592 return http
593
594 ## Private methods.
595
596 def _get_storage(self):
597 """Returns oauth2client.Storage with cached tokens."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000598 # Do not mix cache keys for different externally provided tokens.
599 if self._external_token:
600 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
601 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
602 else:
603 cache_key = self._token_cache_key
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000604 path = _get_token_cache_path()
605 logging.debug('Using token storage %r (cache key %r)', path, cache_key)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000606 return multistore_file.get_credential_storage_custom_string_key(
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000607 path, cache_key)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000608
609 def _get_cached_credentials(self):
610 """Returns oauth2client.Credentials loaded from storage."""
611 storage = self._get_storage()
612 credentials = storage.get()
613
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000614 if not credentials:
615 logging.debug('No cached token')
616 else:
617 _log_credentials_info('cached token', credentials)
618
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000619 # Is using --auth-refresh-token-json?
620 if self._external_token:
621 # Cached credentials are valid and match external token -> use them. It is
622 # important to reuse credentials from the storage because they contain
623 # cached access token.
624 valid = (
625 credentials and not credentials.invalid and
626 credentials.refresh_token == self._external_token.refresh_token and
627 credentials.client_id == self._external_token.client_id and
628 credentials.client_secret == self._external_token.client_secret)
629 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000630 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000631 return credentials
632 # Construct new credentials from externally provided refresh token,
633 # associate them with cache storage (so that access_token will be placed
634 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000635 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000636 credentials = client.OAuth2Credentials(
637 access_token=None,
638 client_id=self._external_token.client_id,
639 client_secret=self._external_token.client_secret,
640 refresh_token=self._external_token.refresh_token,
641 token_expiry=None,
642 token_uri='https://accounts.google.com/o/oauth2/token',
643 user_agent=None,
644 revoke_uri=None)
645 credentials.set_store(storage)
646 storage.put(credentials)
647 return credentials
648
649 # Not using external refresh token -> return whatever is cached.
650 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000651
652 def _load_access_token(self):
653 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000654 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000655 creds = self._get_cached_credentials()
656 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000657 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000658 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000659 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000660
661 def _create_access_token(self, allow_user_interaction=False):
662 """Mints and caches a new access token, launching OAuth2 dance if necessary.
663
664 Uses cached refresh token, if present. In that case user interaction is not
665 required and function will finish quietly. Otherwise it will launch 3-legged
666 OAuth2 flow, that needs user interaction.
667
668 Args:
669 allow_user_interaction: if True, allow interaction with the user (e.g.
670 reading standard input, or launching a browser).
671
672 Returns:
673 AccessToken.
674
675 Raises:
676 AuthenticationError on error or if authentication flow was interrupted.
677 LoginRequiredError if user interaction is required, but
678 allow_user_interaction is False.
679 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000680 logging.debug(
681 'Making new access token (allow_user_interaction=%r)',
682 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000683 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000684
685 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000686 refreshed = False
687 if credentials and not credentials.invalid:
688 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000689 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000690 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000691 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000692 refreshed = True
693 except client.Error as err:
694 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000695 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000696 'Attempting a full authentication flow.', err)
697
698 # Refresh token is missing or invalid, go through the full flow.
699 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000700 # Can't refresh externally provided token.
701 if self._external_token:
702 raise AuthenticationError(
703 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000704 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000705 logging.debug('Requesting user to login')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000706 raise LoginRequiredError(self._token_cache_key)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000707 logging.debug('Launching OAuth browser flow')
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000708 credentials = _run_oauth_dance(self._config, self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000709 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000710
711 logging.info(
712 'OAuth access_token refreshed. Expires in %s.',
713 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000714 storage = self._get_storage()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000715 credentials.set_store(storage)
716 storage.put(credentials)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000717 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000718
719
720## Private functions.
721
722
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000723def _get_token_cache_path():
724 # On non Win just use HOME.
725 if sys.platform != 'win32':
726 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
727 # Prefer USERPROFILE over HOME, since HOME is overridden in
728 # git-..._bin/cmd/git.cmd to point to depot_tools. depot-tools-auth.py script
729 # (and all other scripts) doesn't use this override and thus uses another
730 # value for HOME. git.cmd doesn't touch USERPROFILE though, and usually
731 # USERPROFILE == HOME on Windows.
732 if 'USERPROFILE' in os.environ:
733 return os.path.join(os.environ['USERPROFILE'], OAUTH_TOKENS_CACHE)
734 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
735
736
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000737def _is_headless():
738 """True if machine doesn't seem to have a display."""
739 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
740
741
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000742def _read_refresh_token_json(path):
743 """Returns RefreshToken by reading it from the JSON file."""
744 try:
745 with open(path, 'r') as f:
746 data = json.load(f)
747 return RefreshToken(
748 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
749 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
750 refresh_token=str(data['refresh_token']))
751 except (IOError, ValueError) as e:
752 raise AuthenticationError(
753 'Failed to read refresh token from %s: %s' % (path, e))
754 except KeyError as e:
755 raise AuthenticationError(
756 'Failed to read refresh token from %s: missing key %s' % (path, e))
757
758
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000759def _log_credentials_info(title, credentials):
760 """Dumps (non sensitive) part of client.Credentials object to debug log."""
761 if credentials:
762 logging.debug('%s info: %r', title, {
763 'access_token_expired': credentials.access_token_expired,
764 'has_access_token': bool(credentials.access_token),
765 'invalid': credentials.invalid,
766 'utcnow': datetime.datetime.utcnow(),
767 'token_expiry': credentials.token_expiry,
768 })
769
770
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000771def _run_oauth_dance(config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000772 """Perform full 3-legged OAuth2 flow with the browser.
773
774 Returns:
775 oauth2client.Credentials.
776
777 Raises:
778 AuthenticationError on errors.
779 """
780 flow = client.OAuth2WebServerFlow(
781 OAUTH_CLIENT_ID,
782 OAUTH_CLIENT_SECRET,
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000783 scopes,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000784 approval_prompt='force')
785
786 use_local_webserver = config.use_local_webserver
787 port = config.webserver_port
788 if config.use_local_webserver:
789 success = False
790 try:
791 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
792 except socket.error:
793 pass
794 else:
795 success = True
796 use_local_webserver = success
797 if not success:
798 print(
799 'Failed to start a local webserver listening on port %d.\n'
800 'Please check your firewall settings and locally running programs that '
801 'may be blocking or using those ports.\n\n'
802 'Falling back to --auth-no-local-webserver and continuing with '
803 'authentication.\n' % port)
804
805 if use_local_webserver:
806 oauth_callback = 'http://localhost:%s/' % port
807 else:
808 oauth_callback = client.OOB_CALLBACK_URN
809 flow.redirect_uri = oauth_callback
810 authorize_url = flow.step1_get_authorize_url()
811
812 if use_local_webserver:
813 webbrowser.open(authorize_url, new=1, autoraise=True)
814 print(
815 'Your browser has been opened to visit:\n\n'
816 ' %s\n\n'
817 'If your browser is on a different machine then exit and re-run this '
818 'application with the command-line parameter\n\n'
819 ' --auth-no-local-webserver\n' % authorize_url)
820 else:
821 print(
822 'Go to the following link in your browser:\n\n'
823 ' %s\n' % authorize_url)
824
825 try:
826 code = None
827 if use_local_webserver:
828 httpd.handle_request()
829 if 'error' in httpd.query_params:
830 raise AuthenticationError(
831 'Authentication request was rejected: %s' %
832 httpd.query_params['error'])
833 if 'code' not in httpd.query_params:
834 raise AuthenticationError(
835 'Failed to find "code" in the query parameters of the redirect.\n'
836 'Try running with --auth-no-local-webserver.')
837 code = httpd.query_params['code']
838 else:
839 code = raw_input('Enter verification code: ').strip()
840 except KeyboardInterrupt:
841 raise AuthenticationError('Authentication was canceled.')
842
843 try:
844 return flow.step2_exchange(code)
845 except client.FlowExchangeError as e:
846 raise AuthenticationError('Authentication has failed: %s' % e)
847
848
849class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
850 """A server to handle OAuth 2.0 redirects back to localhost.
851
852 Waits for a single request and parses the query parameters
853 into query_params and then stops serving.
854 """
855 query_params = {}
856
857
858class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
859 """A handler for OAuth 2.0 redirects back to localhost.
860
861 Waits for a single request and parses the query parameters
862 into the servers query_params and then stops serving.
863 """
864
865 def do_GET(self):
866 """Handle a GET request.
867
868 Parses the query parameters and prints a message
869 if the flow has completed. Note that we can't detect
870 if an error occurred.
871 """
872 self.send_response(200)
873 self.send_header('Content-type', 'text/html')
874 self.end_headers()
875 query = self.path.split('?', 1)[-1]
876 query = dict(urlparse.parse_qsl(query))
877 self.server.query_params = query
878 self.wfile.write('<html><head><title>Authentication Status</title></head>')
879 self.wfile.write('<body><p>The authentication flow has completed.</p>')
880 self.wfile.write('</body></html>')
881
882 def log_message(self, _format, *args):
883 """Do not log messages to stdout while running as command line program."""