blob: 285fd6e969eada8a865d952ca831cda8011f2814 [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
6import collections
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +00007import datetime
8import functools
Edward Lemur202c5592019-10-21 22:44:52 +00009import httplib2
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000010import json
11import logging
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000012import os
Edward Lemurba5bc992019-09-23 22:59:17 +000013
14import subprocess2
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000015
Mike Frysinger124bb8e2023-09-06 05:48:55 +000016# TODO: Should fix these warnings.
17# pylint: disable=line-too-long
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000018
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070019# This is what most GAE apps require for authentication.
20OAUTH_SCOPE_EMAIL = 'https://www.googleapis.com/auth/userinfo.email'
21# Gerrit and Git on *.googlesource.com require this scope.
22OAUTH_SCOPE_GERRIT = 'https://www.googleapis.com/auth/gerritcodereview'
23# Deprecated. Use OAUTH_SCOPE_EMAIL instead.
24OAUTH_SCOPES = OAUTH_SCOPE_EMAIL
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000025
vadimsh@chromium.orgcf6a5d22015-04-09 22:02:00 +000026
Edward Lemuracf922c2019-10-18 18:02:43 +000027# Mockable datetime.datetime.utcnow for testing.
28def datetime_now():
Mike Frysinger124bb8e2023-09-06 05:48:55 +000029 return datetime.datetime.utcnow()
Edward Lemuracf922c2019-10-18 18:02:43 +000030
31
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +000032# OAuth access token or ID token with its expiration time (UTC datetime or None
33# if unknown).
34class Token(collections.namedtuple('Token', [
35 'token',
36 'expires_at',
37])):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000038 def needs_refresh(self):
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +000039 """True if this token should be refreshed."""
Mike Frysinger124bb8e2023-09-06 05:48:55 +000040 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.orgeed4df32015-04-10 21:30:20 +000046
47
Edward Lemur5b929a42019-10-21 17:57:39 +000048class LoginRequiredError(Exception):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000049 """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.orgeed4df32015-04-10 21:30:20 +000054
55
Andrii Shyshkalov733d4ec2018-04-19 11:48:58 -070056def has_luci_context_local_auth():
Mike Frysinger124bb8e2023-09-06 05:48:55 +000057 """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.orgeed4df32015-04-10 21:30:20 +000067
68
69class Authenticator(object):
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +000070 """Object that knows how to refresh access tokens or id tokens when needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000071
72 Args:
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +000073 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.orgeed4df32015-04-10 21:30:20 +000076 """
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +000077 def __init__(self, scopes=OAUTH_SCOPE_EMAIL, audience=None):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000078 self._access_token = None
79 self._scopes = scopes
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +000080 self._id_token = None
81 self._audience = audience
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000082
Mike Frysinger124bb8e2023-09-06 05:48:55 +000083 def has_cached_credentials(self):
84 """Returns True if credentials can be obtained.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000085
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +000086 If returns False, get_access_token() or get_id_token() later will probably
87 ask for interactive login by raising LoginRequiredError.
Edward Lesmes989bc352019-10-17 05:45:35 +000088
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +000089 If returns True, get_access_token() or get_id_token() won't ask for
90 interactive login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000091 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000092 return bool(self._get_luci_auth_token())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000093
Mike Frysinger124bb8e2023-09-06 05:48:55 +000094 def get_access_token(self):
95 """Returns AccessToken, refreshing it if necessary.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000096
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000097 Raises:
Edward Lemur5b929a42019-10-21 17:57:39 +000098 LoginRequiredError if user interaction is required.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000099 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000100 if self._access_token and not self._access_token.needs_refresh():
101 return self._access_token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000102
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000103 # 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 Lesmes989bc352019-10-17 05:45:35 +0000108
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000109 # Nope, still expired. Needs user interaction.
110 logging.error('Failed to create access token')
111 raise LoginRequiredError(self._scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000112
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +0000113 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 Frysinger124bb8e2023-09-06 05:48:55 +0000134 """Monkey patches authentication logic of httplib2.Http instance.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000135
136 The modified http.request method will add authentication headers to each
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000137 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 Frysinger124bb8e2023-09-06 05:48:55 +0000145 # Adapted from oauth2client.OAuth2Credentials.authorize.
146 request_orig = http.request
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000147
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000148 @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 Huang4c1d6d92023-09-28 22:02:04 +0000156 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 Frysinger124bb8e2023-09-06 05:48:55 +0000159 return request_orig(uri, method, body, headers, redirections,
160 connection_type)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000161
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000162 http.request = new_request
163 return http
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000164
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000165 ## Private methods.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000166
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000167 def _run_luci_auth_login(self):
168 """Run luci-auth login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000169
170 Returns:
Edward Lemuracf922c2019-10-18 18:02:43 +0000171 AccessToken with credentials.
Edward Lesmes989bc352019-10-17 05:45:35 +0000172 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000173 logging.debug('Running luci-auth login')
174 subprocess2.check_call(['luci-auth', 'login', '-scopes', self._scopes])
175 return self._get_luci_auth_token()
Edward Lesmes989bc352019-10-17 05:45:35 +0000176
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +0000177 def _get_luci_auth_token(self, use_id_token=False):
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000178 logging.debug('Running luci-auth token')
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +0000179 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 Frysinger124bb8e2023-09-06 05:48:55 +0000184 try:
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +0000185 out, err = subprocess2.check_call_out(['luci-auth', 'token'] +
186 args + ['-json-output', '-'],
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000187 stdout=subprocess2.PIPE,
188 stderr=subprocess2.PIPE)
189 logging.debug('luci-auth token stderr:\n%s', err)
190 token_info = json.loads(out)
Yuanjun Huang4c1d6d92023-09-28 22:02:04 +0000191 return Token(
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000192 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