blob: 84011b3ad73dd66b2a0248797cdbd5a97cc856b0 [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.orgcf6a5d22015-04-09 22:02:00 +00009import collections
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000010import datetime
11import functools
12import json
13import logging
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000014import optparse
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000015import os
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000016import sys
17import threading
18import urllib
19import urlparse
Edward Lemurba5bc992019-09-23 22:59:17 +000020
21import subprocess2
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000022
Nodir Turakulov5abb9b72019-10-12 20:55:10 +000023from third_party import httplib2
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000024
25
26# depot_tools/.
27DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
28
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070029# This is what most GAE apps require for authentication.
30OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
31# Gerrit and Git on *.googlesource.com require this scope.
32OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
33# Deprecated. Use OAUTH_SCOPE_EMAIL instead.
34OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000035
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000036
37# Authentication configuration extracted from command line options.
38# See doc string for 'make_auth_config' for meaning of fields.
39AuthConfig = collections.namedtuple('AuthConfig', [
40 'use_oauth2', # deprecated, will be always True
41 'save_cookies', # deprecated, will be removed
42 'use_local_webserver',
43 'webserver_port',
44])
45
46
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000047# OAuth access token with its expiration time (UTC datetime or None if unknown).
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070048class AccessToken(collections.namedtuple('AccessToken', [
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000049 'token',
50 'expires_at',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070051 ])):
52
53 def needs_refresh(self, now=None):
54 """True if this AccessToken should be refreshed."""
55 if self.expires_at is not None:
56 now = now or datetime.datetime.utcnow()
Edward Lemur55e58532019-10-17 00:00:01 +000057 # Allow 30s of clock skew between client and backend.
58 now += datetime.timedelta(seconds=30)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070059 return now >= self.expires_at
60 # Token without expiration time never expires.
61 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000062
63
64class AuthenticationError(Exception):
65 """Raised on errors related to authentication."""
66
67
68class LoginRequiredError(AuthenticationError):
69 """Interaction with the user is required to authenticate."""
70
Edward Lemurba5bc992019-09-23 22:59:17 +000071 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000072 msg = (
73 'You are not logged in. Please login first by running:\n'
Edward Lemurba5bc992019-09-23 22:59:17 +000074 ' luci-auth login -scopes %s' % scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000075 super(LoginRequiredError, self).__init__(msg)
76
77
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080078class LuciContextAuthError(Exception):
79 """Raised on errors related to unsuccessful attempts to load LUCI_CONTEXT"""
80
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070081 def __init__(self, msg, exc=None):
82 if exc is None:
83 logging.error(msg)
84 else:
85 logging.exception(msg)
86 msg = '%s: %s' % (msg, exc)
87 super(LuciContextAuthError, self).__init__(msg)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080088
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070089
90def has_luci_context_local_auth():
91 """Returns whether LUCI_CONTEXT should be used for ambient authentication.
92 """
93 try:
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -070094 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070095 except LuciContextAuthError:
96 return False
97 if params is None:
98 return False
99 return bool(params.default_account_id)
100
101
Edward Lemur55e58532019-10-17 00:00:01 +0000102# TODO(crbug.com/1001756): Remove. luci-auth uses local auth if available,
103# making this unnecessary.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700104def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800105 """Returns a valid AccessToken from the local LUCI context auth server.
106
107 Adapted from
108 https://chromium.googlesource.com/infra/luci/luci-py/+/master/client/libs/luci_context/luci_context.py
109 See the link above for more details.
110
111 Returns:
112 AccessToken if LUCI_CONTEXT is present and attempt to load it is successful.
113 None if LUCI_CONTEXT is absent.
114
115 Raises:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700116 LuciContextAuthError if LUCI_CONTEXT is present, but there was a failure
117 obtaining its access token.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800118 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700119 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700120 if params is None:
121 return None
122 return _get_luci_context_access_token(
123 params, datetime.datetime.utcnow(), scopes)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800124
125
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700126_LuciContextLocalAuthParams = collections.namedtuple(
127 '_LuciContextLocalAuthParams', [
128 'default_account_id',
129 'secret',
130 'rpc_port',
131])
132
133
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700134def _cache_thread_safe(f):
135 """Decorator caching result of nullary function in thread-safe way."""
136 lock = threading.Lock()
137 cache = []
138
139 @functools.wraps(f)
140 def caching_wrapper():
141 if not cache:
142 with lock:
143 if not cache:
144 cache.append(f())
145 return cache[0]
146
147 # Allow easy way to clear cache, particularly useful in tests.
148 caching_wrapper.clear_cache = lambda: cache.pop() if cache else None
149 return caching_wrapper
150
151
152@_cache_thread_safe
153def _get_luci_context_local_auth_params():
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700154 """Returns local auth parameters if local auth is configured else None.
155
156 Raises LuciContextAuthError on unexpected failures.
157 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700158 ctx_path = os.environ.get('LUCI_CONTEXT')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800159 if not ctx_path:
160 return None
161 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800162 try:
163 loaded = _load_luci_context(ctx_path)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700164 except (OSError, IOError, ValueError) as e:
165 raise LuciContextAuthError('Failed to open, read or decode LUCI_CONTEXT', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800166 try:
167 local_auth = loaded.get('local_auth')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700168 except AttributeError as e:
169 raise LuciContextAuthError('LUCI_CONTEXT not in proper format', e)
170 if local_auth is None:
171 logging.debug('LUCI_CONTEXT configured w/o local auth')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800172 return None
173 try:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700174 return _LuciContextLocalAuthParams(
175 default_account_id=local_auth.get('default_account_id'),
176 secret=local_auth.get('secret'),
177 rpc_port=int(local_auth.get('rpc_port')))
178 except (AttributeError, ValueError) as e:
179 raise LuciContextAuthError('local_auth config malformed', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800180
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700181
182def _load_luci_context(ctx_path):
183 # Kept separate for test mocking.
184 with open(ctx_path) as f:
185 return json.load(f)
186
187
188def _get_luci_context_access_token(params, now, scopes=OAUTH_SCOPE_EMAIL):
189 # No account, local_auth shouldn't be used.
190 if not params.default_account_id:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800191 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700192 if not params.secret:
193 raise LuciContextAuthError('local_auth: no secret')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800194
195 logging.debug('local_auth: requesting an access token for account "%s"',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700196 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800197 http = httplib2.Http()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700198 host = '127.0.0.1:%d' % params.rpc_port
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800199 resp, content = http.request(
200 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
201 method='POST',
202 body=json.dumps({
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700203 'account_id': params.default_account_id,
204 'scopes': scopes.split(' '),
205 'secret': params.secret,
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800206 }),
207 headers={'Content-Type': 'application/json'})
208 if resp.status != 200:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700209 raise LuciContextAuthError(
210 'local_auth: Failed to grab access token from '
211 'LUCI context server with status %d: %r' % (resp.status, content))
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800212 try:
213 token = json.loads(content)
214 error_code = token.get('error_code')
215 error_message = token.get('error_message')
216 access_token = token.get('access_token')
217 expiry = token.get('expiry')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700218 except (AttributeError, ValueError) as e:
219 raise LuciContextAuthError('Unexpected access token response format', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800220 if error_code:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700221 raise LuciContextAuthError(
222 'Error %d in retrieving access token: %s', error_code, error_message)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800223 if not access_token:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700224 raise LuciContextAuthError(
225 'No access token returned from LUCI context server')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800226 expiry_dt = None
227 if expiry:
228 try:
229 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800230 logging.debug(
231 'local_auth: got an access token for '
232 'account "%s" that expires in %d sec',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700233 params.default_account_id, (expiry_dt - now).total_seconds())
234 except (TypeError, ValueError) as e:
235 raise LuciContextAuthError('Invalid expiry in returned token', e)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800236 else:
237 logging.debug(
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700238 'local auth: got an access token for account "%s" that does not expire',
239 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800240 access_token = AccessToken(access_token, expiry_dt)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700241 if access_token.needs_refresh(now=now):
242 raise LuciContextAuthError('Received access token is already expired')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800243 return access_token
244
245
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000246def make_auth_config(
247 use_oauth2=None,
248 save_cookies=None,
249 use_local_webserver=None,
Edward Lemura0568172019-10-16 15:37:58 +0000250 webserver_port=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000251 """Returns new instance of AuthConfig.
252
253 If some config option is None, it will be set to a reasonable default value.
254 This function also acts as an authoritative place for default values of
255 corresponding command line options.
256 """
257 default = lambda val, d: val if val is not None else d
258 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000259 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000260 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000261 default(use_local_webserver, not _is_headless()),
Edward Lemura0568172019-10-16 15:37:58 +0000262 default(webserver_port, 8090))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000263
264
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000265def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000266 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000267 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000268 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000269 parser.add_option_group(parser.auth_group)
270
271 # OAuth2 vs password switch.
272 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000273 parser.auth_group.add_option(
274 '--oauth2',
275 action='store_true',
276 dest='use_oauth2',
277 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000278 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000279 parser.auth_group.add_option(
280 '--no-oauth2',
281 action='store_false',
282 dest='use_oauth2',
283 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000284 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
285
286 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000287 parser.auth_group.add_option(
288 '--no-cookies',
289 action='store_false',
290 dest='save_cookies',
291 default=default_config.save_cookies,
292 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000293
294 # OAuth2 related options.
Edward Lemur55e58532019-10-17 00:00:01 +0000295 # TODO(crbug.com/1001756): Remove. No longer supported.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000296 parser.auth_group.add_option(
297 '--auth-no-local-webserver',
298 action='store_false',
299 dest='use_local_webserver',
300 default=default_config.use_local_webserver,
Edward Lemur55e58532019-10-17 00:00:01 +0000301 help='DEPRECATED. Do not use')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000302 parser.auth_group.add_option(
303 '--auth-host-port',
304 type=int,
305 default=default_config.webserver_port,
Edward Lemur55e58532019-10-17 00:00:01 +0000306 help='DEPRECATED. Do not use')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000307 parser.auth_group.add_option(
308 '--auth-refresh-token-json',
Edward Lemura0568172019-10-16 15:37:58 +0000309 help='DEPRECATED. Do not use')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000310
311
312def extract_auth_config_from_options(options):
313 """Given OptionParser parsed options, extracts AuthConfig from it.
314
315 OptionParser should be populated with auth options by 'add_auth_options'.
316 """
317 return make_auth_config(
318 use_oauth2=options.use_oauth2,
319 save_cookies=False if options.use_oauth2 else options.save_cookies,
320 use_local_webserver=options.use_local_webserver,
Edward Lemura0568172019-10-16 15:37:58 +0000321 webserver_port=options.auth_host_port)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000322
323
324def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000325 """AuthConfig -> list of strings with command line options.
326
327 Omits options that are set to default values.
328 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000329 if not auth_config:
330 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000331 defaults = make_auth_config()
332 opts = []
333 if auth_config.use_oauth2 != defaults.use_oauth2:
334 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
335 if auth_config.save_cookies != auth_config.save_cookies:
336 if not auth_config.save_cookies:
337 opts.append('--no-cookies')
338 if auth_config.use_local_webserver != defaults.use_local_webserver:
339 if not auth_config.use_local_webserver:
340 opts.append('--auth-no-local-webserver')
341 if auth_config.webserver_port != defaults.webserver_port:
342 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000343 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000344
345
Edward Lemurb4a587d2019-10-09 23:56:38 +0000346def get_authenticator(config, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000347 """Returns Authenticator instance to access given host.
348
349 Args:
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000350 config: AuthConfig instance.
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700351 scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000352
353 Returns:
354 Authenticator object.
355 """
Edward Lemurb4a587d2019-10-09 23:56:38 +0000356 return Authenticator(config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000357
358
359class Authenticator(object):
360 """Object that knows how to refresh access tokens when needed.
361
362 Args:
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000363 config: AuthConfig object that holds authentication configuration.
364 """
365
Edward Lemurb4a587d2019-10-09 23:56:38 +0000366 def __init__(self, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000367 assert isinstance(config, AuthConfig)
368 assert config.use_oauth2
369 self._access_token = None
370 self._config = config
371 self._lock = threading.Lock()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000372 self._scopes = scopes
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000373 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000374
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000375 def has_cached_credentials(self):
Edward Lemur55e58532019-10-17 00:00:01 +0000376 """Returns True if credentials can be obtained.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000377
Edward Lemur55e58532019-10-17 00:00:01 +0000378 If returns False, get_access_token() later will probably ask for interactive
379 login by raising LoginRequiredError, unless local auth in configured.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000380
381 If returns True, most probably get_access_token() won't ask for interactive
Edward Lemur55e58532019-10-17 00:00:01 +0000382 login, unless an external token is provided that has been revoked.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000383 """
384 with self._lock:
Edward Lemur55e58532019-10-17 00:00:01 +0000385 return bool(self._get_luci_auth_token())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000386
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800387 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
388 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000389 """Returns AccessToken, refreshing it if necessary.
390
391 Args:
Edward Lemur55e58532019-10-17 00:00:01 +0000392 TODO(crbug.com/1001756): Remove. luci-auth doesn't support
393 force-refreshing tokens.
394 force_refresh: Ignored,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000395 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800396 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000397
398 Raises:
399 AuthenticationError on error or if authentication flow was interrupted.
400 LoginRequiredError if user interaction is required, but
401 allow_user_interaction is False.
402 """
403 with self._lock:
Edward Lemur55e58532019-10-17 00:00:01 +0000404 if self._access_token and not self._access_token.needs_refresh():
405 return self._access_token
406
407 # Token expired or missing. Maybe some other process already updated it,
408 # reload from the cache.
409 self._access_token = self._get_luci_auth_token()
410 if self._access_token and not self._access_token.needs_refresh():
411 return self._access_token
412
413 # Nope, still expired, need to run the refresh flow.
414 if not self._external_token and allow_user_interaction:
415 logging.debug('Launching luci-auth login')
416 self._access_token = self._run_oauth_dance()
417 if self._access_token and not self._access_token.needs_refresh():
418 return self._access_token
419
420 # TODO(crbug.com/1001756): Remove. luci-auth uses local auth if it exists.
421 # Refresh flow failed. Try local auth.
422 if use_local_auth:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800423 try:
Edward Lemur55e58532019-10-17 00:00:01 +0000424 self._access_token = get_luci_context_access_token()
425 except LuciContextAuthError:
426 logging.exception('Failed to use local auth')
427 if self._access_token and not self._access_token.needs_refresh():
428 return self._access_token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000429
Edward Lemur55e58532019-10-17 00:00:01 +0000430 # Give up.
431 logging.error('Failed to create access token')
432 raise LoginRequiredError(self._scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000433
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000434 def authorize(self, http):
435 """Monkey patches authentication logic of httplib2.Http instance.
436
437 The modified http.request method will add authentication headers to each
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000438 request.
439
440 Args:
441 http: An instance of httplib2.Http.
442
443 Returns:
444 A modified instance of http that was passed in.
445 """
446 # Adapted from oauth2client.OAuth2Credentials.authorize.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000447 request_orig = http.request
448
449 @functools.wraps(request_orig)
450 def new_request(
451 uri, method='GET', body=None, headers=None,
452 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
453 connection_type=None):
454 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000455 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
Edward Lemur55e58532019-10-17 00:00:01 +0000456 return request_orig(
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000457 uri, method, body, headers, redirections, connection_type)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000458
459 http.request = new_request
460 return http
461
462 ## Private methods.
463
Edward Lemur55e58532019-10-17 00:00:01 +0000464 def _run_luci_auth_login(self):
465 """Run luci-auth login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000466
467 Returns:
Edward Lemur55e58532019-10-17 00:00:01 +0000468 AccessToken with credentials.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000469 """
Edward Lemur55e58532019-10-17 00:00:01 +0000470 logging.debug('Running luci-auth login')
471 subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes])
472 return self._get_luci_auth_token()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000473
Edward Lemur55e58532019-10-17 00:00:01 +0000474 def _get_luci_auth_token(self):
475 logging.debug('Running luci-auth token')
476 try:
477 out, err = subprocess2.check_call_out(
478 ['luci-auth', 'token', '-scopes', self._scopes, '-json-output', '-'],
479 stdout=subprocess2.PIPE, stderr=subprocess2.PIPE)
480 logging.debug('luci-auth token stderr:\n%s', err)
481 token_info = json.loads(out)
482 return AccessToken(
483 token_info['token'],
484 datetime.datetime.utcfromtimestamp(token_info['expiry']))
485 except subprocess2.CalledProcessError:
486 return None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000487
488
489## Private functions.
490
491
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000492def _is_headless():
493 """True if machine doesn't seem to have a display."""
494 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')