blob: 08aeeefca1af5e00eca1c059a35a4197d3c0fbb2 [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
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000032# OAuth access token with its expiration time (UTC datetime or None if unknown).
Mike Frysinger124bb8e2023-09-06 05:48:55 +000033class AccessToken(
34 collections.namedtuple('AccessToken', [
35 'token',
36 'expires_at',
37 ])):
38 def needs_refresh(self):
39 """True if this AccessToken should be refreshed."""
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.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):
Mike Frysinger124bb8e2023-09-06 05:48:55 +000070 """Object that knows how to refresh access tokens when needed.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000071
72 Args:
Edward Lemur5b929a42019-10-21 17:57:39 +000073 scopes: space separated oauth scopes. Defaults to OAUTH_SCOPE_EMAIL.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000074 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000075 def __init__(self, scopes=OAUTH_SCOPE_EMAIL):
76 self._access_token = None
77 self._scopes = scopes
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000078
Mike Frysinger124bb8e2023-09-06 05:48:55 +000079 def has_cached_credentials(self):
80 """Returns True if credentials can be obtained.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000081
Edward Lemuracf922c2019-10-18 18:02:43 +000082 If returns False, get_access_token() later will probably ask for interactive
Edward Lemur5b929a42019-10-21 17:57:39 +000083 login by raising LoginRequiredError.
Edward Lesmes989bc352019-10-17 05:45:35 +000084
Edward Lemuracf922c2019-10-18 18:02:43 +000085 If returns True, get_access_token() won't ask for interactive login.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000086 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000087 return bool(self._get_luci_auth_token())
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000088
Mike Frysinger124bb8e2023-09-06 05:48:55 +000089 def get_access_token(self):
90 """Returns AccessToken, refreshing it if necessary.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000091
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000092 Raises:
Edward Lemur5b929a42019-10-21 17:57:39 +000093 LoginRequiredError if user interaction is required.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000094 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +000095 if self._access_token and not self._access_token.needs_refresh():
96 return self._access_token
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +000097
Mike Frysinger124bb8e2023-09-06 05:48:55 +000098 # Token expired or missing. Maybe some other process already updated it,
99 # reload from the cache.
100 self._access_token = self._get_luci_auth_token()
101 if self._access_token and not self._access_token.needs_refresh():
102 return self._access_token
Edward Lesmes989bc352019-10-17 05:45:35 +0000103
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000104 # Nope, still expired. Needs user interaction.
105 logging.error('Failed to create access token')
106 raise LoginRequiredError(self._scopes)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000107
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000108 def authorize(self, http):
109 """Monkey patches authentication logic of httplib2.Http instance.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000110
111 The modified http.request method will add authentication headers to each
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000112 request.
113
114 Args:
115 http: An instance of httplib2.Http.
116
117 Returns:
118 A modified instance of http that was passed in.
119 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000120 # Adapted from oauth2client.OAuth2Credentials.authorize.
121 request_orig = http.request
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000122
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000123 @functools.wraps(request_orig)
124 def new_request(uri,
125 method='GET',
126 body=None,
127 headers=None,
128 redirections=httplib2.DEFAULT_MAX_REDIRECTS,
129 connection_type=None):
130 headers = (headers or {}).copy()
131 headers['Authorization'] = 'Bearer %s' % self.get_access_token(
132 ).token
133 return request_orig(uri, method, body, headers, redirections,
134 connection_type)
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000135
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000136 http.request = new_request
137 return http
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000138
Mike Frysinger124bb8e2023-09-06 05:48:55 +0000139 ## Private methods.
vadimsh@chromium.orgeed4df32015-04-10 21:30:20 +0000140
Mike Frysinger124bb8e2023-09-06 05:48:55 +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 """
Mike Frysinger124bb8e2023-09-06 05:48:55 +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
Mike Frysinger124bb8e2023-09-06 05:48:55 +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 '-'
157 ],
158 stdout=subprocess2.PIPE,
159 stderr=subprocess2.PIPE)
160 logging.debug('luci-auth token stderr:\n%s', err)
161 token_info = json.loads(out)
162 return AccessToken(
163 token_info['token'],
164 datetime.datetime.utcfromtimestamp(token_info['expiry']))
165 except subprocess2.CalledProcessError as e:
166 # subprocess2.CalledProcessError.__str__ nicely formats
167 # stdout/stderr.
168 logging.error('luci-auth token failed: %s', e)
169 return None