vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 1 | # Copyright 2015 The Chromium Authors. All rights reserved. |
vadimsh@chromium.org | cf6a5d2 | 2015-04-09 22:02:00 +0000 | [diff] [blame] | 2 | # Use of this source code is governed by a BSD-style license that can be |
| 3 | # found in the LICENSE file. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 4 | """Google OAuth2 related functions.""" |
vadimsh@chromium.org | cf6a5d2 | 2015-04-09 22:02:00 +0000 | [diff] [blame] | 5 | |
| 6 | import collections |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 7 | import datetime |
| 8 | import functools |
Edward Lemur | 202c559 | 2019-10-21 22:44:52 +0000 | [diff] [blame] | 9 | import httplib2 |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 10 | import json |
| 11 | import logging |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 12 | import os |
Edward Lemur | ba5bc99 | 2019-09-23 22:59:17 +0000 | [diff] [blame] | 13 | |
| 14 | import subprocess2 |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 15 | |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 16 | # TODO: Should fix these warnings. |
| 17 | # pylint: disable=line-too-long |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 18 | |
Andrii Shyshkalov | 733d4ec | 2018-04-19 11:48:58 -0700 | [diff] [blame] | 19 | # This is what most GAE apps require for authentication. |
| 20 | OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email' |
| 21 | # Gerrit and Git on *.googlesource.com require this scope. |
| 22 | OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview' |
| 23 | # Deprecated. Use OAUTH_SCOPE_EMAIL instead. |
| 24 | OAUTH_SCOPES = OAUTH_SCOPE_EMAIL |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 25 | |
vadimsh@chromium.org | cf6a5d2 | 2015-04-09 22:02:00 +0000 | [diff] [blame] | 26 | |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 27 | # Mockable datetime.datetime.utcnow for testing. |
| 28 | def datetime_now(): |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 29 | return datetime.datetime.utcnow() |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 30 | |
| 31 | |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 32 | # OAuth access token or ID token with its expiration time (UTC datetime or None |
| 33 | # if unknown). |
| 34 | class Token(collections.namedtuple('Token', [ |
| 35 | 'token', |
| 36 | 'expires_at', |
| 37 | ])): |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 38 | def needs_refresh(self): |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 39 | """True if this token should be refreshed.""" |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 40 | if self.expires_at is not None: |
| 41 | # Allow 30s of clock skew between client and backend. |
| 42 | return datetime_now() + datetime.timedelta( |
| 43 | seconds=30) >= self.expires_at |
| 44 | # Token without expiration time never expires. |
| 45 | return False |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 46 | |
| 47 | |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 48 | class LoginRequiredError(Exception): |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 49 | """Interaction with the user is required to authenticate.""" |
| 50 | def __init__(self, scopes=OAUTH_SCOPE_EMAIL): |
| 51 | msg = ('You are not logged in. Please login first by running:\n' |
| 52 | ' luci-auth login -scopes %s' % scopes) |
| 53 | super(LoginRequiredError, self).__init__(msg) |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 54 | |
| 55 | |
Andrii Shyshkalov | 733d4ec | 2018-04-19 11:48:58 -0700 | [diff] [blame] | 56 | def has_luci_context_local_auth(): |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 57 | """Returns whether LUCI_CONTEXT should be used for ambient authentication.""" |
| 58 | ctx_path = os.environ.get('LUCI_CONTEXT') |
| 59 | if not ctx_path: |
| 60 | return False |
| 61 | try: |
| 62 | with open(ctx_path) as f: |
| 63 | loaded = json.load(f) |
| 64 | except (OSError, IOError, ValueError): |
| 65 | return False |
| 66 | return loaded.get('local_auth', {}).get('default_account_id') is not None |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 67 | |
| 68 | |
| 69 | class Authenticator(object): |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 70 | """Object that knows how to refresh access tokens or id tokens when needed. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 71 | |
| 72 | Args: |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 73 | scopes: space separated oauth scopes. It's used to generate access tokens. |
| 74 | Defaults to OAUTH_SCOPE_EMAIL. |
| 75 | audience: An audience in ID tokens to claim which clients should accept it. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 76 | """ |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 77 | def __init__(self, scopes=OAUTH_SCOPE_EMAIL, audience=None): |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 78 | self._access_token = None |
| 79 | self._scopes = scopes |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 80 | self._id_token = None |
| 81 | self._audience = audience |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 82 | |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 83 | def has_cached_credentials(self): |
| 84 | """Returns True if credentials can be obtained. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 85 | |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 86 | If returns False, get_access_token() or get_id_token() later will probably |
| 87 | ask for interactive login by raising LoginRequiredError. |
Edward Lesmes | 989bc35 | 2019-10-17 05:45:35 +0000 | [diff] [blame] | 88 | |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 89 | If returns True, get_access_token() or get_id_token() won't ask for |
| 90 | interactive login. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 91 | """ |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 92 | return bool(self._get_luci_auth_token()) |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 93 | |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 94 | def get_access_token(self): |
| 95 | """Returns AccessToken, refreshing it if necessary. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 96 | |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 97 | Raises: |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 98 | LoginRequiredError if user interaction is required. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 99 | """ |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 100 | if self._access_token and not self._access_token.needs_refresh(): |
| 101 | return self._access_token |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 102 | |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 103 | # Token expired or missing. Maybe some other process already updated it, |
| 104 | # reload from the cache. |
| 105 | self._access_token = self._get_luci_auth_token() |
| 106 | if self._access_token and not self._access_token.needs_refresh(): |
| 107 | return self._access_token |
Edward Lesmes | 989bc35 | 2019-10-17 05:45:35 +0000 | [diff] [blame] | 108 | |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 109 | # Nope, still expired. Needs user interaction. |
| 110 | logging.error('Failed to create access token') |
| 111 | raise LoginRequiredError(self._scopes) |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 112 | |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 113 | def get_id_token(self): |
| 114 | """Returns id token, refreshing it if necessary. |
| 115 | |
| 116 | Returns: |
| 117 | A Token object. |
| 118 | |
| 119 | Raises: |
| 120 | LoginRequiredError if user interaction is required. |
| 121 | """ |
| 122 | if self._id_token and not self._id_token.needs_refresh(): |
| 123 | return self._id_token |
| 124 | |
| 125 | self._id_token = self._get_luci_auth_token(use_id_token=True) |
| 126 | if self._id_token and not self._id_token.needs_refresh(): |
| 127 | return self._id_token |
| 128 | |
| 129 | # Nope, still expired. Needs user interaction. |
| 130 | logging.error('Failed to create id token') |
| 131 | raise LoginRequiredError() |
| 132 | |
| 133 | def authorize(self, http, use_id_token=False): |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 134 | """Monkey patches authentication logic of httplib2.Http instance. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 135 | |
| 136 | The modified http.request method will add authentication headers to each |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 137 | request. |
| 138 | |
| 139 | Args: |
| 140 | http: An instance of httplib2.Http. |
| 141 | |
| 142 | Returns: |
| 143 | A modified instance of http that was passed in. |
| 144 | """ |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 145 | # Adapted from oauth2client.OAuth2Credentials.authorize. |
| 146 | request_orig = http.request |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 147 | |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 148 | @functools.wraps(request_orig) |
| 149 | def new_request(uri, |
| 150 | method='GET', |
| 151 | body=None, |
| 152 | headers=None, |
| 153 | redirections=httplib2.DEFAULT_MAX_REDIRECTS, |
| 154 | connection_type=None): |
| 155 | headers = (headers or {}).copy() |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 156 | auth_token = self.get_access_token( |
| 157 | ) if not use_id_token else self.get_id_token() |
| 158 | headers['Authorization'] = 'Bearer %s' % auth_token.token |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 159 | return request_orig(uri, method, body, headers, redirections, |
| 160 | connection_type) |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 161 | |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 162 | http.request = new_request |
| 163 | return http |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 164 | |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 165 | ## Private methods. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 166 | |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 167 | def _run_luci_auth_login(self): |
| 168 | """Run luci-auth login. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 169 | |
| 170 | Returns: |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 171 | AccessToken with credentials. |
Edward Lesmes | 989bc35 | 2019-10-17 05:45:35 +0000 | [diff] [blame] | 172 | """ |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 173 | logging.debug('Running luci-auth login') |
| 174 | subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes]) |
| 175 | return self._get_luci_auth_token() |
Edward Lesmes | 989bc35 | 2019-10-17 05:45:35 +0000 | [diff] [blame] | 176 | |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 177 | def _get_luci_auth_token(self, use_id_token=False): |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 178 | logging.debug('Running luci-auth token') |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 179 | if use_id_token: |
| 180 | args = ['-use-id-token'] + ['-audience', self._audience |
| 181 | ] if self._audience else [] |
| 182 | else: |
| 183 | args = ['-scopes', self._scopes] |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 184 | try: |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 185 | out, err = subprocess2.check_call_out(['luci-auth', 'token'] + |
| 186 | args + ['-json-output', '-'], |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 187 | stdout=subprocess2.PIPE, |
| 188 | stderr=subprocess2.PIPE) |
| 189 | logging.debug('luci-auth token stderr:\n%s', err) |
| 190 | token_info = json.loads(out) |
Yuanjun Huang | 4c1d6d9 | 2023-09-28 22:02:04 +0000 | [diff] [blame^] | 191 | return Token( |
Mike Frysinger | 124bb8e | 2023-09-06 05:48:55 +0000 | [diff] [blame] | 192 | token_info['token'], |
| 193 | datetime.datetime.utcfromtimestamp(token_info['expiry'])) |
| 194 | except subprocess2.CalledProcessError as e: |
| 195 | # subprocess2.CalledProcessError.__str__ nicely formats |
| 196 | # stdout/stderr. |
| 197 | logging.error('luci-auth token failed: %s', e) |
| 198 | return None |