blob: dcda8c3b7e48fae9ffa4c50f3bcfa236c3c83faf [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
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00007import BaseHTTPServer
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +00008import collections
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00009import datetime
10import functools
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000011import hashlib
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000012import json
13import logging
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000014import optparse
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000015import os
16import socket
17import sys
18import threading
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -080019import time
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000020import urllib
21import urlparse
22import webbrowser
23
24from third_party import httplib2
25from third_party.oauth2client import client
26from third_party.oauth2client import multistore_file
27
28
29# depot_tools/.
30DEPOT_TOOLS_DIR = os.path.dirname(os.path.abspath(__file__))
31
32
33# Google OAuth2 clients always have a secret, even if the client is an installed
34# application/utility such as this. Of course, in such cases the "secret" is
35# actually publicly known; security depends entirely on the secrecy of refresh
36# tokens, which effectively become bearer tokens. An attacker can impersonate
37# service's identity in OAuth2 flow. But that's generally fine as long as a list
38# of allowed redirect_uri's associated with client_id is limited to 'localhost'
39# or 'urn:ietf:wg:oauth:2.0:oob'. In that case attacker needs some process
40# running on user's machine to successfully complete the flow and grab refresh
41# token. When you have a malicious code running on your machine, you're screwed
42# anyway.
43# This particular set is managed by API Console project "chrome-infra-auth".
44OAUTH_CLIENT_ID = (
45 '446450136466-2hr92jrq8e6i4tnsa56b52vacp7t3936.apps.googleusercontent.com')
46OAUTH_CLIENT_SECRET = 'uBfbay2KCy9t4QveJ-dOqHtp'
47
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070048# This is what most GAE apps require for authentication.
49OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
50# Gerrit and Git on *.googlesource.com require this scope.
51OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
52# Deprecated. Use OAUTH_SCOPE_EMAIL instead.
53OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000054
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +000055# Path to a file with cached OAuth2 credentials used by default relative to the
56# home dir (see _get_token_cache_path). It should be a safe location accessible
57# only to a current user: knowing content of this file is roughly equivalent to
58# knowing account password. Single file can hold multiple independent tokens
59# identified by token_cache_key (see Authenticator).
60OAUTH_TOKENS_CACHE = '.depot_tools_oauth2_tokens'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000061
62
63# Authentication configuration extracted from command line options.
64# See doc string for 'make_auth_config' for meaning of fields.
65AuthConfig = collections.namedtuple('AuthConfig', [
66 'use_oauth2', # deprecated, will be always True
67 'save_cookies', # deprecated, will be removed
68 'use_local_webserver',
69 'webserver_port',
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000070 'refresh_token_json',
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000071])
72
73
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000074# OAuth access token with its expiration time (UTC datetime or None if unknown).
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070075class AccessToken(collections.namedtuple('AccessToken', [
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000076 'token',
77 'expires_at',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070078 ])):
79
80 def needs_refresh(self, now=None):
81 """True if this AccessToken should be refreshed."""
82 if self.expires_at is not None:
83 now = now or datetime.datetime.utcnow()
84 # Allow 5 min of clock skew between client and backend.
85 now += datetime.timedelta(seconds=300)
86 return now >= self.expires_at
87 # Token without expiration time never expires.
88 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000089
90
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +000091# Refresh token passed via --auth-refresh-token-json.
92RefreshToken = collections.namedtuple('RefreshToken', [
93 'client_id',
94 'client_secret',
95 'refresh_token',
96])
97
98
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000099class AuthenticationError(Exception):
100 """Raised on errors related to authentication."""
101
102
103class LoginRequiredError(AuthenticationError):
104 """Interaction with the user is required to authenticate."""
105
106 def __init__(self, token_cache_key):
107 # HACK(vadimsh): It is assumed here that the token cache key is a hostname.
108 msg = (
109 'You are not logged in. Please login first by running:\n'
110 ' depot-tools-auth login %s' % token_cache_key)
111 super(LoginRequiredError, self).__init__(msg)
112
113
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800114class LuciContextAuthError(Exception):
115 """Raised on errors related to unsuccessful attempts to load LUCI_CONTEXT"""
116
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700117 def __init__(self, msg, exc=None):
118 if exc is None:
119 logging.error(msg)
120 else:
121 logging.exception(msg)
122 msg = '%s: %s' % (msg, exc)
123 super(LuciContextAuthError, self).__init__(msg)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800124
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700125
126def has_luci_context_local_auth():
127 """Returns whether LUCI_CONTEXT should be used for ambient authentication.
128 """
129 try:
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700130 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700131 except LuciContextAuthError:
132 return False
133 if params is None:
134 return False
135 return bool(params.default_account_id)
136
137
138def get_luci_context_access_token(scopes=OAUTH_SCOPE_EMAIL):
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800139 """Returns a valid AccessToken from the local LUCI context auth server.
140
141 Adapted from
142 https://chromium.googlesource.com/infra/luci/luci-py/+/master/client/libs/luci_context/luci_context.py
143 See the link above for more details.
144
145 Returns:
146 AccessToken if LUCI_CONTEXT is present and attempt to load it is successful.
147 None if LUCI_CONTEXT is absent.
148
149 Raises:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700150 LuciContextAuthError if LUCI_CONTEXT is present, but there was a failure
151 obtaining its access token.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800152 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700153 params = _get_luci_context_local_auth_params()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700154 if params is None:
155 return None
156 return _get_luci_context_access_token(
157 params, datetime.datetime.utcnow(), scopes)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800158
159
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700160_LuciContextLocalAuthParams = collections.namedtuple(
161 '_LuciContextLocalAuthParams', [
162 'default_account_id',
163 'secret',
164 'rpc_port',
165])
166
167
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700168def _cache_thread_safe(f):
169 """Decorator caching result of nullary function in thread-safe way."""
170 lock = threading.Lock()
171 cache = []
172
173 @functools.wraps(f)
174 def caching_wrapper():
175 if not cache:
176 with lock:
177 if not cache:
178 cache.append(f())
179 return cache[0]
180
181 # Allow easy way to clear cache, particularly useful in tests.
182 caching_wrapper.clear_cache = lambda: cache.pop() if cache else None
183 return caching_wrapper
184
185
186@_cache_thread_safe
187def _get_luci_context_local_auth_params():
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700188 """Returns local auth parameters if local auth is configured else None.
189
190 Raises LuciContextAuthError on unexpected failures.
191 """
Andrii Shyshkalovb3c44412018-04-19 14:27:19 -0700192 ctx_path = os.environ.get('LUCI_CONTEXT')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800193 if not ctx_path:
194 return None
195 ctx_path = ctx_path.decode(sys.getfilesystemencoding())
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800196 try:
197 loaded = _load_luci_context(ctx_path)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700198 except (OSError, IOError, ValueError) as e:
199 raise LuciContextAuthError('Failed to open, read or decode LUCI_CONTEXT', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800200 try:
201 local_auth = loaded.get('local_auth')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700202 except AttributeError as e:
203 raise LuciContextAuthError('LUCI_CONTEXT not in proper format', e)
204 if local_auth is None:
205 logging.debug('LUCI_CONTEXT configured w/o local auth')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800206 return None
207 try:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700208 return _LuciContextLocalAuthParams(
209 default_account_id=local_auth.get('default_account_id'),
210 secret=local_auth.get('secret'),
211 rpc_port=int(local_auth.get('rpc_port')))
212 except (AttributeError, ValueError) as e:
213 raise LuciContextAuthError('local_auth config malformed', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800214
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700215
216def _load_luci_context(ctx_path):
217 # Kept separate for test mocking.
218 with open(ctx_path) as f:
219 return json.load(f)
220
221
222def _get_luci_context_access_token(params, now, scopes=OAUTH_SCOPE_EMAIL):
223 # No account, local_auth shouldn't be used.
224 if not params.default_account_id:
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800225 return None
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700226 if not params.secret:
227 raise LuciContextAuthError('local_auth: no secret')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800228
229 logging.debug('local_auth: requesting an access token for account "%s"',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700230 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800231 http = httplib2.Http()
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700232 host = '127.0.0.1:%d' % params.rpc_port
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800233 resp, content = http.request(
234 uri='http://%s/rpc/LuciLocalAuthService.GetOAuthToken' % host,
235 method='POST',
236 body=json.dumps({
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700237 'account_id': params.default_account_id,
238 'scopes': scopes.split(' '),
239 'secret': params.secret,
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800240 }),
241 headers={'Content-Type': 'application/json'})
242 if resp.status != 200:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700243 raise LuciContextAuthError(
244 'local_auth: Failed to grab access token from '
245 'LUCI context server with status %d: %r' % (resp.status, content))
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800246 try:
247 token = json.loads(content)
248 error_code = token.get('error_code')
249 error_message = token.get('error_message')
250 access_token = token.get('access_token')
251 expiry = token.get('expiry')
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700252 except (AttributeError, ValueError) as e:
253 raise LuciContextAuthError('Unexpected access token response format', e)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800254 if error_code:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700255 raise LuciContextAuthError(
256 'Error %d in retrieving access token: %s', error_code, error_message)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800257 if not access_token:
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700258 raise LuciContextAuthError(
259 'No access token returned from LUCI context server')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800260 expiry_dt = None
261 if expiry:
262 try:
263 expiry_dt = datetime.datetime.utcfromtimestamp(expiry)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800264 logging.debug(
265 'local_auth: got an access token for '
266 'account "%s" that expires in %d sec',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700267 params.default_account_id, (expiry_dt - now).total_seconds())
268 except (TypeError, ValueError) as e:
269 raise LuciContextAuthError('Invalid expiry in returned token', e)
Mun Yong Jang1728f5f2017-11-27 13:29:08 -0800270 else:
271 logging.debug(
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700272 'local auth: got an access token for account "%s" that does not expire',
273 params.default_account_id)
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800274 access_token = AccessToken(access_token, expiry_dt)
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700275 if access_token.needs_refresh(now=now):
276 raise LuciContextAuthError('Received access token is already expired')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800277 return access_token
278
279
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000280def make_auth_config(
281 use_oauth2=None,
282 save_cookies=None,
283 use_local_webserver=None,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000284 webserver_port=None,
285 refresh_token_json=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000286 """Returns new instance of AuthConfig.
287
288 If some config option is None, it will be set to a reasonable default value.
289 This function also acts as an authoritative place for default values of
290 corresponding command line options.
291 """
292 default = lambda val, d: val if val is not None else d
293 return AuthConfig(
vadimsh@chromium.org19f3fe62015-04-20 17:03:10 +0000294 default(use_oauth2, True),
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000295 default(save_cookies, True),
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000296 default(use_local_webserver, not _is_headless()),
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000297 default(webserver_port, 8090),
298 default(refresh_token_json, ''))
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000299
300
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000301def add_auth_options(parser, default_config=None):
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000302 """Appends OAuth related options to OptionParser."""
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000303 default_config = default_config or make_auth_config()
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000304 parser.auth_group = optparse.OptionGroup(parser, 'Auth options')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000305 parser.add_option_group(parser.auth_group)
306
307 # OAuth2 vs password switch.
308 auth_default = 'use OAuth2' if default_config.use_oauth2 else 'use password'
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000309 parser.auth_group.add_option(
310 '--oauth2',
311 action='store_true',
312 dest='use_oauth2',
313 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000314 help='Use OAuth 2.0 instead of a password. [default: %s]' % auth_default)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000315 parser.auth_group.add_option(
316 '--no-oauth2',
317 action='store_false',
318 dest='use_oauth2',
319 default=default_config.use_oauth2,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000320 help='Use password instead of OAuth 2.0. [default: %s]' % auth_default)
321
322 # Password related options, deprecated.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000323 parser.auth_group.add_option(
324 '--no-cookies',
325 action='store_false',
326 dest='save_cookies',
327 default=default_config.save_cookies,
328 help='Do not save authentication cookies to local disk.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000329
330 # OAuth2 related options.
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000331 parser.auth_group.add_option(
332 '--auth-no-local-webserver',
333 action='store_false',
334 dest='use_local_webserver',
335 default=default_config.use_local_webserver,
336 help='Do not run a local web server when performing OAuth2 login flow.')
337 parser.auth_group.add_option(
338 '--auth-host-port',
339 type=int,
340 default=default_config.webserver_port,
341 help='Port a local web server should listen on. Used only if '
342 '--auth-no-local-webserver is not set. [default: %default]')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000343 parser.auth_group.add_option(
344 '--auth-refresh-token-json',
345 default=default_config.refresh_token_json,
346 help='Path to a JSON file with role account refresh token to use.')
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000347
348
349def extract_auth_config_from_options(options):
350 """Given OptionParser parsed options, extracts AuthConfig from it.
351
352 OptionParser should be populated with auth options by 'add_auth_options'.
353 """
354 return make_auth_config(
355 use_oauth2=options.use_oauth2,
356 save_cookies=False if options.use_oauth2 else options.save_cookies,
357 use_local_webserver=options.use_local_webserver,
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000358 webserver_port=options.auth_host_port,
359 refresh_token_json=options.auth_refresh_token_json)
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000360
361
362def auth_config_to_command_options(auth_config):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000363 """AuthConfig -> list of strings with command line options.
364
365 Omits options that are set to default values.
366 """
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000367 if not auth_config:
368 return []
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000369 defaults = make_auth_config()
370 opts = []
371 if auth_config.use_oauth2 != defaults.use_oauth2:
372 opts.append('--oauth2' if auth_config.use_oauth2 else '--no-oauth2')
373 if auth_config.save_cookies != auth_config.save_cookies:
374 if not auth_config.save_cookies:
375 opts.append('--no-cookies')
376 if auth_config.use_local_webserver != defaults.use_local_webserver:
377 if not auth_config.use_local_webserver:
378 opts.append('--auth-no-local-webserver')
379 if auth_config.webserver_port != defaults.webserver_port:
380 opts.extend(['--auth-host-port', str(auth_config.webserver_port)])
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000381 if auth_config.refresh_token_json != defaults.refresh_token_json:
382 opts.extend([
383 '--auth-refresh-token-json', str(auth_config.refresh_token_json)])
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +0000384 return opts
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000385
386
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700387def get_authenticator_for_host(hostname, config, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000388 """Returns Authenticator instance to access given host.
389
390 Args:
391 hostname: a naked hostname or http(s)://<hostname>[/] URL. Used to derive
392 a cache key for token cache.
393 config: AuthConfig instance.
Andrii Shyshkalov741afe82018-04-19 14:32:18 -0700394 scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000395
396 Returns:
397 Authenticator object.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800398
399 Raises:
400 AuthenticationError if hostname is invalid.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000401 """
402 hostname = hostname.lower().rstrip('/')
403 # Append some scheme, otherwise urlparse puts hostname into parsed.path.
404 if '://' not in hostname:
405 hostname = 'https://' + hostname
406 parsed = urlparse.urlparse(hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000407
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000408 if parsed.path or parsed.params or parsed.query or parsed.fragment:
409 raise AuthenticationError(
410 'Expecting a hostname or root host URL, got %s instead' % hostname)
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000411 return Authenticator(parsed.netloc, config, scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000412
413
414class Authenticator(object):
415 """Object that knows how to refresh access tokens when needed.
416
417 Args:
418 token_cache_key: string key of a section of the token cache file to use
419 to keep the tokens. See hostname_to_token_cache_key.
420 config: AuthConfig object that holds authentication configuration.
421 """
422
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000423 def __init__(self, token_cache_key, config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000424 assert isinstance(config, AuthConfig)
425 assert config.use_oauth2
426 self._access_token = None
427 self._config = config
428 self._lock = threading.Lock()
429 self._token_cache_key = token_cache_key
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000430 self._external_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000431 self._scopes = scopes
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000432 if config.refresh_token_json:
433 self._external_token = _read_refresh_token_json(config.refresh_token_json)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000434 logging.debug('Using auth config %r', config)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000435
436 def login(self):
437 """Performs interactive login flow if necessary.
438
439 Raises:
440 AuthenticationError on error or if interrupted.
441 """
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000442 if self._external_token:
443 raise AuthenticationError(
444 'Can\'t run login flow when using --auth-refresh-token-json.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000445 return self.get_access_token(
446 force_refresh=True, allow_user_interaction=True)
447
448 def logout(self):
449 """Revokes the refresh token and deletes it from the cache.
450
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000451 Returns True if had some credentials cached.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000452 """
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000453 with self._lock:
454 self._access_token = None
455 storage = self._get_storage()
456 credentials = storage.get()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000457 had_creds = bool(credentials)
458 if credentials and credentials.refresh_token and credentials.revoke_uri:
459 try:
460 credentials.revoke(httplib2.Http())
461 except client.TokenRevokeError as e:
462 logging.warning('Failed to revoke refresh token: %s', e)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000463 storage.delete()
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000464 return had_creds
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000465
466 def has_cached_credentials(self):
467 """Returns True if long term credentials (refresh token) are in cache.
468
469 Doesn't make network calls.
470
471 If returns False, get_access_token() later will ask for interactive login by
472 raising LoginRequiredError.
473
474 If returns True, most probably get_access_token() won't ask for interactive
475 login, though it is not guaranteed, since cached token can be already
476 revoked and there's no way to figure this out without actually trying to use
477 it.
478 """
479 with self._lock:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000480 return bool(self._get_cached_credentials())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000481
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800482 def get_access_token(self, force_refresh=False, allow_user_interaction=False,
483 use_local_auth=True):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000484 """Returns AccessToken, refreshing it if necessary.
485
486 Args:
487 force_refresh: forcefully refresh access token even if it is not expired.
488 allow_user_interaction: True to enable blocking for user input if needed.
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800489 use_local_auth: default to local auth if needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000490
491 Raises:
492 AuthenticationError on error or if authentication flow was interrupted.
493 LoginRequiredError if user interaction is required, but
494 allow_user_interaction is False.
495 """
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800496 def get_loc_auth_tkn():
497 exi = sys.exc_info()
498 if not use_local_auth:
499 logging.error('Failed to create access token')
500 raise
501 try:
502 self._access_token = get_luci_context_access_token()
503 if not self._access_token:
504 logging.error('Failed to create access token')
505 raise
506 return self._access_token
507 except LuciContextAuthError:
508 logging.exception('Failed to use local auth')
509 raise exi[0], exi[1], exi[2]
510
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000511 with self._lock:
512 if force_refresh:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000513 logging.debug('Forcing access token refresh')
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800514 try:
515 self._access_token = self._create_access_token(allow_user_interaction)
516 return self._access_token
517 except LoginRequiredError:
518 return get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000519
520 # Load from on-disk cache on a first access.
521 if not self._access_token:
522 self._access_token = self._load_access_token()
523
524 # Refresh if expired or missing.
Andrii Shyshkalov94580ab2018-04-19 18:04:54 -0700525 if not self._access_token or self._access_token.needs_refresh():
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000526 # Maybe some other process already updated it, reload from the cache.
527 self._access_token = self._load_access_token()
528 # Nope, still expired, need to run the refresh flow.
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -0700529 if not self._access_token or self._access_token.needs_refresh():
Mun Yong Jangacc8e3e2017-11-22 10:49:56 -0800530 try:
531 self._access_token = self._create_access_token(
532 allow_user_interaction)
533 except LoginRequiredError:
534 get_loc_auth_tkn()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000535
536 return self._access_token
537
538 def get_token_info(self):
539 """Returns a result of /oauth2/v2/tokeninfo call with token info."""
540 access_token = self.get_access_token()
541 resp, content = httplib2.Http().request(
542 uri='https://www.googleapis.com/oauth2/v2/tokeninfo?%s' % (
543 urllib.urlencode({'access_token': access_token.token})))
544 if resp.status == 200:
545 return json.loads(content)
546 raise AuthenticationError('Failed to fetch the token info: %r' % content)
547
548 def authorize(self, http):
549 """Monkey patches authentication logic of httplib2.Http instance.
550
551 The modified http.request method will add authentication headers to each
552 request and will refresh access_tokens when a 401 is received on a
553 request.
554
555 Args:
556 http: An instance of httplib2.Http.
557
558 Returns:
559 A modified instance of http that was passed in.
560 """
561 # Adapted from oauth2client.OAuth2Credentials.authorize.
562
563 request_orig = http.request
564
565 @functools.wraps(request_orig)
566 def new_request(
567 uri, method='GET', body=None, headers=None,
568 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
569 connection_type=None):
570 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000571 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000572 resp, content = request_orig(
573 uri, method, body, headers, redirections, connection_type)
574 if resp.status in client.REFRESH_STATUS_CODES:
575 logging.info('Refreshing due to a %s', resp.status)
576 access_token = self.get_access_token(force_refresh=True)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000577 headers['Authorization'] = 'Bearer %s' % access_token.token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000578 return request_orig(
579 uri, method, body, headers, redirections, connection_type)
580 else:
581 return (resp, content)
582
583 http.request = new_request
584 return http
585
586 ## Private methods.
587
588 def _get_storage(self):
589 """Returns oauth2client.Storage with cached tokens."""
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000590 # Do not mix cache keys for different externally provided tokens.
591 if self._external_token:
592 token_hash = hashlib.sha1(self._external_token.refresh_token).hexdigest()
593 cache_key = '%s:refresh_tok:%s' % (self._token_cache_key, token_hash)
594 else:
595 cache_key = self._token_cache_key
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000596 path = _get_token_cache_path()
597 logging.debug('Using token storage %r (cache key %r)', path, cache_key)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000598 return multistore_file.get_credential_storage_custom_string_key(
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000599 path, cache_key)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000600
601 def _get_cached_credentials(self):
602 """Returns oauth2client.Credentials loaded from storage."""
603 storage = self._get_storage()
604 credentials = storage.get()
605
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000606 if not credentials:
607 logging.debug('No cached token')
608 else:
609 _log_credentials_info('cached token', credentials)
610
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000611 # Is using --auth-refresh-token-json?
612 if self._external_token:
613 # Cached credentials are valid and match external token -> use them. It is
614 # important to reuse credentials from the storage because they contain
615 # cached access token.
616 valid = (
617 credentials and not credentials.invalid and
618 credentials.refresh_token == self._external_token.refresh_token and
619 credentials.client_id == self._external_token.client_id and
620 credentials.client_secret == self._external_token.client_secret)
621 if valid:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000622 logging.debug('Cached credentials match external refresh token')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000623 return credentials
624 # Construct new credentials from externally provided refresh token,
625 # associate them with cache storage (so that access_token will be placed
626 # in the cache later too).
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000627 logging.debug('Putting external refresh token into the cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000628 credentials = client.OAuth2Credentials(
629 access_token=None,
630 client_id=self._external_token.client_id,
631 client_secret=self._external_token.client_secret,
632 refresh_token=self._external_token.refresh_token,
633 token_expiry=None,
634 token_uri='https://accounts.google.com/o/oauth2/token',
635 user_agent=None,
636 revoke_uri=None)
637 credentials.set_store(storage)
638 storage.put(credentials)
639 return credentials
640
641 # Not using external refresh token -> return whatever is cached.
642 return credentials if (credentials and not credentials.invalid) else None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000643
644 def _load_access_token(self):
645 """Returns cached AccessToken if it is not expired yet."""
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000646 logging.debug('Reloading access token from cache')
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000647 creds = self._get_cached_credentials()
648 if not creds or not creds.access_token or creds.access_token_expired:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000649 logging.debug('Access token is missing or expired')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000650 return None
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000651 return AccessToken(str(creds.access_token), creds.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000652
653 def _create_access_token(self, allow_user_interaction=False):
654 """Mints and caches a new access token, launching OAuth2 dance if necessary.
655
656 Uses cached refresh token, if present. In that case user interaction is not
657 required and function will finish quietly. Otherwise it will launch 3-legged
658 OAuth2 flow, that needs user interaction.
659
660 Args:
661 allow_user_interaction: if True, allow interaction with the user (e.g.
662 reading standard input, or launching a browser).
663
664 Returns:
665 AccessToken.
666
667 Raises:
668 AuthenticationError on error or if authentication flow was interrupted.
669 LoginRequiredError if user interaction is required, but
670 allow_user_interaction is False.
671 """
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000672 logging.debug(
673 'Making new access token (allow_user_interaction=%r)',
674 allow_user_interaction)
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000675 credentials = self._get_cached_credentials()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000676
677 # 3-legged flow with (perhaps cached) refresh token.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000678 refreshed = False
679 if credentials and not credentials.invalid:
680 try:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000681 logging.debug('Attempting to refresh access_token')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000682 credentials.refresh(httplib2.Http())
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000683 _log_credentials_info('refreshed token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000684 refreshed = True
685 except client.Error as err:
686 logging.warning(
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000687 'OAuth error during access token refresh (%s). '
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000688 'Attempting a full authentication flow.', err)
689
690 # Refresh token is missing or invalid, go through the full flow.
691 if not refreshed:
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000692 # Can't refresh externally provided token.
693 if self._external_token:
694 raise AuthenticationError(
695 'Token provided via --auth-refresh-token-json is no longer valid.')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000696 if not allow_user_interaction:
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000697 logging.debug('Requesting user to login')
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000698 raise LoginRequiredError(self._token_cache_key)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000699 logging.debug('Launching OAuth browser flow')
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000700 credentials = _run_oauth_dance(self._config, self._scopes)
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000701 _log_credentials_info('new token', credentials)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000702
703 logging.info(
704 'OAuth access_token refreshed. Expires in %s.',
705 credentials.token_expiry - datetime.datetime.utcnow())
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000706 storage = self._get_storage()
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000707 credentials.set_store(storage)
708 storage.put(credentials)
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000709 return AccessToken(str(credentials.access_token), credentials.token_expiry)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000710
711
712## Private functions.
713
714
vadimsh@chromium.org148f76f2015-04-21 01:44:13 +0000715def _get_token_cache_path():
716 # On non Win just use HOME.
717 if sys.platform != 'win32':
718 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
719 # Prefer USERPROFILE over HOME, since HOME is overridden in
720 # git-..._bin/cmd/git.cmd to point to depot_tools. depot-tools-auth.py script
721 # (and all other scripts) doesn't use this override and thus uses another
722 # value for HOME. git.cmd doesn't touch USERPROFILE though, and usually
723 # USERPROFILE == HOME on Windows.
724 if 'USERPROFILE' in os.environ:
725 return os.path.join(os.environ['USERPROFILE'], OAUTH_TOKENS_CACHE)
726 return os.path.join(os.path.expanduser('~'), OAUTH_TOKENS_CACHE)
727
728
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000729def _is_headless():
730 """True if machine doesn't seem to have a display."""
731 return sys.platform == 'linux2' and not os.environ.get('DISPLAY')
732
733
vadimsh@chromium.org24daf9e2015-04-17 02:42:44 +0000734def _read_refresh_token_json(path):
735 """Returns RefreshToken by reading it from the JSON file."""
736 try:
737 with open(path, 'r') as f:
738 data = json.load(f)
739 return RefreshToken(
740 client_id=str(data.get('client_id', OAUTH_CLIENT_ID)),
741 client_secret=str(data.get('client_secret', OAUTH_CLIENT_SECRET)),
742 refresh_token=str(data['refresh_token']))
743 except (IOError, ValueError) as e:
744 raise AuthenticationError(
745 'Failed to read refresh token from %s: %s' % (path, e))
746 except KeyError as e:
747 raise AuthenticationError(
748 'Failed to read refresh token from %s: missing key %s' % (path, e))
749
750
vadimsh@chromium.orgcfbeecb2015-04-21 00:12:36 +0000751def _log_credentials_info(title, credentials):
752 """Dumps (non sensitive) part of client.Credentials object to debug log."""
753 if credentials:
754 logging.debug('%s info: %r', title, {
755 'access_token_expired': credentials.access_token_expired,
756 'has_access_token': bool(credentials.access_token),
757 'invalid': credentials.invalid,
758 'utcnow': datetime.datetime.utcnow(),
759 'token_expiry': credentials.token_expiry,
760 })
761
762
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000763def _run_oauth_dance(config, scopes):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000764 """Perform full 3-legged OAuth2 flow with the browser.
765
766 Returns:
767 oauth2client.Credentials.
768
769 Raises:
770 AuthenticationError on errors.
771 """
772 flow = client.OAuth2WebServerFlow(
773 OAUTH_CLIENT_ID,
774 OAUTH_CLIENT_SECRET,
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +0000775 scopes,
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000776 approval_prompt='force')
777
778 use_local_webserver = config.use_local_webserver
779 port = config.webserver_port
780 if config.use_local_webserver:
781 success = False
782 try:
783 httpd = _ClientRedirectServer(('localhost', port), _ClientRedirectHandler)
784 except socket.error:
785 pass
786 else:
787 success = True
788 use_local_webserver = success
789 if not success:
790 print(
791 'Failed to start a local webserver listening on port %d.\n'
792 'Please check your firewall settings and locally running programs that '
793 'may be blocking or using those ports.\n\n'
794 'Falling back to --auth-no-local-webserver and continuing with '
795 'authentication.\n' % port)
796
797 if use_local_webserver:
798 oauth_callback = 'http://localhost:%s/' % port
799 else:
800 oauth_callback = client.OOB_CALLBACK_URN
801 flow.redirect_uri = oauth_callback
802 authorize_url = flow.step1_get_authorize_url()
803
804 if use_local_webserver:
805 webbrowser.open(authorize_url, new=1, autoraise=True)
806 print(
807 'Your browser has been opened to visit:\n\n'
808 ' %s\n\n'
809 'If your browser is on a different machine then exit and re-run this '
810 'application with the command-line parameter\n\n'
811 ' --auth-no-local-webserver\n' % authorize_url)
812 else:
813 print(
814 'Go to the following link in your browser:\n\n'
815 ' %s\n' % authorize_url)
816
817 try:
818 code = None
819 if use_local_webserver:
820 httpd.handle_request()
821 if 'error' in httpd.query_params:
822 raise AuthenticationError(
823 'Authentication request was rejected: %s' %
824 httpd.query_params['error'])
825 if 'code' not in httpd.query_params:
826 raise AuthenticationError(
827 'Failed to find "code" in the query parameters of the redirect.\n'
828 'Try running with --auth-no-local-webserver.')
829 code = httpd.query_params['code']
830 else:
831 code = raw_input('Enter verification code: ').strip()
832 except KeyboardInterrupt:
833 raise AuthenticationError('Authentication was canceled.')
834
835 try:
836 return flow.step2_exchange(code)
837 except client.FlowExchangeError as e:
838 raise AuthenticationError('Authentication has failed: %s' % e)
839
840
841class _ClientRedirectServer(BaseHTTPServer.HTTPServer):
842 """A server to handle OAuth 2.0 redirects back to localhost.
843
844 Waits for a single request and parses the query parameters
845 into query_params and then stops serving.
846 """
847 query_params = {}
848
849
850class _ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
851 """A handler for OAuth 2.0 redirects back to localhost.
852
853 Waits for a single request and parses the query parameters
854 into the servers query_params and then stops serving.
855 """
856
857 def do_GET(self):
858 """Handle a GET request.
859
860 Parses the query parameters and prints a message
861 if the flow has completed. Note that we can't detect
862 if an error occurred.
863 """
864 self.send_response(200)
865 self.send_header('Content-type', 'text/html')
866 self.end_headers()
867 query = self.path.split('?', 1)[-1]
868 query = dict(urlparse.parse_qsl(query))
869 self.server.query_params = query
870 self.wfile.write('<html><head><title>Authentication Status</title></head>')
871 self.wfile.write('<body><p>The authentication flow has completed.</p>')
872 self.wfile.write('</body></html>')
873
874 def log_message(self, _format, *args):
875 """Do not log messages to stdout while running as command line program."""