blob: 0da997c7a7295a2fd41df67694c4cf44fbbfc635 [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.org148f76f2015-04-21 01:44:13 +000053# Path to a file with cached OAuth2 credentials used by default relative to the
54# home dir (see _get_token_cache_path). It should be a safe location accessible
55# only to a current user: knowing content of this file is roughly equivalent to
56# knowing account password. Single file can hold multiple independent tokens
57# identified by token_cache_key (see Authenticator).
58OAUTH_TOKENS_CACHE = '.depot_tools_oauth2_tokens'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000059
60
61# Authentication configuration extracted from command line options.
62# See doc string for 'make_auth_config' for meaning of fields.
63AuthConfig = collections.namedtuple('AuthConfig', [
64 'use_oauth2', # deprecated, will be always True
65 'save_cookies', # deprecated, will be removed
66 'use_local_webserver',
67 'webserver_port',
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000068 'refresh_token_json',
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000069])
70
71
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000072# OAuth access token with its expiration time (UTC datetime or None if unknown).
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070073class AccessToken(collections.namedtuple('AccessToken', [
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000074 'token',
75 'expires_at',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070076 ])):
77
78 def needs_refresh(self, now=None):
79 """True if this AccessToken should be refreshed."""
80 if self.expires_at is not None:
81 now = now or datetime.datetime.utcnow()
Andrii Shyshkalov142a92c2018-05-04 12:21:24 -070082 # Allow 3 min of clock skew between client and backend.
83 now += datetime.timedelta(seconds=180)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070084 return now >= self.expires_at
85 # Token without expiration time never expires.
86 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000087
88
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000089# Refresh token passed via --auth-refresh-token-json.
90RefreshToken = collections.namedtuple('RefreshToken', [
91 'client_id',
92 'client_secret',
93 'refresh_token',
94])
95
96
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000097class AuthenticationError(Exception):
98 """Raised on errors related to authentication."""
99
100
101class LoginRequiredError(AuthenticationError):
102 """Interaction with the user is required to authenticate."""
103
Edward Lemurba5bc992019-09-23 22:59:17 +0000104 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000105 msg = (
106 'You are not logged in. Please login first by running:\n'
Edward Lemurba5bc992019-09-23 22:59:17 +0000107 ' luci-auth login -scopes %s' % scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000108 super(LoginRequiredError, self).__init__(msg)
109
110
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800111class LuciContextAuthError(Exception):
112 """Raised on errors related to unsuccessful attempts to load LUCI_CONTEXT"""
113
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700114 def __init__(self, msg, exc=None):
115 if exc is None:
116 logging.error(msg)
117 else:
118 logging.exception(msg)
119 msg = '%s: %s' % (msg, exc)
120 super(LuciContextAuthError, self).__init__(msg)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800121
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700122
123def has_luci_context_local_auth():
124 """Returns whether LUCI_CONTEXT should be used for ambient authentication.
125 """
126 try:
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700127 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700128 except LuciContextAuthError:
129 return False
130 if params is None:
131 return False
132 return bool(params.default_account_id)
133
134
135def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800136 """Returns a valid AccessToken from the local LUCI context auth server.
137
138 Adapted from
139 https://chromium.googlesource.com/infra/luci/luci-py/+/master/client/libs/luci_context/luci_context.py
140 See the link above for more details.
141
142 Returns:
143 AccessToken if LUCI_CONTEXT is present and attempt to load it is successful.
144 None if LUCI_CONTEXT is absent.
145
146 Raises:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700147 LuciContextAuthError if LUCI_CONTEXT is present, but there was a failure
148 obtaining its access token.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800149 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700150 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700151 if params is None:
152 return None
153 return _get_luci_context_access_token(
154 params, datetime.datetime.utcnow(), scopes)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800155
156
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700157_LuciContextLocalAuthParams = collections.namedtuple(
158 '_LuciContextLocalAuthParams', [
159 'default_account_id',
160 'secret',
161 'rpc_port',
162])
163
164
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700165def _cache_thread_safe(f):
166 """Decorator caching result of nullary function in thread-safe way."""
167 lock = threading.Lock()
168 cache = []
169
170 @functools.wraps(f)
171 def caching_wrapper():
172 if not cache:
173 with lock:
174 if not cache:
175 cache.append(f())
176 return cache[0]
177
178 # Allow easy way to clear cache, particularly useful in tests.
179 caching_wrapper.clear_cache = lambda: cache.pop() if cache else None
180 return caching_wrapper
181
182
183@_cache_thread_safe
184def _get_luci_context_local_auth_params():
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700185 """Returns local auth parameters if local auth is configured else None.
186
187 Raises LuciContextAuthError on unexpected failures.
188 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700189 ctx_path = os.environ.get('LUCI_CONTEXT')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800190 if not ctx_path:
191 return None
192 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800193 try:
194 loaded = _load_luci_context(ctx_path)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700195 except (OSError, IOError, ValueError) as e:
196 raise LuciContextAuthError('Failed to open, read or decode LUCI_CONTEXT', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800197 try:
198 local_auth = loaded.get('local_auth')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700199 except AttributeError as e:
200 raise LuciContextAuthError('LUCI_CONTEXT not in proper format', e)
201 if local_auth is None:
202 logging.debug('LUCI_CONTEXT configured w/o local auth')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800203 return None
204 try:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700205 return _LuciContextLocalAuthParams(
206 default_account_id=local_auth.get('default_account_id'),
207 secret=local_auth.get('secret'),
208 rpc_port=int(local_auth.get('rpc_port')))
209 except (AttributeError, ValueError) as e:
210 raise LuciContextAuthError('local_auth config malformed', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800211
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700212
213def _load_luci_context(ctx_path):
214 # Kept separate for test mocking.
215 with open(ctx_path) as f:
216 return json.load(f)
217
218
219def _get_luci_context_access_token(params, now, scopes=OAUTH_SCOPE_EMAIL):
220 # No account, local_auth shouldn't be used.
221 if not params.default_account_id:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800222 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700223 if not params.secret:
224 raise LuciContextAuthError('local_auth: no secret')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800225
226 logging.debug('local_auth: requesting an access token for account "%s"',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700227 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800228 http = httplib2.Http()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700229 host = '127.0.0.1:%d' % params.rpc_port
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800230 resp, content = http.request(
231 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
232 method='POST',
233 body=json.dumps({
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700234 'account_id': params.default_account_id,
235 'scopes': scopes.split(' '),
236 'secret': params.secret,
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800237 }),
238 headers={'Content-Type': 'application/json'})
239 if resp.status != 200:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700240 raise LuciContextAuthError(
241 'local_auth: Failed to grab access token from '
242 'LUCI context server with status %d: %r' % (resp.status, content))
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800243 try:
244 token = json.loads(content)
245 error_code = token.get('error_code')
246 error_message = token.get('error_message')
247 access_token = token.get('access_token')
248 expiry = token.get('expiry')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700249 except (AttributeError, ValueError) as e:
250 raise LuciContextAuthError('Unexpected access token response format', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800251 if error_code:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700252 raise LuciContextAuthError(
253 'Error %d in retrieving access token: %s', error_code, error_message)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800254 if not access_token:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700255 raise LuciContextAuthError(
256 'No access token returned from LUCI context server')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800257 expiry_dt = None
258 if expiry:
259 try:
260 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800261 logging.debug(
262 'local_auth: got an access token for '
263 'account "%s" that expires in %d sec',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700264 params.default_account_id, (expiry_dt - now).total_seconds())
265 except (TypeError, ValueError) as e:
266 raise LuciContextAuthError('Invalid expiry in returned token', e)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800267 else:
268 logging.debug(
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700269 'local auth: got an access token for account "%s" that does not expire',
270 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800271 access_token = AccessToken(access_token, expiry_dt)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700272 if access_token.needs_refresh(now=now):
273 raise LuciContextAuthError('Received access token is already expired')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800274 return access_token
275
276
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000277def make_auth_config(
278 use_oauth2=None,
279 save_cookies=None,
280 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000281 webserver_port=None,
282 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000283 """Returns new instance of AuthConfig.
284
285 If some config option is None, it will be set to a reasonable default value.
286 This function also acts as an authoritative place for default values of
287 corresponding command line options.
288 """
289 default = lambda val, d: val if val is not None else d
290 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000291 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000292 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000293 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000294 default(webserver_port, 8090),
295 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000296
297
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000298def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000299 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000300 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000301 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000302 parser.add_option_group(parser.auth_group)
303
304 # OAuth2 vs password switch.
305 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000306 parser.auth_group.add_option(
307 '--oauth2',
308 action='store_true',
309 dest='use_oauth2',
310 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000311 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000312 parser.auth_group.add_option(
313 '--no-oauth2',
314 action='store_false',
315 dest='use_oauth2',
316 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000317 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
318
319 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000320 parser.auth_group.add_option(
321 '--no-cookies',
322 action='store_false',
323 dest='save_cookies',
324 default=default_config.save_cookies,
325 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000326
327 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000328 parser.auth_group.add_option(
329 '--auth-no-local-webserver',
330 action='store_false',
331 dest='use_local_webserver',
332 default=default_config.use_local_webserver,
333 help='Do not run a local web server when performing OAuth2 login flow.')
334 parser.auth_group.add_option(
335 '--auth-host-port',
336 type=int,
337 default=default_config.webserver_port,
338 help='Port a local web server should listen on. Used only if '
339 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000340 parser.auth_group.add_option(
341 '--auth-refresh-token-json',
342 default=default_config.refresh_token_json,
343 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000344
345
346def extract_auth_config_from_options(options):
347 """Given OptionParser parsed options, extracts AuthConfig from it.
348
349 OptionParser should be populated with auth options by 'add_auth_options'.
350 """
351 return make_auth_config(
352 use_oauth2=options.use_oauth2,
353 save_cookies=False if options.use_oauth2 else options.save_cookies,
354 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000355 webserver_port=options.auth_host_port,
356 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000357
358
359def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000360 """AuthConfig -> list of strings with command line options.
361
362 Omits options that are set to default values.
363 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000364 if not auth_config:
365 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000366 defaults = make_auth_config()
367 opts = []
368 if auth_config.use_oauth2 != defaults.use_oauth2:
369 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
370 if auth_config.save_cookies != auth_config.save_cookies:
371 if not auth_config.save_cookies:
372 opts.append('--no-cookies')
373 if auth_config.use_local_webserver != defaults.use_local_webserver:
374 if not auth_config.use_local_webserver:
375 opts.append('--auth-no-local-webserver')
376 if auth_config.webserver_port != defaults.webserver_port:
377 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000378 if auth_config.refresh_token_json != defaults.refresh_token_json:
379 opts.extend([
380 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000381 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000382
383
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700384def get_authenticator_for_host(hostname, config, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000385 """Returns Authenticator instance to access given host.
386
387 Args:
388 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
389 a cache key for token cache.
390 config: AuthConfig instance.
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700391 scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000392
393 Returns:
394 Authenticator object.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800395
396 Raises:
397 AuthenticationError if hostname is invalid.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000398 """
399 hostname = hostname.lower().rstrip('/')
400 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
401 if '://' not in hostname:
402 hostname = 'https://' + hostname
403 parsed = urlparse.urlparse(hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000404
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000405 if parsed.path or parsed.params or parsed.query or parsed.fragment:
406 raise AuthenticationError(
407 'Expecting a hostname or root host URL, got %s instead' % hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000408 return Authenticator(parsed.netloc, config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000409
410
411class Authenticator(object):
412 """Object that knows how to refresh access tokens when needed.
413
414 Args:
415 token_cache_key: string key of a section of the token cache file to use
416 to keep the tokens. See hostname_to_token_cache_key.
417 config: AuthConfig object that holds authentication configuration.
418 """
419
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000420 def __init__(self, token_cache_key, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000421 assert isinstance(config, AuthConfig)
422 assert config.use_oauth2
423 self._access_token = None
424 self._config = config
425 self._lock = threading.Lock()
426 self._token_cache_key = token_cache_key
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000427 self._external_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000428 self._scopes = scopes
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000429 if config.refresh_token_json:
430 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000431 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000432
433 def login(self):
434 """Performs interactive login flow if necessary.
435
436 Raises:
437 AuthenticationError on error or if interrupted.
438 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000439 if self._external_token:
440 raise AuthenticationError(
441 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000442 return self.get_access_token(
443 force_refresh=True, allow_user_interaction=True)
444
445 def logout(self):
446 """Revokes the refresh token and deletes it from the cache.
447
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000448 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000449 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000450 with self._lock:
451 self._access_token = None
Edward Lemurba5bc992019-09-23 22:59:17 +0000452 had_creds = bool(_get_luci_auth_credentials(self._scopes))
453 subprocess2.check_call(
454 ['luci-auth', 'logout', '-scopes', self._scopes])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000455 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000456
457 def has_cached_credentials(self):
458 """Returns True if long term credentials (refresh token) are in cache.
459
460 Doesn't make network calls.
461
462 If returns False, get_access_token() later will ask for interactive login by
463 raising LoginRequiredError.
464
465 If returns True, most probably get_access_token() won't ask for interactive
466 login, though it is not guaranteed, since cached token can be already
467 revoked and there's no way to figure this out without actually trying to use
468 it.
469 """
470 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000471 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000472
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800473 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
474 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000475 """Returns AccessToken, refreshing it if necessary.
476
477 Args:
478 force_refresh: forcefully refresh access token even if it is not expired.
479 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800480 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000481
482 Raises:
483 AuthenticationError on error or if authentication flow was interrupted.
484 LoginRequiredError if user interaction is required, but
485 allow_user_interaction is False.
486 """
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800487 def get_loc_auth_tkn():
488 exi = sys.exc_info()
489 if not use_local_auth:
490 logging.error('Failed to create access token')
491 raise
492 try:
493 self._access_token = get_luci_context_access_token()
494 if not self._access_token:
495 logging.error('Failed to create access token')
496 raise
497 return self._access_token
498 except LuciContextAuthError:
499 logging.exception('Failed to use local auth')
500 raise exi[0], exi[1], exi[2]
501
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000502 with self._lock:
503 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000504 logging.debug('Forcing access token refresh')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800505 try:
506 self._access_token = self._create_access_token(allow_user_interaction)
507 return self._access_token
508 except LoginRequiredError:
509 return get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000510
511 # Load from on-disk cache on a first access.
512 if not self._access_token:
513 self._access_token = self._load_access_token()
514
515 # Refresh if expired or missing.
Andrii Shyshkalov94580ab2018-04-19 18:04:54 -0700516 if not self._access_token or self._access_token.needs_refresh():
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000517 # Maybe some other process already updated it, reload from the cache.
518 self._access_token = self._load_access_token()
519 # Nope, still expired, need to run the refresh flow.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700520 if not self._access_token or self._access_token.needs_refresh():
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800521 try:
522 self._access_token = self._create_access_token(
523 allow_user_interaction)
524 except LoginRequiredError:
525 get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000526
527 return self._access_token
528
529 def get_token_info(self):
530 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
531 access_token = self.get_access_token()
532 resp, content = httplib2.Http().request(
533 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
534 urllib.urlencode({'access_token': access_token.token})))
535 if resp.status == 200:
536 return json.loads(content)
537 raise AuthenticationError('Failed to fetch the token info: %r' % content)
538
539 def authorize(self, http):
540 """Monkey patches authentication logic of httplib2.Http instance.
541
542 The modified http.request method will add authentication headers to each
543 request and will refresh access_tokens when a 401 is received on a
544 request.
545
546 Args:
547 http: An instance of httplib2.Http.
548
549 Returns:
550 A modified instance of http that was passed in.
551 """
552 # Adapted from oauth2client.OAuth2Credentials.authorize.
553
554 request_orig = http.request
555
556 @functools.wraps(request_orig)
557 def new_request(
558 uri, method='GET', body=None, headers=None,
559 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
560 connection_type=None):
561 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000562 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000563 resp, content = request_orig(
564 uri, method, body, headers, redirections, connection_type)
565 if resp.status in client.REFRESH_STATUS_CODES:
566 logging.info('Refreshing due to a %s', resp.status)
567 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000568 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000569 return request_orig(
570 uri, method, body, headers, redirections, connection_type)
571 else:
572 return (resp, content)
573
574 http.request = new_request
575 return http
576
577 ## Private methods.
578
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000579 def _get_cached_credentials(self):
Edward Lemurba5bc992019-09-23 22:59:17 +0000580 """Returns oauth2client.Credentials loaded from luci-auth."""
581 credentials = _get_luci_auth_credentials(self._scopes)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000582
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000583 if not credentials:
584 logging.debug('No cached token')
585 else:
586 _log_credentials_info('cached token', credentials)
587
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000588 # Is using --auth-refresh-token-json?
589 if self._external_token:
590 # Cached credentials are valid and match external token -> use them. It is
591 # important to reuse credentials from the storage because they contain
592 # cached access token.
593 valid = (
594 credentials and not credentials.invalid and
595 credentials.refresh_token == self._external_token.refresh_token and
596 credentials.client_id == self._external_token.client_id and
597 credentials.client_secret == self._external_token.client_secret)
598 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000599 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000600 return credentials
601 # Construct new credentials from externally provided refresh token,
602 # associate them with cache storage (so that access_token will be placed
603 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000604 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000605 credentials = client.OAuth2Credentials(
606 access_token=None,
607 client_id=self._external_token.client_id,
608 client_secret=self._external_token.client_secret,
609 refresh_token=self._external_token.refresh_token,
610 token_expiry=None,
611 token_uri='https://accounts.google.com/o/oauth2/token',
612 user_agent=None,
613 revoke_uri=None)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000614 return credentials
615
616 # Not using external refresh token -> return whatever is cached.
617 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000618
619 def _load_access_token(self):
620 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000621 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000622 creds = self._get_cached_credentials()
623 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000624 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000625 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000626 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000627
628 def _create_access_token(self, allow_user_interaction=False):
629 """Mints and caches a new access token, launching OAuth2 dance if necessary.
630
631 Uses cached refresh token, if present. In that case user interaction is not
632 required and function will finish quietly. Otherwise it will launch 3-legged
633 OAuth2 flow, that needs user interaction.
634
635 Args:
636 allow_user_interaction: if True, allow interaction with the user (e.g.
637 reading standard input, or launching a browser).
638
639 Returns:
640 AccessToken.
641
642 Raises:
643 AuthenticationError on error or if authentication flow was interrupted.
644 LoginRequiredError if user interaction is required, but
645 allow_user_interaction is False.
646 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000647 logging.debug(
648 'Making new access token (allow_user_interaction=%r)',
649 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000650 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000651
652 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000653 refreshed = False
654 if credentials and not credentials.invalid:
655 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000656 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000657 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000658 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000659 refreshed = True
660 except client.Error as err:
661 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000662 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000663 'Attempting a full authentication flow.', err)
664
665 # Refresh token is missing or invalid, go through the full flow.
666 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000667 # Can't refresh externally provided token.
668 if self._external_token:
669 raise AuthenticationError(
670 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000671 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000672 logging.debug('Requesting user to login')
Edward Lemurba5bc992019-09-23 22:59:17 +0000673 raise LoginRequiredError(self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000674 logging.debug('Launching OAuth browser flow')
Edward Lemurba5bc992019-09-23 22:59:17 +0000675 credentials = _run_oauth_dance(self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000676 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000677
678 logging.info(
679 'OAuth access_token refreshed. Expires in %s.',
680 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000681 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000682
683
684## Private functions.
685
686
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000687def _get_token_cache_path():
688 # On non Win just use HOME.
689 if sys.platform != 'win32':
690 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
691 # Prefer USERPROFILE over HOME, since HOME is overridden in
692 # git-..._bin/cmd/git.cmd to point to depot_tools. depot-tools-auth.py script
693 # (and all other scripts) doesn't use this override and thus uses another
694 # value for HOME. git.cmd doesn't touch USERPROFILE though, and usually
695 # USERPROFILE == HOME on Windows.
696 if 'USERPROFILE' in os.environ:
697 return os.path.join(os.environ['USERPROFILE'], OAUTH_TOKENS_CACHE)
698 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
699
700
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000701def _is_headless():
702 """True if machine doesn't seem to have a display."""
703 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
704
705
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000706def _read_refresh_token_json(path):
707 """Returns RefreshToken by reading it from the JSON file."""
708 try:
709 with open(path, 'r') as f:
710 data = json.load(f)
711 return RefreshToken(
712 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
713 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
714 refresh_token=str(data['refresh_token']))
715 except (IOError, ValueError) as e:
716 raise AuthenticationError(
717 'Failed to read refresh token from %s: %s' % (path, e))
718 except KeyError as e:
719 raise AuthenticationError(
720 'Failed to read refresh token from %s: missing key %s' % (path, e))
721
722
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000723def _log_credentials_info(title, credentials):
724 """Dumps (non sensitive) part of client.Credentials object to debug log."""
725 if credentials:
726 logging.debug('%s info: %r', title, {
727 'access_token_expired': credentials.access_token_expired,
728 'has_access_token': bool(credentials.access_token),
729 'invalid': credentials.invalid,
730 'utcnow': datetime.datetime.utcnow(),
731 'token_expiry': credentials.token_expiry,
732 })
733
734
Edward Lemurba5bc992019-09-23 22:59:17 +0000735def _get_luci_auth_credentials(scopes):
736 try:
737 token_info = json.loads(subprocess2.check_output(
738 ['luci-auth', 'token', '-scopes', scopes, '-json-output', '-'],
739 stderr=subprocess2.VOID))
740 except subprocess2.CalledProcessError:
741 return None
742
743 return client.OAuth2Credentials(
744 access_token=token_info['token'],
745 client_id=OAUTH_CLIENT_ID,
746 client_secret=OAUTH_CLIENT_SECRET,
747 refresh_token=None,
748 token_expiry=datetime.datetime.utcfromtimestamp(token_info['expiry']),
749 token_uri=None,
750 user_agent=None,
751 revoke_uri=None)
752
753def _run_oauth_dance(scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000754 """Perform full 3-legged OAuth2 flow with the browser.
755
756 Returns:
757 oauth2client.Credentials.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000758 """
Edward Lemurba5bc992019-09-23 22:59:17 +0000759 subprocess2.check_call(['luci-auth', 'login', '-scopes', scopes])
760 return _get_luci_auth_credentials(scopes)