blob: 7baa1d0ba8c5bec094e0d15940a504f9152ecbc7 [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
Raul Tambre80ee78e2019-05-06 22:41:05 +00007from __future__ import print_function
8
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00009import BaseHTTPServer
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000010import collections
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000011import datetime
12import functools
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000013import hashlib
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000014import json
15import logging
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000016import optparse
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000017import os
18import socket
19import sys
20import threading
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080021import time
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000022import urllib
23import urlparse
24import webbrowser
25
26from third_party import httplib2
27from third_party.oauth2client import client
28from third_party.oauth2client import multistore_file
29
30
31# depot_tools/.
32DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
33
34
35# Google OAuth2 clients always have a secret, even if the client is an installed
36# application/utility such as this. Of course, in such cases the "secret" is
37# actually publicly known; security depends entirely on the secrecy of refresh
38# tokens, which effectively become bearer tokens. An attacker can impersonate
39# service's identity in OAuth2 flow. But that's generally fine as long as a list
40# of allowed redirect_uri's associated with client_id is limited to 'localhost'
41# or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process
42# running on user's machine to successfully complete the flow and grab refresh
43# token. When you have a malicious code running on your machine, you're screwed
44# anyway.
45# This particular set is managed by API Console project "chrome-infra-auth".
46OAUTH_CLIENT_ID = (
47 '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com')
48OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp'
49
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070050# This is what most GAE apps require for authentication.
51OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
52# Gerrit and Git on *.googlesource.com require this scope.
53OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
54# Deprecated. Use OAUTH_SCOPE_EMAIL instead.
55OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000056
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +000057# Path to a file with cached OAuth2 credentials used by default relative to the
58# home dir (see _get_token_cache_path). It should be a safe location accessible
59# only to a current user: knowing content of this file is roughly equivalent to
60# knowing account password. Single file can hold multiple independent tokens
61# identified by token_cache_key (see Authenticator).
62OAUTH_TOKENS_CACHE = '.depot_tools_oauth2_tokens'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000063
64
65# Authentication configuration extracted from command line options.
66# See doc string for 'make_auth_config' for meaning of fields.
67AuthConfig = collections.namedtuple('AuthConfig', [
68 'use_oauth2', # deprecated, will be always True
69 'save_cookies', # deprecated, will be removed
70 'use_local_webserver',
71 'webserver_port',
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000072 'refresh_token_json',
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000073])
74
75
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000076# OAuth access token with its expiration time (UTC datetime or None if unknown).
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070077class AccessToken(collections.namedtuple('AccessToken', [
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000078 'token',
79 'expires_at',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070080 ])):
81
82 def needs_refresh(self, now=None):
83 """True if this AccessToken should be refreshed."""
84 if self.expires_at is not None:
85 now = now or datetime.datetime.utcnow()
Andrii Shyshkalov142a92c2018-05-04 12:21:24 -070086 # Allow 3 min of clock skew between client and backend.
87 now += datetime.timedelta(seconds=180)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070088 return now >= self.expires_at
89 # Token without expiration time never expires.
90 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000091
92
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000093# Refresh token passed via --auth-refresh-token-json.
94RefreshToken = collections.namedtuple('RefreshToken', [
95 'client_id',
96 'client_secret',
97 'refresh_token',
98])
99
100
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000101class AuthenticationError(Exception):
102 """Raised on errors related to authentication."""
103
104
105class LoginRequiredError(AuthenticationError):
106 """Interaction with the user is required to authenticate."""
107
108 def __init__(self, token_cache_key):
109 # HACK(vadimsh): It is assumed here that the token cache key is a hostname.
110 msg = (
111 'You are not logged in. Please login first by running:\n'
112 ' depot-tools-auth login %s' % token_cache_key)
113 super(LoginRequiredError, self).__init__(msg)
114
115
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800116class LuciContextAuthError(Exception):
117 """Raised on errors related to unsuccessful attempts to load LUCI_CONTEXT"""
118
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700119 def __init__(self, msg, exc=None):
120 if exc is None:
121 logging.error(msg)
122 else:
123 logging.exception(msg)
124 msg = '%s: %s' % (msg, exc)
125 super(LuciContextAuthError, self).__init__(msg)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800126
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700127
128def has_luci_context_local_auth():
129 """Returns whether LUCI_CONTEXT should be used for ambient authentication.
130 """
131 try:
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700132 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700133 except LuciContextAuthError:
134 return False
135 if params is None:
136 return False
137 return bool(params.default_account_id)
138
139
140def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800141 """Returns a valid AccessToken from the local LUCI context auth server.
142
143 Adapted from
144 https://chromium.googlesource.com/infra/luci/luci-py/+/master/client/libs/luci_context/luci_context.py
145 See the link above for more details.
146
147 Returns:
148 AccessToken if LUCI_CONTEXT is present and attempt to load it is successful.
149 None if LUCI_CONTEXT is absent.
150
151 Raises:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700152 LuciContextAuthError if LUCI_CONTEXT is present, but there was a failure
153 obtaining its access token.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800154 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700155 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700156 if params is None:
157 return None
158 return _get_luci_context_access_token(
159 params, datetime.datetime.utcnow(), scopes)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800160
161
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700162_LuciContextLocalAuthParams = collections.namedtuple(
163 '_LuciContextLocalAuthParams', [
164 'default_account_id',
165 'secret',
166 'rpc_port',
167])
168
169
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700170def _cache_thread_safe(f):
171 """Decorator caching result of nullary function in thread-safe way."""
172 lock = threading.Lock()
173 cache = []
174
175 @functools.wraps(f)
176 def caching_wrapper():
177 if not cache:
178 with lock:
179 if not cache:
180 cache.append(f())
181 return cache[0]
182
183 # Allow easy way to clear cache, particularly useful in tests.
184 caching_wrapper.clear_cache = lambda: cache.pop() if cache else None
185 return caching_wrapper
186
187
188@_cache_thread_safe
189def _get_luci_context_local_auth_params():
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700190 """Returns local auth parameters if local auth is configured else None.
191
192 Raises LuciContextAuthError on unexpected failures.
193 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700194 ctx_path = os.environ.get('LUCI_CONTEXT')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800195 if not ctx_path:
196 return None
197 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800198 try:
199 loaded = _load_luci_context(ctx_path)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700200 except (OSError, IOError, ValueError) as e:
201 raise LuciContextAuthError('Failed to open, read or decode LUCI_CONTEXT', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800202 try:
203 local_auth = loaded.get('local_auth')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700204 except AttributeError as e:
205 raise LuciContextAuthError('LUCI_CONTEXT not in proper format', e)
206 if local_auth is None:
207 logging.debug('LUCI_CONTEXT configured w/o local auth')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800208 return None
209 try:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700210 return _LuciContextLocalAuthParams(
211 default_account_id=local_auth.get('default_account_id'),
212 secret=local_auth.get('secret'),
213 rpc_port=int(local_auth.get('rpc_port')))
214 except (AttributeError, ValueError) as e:
215 raise LuciContextAuthError('local_auth config malformed', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800216
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700217
218def _load_luci_context(ctx_path):
219 # Kept separate for test mocking.
220 with open(ctx_path) as f:
221 return json.load(f)
222
223
224def _get_luci_context_access_token(params, now, scopes=OAUTH_SCOPE_EMAIL):
225 # No account, local_auth shouldn't be used.
226 if not params.default_account_id:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800227 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700228 if not params.secret:
229 raise LuciContextAuthError('local_auth: no secret')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800230
231 logging.debug('local_auth: requesting an access token for account "%s"',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700232 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800233 http = httplib2.Http()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700234 host = '127.0.0.1:%d' % params.rpc_port
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800235 resp, content = http.request(
236 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
237 method='POST',
238 body=json.dumps({
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700239 'account_id': params.default_account_id,
240 'scopes': scopes.split(' '),
241 'secret': params.secret,
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800242 }),
243 headers={'Content-Type': 'application/json'})
244 if resp.status != 200:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700245 raise LuciContextAuthError(
246 'local_auth: Failed to grab access token from '
247 'LUCI context server with status %d: %r' % (resp.status, content))
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800248 try:
249 token = json.loads(content)
250 error_code = token.get('error_code')
251 error_message = token.get('error_message')
252 access_token = token.get('access_token')
253 expiry = token.get('expiry')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700254 except (AttributeError, ValueError) as e:
255 raise LuciContextAuthError('Unexpected access token response format', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800256 if error_code:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700257 raise LuciContextAuthError(
258 'Error %d in retrieving access token: %s', error_code, error_message)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800259 if not access_token:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700260 raise LuciContextAuthError(
261 'No access token returned from LUCI context server')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800262 expiry_dt = None
263 if expiry:
264 try:
265 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800266 logging.debug(
267 'local_auth: got an access token for '
268 'account "%s" that expires in %d sec',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700269 params.default_account_id, (expiry_dt - now).total_seconds())
270 except (TypeError, ValueError) as e:
271 raise LuciContextAuthError('Invalid expiry in returned token', e)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800272 else:
273 logging.debug(
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700274 'local auth: got an access token for account "%s" that does not expire',
275 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800276 access_token = AccessToken(access_token, expiry_dt)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700277 if access_token.needs_refresh(now=now):
278 raise LuciContextAuthError('Received access token is already expired')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800279 return access_token
280
281
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000282def make_auth_config(
283 use_oauth2=None,
284 save_cookies=None,
285 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000286 webserver_port=None,
287 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000288 """Returns new instance of AuthConfig.
289
290 If some config option is None, it will be set to a reasonable default value.
291 This function also acts as an authoritative place for default values of
292 corresponding command line options.
293 """
294 default = lambda val, d: val if val is not None else d
295 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000296 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000297 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000298 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000299 default(webserver_port, 8090),
300 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000301
302
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000303def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000304 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000305 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000306 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000307 parser.add_option_group(parser.auth_group)
308
309 # OAuth2 vs password switch.
310 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000311 parser.auth_group.add_option(
312 '--oauth2',
313 action='store_true',
314 dest='use_oauth2',
315 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000316 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000317 parser.auth_group.add_option(
318 '--no-oauth2',
319 action='store_false',
320 dest='use_oauth2',
321 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000322 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
323
324 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000325 parser.auth_group.add_option(
326 '--no-cookies',
327 action='store_false',
328 dest='save_cookies',
329 default=default_config.save_cookies,
330 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000331
332 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000333 parser.auth_group.add_option(
334 '--auth-no-local-webserver',
335 action='store_false',
336 dest='use_local_webserver',
337 default=default_config.use_local_webserver,
338 help='Do not run a local web server when performing OAuth2 login flow.')
339 parser.auth_group.add_option(
340 '--auth-host-port',
341 type=int,
342 default=default_config.webserver_port,
343 help='Port a local web server should listen on. Used only if '
344 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000345 parser.auth_group.add_option(
346 '--auth-refresh-token-json',
347 default=default_config.refresh_token_json,
348 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000349
350
351def extract_auth_config_from_options(options):
352 """Given OptionParser parsed options, extracts AuthConfig from it.
353
354 OptionParser should be populated with auth options by 'add_auth_options'.
355 """
356 return make_auth_config(
357 use_oauth2=options.use_oauth2,
358 save_cookies=False if options.use_oauth2 else options.save_cookies,
359 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000360 webserver_port=options.auth_host_port,
361 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000362
363
364def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000365 """AuthConfig -> list of strings with command line options.
366
367 Omits options that are set to default values.
368 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000369 if not auth_config:
370 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000371 defaults = make_auth_config()
372 opts = []
373 if auth_config.use_oauth2 != defaults.use_oauth2:
374 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
375 if auth_config.save_cookies != auth_config.save_cookies:
376 if not auth_config.save_cookies:
377 opts.append('--no-cookies')
378 if auth_config.use_local_webserver != defaults.use_local_webserver:
379 if not auth_config.use_local_webserver:
380 opts.append('--auth-no-local-webserver')
381 if auth_config.webserver_port != defaults.webserver_port:
382 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000383 if auth_config.refresh_token_json != defaults.refresh_token_json:
384 opts.extend([
385 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000386 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000387
388
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700389def get_authenticator_for_host(hostname, config, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000390 """Returns Authenticator instance to access given host.
391
392 Args:
393 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
394 a cache key for token cache.
395 config: AuthConfig instance.
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700396 scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000397
398 Returns:
399 Authenticator object.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800400
401 Raises:
402 AuthenticationError if hostname is invalid.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000403 """
404 hostname = hostname.lower().rstrip('/')
405 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
406 if '://' not in hostname:
407 hostname = 'https://' + hostname
408 parsed = urlparse.urlparse(hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000409
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000410 if parsed.path or parsed.params or parsed.query or parsed.fragment:
411 raise AuthenticationError(
412 'Expecting a hostname or root host URL, got %s instead' % hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000413 return Authenticator(parsed.netloc, config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000414
415
416class Authenticator(object):
417 """Object that knows how to refresh access tokens when needed.
418
419 Args:
420 token_cache_key: string key of a section of the token cache file to use
421 to keep the tokens. See hostname_to_token_cache_key.
422 config: AuthConfig object that holds authentication configuration.
423 """
424
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000425 def __init__(self, token_cache_key, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000426 assert isinstance(config, AuthConfig)
427 assert config.use_oauth2
428 self._access_token = None
429 self._config = config
430 self._lock = threading.Lock()
431 self._token_cache_key = token_cache_key
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000432 self._external_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000433 self._scopes = scopes
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000434 if config.refresh_token_json:
435 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000436 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000437
438 def login(self):
439 """Performs interactive login flow if necessary.
440
441 Raises:
442 AuthenticationError on error or if interrupted.
443 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000444 if self._external_token:
445 raise AuthenticationError(
446 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000447 return self.get_access_token(
448 force_refresh=True, allow_user_interaction=True)
449
450 def logout(self):
451 """Revokes the refresh token and deletes it from the cache.
452
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000453 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000454 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000455 with self._lock:
456 self._access_token = None
457 storage = self._get_storage()
458 credentials = storage.get()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000459 had_creds = bool(credentials)
460 if credentials and credentials.refresh_token and credentials.revoke_uri:
461 try:
462 credentials.revoke(httplib2.Http())
463 except client.TokenRevokeError as e:
464 logging.warning('Failed to revoke refresh token: %s', e)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000465 storage.delete()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000466 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000467
468 def has_cached_credentials(self):
469 """Returns True if long term credentials (refresh token) are in cache.
470
471 Doesn't make network calls.
472
473 If returns False, get_access_token() later will ask for interactive login by
474 raising LoginRequiredError.
475
476 If returns True, most probably get_access_token() won't ask for interactive
477 login, though it is not guaranteed, since cached token can be already
478 revoked and there's no way to figure this out without actually trying to use
479 it.
480 """
481 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000482 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000483
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800484 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
485 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000486 """Returns AccessToken, refreshing it if necessary.
487
488 Args:
489 force_refresh: forcefully refresh access token even if it is not expired.
490 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800491 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000492
493 Raises:
494 AuthenticationError on error or if authentication flow was interrupted.
495 LoginRequiredError if user interaction is required, but
496 allow_user_interaction is False.
497 """
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800498 def get_loc_auth_tkn():
499 exi = sys.exc_info()
500 if not use_local_auth:
501 logging.error('Failed to create access token')
502 raise
503 try:
504 self._access_token = get_luci_context_access_token()
505 if not self._access_token:
506 logging.error('Failed to create access token')
507 raise
508 return self._access_token
509 except LuciContextAuthError:
510 logging.exception('Failed to use local auth')
511 raise exi[0], exi[1], exi[2]
512
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000513 with self._lock:
514 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000515 logging.debug('Forcing access token refresh')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800516 try:
517 self._access_token = self._create_access_token(allow_user_interaction)
518 return self._access_token
519 except LoginRequiredError:
520 return get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000521
522 # Load from on-disk cache on a first access.
523 if not self._access_token:
524 self._access_token = self._load_access_token()
525
526 # Refresh if expired or missing.
Andrii Shyshkalov94580ab2018-04-19 18:04:54 -0700527 if not self._access_token or self._access_token.needs_refresh():
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000528 # Maybe some other process already updated it, reload from the cache.
529 self._access_token = self._load_access_token()
530 # Nope, still expired, need to run the refresh flow.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700531 if not self._access_token or self._access_token.needs_refresh():
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800532 try:
533 self._access_token = self._create_access_token(
534 allow_user_interaction)
535 except LoginRequiredError:
536 get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000537
538 return self._access_token
539
540 def get_token_info(self):
541 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
542 access_token = self.get_access_token()
543 resp, content = httplib2.Http().request(
544 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
545 urllib.urlencode({'access_token': access_token.token})))
546 if resp.status == 200:
547 return json.loads(content)
548 raise AuthenticationError('Failed to fetch the token info: %r' % content)
549
550 def authorize(self, http):
551 """Monkey patches authentication logic of httplib2.Http instance.
552
553 The modified http.request method will add authentication headers to each
554 request and will refresh access_tokens when a 401 is received on a
555 request.
556
557 Args:
558 http: An instance of httplib2.Http.
559
560 Returns:
561 A modified instance of http that was passed in.
562 """
563 # Adapted from oauth2client.OAuth2Credentials.authorize.
564
565 request_orig = http.request
566
567 @functools.wraps(request_orig)
568 def new_request(
569 uri, method='GET', body=None, headers=None,
570 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
571 connection_type=None):
572 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000573 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000574 resp, content = request_orig(
575 uri, method, body, headers, redirections, connection_type)
576 if resp.status in client.REFRESH_STATUS_CODES:
577 logging.info('Refreshing due to a %s', resp.status)
578 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000579 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000580 return request_orig(
581 uri, method, body, headers, redirections, connection_type)
582 else:
583 return (resp, content)
584
585 http.request = new_request
586 return http
587
588 ## Private methods.
589
590 def _get_storage(self):
591 """Returns oauth2client.Storage with cached tokens."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000592 # Do not mix cache keys for different externally provided tokens.
593 if self._external_token:
594 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
595 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
596 else:
597 cache_key = self._token_cache_key
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000598 path = _get_token_cache_path()
599 logging.debug('Using token storage %r (cache key %r)', path, cache_key)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000600 return multistore_file.get_credential_storage_custom_string_key(
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000601 path, cache_key)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000602
603 def _get_cached_credentials(self):
604 """Returns oauth2client.Credentials loaded from storage."""
605 storage = self._get_storage()
606 credentials = storage.get()
607
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000608 if not credentials:
609 logging.debug('No cached token')
610 else:
611 _log_credentials_info('cached token', credentials)
612
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000613 # Is using --auth-refresh-token-json?
614 if self._external_token:
615 # Cached credentials are valid and match external token -> use them. It is
616 # important to reuse credentials from the storage because they contain
617 # cached access token.
618 valid = (
619 credentials and not credentials.invalid and
620 credentials.refresh_token == self._external_token.refresh_token and
621 credentials.client_id == self._external_token.client_id and
622 credentials.client_secret == self._external_token.client_secret)
623 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000624 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000625 return credentials
626 # Construct new credentials from externally provided refresh token,
627 # associate them with cache storage (so that access_token will be placed
628 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000629 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000630 credentials = client.OAuth2Credentials(
631 access_token=None,
632 client_id=self._external_token.client_id,
633 client_secret=self._external_token.client_secret,
634 refresh_token=self._external_token.refresh_token,
635 token_expiry=None,
636 token_uri='https://accounts.google.com/o/oauth2/token',
637 user_agent=None,
638 revoke_uri=None)
639 credentials.set_store(storage)
640 storage.put(credentials)
641 return credentials
642
643 # Not using external refresh token -> return whatever is cached.
644 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000645
646 def _load_access_token(self):
647 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000648 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000649 creds = self._get_cached_credentials()
650 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000651 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000652 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000653 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000654
655 def _create_access_token(self, allow_user_interaction=False):
656 """Mints and caches a new access token, launching OAuth2 dance if necessary.
657
658 Uses cached refresh token, if present. In that case user interaction is not
659 required and function will finish quietly. Otherwise it will launch 3-legged
660 OAuth2 flow, that needs user interaction.
661
662 Args:
663 allow_user_interaction: if True, allow interaction with the user (e.g.
664 reading standard input, or launching a browser).
665
666 Returns:
667 AccessToken.
668
669 Raises:
670 AuthenticationError on error or if authentication flow was interrupted.
671 LoginRequiredError if user interaction is required, but
672 allow_user_interaction is False.
673 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000674 logging.debug(
675 'Making new access token (allow_user_interaction=%r)',
676 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000677 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000678
679 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000680 refreshed = False
681 if credentials and not credentials.invalid:
682 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000683 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000684 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000685 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000686 refreshed = True
687 except client.Error as err:
688 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000689 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000690 'Attempting a full authentication flow.', err)
691
692 # Refresh token is missing or invalid, go through the full flow.
693 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000694 # Can't refresh externally provided token.
695 if self._external_token:
696 raise AuthenticationError(
697 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000698 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000699 logging.debug('Requesting user to login')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000700 raise LoginRequiredError(self._token_cache_key)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000701 logging.debug('Launching OAuth browser flow')
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000702 credentials = _run_oauth_dance(self._config, self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000703 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000704
705 logging.info(
706 'OAuth access_token refreshed. Expires in %s.',
707 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000708 storage = self._get_storage()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000709 credentials.set_store(storage)
710 storage.put(credentials)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000711 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000712
713
714## Private functions.
715
716
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000717def _get_token_cache_path():
718 # On non Win just use HOME.
719 if sys.platform != 'win32':
720 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
721 # Prefer USERPROFILE over HOME, since HOME is overridden in
722 # git-..._bin/cmd/git.cmd to point to depot_tools. depot-tools-auth.py script
723 # (and all other scripts) doesn't use this override and thus uses another
724 # value for HOME. git.cmd doesn't touch USERPROFILE though, and usually
725 # USERPROFILE == HOME on Windows.
726 if 'USERPROFILE' in os.environ:
727 return os.path.join(os.environ['USERPROFILE'], OAUTH_TOKENS_CACHE)
728 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
729
730
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000731def _is_headless():
732 """True if machine doesn't seem to have a display."""
733 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
734
735
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000736def _read_refresh_token_json(path):
737 """Returns RefreshToken by reading it from the JSON file."""
738 try:
739 with open(path, 'r') as f:
740 data = json.load(f)
741 return RefreshToken(
742 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
743 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
744 refresh_token=str(data['refresh_token']))
745 except (IOError, ValueError) as e:
746 raise AuthenticationError(
747 'Failed to read refresh token from %s: %s' % (path, e))
748 except KeyError as e:
749 raise AuthenticationError(
750 'Failed to read refresh token from %s: missing key %s' % (path, e))
751
752
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000753def _log_credentials_info(title, credentials):
754 """Dumps (non sensitive) part of client.Credentials object to debug log."""
755 if credentials:
756 logging.debug('%s info: %r', title, {
757 'access_token_expired': credentials.access_token_expired,
758 'has_access_token': bool(credentials.access_token),
759 'invalid': credentials.invalid,
760 'utcnow': datetime.datetime.utcnow(),
761 'token_expiry': credentials.token_expiry,
762 })
763
764
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000765def _run_oauth_dance(config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000766 """Perform full 3-legged OAuth2 flow with the browser.
767
768 Returns:
769 oauth2client.Credentials.
770
771 Raises:
772 AuthenticationError on errors.
773 """
774 flow = client.OAuth2WebServerFlow(
775 OAUTH_CLIENT_ID,
776 OAUTH_CLIENT_SECRET,
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000777 scopes,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000778 approval_prompt='force')
779
780 use_local_webserver = config.use_local_webserver
781 port = config.webserver_port
782 if config.use_local_webserver:
783 success = False
784 try:
785 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
786 except socket.error:
787 pass
788 else:
789 success = True
790 use_local_webserver = success
791 if not success:
792 print(
793 'Failed to start a local webserver listening on port %d.\n'
794 'Please check your firewall settings and locally running programs that '
795 'may be blocking or using those ports.\n\n'
796 'Falling back to --auth-no-local-webserver and continuing with '
797 'authentication.\n' % port)
798
799 if use_local_webserver:
800 oauth_callback = 'http://localhost:%s/' % port
801 else:
802 oauth_callback = client.OOB_CALLBACK_URN
803 flow.redirect_uri = oauth_callback
804 authorize_url = flow.step1_get_authorize_url()
805
806 if use_local_webserver:
807 webbrowser.open(authorize_url, new=1, autoraise=True)
808 print(
809 'Your browser has been opened to visit:\n\n'
810 ' %s\n\n'
811 'If your browser is on a different machine then exit and re-run this '
812 'application with the command-line parameter\n\n'
813 ' --auth-no-local-webserver\n' % authorize_url)
814 else:
815 print(
816 'Go to the following link in your browser:\n\n'
817 ' %s\n' % authorize_url)
818
819 try:
820 code = None
821 if use_local_webserver:
822 httpd.handle_request()
823 if 'error' in httpd.query_params:
824 raise AuthenticationError(
825 'Authentication request was rejected: %s' %
826 httpd.query_params['error'])
827 if 'code' not in httpd.query_params:
828 raise AuthenticationError(
829 'Failed to find "code" in the query parameters of the redirect.\n'
830 'Try running with --auth-no-local-webserver.')
831 code = httpd.query_params['code']
832 else:
833 code = raw_input('Enter verification code: ').strip()
834 except KeyboardInterrupt:
835 raise AuthenticationError('Authentication was canceled.')
836
837 try:
838 return flow.step2_exchange(code)
839 except client.FlowExchangeError as e:
840 raise AuthenticationError('Authentication has failed: %s' % e)
841
842
843class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
844 """A server to handle OAuth 2.0 redirects back to localhost.
845
846 Waits for a single request and parses the query parameters
847 into query_params and then stops serving.
848 """
849 query_params = {}
850
851
852class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
853 """A handler for OAuth 2.0 redirects back to localhost.
854
855 Waits for a single request and parses the query parameters
856 into the servers query_params and then stops serving.
857 """
858
859 def do_GET(self):
860 """Handle a GET request.
861
862 Parses the query parameters and prints a message
863 if the flow has completed. Note that we can't detect
864 if an error occurred.
865 """
866 self.send_response(200)
867 self.send_header('Content-type', 'text/html')
868 self.end_headers()
869 query = self.path.split('?', 1)[-1]
870 query = dict(urlparse.parse_qsl(query))
871 self.server.query_params = query
872 self.wfile.write('<html><head><title>Authentication Status</title></head>')
873 self.wfile.write('<body><p>The authentication flow has completed.</p>')
874 self.wfile.write('</body></html>')
875
876 def log_message(self, _format, *args):
877 """Do not log messages to stdout while running as command line program."""