blob: f717be15d2ea13ff0c2c110565014b5846fe0a8b [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
23from third_party import httplib2
24from 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
30
31# Google OAuth2 clients always have a secret, even if the client is an installed
32# application/utility such as this. Of course, in such cases the "secret" is
33# actually publicly known; security depends entirely on the secrecy of refresh
34# tokens, which effectively become bearer tokens. An attacker can impersonate
35# service's identity in OAuth2 flow. But that's generally fine as long as a list
36# of allowed redirect_uri's associated with client_id is limited to 'localhost'
37# or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process
38# running on user's machine to successfully complete the flow and grab refresh
39# token. When you have a malicious code running on your machine, you're screwed
40# anyway.
41# This particular set is managed by API Console project "chrome-infra-auth".
42OAUTH_CLIENT_ID = (
43 '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com')
44OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp'
45
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070046# This is what most GAE apps require for authentication.
47OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
48# Gerrit and Git on *.googlesource.com require this scope.
49OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
50# Deprecated. Use OAUTH_SCOPE_EMAIL instead.
51OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000052
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000053
54# Authentication configuration extracted from command line options.
55# See doc string for 'make_auth_config' for meaning of fields.
56AuthConfig = collections.namedtuple('AuthConfig', [
57 'use_oauth2', # deprecated, will be always True
58 'save_cookies', # deprecated, will be removed
59 'use_local_webserver',
60 'webserver_port',
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000061 'refresh_token_json',
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000062])
63
64
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000065# OAuth access token with its expiration time (UTC datetime or None if unknown).
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070066class AccessToken(collections.namedtuple('AccessToken', [
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000067 'token',
68 'expires_at',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070069 ])):
70
71 def needs_refresh(self, now=None):
72 """True if this AccessToken should be refreshed."""
73 if self.expires_at is not None:
74 now = now or datetime.datetime.utcnow()
Andrii Shyshkalov142a92c2018-05-04 12:21:24 -070075 # Allow 3 min of clock skew between client and backend.
76 now += datetime.timedelta(seconds=180)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070077 return now >= self.expires_at
78 # Token without expiration time never expires.
79 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000080
81
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000082# Refresh token passed via --auth-refresh-token-json.
83RefreshToken = collections.namedtuple('RefreshToken', [
84 'client_id',
85 'client_secret',
86 'refresh_token',
87])
88
89
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000090class AuthenticationError(Exception):
91 """Raised on errors related to authentication."""
92
93
94class LoginRequiredError(AuthenticationError):
95 """Interaction with the user is required to authenticate."""
96
Edward Lemurba5bc992019-09-23 22:59:17 +000097 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000098 msg = (
99 'You are not logged in. Please login first by running:\n'
Edward Lemurba5bc992019-09-23 22:59:17 +0000100 ' luci-auth login -scopes %s' % scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000101 super(LoginRequiredError, self).__init__(msg)
102
103
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800104class LuciContextAuthError(Exception):
105 """Raised on errors related to unsuccessful attempts to load LUCI_CONTEXT"""
106
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700107 def __init__(self, msg, exc=None):
108 if exc is None:
109 logging.error(msg)
110 else:
111 logging.exception(msg)
112 msg = '%s: %s' % (msg, exc)
113 super(LuciContextAuthError, self).__init__(msg)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800114
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700115
116def has_luci_context_local_auth():
117 """Returns whether LUCI_CONTEXT should be used for ambient authentication.
118 """
119 try:
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700120 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700121 except LuciContextAuthError:
122 return False
123 if params is None:
124 return False
125 return bool(params.default_account_id)
126
127
128def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800129 """Returns a valid AccessToken from the local LUCI context auth server.
130
131 Adapted from
132 https://chromium.googlesource.com/infra/luci/luci-py/+/master/client/libs/luci_context/luci_context.py
133 See the link above for more details.
134
135 Returns:
136 AccessToken if LUCI_CONTEXT is present and attempt to load it is successful.
137 None if LUCI_CONTEXT is absent.
138
139 Raises:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700140 LuciContextAuthError if LUCI_CONTEXT is present, but there was a failure
141 obtaining its access token.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800142 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700143 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700144 if params is None:
145 return None
146 return _get_luci_context_access_token(
147 params, datetime.datetime.utcnow(), scopes)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800148
149
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700150_LuciContextLocalAuthParams = collections.namedtuple(
151 '_LuciContextLocalAuthParams', [
152 'default_account_id',
153 'secret',
154 'rpc_port',
155])
156
157
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700158def _cache_thread_safe(f):
159 """Decorator caching result of nullary function in thread-safe way."""
160 lock = threading.Lock()
161 cache = []
162
163 @functools.wraps(f)
164 def caching_wrapper():
165 if not cache:
166 with lock:
167 if not cache:
168 cache.append(f())
169 return cache[0]
170
171 # Allow easy way to clear cache, particularly useful in tests.
172 caching_wrapper.clear_cache = lambda: cache.pop() if cache else None
173 return caching_wrapper
174
175
176@_cache_thread_safe
177def _get_luci_context_local_auth_params():
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700178 """Returns local auth parameters if local auth is configured else None.
179
180 Raises LuciContextAuthError on unexpected failures.
181 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700182 ctx_path = os.environ.get('LUCI_CONTEXT')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800183 if not ctx_path:
184 return None
185 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800186 try:
187 loaded = _load_luci_context(ctx_path)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700188 except (OSError, IOError, ValueError) as e:
189 raise LuciContextAuthError('Failed to open, read or decode LUCI_CONTEXT', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800190 try:
191 local_auth = loaded.get('local_auth')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700192 except AttributeError as e:
193 raise LuciContextAuthError('LUCI_CONTEXT not in proper format', e)
194 if local_auth is None:
195 logging.debug('LUCI_CONTEXT configured w/o local auth')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800196 return None
197 try:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700198 return _LuciContextLocalAuthParams(
199 default_account_id=local_auth.get('default_account_id'),
200 secret=local_auth.get('secret'),
201 rpc_port=int(local_auth.get('rpc_port')))
202 except (AttributeError, ValueError) as e:
203 raise LuciContextAuthError('local_auth config malformed', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800204
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700205
206def _load_luci_context(ctx_path):
207 # Kept separate for test mocking.
208 with open(ctx_path) as f:
209 return json.load(f)
210
211
212def _get_luci_context_access_token(params, now, scopes=OAUTH_SCOPE_EMAIL):
213 # No account, local_auth shouldn't be used.
214 if not params.default_account_id:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800215 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700216 if not params.secret:
217 raise LuciContextAuthError('local_auth: no secret')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800218
219 logging.debug('local_auth: requesting an access token for account "%s"',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700220 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800221 http = httplib2.Http()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700222 host = '127.0.0.1:%d' % params.rpc_port
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800223 resp, content = http.request(
224 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
225 method='POST',
226 body=json.dumps({
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700227 'account_id': params.default_account_id,
228 'scopes': scopes.split(' '),
229 'secret': params.secret,
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800230 }),
231 headers={'Content-Type': 'application/json'})
232 if resp.status != 200:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700233 raise LuciContextAuthError(
234 'local_auth: Failed to grab access token from '
235 'LUCI context server with status %d: %r' % (resp.status, content))
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800236 try:
237 token = json.loads(content)
238 error_code = token.get('error_code')
239 error_message = token.get('error_message')
240 access_token = token.get('access_token')
241 expiry = token.get('expiry')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700242 except (AttributeError, ValueError) as e:
243 raise LuciContextAuthError('Unexpected access token response format', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800244 if error_code:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700245 raise LuciContextAuthError(
246 'Error %d in retrieving access token: %s', error_code, error_message)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800247 if not access_token:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700248 raise LuciContextAuthError(
249 'No access token returned from LUCI context server')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800250 expiry_dt = None
251 if expiry:
252 try:
253 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800254 logging.debug(
255 'local_auth: got an access token for '
256 'account "%s" that expires in %d sec',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700257 params.default_account_id, (expiry_dt - now).total_seconds())
258 except (TypeError, ValueError) as e:
259 raise LuciContextAuthError('Invalid expiry in returned token', e)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800260 else:
261 logging.debug(
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700262 'local auth: got an access token for account "%s" that does not expire',
263 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800264 access_token = AccessToken(access_token, expiry_dt)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700265 if access_token.needs_refresh(now=now):
266 raise LuciContextAuthError('Received access token is already expired')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800267 return access_token
268
269
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000270def make_auth_config(
271 use_oauth2=None,
272 save_cookies=None,
273 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000274 webserver_port=None,
275 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000276 """Returns new instance of AuthConfig.
277
278 If some config option is None, it will be set to a reasonable default value.
279 This function also acts as an authoritative place for default values of
280 corresponding command line options.
281 """
282 default = lambda val, d: val if val is not None else d
283 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000284 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000285 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000286 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000287 default(webserver_port, 8090),
288 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000289
290
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000291def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000292 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000293 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000294 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000295 parser.add_option_group(parser.auth_group)
296
297 # OAuth2 vs password switch.
298 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000299 parser.auth_group.add_option(
300 '--oauth2',
301 action='store_true',
302 dest='use_oauth2',
303 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000304 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000305 parser.auth_group.add_option(
306 '--no-oauth2',
307 action='store_false',
308 dest='use_oauth2',
309 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000310 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
311
312 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000313 parser.auth_group.add_option(
314 '--no-cookies',
315 action='store_false',
316 dest='save_cookies',
317 default=default_config.save_cookies,
318 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000319
320 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000321 parser.auth_group.add_option(
322 '--auth-no-local-webserver',
323 action='store_false',
324 dest='use_local_webserver',
325 default=default_config.use_local_webserver,
326 help='Do not run a local web server when performing OAuth2 login flow.')
327 parser.auth_group.add_option(
328 '--auth-host-port',
329 type=int,
330 default=default_config.webserver_port,
331 help='Port a local web server should listen on. Used only if '
332 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000333 parser.auth_group.add_option(
334 '--auth-refresh-token-json',
335 default=default_config.refresh_token_json,
336 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000337
338
339def extract_auth_config_from_options(options):
340 """Given OptionParser parsed options, extracts AuthConfig from it.
341
342 OptionParser should be populated with auth options by 'add_auth_options'.
343 """
344 return make_auth_config(
345 use_oauth2=options.use_oauth2,
346 save_cookies=False if options.use_oauth2 else options.save_cookies,
347 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000348 webserver_port=options.auth_host_port,
349 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000350
351
352def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000353 """AuthConfig -> list of strings with command line options.
354
355 Omits options that are set to default values.
356 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000357 if not auth_config:
358 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000359 defaults = make_auth_config()
360 opts = []
361 if auth_config.use_oauth2 != defaults.use_oauth2:
362 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
363 if auth_config.save_cookies != auth_config.save_cookies:
364 if not auth_config.save_cookies:
365 opts.append('--no-cookies')
366 if auth_config.use_local_webserver != defaults.use_local_webserver:
367 if not auth_config.use_local_webserver:
368 opts.append('--auth-no-local-webserver')
369 if auth_config.webserver_port != defaults.webserver_port:
370 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000371 if auth_config.refresh_token_json != defaults.refresh_token_json:
372 opts.extend([
373 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000374 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000375
376
Edward Lemurb4a587d2019-10-09 23:56:38 +0000377def get_authenticator(config, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000378 """Returns Authenticator instance to access given host.
379
380 Args:
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000381 config: AuthConfig instance.
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700382 scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000383
384 Returns:
385 Authenticator object.
386 """
Edward Lemurb4a587d2019-10-09 23:56:38 +0000387 return Authenticator(config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000388
389
390class Authenticator(object):
391 """Object that knows how to refresh access tokens when needed.
392
393 Args:
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000394 config: AuthConfig object that holds authentication configuration.
395 """
396
Edward Lemurb4a587d2019-10-09 23:56:38 +0000397 def __init__(self, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000398 assert isinstance(config, AuthConfig)
399 assert config.use_oauth2
400 self._access_token = None
401 self._config = config
402 self._lock = threading.Lock()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000403 self._external_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000404 self._scopes = scopes
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000405 if config.refresh_token_json:
406 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000407 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000408
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000409 def has_cached_credentials(self):
410 """Returns True if long term credentials (refresh token) are in cache.
411
412 Doesn't make network calls.
413
414 If returns False, get_access_token() later will ask for interactive login by
415 raising LoginRequiredError.
416
417 If returns True, most probably get_access_token() won't ask for interactive
418 login, though it is not guaranteed, since cached token can be already
419 revoked and there's no way to figure this out without actually trying to use
420 it.
421 """
422 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000423 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000424
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800425 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
426 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000427 """Returns AccessToken, refreshing it if necessary.
428
429 Args:
430 force_refresh: forcefully refresh access token even if it is not expired.
431 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800432 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000433
434 Raises:
435 AuthenticationError on error or if authentication flow was interrupted.
436 LoginRequiredError if user interaction is required, but
437 allow_user_interaction is False.
438 """
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800439 def get_loc_auth_tkn():
440 exi = sys.exc_info()
441 if not use_local_auth:
442 logging.error('Failed to create access token')
443 raise
444 try:
445 self._access_token = get_luci_context_access_token()
446 if not self._access_token:
447 logging.error('Failed to create access token')
448 raise
449 return self._access_token
450 except LuciContextAuthError:
451 logging.exception('Failed to use local auth')
452 raise exi[0], exi[1], exi[2]
453
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000454 with self._lock:
455 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000456 logging.debug('Forcing access token refresh')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800457 try:
458 self._access_token = self._create_access_token(allow_user_interaction)
459 return self._access_token
460 except LoginRequiredError:
461 return get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000462
463 # Load from on-disk cache on a first access.
464 if not self._access_token:
465 self._access_token = self._load_access_token()
466
467 # Refresh if expired or missing.
Andrii Shyshkalov94580ab2018-04-19 18:04:54 -0700468 if not self._access_token or self._access_token.needs_refresh():
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000469 # Maybe some other process already updated it, reload from the cache.
470 self._access_token = self._load_access_token()
471 # Nope, still expired, need to run the refresh flow.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700472 if not self._access_token or self._access_token.needs_refresh():
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800473 try:
474 self._access_token = self._create_access_token(
475 allow_user_interaction)
476 except LoginRequiredError:
477 get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000478
479 return self._access_token
480
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000481 def authorize(self, http):
482 """Monkey patches authentication logic of httplib2.Http instance.
483
484 The modified http.request method will add authentication headers to each
485 request and will refresh access_tokens when a 401 is received on a
486 request.
487
488 Args:
489 http: An instance of httplib2.Http.
490
491 Returns:
492 A modified instance of http that was passed in.
493 """
494 # Adapted from oauth2client.OAuth2Credentials.authorize.
495
496 request_orig = http.request
497
498 @functools.wraps(request_orig)
499 def new_request(
500 uri, method='GET', body=None, headers=None,
501 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
502 connection_type=None):
503 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000504 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000505 resp, content = request_orig(
506 uri, method, body, headers, redirections, connection_type)
507 if resp.status in client.REFRESH_STATUS_CODES:
508 logging.info('Refreshing due to a %s', resp.status)
509 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000510 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000511 return request_orig(
512 uri, method, body, headers, redirections, connection_type)
513 else:
514 return (resp, content)
515
516 http.request = new_request
517 return http
518
519 ## Private methods.
520
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000521 def _get_cached_credentials(self):
Edward Lemurba5bc992019-09-23 22:59:17 +0000522 """Returns oauth2client.Credentials loaded from luci-auth."""
523 credentials = _get_luci_auth_credentials(self._scopes)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000524
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000525 if not credentials:
526 logging.debug('No cached token')
527 else:
528 _log_credentials_info('cached token', credentials)
529
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000530 # Is using --auth-refresh-token-json?
531 if self._external_token:
532 # Cached credentials are valid and match external token -> use them. It is
533 # important to reuse credentials from the storage because they contain
534 # cached access token.
535 valid = (
536 credentials and not credentials.invalid and
537 credentials.refresh_token == self._external_token.refresh_token and
538 credentials.client_id == self._external_token.client_id and
539 credentials.client_secret == self._external_token.client_secret)
540 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000541 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000542 return credentials
543 # Construct new credentials from externally provided refresh token,
544 # associate them with cache storage (so that access_token will be placed
545 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000546 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000547 credentials = client.OAuth2Credentials(
548 access_token=None,
549 client_id=self._external_token.client_id,
550 client_secret=self._external_token.client_secret,
551 refresh_token=self._external_token.refresh_token,
552 token_expiry=None,
553 token_uri='https://accounts.google.com/o/oauth2/token',
554 user_agent=None,
555 revoke_uri=None)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000556 return credentials
557
558 # Not using external refresh token -> return whatever is cached.
559 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000560
561 def _load_access_token(self):
562 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000563 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000564 creds = self._get_cached_credentials()
565 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000566 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000567 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000568 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000569
570 def _create_access_token(self, allow_user_interaction=False):
571 """Mints and caches a new access token, launching OAuth2 dance if necessary.
572
573 Uses cached refresh token, if present. In that case user interaction is not
574 required and function will finish quietly. Otherwise it will launch 3-legged
575 OAuth2 flow, that needs user interaction.
576
577 Args:
578 allow_user_interaction: if True, allow interaction with the user (e.g.
579 reading standard input, or launching a browser).
580
581 Returns:
582 AccessToken.
583
584 Raises:
585 AuthenticationError on error or if authentication flow was interrupted.
586 LoginRequiredError if user interaction is required, but
587 allow_user_interaction is False.
588 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000589 logging.debug(
590 'Making new access token (allow_user_interaction=%r)',
591 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000592 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000593
594 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000595 refreshed = False
596 if credentials and not credentials.invalid:
597 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000598 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000599 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000600 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000601 refreshed = True
602 except client.Error as err:
603 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000604 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000605 'Attempting a full authentication flow.', err)
606
607 # Refresh token is missing or invalid, go through the full flow.
608 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000609 # Can't refresh externally provided token.
610 if self._external_token:
611 raise AuthenticationError(
612 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000613 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000614 logging.debug('Requesting user to login')
Edward Lemurba5bc992019-09-23 22:59:17 +0000615 raise LoginRequiredError(self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000616 logging.debug('Launching OAuth browser flow')
Edward Lemurba5bc992019-09-23 22:59:17 +0000617 credentials = _run_oauth_dance(self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000618 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000619
620 logging.info(
621 'OAuth access_token refreshed. Expires in %s.',
622 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000623 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000624
625
626## Private functions.
627
628
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000629def _is_headless():
630 """True if machine doesn't seem to have a display."""
631 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
632
633
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000634def _read_refresh_token_json(path):
635 """Returns RefreshToken by reading it from the JSON file."""
636 try:
637 with open(path, 'r') as f:
638 data = json.load(f)
639 return RefreshToken(
640 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
641 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
642 refresh_token=str(data['refresh_token']))
643 except (IOError, ValueError) as e:
644 raise AuthenticationError(
645 'Failed to read refresh token from %s: %s' % (path, e))
646 except KeyError as e:
647 raise AuthenticationError(
648 'Failed to read refresh token from %s: missing key %s' % (path, e))
649
650
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000651def _log_credentials_info(title, credentials):
652 """Dumps (non sensitive) part of client.Credentials object to debug log."""
653 if credentials:
654 logging.debug('%s info: %r', title, {
655 'access_token_expired': credentials.access_token_expired,
656 'has_access_token': bool(credentials.access_token),
657 'invalid': credentials.invalid,
658 'utcnow': datetime.datetime.utcnow(),
659 'token_expiry': credentials.token_expiry,
660 })
661
662
Edward Lemurba5bc992019-09-23 22:59:17 +0000663def _get_luci_auth_credentials(scopes):
664 try:
665 token_info = json.loads(subprocess2.check_output(
666 ['luci-auth', 'token', '-scopes', scopes, '-json-output', '-'],
667 stderr=subprocess2.VOID))
668 except subprocess2.CalledProcessError:
669 return None
670
671 return client.OAuth2Credentials(
672 access_token=token_info['token'],
673 client_id=OAUTH_CLIENT_ID,
674 client_secret=OAUTH_CLIENT_SECRET,
675 refresh_token=None,
676 token_expiry=datetime.datetime.utcfromtimestamp(token_info['expiry']),
677 token_uri=None,
678 user_agent=None,
679 revoke_uri=None)
680
Edward Lemurb4a587d2019-10-09 23:56:38 +0000681
Edward Lemurba5bc992019-09-23 22:59:17 +0000682def _run_oauth_dance(scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000683 """Perform full 3-legged OAuth2 flow with the browser.
684
685 Returns:
686 oauth2client.Credentials.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000687 """
Edward Lemurba5bc992019-09-23 22:59:17 +0000688 subprocess2.check_call(['luci-auth', 'login', '-scopes', scopes])
689 return _get_luci_auth_credentials(scopes)