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. |
| 4 | |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 5 | """Google OAuth2 related functions.""" |
vadimsh@chromium.org | cf6a5d2 | 2015-04-09 22:02:00 +0000 | [diff] [blame] | 6 | |
Raul Tambre | 80ee78e | 2019-05-06 22:41:05 +0000 | [diff] [blame] | 7 | from __future__ import print_function |
| 8 | |
vadimsh@chromium.org | cf6a5d2 | 2015-04-09 22:02:00 +0000 | [diff] [blame] | 9 | import collections |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 10 | import datetime |
| 11 | import functools |
| 12 | import json |
| 13 | import logging |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 14 | import os |
Edward Lemur | ba5bc99 | 2019-09-23 22:59:17 +0000 | [diff] [blame] | 15 | |
| 16 | import subprocess2 |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 17 | |
Nodir Turakulov | 5abb9b7 | 2019-10-12 20:55:10 +0000 | [diff] [blame] | 18 | from third_party import httplib2 |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 19 | |
| 20 | |
Andrii Shyshkalov | 733d4ec | 2018-04-19 11:48:58 -0700 | [diff] [blame] | 21 | # This is what most GAE apps require for authentication. |
| 22 | OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email' |
| 23 | # Gerrit and Git on *.googlesource.com require this scope. |
| 24 | OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview' |
| 25 | # Deprecated. Use OAUTH_SCOPE_EMAIL instead. |
| 26 | OAUTH_SCOPES = OAUTH_SCOPE_EMAIL |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 27 | |
vadimsh@chromium.org | cf6a5d2 | 2015-04-09 22:02:00 +0000 | [diff] [blame] | 28 | |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 29 | # Mockable datetime.datetime.utcnow for testing. |
| 30 | def datetime_now(): |
| 31 | return datetime.datetime.utcnow() |
| 32 | |
| 33 | |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 34 | # OAuth access token with its expiration time (UTC datetime or None if unknown). |
Andrii Shyshkalov | 733d4ec | 2018-04-19 11:48:58 -0700 | [diff] [blame] | 35 | class AccessToken(collections.namedtuple('AccessToken', [ |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 36 | 'token', |
| 37 | 'expires_at', |
Andrii Shyshkalov | 733d4ec | 2018-04-19 11:48:58 -0700 | [diff] [blame] | 38 | ])): |
| 39 | |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 40 | def needs_refresh(self): |
Andrii Shyshkalov | 733d4ec | 2018-04-19 11:48:58 -0700 | [diff] [blame] | 41 | """True if this AccessToken should be refreshed.""" |
| 42 | if self.expires_at is not None: |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 43 | # Allow 30s of clock skew between client and backend. |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 44 | return datetime_now() + datetime.timedelta(seconds=30) >= self.expires_at |
Andrii Shyshkalov | 733d4ec | 2018-04-19 11:48:58 -0700 | [diff] [blame] | 45 | # Token without expiration time never expires. |
| 46 | return False |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 47 | |
| 48 | |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 49 | class LoginRequiredError(Exception): |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 50 | """Interaction with the user is required to authenticate.""" |
| 51 | |
Edward Lemur | ba5bc99 | 2019-09-23 22:59:17 +0000 | [diff] [blame] | 52 | def __init__(self, scopes=OAUTH_SCOPE_EMAIL): |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 53 | msg = ( |
| 54 | 'You are not logged in. Please login first by running:\n' |
Edward Lemur | ba5bc99 | 2019-09-23 22:59:17 +0000 | [diff] [blame] | 55 | ' luci-auth login -scopes %s' % scopes) |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 56 | super(LoginRequiredError, self).__init__(msg) |
| 57 | |
| 58 | |
Andrii Shyshkalov | 733d4ec | 2018-04-19 11:48:58 -0700 | [diff] [blame] | 59 | def has_luci_context_local_auth(): |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 60 | """Returns whether LUCI_CONTEXT should be used for ambient authentication.""" |
| 61 | return bool(os.environ.get('LUCI_CONTEXT')) |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 62 | |
| 63 | |
| 64 | class Authenticator(object): |
| 65 | """Object that knows how to refresh access tokens when needed. |
| 66 | |
| 67 | Args: |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 68 | scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 69 | """ |
| 70 | |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 71 | def __init__(self, scopes=OAUTH_SCOPE_EMAIL): |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 72 | self._access_token = None |
seanmccullough@chromium.org | 3e4a581 | 2015-06-11 17:48:47 +0000 | [diff] [blame] | 73 | self._scopes = scopes |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 74 | |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 75 | def has_cached_credentials(self): |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 76 | """Returns True if credentials can be obtained. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 77 | |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 78 | If returns False, get_access_token() later will probably ask for interactive |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 79 | login by raising LoginRequiredError. |
Edward Lesmes | 989bc35 | 2019-10-17 05:45:35 +0000 | [diff] [blame] | 80 | |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 81 | If returns True, get_access_token() won't ask for interactive login. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 82 | """ |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 83 | return bool(self._get_luci_auth_token()) |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 84 | |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 85 | def get_access_token(self): |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 86 | """Returns AccessToken, refreshing it if necessary. |
| 87 | |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 88 | Raises: |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 89 | LoginRequiredError if user interaction is required. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 90 | """ |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 91 | if self._access_token and not self._access_token.needs_refresh(): |
| 92 | return self._access_token |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 93 | |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 94 | # Token expired or missing. Maybe some other process already updated it, |
| 95 | # reload from the cache. |
| 96 | self._access_token = self._get_luci_auth_token() |
| 97 | if self._access_token and not self._access_token.needs_refresh(): |
| 98 | return self._access_token |
Edward Lesmes | 989bc35 | 2019-10-17 05:45:35 +0000 | [diff] [blame] | 99 | |
Edward Lemur | 5b929a4 | 2019-10-21 17:57:39 +0000 | [diff] [blame] | 100 | # Nope, still expired. Needs user interaction. |
| 101 | logging.error('Failed to create access token') |
| 102 | raise LoginRequiredError(self._scopes) |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 103 | |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 104 | def authorize(self, http): |
| 105 | """Monkey patches authentication logic of httplib2.Http instance. |
| 106 | |
| 107 | The modified http.request method will add authentication headers to each |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 108 | request. |
| 109 | |
| 110 | Args: |
| 111 | http: An instance of httplib2.Http. |
| 112 | |
| 113 | Returns: |
| 114 | A modified instance of http that was passed in. |
| 115 | """ |
| 116 | # Adapted from oauth2client.OAuth2Credentials.authorize. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 117 | request_orig = http.request |
| 118 | |
| 119 | @functools.wraps(request_orig) |
| 120 | def new_request( |
| 121 | uri, method='GET', body=None, headers=None, |
| 122 | redirections=httplib2.DEFAULT_MAX_REDIRECTS, |
| 123 | connection_type=None): |
| 124 | headers = (headers or {}).copy() |
vadimsh@chromium.org | afbb019 | 2015-04-13 23:26:31 +0000 | [diff] [blame] | 125 | headers['Authorization'] = 'Bearer %s' % self.get_access_token().token |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 126 | return request_orig( |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 127 | uri, method, body, headers, redirections, connection_type) |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 128 | |
| 129 | http.request = new_request |
| 130 | return http |
| 131 | |
| 132 | ## Private methods. |
| 133 | |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 134 | def _run_luci_auth_login(self): |
| 135 | """Run luci-auth login. |
vadimsh@chromium.org | eed4df3 | 2015-04-10 21:30:20 +0000 | [diff] [blame] | 136 | |
| 137 | Returns: |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 138 | AccessToken with credentials. |
Edward Lesmes | 989bc35 | 2019-10-17 05:45:35 +0000 | [diff] [blame] | 139 | """ |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 140 | logging.debug('Running luci-auth login') |
| 141 | subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes]) |
| 142 | return self._get_luci_auth_token() |
Edward Lesmes | 989bc35 | 2019-10-17 05:45:35 +0000 | [diff] [blame] | 143 | |
Edward Lemur | acf922c | 2019-10-18 18:02:43 +0000 | [diff] [blame] | 144 | def _get_luci_auth_token(self): |
| 145 | logging.debug('Running luci-auth token') |
| 146 | try: |
| 147 | out, err = subprocess2.check_call_out( |
| 148 | ['luci-auth', 'token', '-scopes', self._scopes, '-json-output', '-'], |
| 149 | stdout=subprocess2.PIPE, stderr=subprocess2.PIPE) |
| 150 | logging.debug('luci-auth token stderr:\n%s', err) |
| 151 | token_info = json.loads(out) |
| 152 | return AccessToken( |
| 153 | token_info['token'], |
| 154 | datetime.datetime.utcfromtimestamp(token_info['expiry'])) |
| 155 | except subprocess2.CalledProcessError: |
| 156 | return None |