blob: 5696a7ac5c8f20f64c599b209f11585e48727264 [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.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00004"""Google OAuth2 related functions."""
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +00005
Raul Tambre80ee78e2019-05-06 22:41:05 +00006from __future__ import print_function
7
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +00008import collections
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00009import datetime
10import functools
Edward Lemur202c5592019-10-21 22:44:52 +000011import httplib2
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000012import json
13import logging
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000014import os
Edward Lemurba5bc992019-09-23 22:59:17 +000015
16import subprocess2
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000017
Mike Frysinger124bb8e2023-09-06 05:48:55 +000018# TODO: Should fix these warnings.
19# pylint: disable=line-too-long
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000020
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070021# This is what most GAE apps require for authentication.
22OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
23# Gerrit and Git on *.googlesource.com require this scope.
24OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
25# Deprecated. Use OAUTH_SCOPE_EMAIL instead.
26OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000027
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000028
Edward Lemuracf922c2019-10-18 18:02:43 +000029# Mockable datetime.datetime.utcnow for testing.
30def datetime_now():
Mike Frysinger124bb8e2023-09-06 05:48:55 +000031 return datetime.datetime.utcnow()
Edward Lemuracf922c2019-10-18 18:02:43 +000032
33
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000034# OAuth access token with its expiration time (UTC datetime or None if unknown).
Mike Frysinger124bb8e2023-09-06 05:48:55 +000035class AccessToken(
36 collections.namedtuple('AccessToken', [
37 'token',
38 'expires_at',
39 ])):
40 def needs_refresh(self):
41 """True if this AccessToken should be refreshed."""
42 if self.expires_at is not None:
43 # Allow 30s of clock skew between client and backend.
44 return datetime_now() + datetime.timedelta(
45 seconds=30) >= self.expires_at
46 # Token without expiration time never expires.
47 return False
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000048
49
Edward Lemur5b929a42019-10-21 17:57:39 +000050class LoginRequiredError(Exception):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000051 """Interaction with the user is required to authenticate."""
52 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
53 msg = ('You are not logged in. Please login first by running:\n'
54 ' luci-auth login -scopes %s' % scopes)
55 super(LoginRequiredError, self).__init__(msg)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000056
57
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070058def has_luci_context_local_auth():
Mike Frysinger124bb8e2023-09-06 05:48:55 +000059 """Returns whether LUCI_CONTEXT should be used for ambient authentication."""
60 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):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000072 """Object that knows how to refresh access tokens when needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000073
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 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000077 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
78 self._access_token = None
79 self._scopes = scopes
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000080
Mike Frysinger124bb8e2023-09-06 05:48:55 +000081 def has_cached_credentials(self):
82 """Returns True if credentials can be obtained.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000083
Edward Lemuracf922c2019-10-18 18:02:43 +000084 If returns False, get_access_token() later will probably ask for interactive
Edward Lemur5b929a42019-10-21 17:57:39 +000085 login by raising LoginRequiredError.
Edward Lesmes989bc352019-10-17 05:45:35 +000086
Edward Lemuracf922c2019-10-18 18:02:43 +000087 If returns True, get_access_token() won't ask for interactive login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000088 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000089 return bool(self._get_luci_auth_token())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000090
Mike Frysinger124bb8e2023-09-06 05:48:55 +000091 def get_access_token(self):
92 """Returns AccessToken, refreshing it if necessary.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000093
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000094 Raises:
Edward Lemur5b929a42019-10-21 17:57:39 +000095 LoginRequiredError if user interaction is required.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000096 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000097 if self._access_token and not self._access_token.needs_refresh():
98 return self._access_token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000099
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000100 # Token expired or missing. Maybe some other process already updated it,
101 # reload from the cache.
102 self._access_token = self._get_luci_auth_token()
103 if self._access_token and not self._access_token.needs_refresh():
104 return self._access_token
Edward Lesmes989bc352019-10-17 05:45:35 +0000105
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000106 # Nope, still expired. Needs user interaction.
107 logging.error('Failed to create access token')
108 raise LoginRequiredError(self._scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000109
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000110 def authorize(self, http):
111 """Monkey patches authentication logic of httplib2.Http instance.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000112
113 The modified http.request method will add authentication headers to each
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000114 request.
115
116 Args:
117 http: An instance of httplib2.Http.
118
119 Returns:
120 A modified instance of http that was passed in.
121 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000122 # Adapted from oauth2client.OAuth2Credentials.authorize.
123 request_orig = http.request
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000124
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000125 @functools.wraps(request_orig)
126 def new_request(uri,
127 method='GET',
128 body=None,
129 headers=None,
130 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
131 connection_type=None):
132 headers = (headers or {}).copy()
133 headers['Authorization'] = 'Bearer %s' % self.get_access_token(
134 ).token
135 return request_orig(uri, method, body, headers, redirections,
136 connection_type)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000137
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000138 http.request = new_request
139 return http
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000140
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000141 ## Private methods.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000142
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000143 def _run_luci_auth_login(self):
144 """Run luci-auth login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000145
146 Returns:
Edward Lemuracf922c2019-10-18 18:02:43 +0000147 AccessToken with credentials.
Edward Lesmes989bc352019-10-17 05:45:35 +0000148 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000149 logging.debug('Running luci-auth login')
150 subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes])
151 return self._get_luci_auth_token()
Edward Lesmes989bc352019-10-17 05:45:35 +0000152
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000153 def _get_luci_auth_token(self):
154 logging.debug('Running luci-auth token')
155 try:
156 out, err = subprocess2.check_call_out([
157 'luci-auth', 'token', '-scopes', self._scopes, '-json-output',
158 '-'
159 ],
160 stdout=subprocess2.PIPE,
161 stderr=subprocess2.PIPE)
162 logging.debug('luci-auth token stderr:\n%s', err)
163 token_info = json.loads(out)
164 return AccessToken(
165 token_info['token'],
166 datetime.datetime.utcfromtimestamp(token_info['expiry']))
167 except subprocess2.CalledProcessError as e:
168 # subprocess2.CalledProcessError.__str__ nicely formats
169 # stdout/stderr.
170 logging.error('luci-auth token failed: %s', e)
171 return None