blob: 204cfb7d4e942e2f74fd5f335e059de97a127e49 [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
Edward Lemur202c5592019-10-21 22:44:52 +000012import httplib2
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000013import json
14import logging
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000015import os
Edward Lemurba5bc992019-09-23 22:59:17 +000016
17import subprocess2
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000018
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000019
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070020# This is what most GAE apps require for authentication.
21OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
22# Gerrit and Git on *.googlesource.com require this scope.
23OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
24# Deprecated. Use OAUTH_SCOPE_EMAIL instead.
25OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000026
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000027
Edward Lemuracf922c2019-10-18 18:02:43 +000028# Mockable datetime.datetime.utcnow for testing.
29def datetime_now():
30 return datetime.datetime.utcnow()
31
32
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000033# OAuth access token with its expiration time (UTC datetime or None if unknown).
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070034class AccessToken(collections.namedtuple('AccessToken', [
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000035 'token',
36 'expires_at',
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070037 ])):
38
Edward Lemur5b929a42019-10-21 17:57:39 +000039 def needs_refresh(self):
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070040 """True if this AccessToken should be refreshed."""
41 if self.expires_at is not None:
Edward Lemuracf922c2019-10-18 18:02:43 +000042 # Allow 30s of clock skew between client and backend.
Edward Lemur5b929a42019-10-21 17:57:39 +000043 return datetime_now() + datetime.timedelta(seconds=30) >= self.expires_at
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070044 # Token without expiration time never expires.
45 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000046
47
Edward Lemur5b929a42019-10-21 17:57:39 +000048class LoginRequiredError(Exception):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000049 """Interaction with the user is required to authenticate."""
50
Edward Lemurba5bc992019-09-23 22:59:17 +000051 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000052 msg = (
53 'You are not logged in. Please login first by running:\n'
Edward Lemurba5bc992019-09-23 22:59:17 +000054 ' luci-auth login -scopes %s' % scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000055 super(LoginRequiredError, self).__init__(msg)
56
57
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070058def has_luci_context_local_auth():
Edward Lemur5b929a42019-10-21 17:57:39 +000059 """Returns whether LUCI_CONTEXT should be used for ambient authentication."""
Edward Lemurb43d98b2019-10-30 17:33:20 +000060 ctx_path = os.environ.get('LUCI_CONTEXT')
61 if not ctx_path:
62 return False
63 try:
64 with open(ctx_path) as f:
65 loaded = json.load(f)
66 except (OSError, IOError, ValueError):
67 return False
68 return loaded.get('local_auth', {}).get('default_account_id') is not None
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000069
70
71class Authenticator(object):
72 """Object that knows how to refresh access tokens when needed.
73
74 Args:
Edward Lemur5b929a42019-10-21 17:57:39 +000075 scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000076 """
77
Edward Lemur5b929a42019-10-21 17:57:39 +000078 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000079 self._access_token = None
seanmccullough@chromium.org3e4a5812015-06-11 17:48:47 +000080 self._scopes = scopes
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000081
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000082 def has_cached_credentials(self):
Edward Lemuracf922c2019-10-18 18:02:43 +000083 """Returns True if credentials can be obtained.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000084
Edward Lemuracf922c2019-10-18 18:02:43 +000085 If returns False, get_access_token() later will probably ask for interactive
Edward Lemur5b929a42019-10-21 17:57:39 +000086 login by raising LoginRequiredError.
Edward Lesmes989bc352019-10-17 05:45:35 +000087
Edward Lemuracf922c2019-10-18 18:02:43 +000088 If returns True, get_access_token() won't ask for interactive login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000089 """
Edward Lemur5b929a42019-10-21 17:57:39 +000090 return bool(self._get_luci_auth_token())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000091
Edward Lemur5b929a42019-10-21 17:57:39 +000092 def get_access_token(self):
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000093 """Returns AccessToken, refreshing it if necessary.
94
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000095 Raises:
Edward Lemur5b929a42019-10-21 17:57:39 +000096 LoginRequiredError if user interaction is required.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000097 """
Edward Lemur5b929a42019-10-21 17:57:39 +000098 if self._access_token and not self._access_token.needs_refresh():
99 return self._access_token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000100
Edward Lemur5b929a42019-10-21 17:57:39 +0000101 # Token expired or missing. Maybe some other process already updated it,
102 # reload from the cache.
103 self._access_token = self._get_luci_auth_token()
104 if self._access_token and not self._access_token.needs_refresh():
105 return self._access_token
Edward Lesmes989bc352019-10-17 05:45:35 +0000106
Edward Lemur5b929a42019-10-21 17:57:39 +0000107 # Nope, still expired. Needs user interaction.
108 logging.error('Failed to create access token')
109 raise LoginRequiredError(self._scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000110
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000111 def authorize(self, http):
112 """Monkey patches authentication logic of httplib2.Http instance.
113
114 The modified http.request method will add authentication headers to each
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000115 request.
116
117 Args:
118 http: An instance of httplib2.Http.
119
120 Returns:
121 A modified instance of http that was passed in.
122 """
123 # Adapted from oauth2client.OAuth2Credentials.authorize.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000124 request_orig = http.request
125
126 @functools.wraps(request_orig)
127 def new_request(
128 uri, method='GET', body=None, headers=None,
129 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
130 connection_type=None):
131 headers = (headers or {}).copy()
vadimsh@chromium.orgafbb0192015-04-13 23:26:31 +0000132 headers['Authorization'] = 'Bearer %s' % self.get_access_token().token
Edward Lemuracf922c2019-10-18 18:02:43 +0000133 return request_orig(
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000134 uri, method, body, headers, redirections, connection_type)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000135
136 http.request = new_request
137 return http
138
139 ## Private methods.
140
Edward Lemuracf922c2019-10-18 18:02:43 +0000141 def _run_luci_auth_login(self):
142 """Run luci-auth login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000143
144 Returns:
Edward Lemuracf922c2019-10-18 18:02:43 +0000145 AccessToken with credentials.
Edward Lesmes989bc352019-10-17 05:45:35 +0000146 """
Edward Lemuracf922c2019-10-18 18:02:43 +0000147 logging.debug('Running luci-auth login')
148 subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes])
149 return self._get_luci_auth_token()
Edward Lesmes989bc352019-10-17 05:45:35 +0000150
Edward Lemuracf922c2019-10-18 18:02:43 +0000151 def _get_luci_auth_token(self):
152 logging.debug('Running luci-auth token')
153 try:
154 out, err = subprocess2.check_call_out(
155 ['luci-auth', 'token', '-scopes', self._scopes, '-json-output', '-'],
156 stdout=subprocess2.PIPE, stderr=subprocess2.PIPE)
157 logging.debug('luci-auth token stderr:\n%s', err)
158 token_info = json.loads(out)
159 return AccessToken(
160 token_info['token'],
161 datetime.datetime.utcfromtimestamp(token_info['expiry']))
162 except subprocess2.CalledProcessError:
163 return None