blob: 7f15fe941aa41155cfaf0a97d2cb7f8a72bd00c1 [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
Edward Lemuracf922c2019-10-18 18:02:43 +000037# Mockable datetime.datetime.utcnow for testing.
38def datetime_now():
39 return datetime.datetime.utcnow()
40
41
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000042# Authentication configuration extracted from command line options.
43# See doc string for 'make_auth_config' for meaning of fields.
44AuthConfig = collections.namedtuple('AuthConfig', [
45 'use_oauth2', # deprecated, will be always True
46 'save_cookies', # deprecated, will be removed
47 'use_local_webserver',
48 'webserver_port',
49])
50
51
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000052# OAuth access token with its expiration time (UTC datetime or None if unknown).
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070053class AccessToken(collections.namedtuple('AccessToken', [
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000054 'token',
55 'expires_at',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070056 ])):
57
58 def needs_refresh(self, now=None):
59 """True if this AccessToken should be refreshed."""
60 if self.expires_at is not None:
Edward Lemuracf922c2019-10-18 18:02:43 +000061 now = now or datetime_now()
62 # Allow 30s of clock skew between client and backend.
63 now += datetime.timedelta(seconds=30)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070064 return now >= self.expires_at
65 # Token without expiration time never expires.
66 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000067
68
69class AuthenticationError(Exception):
70 """Raised on errors related to authentication."""
71
72
73class LoginRequiredError(AuthenticationError):
74 """Interaction with the user is required to authenticate."""
75
Edward Lemurba5bc992019-09-23 22:59:17 +000076 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000077 msg = (
78 'You are not logged in. Please login first by running:\n'
Edward Lemurba5bc992019-09-23 22:59:17 +000079 ' luci-auth login -scopes %s' % scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000080 super(LoginRequiredError, self).__init__(msg)
81
82
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080083class LuciContextAuthError(Exception):
84 """Raised on errors related to unsuccessful attempts to load LUCI_CONTEXT"""
85
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070086 def __init__(self, msg, exc=None):
87 if exc is None:
88 logging.error(msg)
89 else:
90 logging.exception(msg)
91 msg = '%s: %s' % (msg, exc)
92 super(LuciContextAuthError, self).__init__(msg)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080093
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070094
95def has_luci_context_local_auth():
96 """Returns whether LUCI_CONTEXT should be used for ambient authentication.
97 """
98 try:
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -070099 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700100 except LuciContextAuthError:
101 return False
102 if params is None:
103 return False
104 return bool(params.default_account_id)
105
106
Edward Lemuracf922c2019-10-18 18:02:43 +0000107# TODO(crbug.com/1001756): Remove. luci-auth uses local auth if available,
108# making this unnecessary.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700109def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800110 """Returns a valid AccessToken from the local LUCI context auth server.
111
112 Adapted from
113 https://chromium.googlesource.com/infra/luci/luci-py/+/master/client/libs/luci_context/luci_context.py
114 See the link above for more details.
115
116 Returns:
117 AccessToken if LUCI_CONTEXT is present and attempt to load it is successful.
118 None if LUCI_CONTEXT is absent.
119
120 Raises:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700121 LuciContextAuthError if LUCI_CONTEXT is present, but there was a failure
122 obtaining its access token.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800123 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700124 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700125 if params is None:
126 return None
127 return _get_luci_context_access_token(
128 params, datetime.datetime.utcnow(), scopes)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800129
130
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700131_LuciContextLocalAuthParams = collections.namedtuple(
132 '_LuciContextLocalAuthParams', [
133 'default_account_id',
134 'secret',
135 'rpc_port',
136])
137
138
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700139def _cache_thread_safe(f):
140 """Decorator caching result of nullary function in thread-safe way."""
141 lock = threading.Lock()
142 cache = []
143
144 @functools.wraps(f)
145 def caching_wrapper():
146 if not cache:
147 with lock:
148 if not cache:
149 cache.append(f())
150 return cache[0]
151
152 # Allow easy way to clear cache, particularly useful in tests.
153 caching_wrapper.clear_cache = lambda: cache.pop() if cache else None
154 return caching_wrapper
155
156
157@_cache_thread_safe
158def _get_luci_context_local_auth_params():
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700159 """Returns local auth parameters if local auth is configured else None.
160
161 Raises LuciContextAuthError on unexpected failures.
162 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700163 ctx_path = os.environ.get('LUCI_CONTEXT')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800164 if not ctx_path:
165 return None
166 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800167 try:
168 loaded = _load_luci_context(ctx_path)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700169 except (OSError, IOError, ValueError) as e:
170 raise LuciContextAuthError('Failed to open, read or decode LUCI_CONTEXT', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800171 try:
172 local_auth = loaded.get('local_auth')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700173 except AttributeError as e:
174 raise LuciContextAuthError('LUCI_CONTEXT not in proper format', e)
175 if local_auth is None:
176 logging.debug('LUCI_CONTEXT configured w/o local auth')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800177 return None
178 try:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700179 return _LuciContextLocalAuthParams(
180 default_account_id=local_auth.get('default_account_id'),
181 secret=local_auth.get('secret'),
182 rpc_port=int(local_auth.get('rpc_port')))
183 except (AttributeError, ValueError) as e:
184 raise LuciContextAuthError('local_auth config malformed', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800185
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700186
187def _load_luci_context(ctx_path):
188 # Kept separate for test mocking.
189 with open(ctx_path) as f:
190 return json.load(f)
191
192
193def _get_luci_context_access_token(params, now, scopes=OAUTH_SCOPE_EMAIL):
194 # No account, local_auth shouldn't be used.
195 if not params.default_account_id:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800196 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700197 if not params.secret:
198 raise LuciContextAuthError('local_auth: no secret')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800199
200 logging.debug('local_auth: requesting an access token for account "%s"',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700201 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800202 http = httplib2.Http()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700203 host = '127.0.0.1:%d' % params.rpc_port
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800204 resp, content = http.request(
205 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
206 method='POST',
207 body=json.dumps({
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700208 'account_id': params.default_account_id,
209 'scopes': scopes.split(' '),
210 'secret': params.secret,
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800211 }),
212 headers={'Content-Type': 'application/json'})
213 if resp.status != 200:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700214 raise LuciContextAuthError(
215 'local_auth: Failed to grab access token from '
216 'LUCI context server with status %d: %r' % (resp.status, content))
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800217 try:
218 token = json.loads(content)
219 error_code = token.get('error_code')
220 error_message = token.get('error_message')
221 access_token = token.get('access_token')
222 expiry = token.get('expiry')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700223 except (AttributeError, ValueError) as e:
224 raise LuciContextAuthError('Unexpected access token response format', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800225 if error_code:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700226 raise LuciContextAuthError(
227 'Error %d in retrieving access token: %s', error_code, error_message)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800228 if not access_token:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700229 raise LuciContextAuthError(
230 'No access token returned from LUCI context server')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800231 expiry_dt = None
232 if expiry:
233 try:
234 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800235 logging.debug(
236 'local_auth: got an access token for '
237 'account "%s" that expires in %d sec',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700238 params.default_account_id, (expiry_dt - now).total_seconds())
239 except (TypeError, ValueError) as e:
240 raise LuciContextAuthError('Invalid expiry in returned token', e)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800241 else:
242 logging.debug(
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700243 'local auth: got an access token for account "%s" that does not expire',
244 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800245 access_token = AccessToken(access_token, expiry_dt)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700246 if access_token.needs_refresh(now=now):
247 raise LuciContextAuthError('Received access token is already expired')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800248 return access_token
249
250
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000251def make_auth_config(
252 use_oauth2=None,
253 save_cookies=None,
254 use_local_webserver=None,
Edward Lemura0568172019-10-16 15:37:58 +0000255 webserver_port=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000256 """Returns new instance of AuthConfig.
257
258 If some config option is None, it will be set to a reasonable default value.
259 This function also acts as an authoritative place for default values of
260 corresponding command line options.
261 """
262 default = lambda val, d: val if val is not None else d
263 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000264 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000265 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000266 default(use_local_webserver, not _is_headless()),
Edward Lemura0568172019-10-16 15:37:58 +0000267 default(webserver_port, 8090))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000268
269
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000270def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000271 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000272 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000273 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000274 parser.add_option_group(parser.auth_group)
275
276 # OAuth2 vs password switch.
277 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000278 parser.auth_group.add_option(
279 '--oauth2',
280 action='store_true',
281 dest='use_oauth2',
282 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000283 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000284 parser.auth_group.add_option(
285 '--no-oauth2',
286 action='store_false',
287 dest='use_oauth2',
288 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000289 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
290
291 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000292 parser.auth_group.add_option(
293 '--no-cookies',
294 action='store_false',
295 dest='save_cookies',
296 default=default_config.save_cookies,
297 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000298
299 # OAuth2 related options.
Edward Lemuracf922c2019-10-18 18:02:43 +0000300 # TODO(crbug.com/1001756): Remove. No longer supported.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000301 parser.auth_group.add_option(
302 '--auth-no-local-webserver',
303 action='store_false',
304 dest='use_local_webserver',
305 default=default_config.use_local_webserver,
Edward Lemuracf922c2019-10-18 18:02:43 +0000306 help='DEPRECATED. Do not use')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000307 parser.auth_group.add_option(
308 '--auth-host-port',
309 type=int,
310 default=default_config.webserver_port,
Edward Lemuracf922c2019-10-18 18:02:43 +0000311 help='DEPRECATED. Do not use')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000312 parser.auth_group.add_option(
313 '--auth-refresh-token-json',
Edward Lemura0568172019-10-16 15:37:58 +0000314 help='DEPRECATED. Do not use')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000315
316
317def extract_auth_config_from_options(options):
318 """Given OptionParser parsed options, extracts AuthConfig from it.
319
320 OptionParser should be populated with auth options by 'add_auth_options'.
321 """
322 return make_auth_config(
323 use_oauth2=options.use_oauth2,
324 save_cookies=False if options.use_oauth2 else options.save_cookies,
325 use_local_webserver=options.use_local_webserver,
Edward Lemura0568172019-10-16 15:37:58 +0000326 webserver_port=options.auth_host_port)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000327
328
329def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000330 """AuthConfig -> list of strings with command line options.
331
332 Omits options that are set to default values.
333 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000334 if not auth_config:
335 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000336 defaults = make_auth_config()
337 opts = []
338 if auth_config.use_oauth2 != defaults.use_oauth2:
339 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
340 if auth_config.save_cookies != auth_config.save_cookies:
341 if not auth_config.save_cookies:
342 opts.append('--no-cookies')
343 if auth_config.use_local_webserver != defaults.use_local_webserver:
344 if not auth_config.use_local_webserver:
345 opts.append('--auth-no-local-webserver')
346 if auth_config.webserver_port != defaults.webserver_port:
347 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000348 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000349
350
Edward Lemurb4a587d2019-10-09 23:56:38 +0000351def get_authenticator(config, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000352 """Returns Authenticator instance to access given host.
353
354 Args:
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000355 config: AuthConfig instance.
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700356 scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000357
358 Returns:
359 Authenticator object.
360 """
Edward Lemurb4a587d2019-10-09 23:56:38 +0000361 return Authenticator(config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000362
363
364class Authenticator(object):
365 """Object that knows how to refresh access tokens when needed.
366
367 Args:
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000368 config: AuthConfig object that holds authentication configuration.
369 """
370
Edward Lemurb4a587d2019-10-09 23:56:38 +0000371 def __init__(self, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000372 assert isinstance(config, AuthConfig)
373 assert config.use_oauth2
374 self._access_token = None
375 self._config = config
376 self._lock = threading.Lock()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000377 self._scopes = scopes
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000378 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000379
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000380 def has_cached_credentials(self):
Edward Lemuracf922c2019-10-18 18:02:43 +0000381 """Returns True if credentials can be obtained.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000382
Edward Lemuracf922c2019-10-18 18:02:43 +0000383 If returns False, get_access_token() later will probably ask for interactive
384 login by raising LoginRequiredError, unless local auth is configured.
Edward Lesmes989bc352019-10-17 05:45:35 +0000385
Edward Lemuracf922c2019-10-18 18:02:43 +0000386 If returns True, get_access_token() won't ask for interactive login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000387 """
388 with self._lock:
Edward Lemuracf922c2019-10-18 18:02:43 +0000389 return bool(self._get_luci_auth_token())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000390
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800391 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
392 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000393 """Returns AccessToken, refreshing it if necessary.
394
395 Args:
Edward Lemuracf922c2019-10-18 18:02:43 +0000396 TODO(crbug.com/1001756): Remove.
397 force_refresh: Ignored, luci-auth doesn't support force-refreshing tokens.
398 allow_user_interaction: Ignored. allow_user_interaction is always False.
399 use_local_auth: Ignored. luci-auth already covers local_auth.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000400
401 Raises:
402 AuthenticationError on error or if authentication flow was interrupted.
403 LoginRequiredError if user interaction is required, but
404 allow_user_interaction is False.
405 """
406 with self._lock:
Edward Lemuracf922c2019-10-18 18:02:43 +0000407 if self._access_token and not self._access_token.needs_refresh():
408 return self._access_token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000409
Edward Lemuracf922c2019-10-18 18:02:43 +0000410 # Token expired or missing. Maybe some other process already updated it,
411 # reload from the cache.
412 self._access_token = self._get_luci_auth_token()
413 if self._access_token and not self._access_token.needs_refresh():
414 return self._access_token
Edward Lesmes989bc352019-10-17 05:45:35 +0000415
Edward Lemuracf922c2019-10-18 18:02:43 +0000416 # Nope, still expired, need to run the refresh flow.
417 logging.error('Failed to create access token')
418 raise LoginRequiredError(self._scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000419
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000420 def authorize(self, http):
421 """Monkey patches authentication logic of httplib2.Http instance.
422
423 The modified http.request method will add authentication headers to each
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000424 request.
425
426 Args:
427 http: An instance of httplib2.Http.
428
429 Returns:
430 A modified instance of http that was passed in.
431 """
432 # Adapted from oauth2client.OAuth2Credentials.authorize.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000433 request_orig = http.request
434
435 @functools.wraps(request_orig)
436 def new_request(
437 uri, method='GET', body=None, headers=None,
438 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
439 connection_type=None):
440 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000441 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
Edward Lemuracf922c2019-10-18 18:02:43 +0000442 return request_orig(
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000443 uri, method, body, headers, redirections, connection_type)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000444
445 http.request = new_request
446 return http
447
448 ## Private methods.
449
Edward Lemuracf922c2019-10-18 18:02:43 +0000450 def _run_luci_auth_login(self):
451 """Run luci-auth login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000452
453 Returns:
Edward Lemuracf922c2019-10-18 18:02:43 +0000454 AccessToken with credentials.
Edward Lesmes989bc352019-10-17 05:45:35 +0000455 """
Edward Lemuracf922c2019-10-18 18:02:43 +0000456 logging.debug('Running luci-auth login')
457 subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes])
458 return self._get_luci_auth_token()
Edward Lesmes989bc352019-10-17 05:45:35 +0000459
Edward Lemuracf922c2019-10-18 18:02:43 +0000460 def _get_luci_auth_token(self):
461 logging.debug('Running luci-auth token')
462 try:
463 out, err = subprocess2.check_call_out(
464 ['luci-auth', 'token', '-scopes', self._scopes, '-json-output', '-'],
465 stdout=subprocess2.PIPE, stderr=subprocess2.PIPE)
466 logging.debug('luci-auth token stderr:\n%s', err)
467 token_info = json.loads(out)
468 return AccessToken(
469 token_info['token'],
470 datetime.datetime.utcfromtimestamp(token_info['expiry']))
471 except subprocess2.CalledProcessError:
472 return None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000473
474
475## Private functions.
476
477
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000478def _is_headless():
479 """True if machine doesn't seem to have a display."""
480 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')