blob: e0afec988b57e486990a37ab9e8051627b7cf5d4 [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
Edward Lesmes989bc352019-10-17 05:45:35 +000024from third_party.oauth2client import client
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000025
26
27# depot_tools/.
28DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
29
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070030# This is what most GAE apps require for authentication.
31OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
32# Gerrit and Git on *.googlesource.com require this scope.
33OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
34# Deprecated. Use OAUTH_SCOPE_EMAIL instead.
35OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000036
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000037
38# Authentication configuration extracted from command line options.
39# See doc string for 'make_auth_config' for meaning of fields.
40AuthConfig = collections.namedtuple('AuthConfig', [
41 'use_oauth2', # deprecated, will be always True
42 'save_cookies', # deprecated, will be removed
43 'use_local_webserver',
44 'webserver_port',
45])
46
47
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000048# OAuth access token with its expiration time (UTC datetime or None if unknown).
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070049class AccessToken(collections.namedtuple('AccessToken', [
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000050 'token',
51 'expires_at',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070052 ])):
53
54 def needs_refresh(self, now=None):
55 """True if this AccessToken should be refreshed."""
56 if self.expires_at is not None:
57 now = now or datetime.datetime.utcnow()
Edward Lesmes989bc352019-10-17 05:45:35 +000058 # Allow 3 min of clock skew between client and backend.
59 now += datetime.timedelta(seconds=180)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070060 return now >= self.expires_at
61 # Token without expiration time never expires.
62 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000063
64
65class AuthenticationError(Exception):
66 """Raised on errors related to authentication."""
67
68
69class LoginRequiredError(AuthenticationError):
70 """Interaction with the user is required to authenticate."""
71
Edward Lemurba5bc992019-09-23 22:59:17 +000072 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000073 msg = (
74 'You are not logged in. Please login first by running:\n'
Edward Lemurba5bc992019-09-23 22:59:17 +000075 ' luci-auth login -scopes %s' % scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000076 super(LoginRequiredError, self).__init__(msg)
77
78
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080079class LuciContextAuthError(Exception):
80 """Raised on errors related to unsuccessful attempts to load LUCI_CONTEXT"""
81
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070082 def __init__(self, msg, exc=None):
83 if exc is None:
84 logging.error(msg)
85 else:
86 logging.exception(msg)
87 msg = '%s: %s' % (msg, exc)
88 super(LuciContextAuthError, self).__init__(msg)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080089
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070090
91def has_luci_context_local_auth():
92 """Returns whether LUCI_CONTEXT should be used for ambient authentication.
93 """
94 try:
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -070095 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070096 except LuciContextAuthError:
97 return False
98 if params is None:
99 return False
100 return bool(params.default_account_id)
101
102
103def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800104 """Returns a valid AccessToken from the local LUCI context auth server.
105
106 Adapted from
107 https://chromium.googlesource.com/infra/luci/luci-py/+/master/client/libs/luci_context/luci_context.py
108 See the link above for more details.
109
110 Returns:
111 AccessToken if LUCI_CONTEXT is present and attempt to load it is successful.
112 None if LUCI_CONTEXT is absent.
113
114 Raises:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700115 LuciContextAuthError if LUCI_CONTEXT is present, but there was a failure
116 obtaining its access token.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800117 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700118 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700119 if params is None:
120 return None
121 return _get_luci_context_access_token(
122 params, datetime.datetime.utcnow(), scopes)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800123
124
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700125_LuciContextLocalAuthParams = collections.namedtuple(
126 '_LuciContextLocalAuthParams', [
127 'default_account_id',
128 'secret',
129 'rpc_port',
130])
131
132
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700133def _cache_thread_safe(f):
134 """Decorator caching result of nullary function in thread-safe way."""
135 lock = threading.Lock()
136 cache = []
137
138 @functools.wraps(f)
139 def caching_wrapper():
140 if not cache:
141 with lock:
142 if not cache:
143 cache.append(f())
144 return cache[0]
145
146 # Allow easy way to clear cache, particularly useful in tests.
147 caching_wrapper.clear_cache = lambda: cache.pop() if cache else None
148 return caching_wrapper
149
150
151@_cache_thread_safe
152def _get_luci_context_local_auth_params():
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700153 """Returns local auth parameters if local auth is configured else None.
154
155 Raises LuciContextAuthError on unexpected failures.
156 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700157 ctx_path = os.environ.get('LUCI_CONTEXT')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800158 if not ctx_path:
159 return None
160 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800161 try:
162 loaded = _load_luci_context(ctx_path)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700163 except (OSError, IOError, ValueError) as e:
164 raise LuciContextAuthError('Failed to open, read or decode LUCI_CONTEXT', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800165 try:
166 local_auth = loaded.get('local_auth')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700167 except AttributeError as e:
168 raise LuciContextAuthError('LUCI_CONTEXT not in proper format', e)
169 if local_auth is None:
170 logging.debug('LUCI_CONTEXT configured w/o local auth')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800171 return None
172 try:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700173 return _LuciContextLocalAuthParams(
174 default_account_id=local_auth.get('default_account_id'),
175 secret=local_auth.get('secret'),
176 rpc_port=int(local_auth.get('rpc_port')))
177 except (AttributeError, ValueError) as e:
178 raise LuciContextAuthError('local_auth config malformed', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800179
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700180
181def _load_luci_context(ctx_path):
182 # Kept separate for test mocking.
183 with open(ctx_path) as f:
184 return json.load(f)
185
186
187def _get_luci_context_access_token(params, now, scopes=OAUTH_SCOPE_EMAIL):
188 # No account, local_auth shouldn't be used.
189 if not params.default_account_id:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800190 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700191 if not params.secret:
192 raise LuciContextAuthError('local_auth: no secret')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800193
194 logging.debug('local_auth: requesting an access token for account "%s"',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700195 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800196 http = httplib2.Http()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700197 host = '127.0.0.1:%d' % params.rpc_port
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800198 resp, content = http.request(
199 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
200 method='POST',
201 body=json.dumps({
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700202 'account_id': params.default_account_id,
203 'scopes': scopes.split(' '),
204 'secret': params.secret,
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800205 }),
206 headers={'Content-Type': 'application/json'})
207 if resp.status != 200:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700208 raise LuciContextAuthError(
209 'local_auth: Failed to grab access token from '
210 'LUCI context server with status %d: %r' % (resp.status, content))
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800211 try:
212 token = json.loads(content)
213 error_code = token.get('error_code')
214 error_message = token.get('error_message')
215 access_token = token.get('access_token')
216 expiry = token.get('expiry')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700217 except (AttributeError, ValueError) as e:
218 raise LuciContextAuthError('Unexpected access token response format', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800219 if error_code:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700220 raise LuciContextAuthError(
221 'Error %d in retrieving access token: %s', error_code, error_message)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800222 if not access_token:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700223 raise LuciContextAuthError(
224 'No access token returned from LUCI context server')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800225 expiry_dt = None
226 if expiry:
227 try:
228 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800229 logging.debug(
230 'local_auth: got an access token for '
231 'account "%s" that expires in %d sec',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700232 params.default_account_id, (expiry_dt - now).total_seconds())
233 except (TypeError, ValueError) as e:
234 raise LuciContextAuthError('Invalid expiry in returned token', e)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800235 else:
236 logging.debug(
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700237 'local auth: got an access token for account "%s" that does not expire',
238 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800239 access_token = AccessToken(access_token, expiry_dt)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700240 if access_token.needs_refresh(now=now):
241 raise LuciContextAuthError('Received access token is already expired')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800242 return access_token
243
244
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000245def make_auth_config(
246 use_oauth2=None,
247 save_cookies=None,
248 use_local_webserver=None,
Edward Lemura0568172019-10-16 15:37:58 +0000249 webserver_port=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000250 """Returns new instance of AuthConfig.
251
252 If some config option is None, it will be set to a reasonable default value.
253 This function also acts as an authoritative place for default values of
254 corresponding command line options.
255 """
256 default = lambda val, d: val if val is not None else d
257 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000258 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000259 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000260 default(use_local_webserver, not _is_headless()),
Edward Lemura0568172019-10-16 15:37:58 +0000261 default(webserver_port, 8090))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000262
263
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000264def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000265 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000266 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000267 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000268 parser.add_option_group(parser.auth_group)
269
270 # OAuth2 vs password switch.
271 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000272 parser.auth_group.add_option(
273 '--oauth2',
274 action='store_true',
275 dest='use_oauth2',
276 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000277 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000278 parser.auth_group.add_option(
279 '--no-oauth2',
280 action='store_false',
281 dest='use_oauth2',
282 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000283 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
284
285 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000286 parser.auth_group.add_option(
287 '--no-cookies',
288 action='store_false',
289 dest='save_cookies',
290 default=default_config.save_cookies,
291 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000292
293 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000294 parser.auth_group.add_option(
295 '--auth-no-local-webserver',
296 action='store_false',
297 dest='use_local_webserver',
298 default=default_config.use_local_webserver,
Edward Lesmes989bc352019-10-17 05:45:35 +0000299 help='Do not run a local web server when performing OAuth2 login flow.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000300 parser.auth_group.add_option(
301 '--auth-host-port',
302 type=int,
303 default=default_config.webserver_port,
Edward Lesmes989bc352019-10-17 05:45:35 +0000304 help='Port a local web server should listen on. Used only if '
305 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000306 parser.auth_group.add_option(
307 '--auth-refresh-token-json',
Edward Lemura0568172019-10-16 15:37:58 +0000308 help='DEPRECATED. Do not use')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000309
310
311def extract_auth_config_from_options(options):
312 """Given OptionParser parsed options, extracts AuthConfig from it.
313
314 OptionParser should be populated with auth options by 'add_auth_options'.
315 """
316 return make_auth_config(
317 use_oauth2=options.use_oauth2,
318 save_cookies=False if options.use_oauth2 else options.save_cookies,
319 use_local_webserver=options.use_local_webserver,
Edward Lemura0568172019-10-16 15:37:58 +0000320 webserver_port=options.auth_host_port)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000321
322
323def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000324 """AuthConfig -> list of strings with command line options.
325
326 Omits options that are set to default values.
327 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000328 if not auth_config:
329 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000330 defaults = make_auth_config()
331 opts = []
332 if auth_config.use_oauth2 != defaults.use_oauth2:
333 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
334 if auth_config.save_cookies != auth_config.save_cookies:
335 if not auth_config.save_cookies:
336 opts.append('--no-cookies')
337 if auth_config.use_local_webserver != defaults.use_local_webserver:
338 if not auth_config.use_local_webserver:
339 opts.append('--auth-no-local-webserver')
340 if auth_config.webserver_port != defaults.webserver_port:
341 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000342 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000343
344
Edward Lemurb4a587d2019-10-09 23:56:38 +0000345def get_authenticator(config, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000346 """Returns Authenticator instance to access given host.
347
348 Args:
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000349 config: AuthConfig instance.
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700350 scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000351
352 Returns:
353 Authenticator object.
354 """
Edward Lemurb4a587d2019-10-09 23:56:38 +0000355 return Authenticator(config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000356
357
358class Authenticator(object):
359 """Object that knows how to refresh access tokens when needed.
360
361 Args:
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000362 config: AuthConfig object that holds authentication configuration.
363 """
364
Edward Lemurb4a587d2019-10-09 23:56:38 +0000365 def __init__(self, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000366 assert isinstance(config, AuthConfig)
367 assert config.use_oauth2
368 self._access_token = None
369 self._config = config
370 self._lock = threading.Lock()
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000371 self._scopes = scopes
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000372 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000373
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000374 def has_cached_credentials(self):
Edward Lesmes989bc352019-10-17 05:45:35 +0000375 """Returns True if long term credentials (refresh token) are in cache.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000376
Edward Lesmes989bc352019-10-17 05:45:35 +0000377 Doesn't make network calls.
378
379 If returns False, get_access_token() later will ask for interactive login by
380 raising LoginRequiredError.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000381
382 If returns True, most probably get_access_token() won't ask for interactive
Edward Lesmes989bc352019-10-17 05:45:35 +0000383 login, though it is not guaranteed, since cached token can be already
384 revoked and there's no way to figure this out without actually trying to use
385 it.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000386 """
387 with self._lock:
Edward Lesmes989bc352019-10-17 05:45:35 +0000388 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000389
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800390 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
391 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000392 """Returns AccessToken, refreshing it if necessary.
393
394 Args:
Edward Lesmes989bc352019-10-17 05:45:35 +0000395 force_refresh: forcefully refresh access token even if it is not expired.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000396 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800397 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000398
399 Raises:
400 AuthenticationError on error or if authentication flow was interrupted.
401 LoginRequiredError if user interaction is required, but
402 allow_user_interaction is False.
403 """
Edward Lesmes989bc352019-10-17 05:45:35 +0000404 def get_loc_auth_tkn():
405 exi = sys.exc_info()
406 if not use_local_auth:
407 logging.error('Failed to create access token')
408 raise
409 try:
410 self._access_token = get_luci_context_access_token()
411 if not self._access_token:
412 logging.error('Failed to create access token')
413 raise
414 return self._access_token
415 except LuciContextAuthError:
416 logging.exception('Failed to use local auth')
417 raise exi[0], exi[1], exi[2]
418
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000419 with self._lock:
Edward Lesmes989bc352019-10-17 05:45:35 +0000420 if force_refresh:
421 logging.debug('Forcing access token refresh')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800422 try:
Edward Lesmes989bc352019-10-17 05:45:35 +0000423 self._access_token = self._create_access_token(allow_user_interaction)
424 return self._access_token
425 except LoginRequiredError:
426 return get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000427
Edward Lesmes989bc352019-10-17 05:45:35 +0000428 # Load from on-disk cache on a first access.
429 if not self._access_token:
430 self._access_token = self._load_access_token()
431
432 # Refresh if expired or missing.
433 if not self._access_token or self._access_token.needs_refresh():
434 # Maybe some other process already updated it, reload from the cache.
435 self._access_token = self._load_access_token()
436 # Nope, still expired, need to run the refresh flow.
437 if not self._access_token or self._access_token.needs_refresh():
438 try:
439 self._access_token = self._create_access_token(
440 allow_user_interaction)
441 except LoginRequiredError:
442 get_loc_auth_tkn()
443
444 return self._access_token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000445
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000446 def authorize(self, http):
447 """Monkey patches authentication logic of httplib2.Http instance.
448
449 The modified http.request method will add authentication headers to each
Edward Lesmes989bc352019-10-17 05:45:35 +0000450 request and will refresh access_tokens when a 401 is received on a
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000451 request.
452
453 Args:
454 http: An instance of httplib2.Http.
455
456 Returns:
457 A modified instance of http that was passed in.
458 """
459 # Adapted from oauth2client.OAuth2Credentials.authorize.
Edward Lesmes989bc352019-10-17 05:45:35 +0000460
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000461 request_orig = http.request
462
463 @functools.wraps(request_orig)
464 def new_request(
465 uri, method='GET', body=None, headers=None,
466 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
467 connection_type=None):
468 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000469 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
Edward Lesmes989bc352019-10-17 05:45:35 +0000470 resp, content = request_orig(
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000471 uri, method, body, headers, redirections, connection_type)
Edward Lesmes989bc352019-10-17 05:45:35 +0000472 if resp.status in client.REFRESH_STATUS_CODES:
473 logging.info('Refreshing due to a %s', resp.status)
474 access_token = self.get_access_token(force_refresh=True)
475 headers['Authorization'] = 'Bearer %s' % access_token.token
476 return request_orig(
477 uri, method, body, headers, redirections, connection_type)
478 else:
479 return (resp, content)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000480
481 http.request = new_request
482 return http
483
484 ## Private methods.
485
Edward Lesmes989bc352019-10-17 05:45:35 +0000486 def _get_cached_credentials(self):
487 """Returns oauth2client.Credentials loaded from luci-auth."""
488 credentials = _get_luci_auth_credentials(self._scopes)
489
490 if not credentials:
491 logging.debug('No cached token')
492 else:
493 _log_credentials_info('cached token', credentials)
494
495 return credentials if (credentials and not credentials.invalid) else None
496
497 def _load_access_token(self):
498 """Returns cached AccessToken if it is not expired yet."""
499 logging.debug('Reloading access token from cache')
500 creds = self._get_cached_credentials()
501 if not creds or not creds.access_token or creds.access_token_expired:
502 logging.debug('Access token is missing or expired')
503 return None
504 return AccessToken(str(creds.access_token), creds.token_expiry)
505
506 def _create_access_token(self, allow_user_interaction=False):
507 """Mints and caches a new access token, launching OAuth2 dance if necessary.
508
509 Uses cached refresh token, if present. In that case user interaction is not
510 required and function will finish quietly. Otherwise it will launch 3-legged
511 OAuth2 flow, that needs user interaction.
512
513 Args:
514 allow_user_interaction: if True, allow interaction with the user (e.g.
515 reading standard input, or launching a browser).
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000516
517 Returns:
Edward Lesmes989bc352019-10-17 05:45:35 +0000518 AccessToken.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000519
Edward Lesmes989bc352019-10-17 05:45:35 +0000520 Raises:
521 AuthenticationError on error or if authentication flow was interrupted.
522 LoginRequiredError if user interaction is required, but
523 allow_user_interaction is False.
524 """
525 logging.debug(
526 'Making new access token (allow_user_interaction=%r)',
527 allow_user_interaction)
528 credentials = self._get_cached_credentials()
529
530 # 3-legged flow with (perhaps cached) refresh token.
531 refreshed = False
532 if credentials and not credentials.invalid:
533 try:
534 logging.debug('Attempting to refresh access_token')
535 credentials.refresh(httplib2.Http())
536 _log_credentials_info('refreshed token', credentials)
537 refreshed = True
538 except client.Error as err:
539 logging.warning(
540 'OAuth error during access token refresh (%s). '
541 'Attempting a full authentication flow.', err)
542
543 # Refresh token is missing or invalid, go through the full flow.
544 if not refreshed:
545 if not allow_user_interaction:
546 logging.debug('Requesting user to login')
547 raise LoginRequiredError(self._scopes)
548 logging.debug('Launching OAuth browser flow')
549 credentials = _run_oauth_dance(self._scopes)
550 _log_credentials_info('new token', credentials)
551
552 logging.info(
553 'OAuth access_token refreshed. Expires in %s.',
554 credentials.token_expiry - datetime.datetime.utcnow())
555 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000556
557
558## Private functions.
559
560
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000561def _is_headless():
562 """True if machine doesn't seem to have a display."""
563 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
Edward Lesmes989bc352019-10-17 05:45:35 +0000564
565
566def _log_credentials_info(title, credentials):
567 """Dumps (non sensitive) part of client.Credentials object to debug log."""
568 if credentials:
569 logging.debug('%s info: %r', title, {
570 'access_token_expired': credentials.access_token_expired,
571 'has_access_token': bool(credentials.access_token),
572 'invalid': credentials.invalid,
573 'utcnow': datetime.datetime.utcnow(),
574 'token_expiry': credentials.token_expiry,
575 })
576
577
578def _get_luci_auth_credentials(scopes):
579 try:
580 token_info = json.loads(subprocess2.check_output(
581 ['luci-auth', 'token', '-scopes', scopes, '-json-output', '-'],
582 stderr=subprocess2.VOID))
583 except subprocess2.CalledProcessError:
584 return None
585
586 return client.OAuth2Credentials(
587 access_token=token_info['token'],
588 client_id=None,
589 client_secret=None,
590 refresh_token=None,
591 token_expiry=datetime.datetime.utcfromtimestamp(token_info['expiry']),
592 token_uri=None,
593 user_agent=None,
594 revoke_uri=None)
595
596
597def _run_oauth_dance(scopes):
598 """Perform full 3-legged OAuth2 flow with the browser.
599
600 Returns:
601 oauth2client.Credentials.
602 """
603 subprocess2.check_call(['luci-auth', 'login', '-scopes', scopes])
604 return _get_luci_auth_credentials(scopes)