blob: 0429ee08f4ff8d4f6e078e130d34bbd5f32f7c8e [file] [log] [blame]
bojeil-googled4d7f382021-02-16 12:33:20 -08001# Copyright 2020 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""External Account Credentials.
16
17This module provides credentials that exchange workload identity pool external
18credentials for Google access tokens. This facilitates accessing Google Cloud
19Platform resources from on-prem and non-Google Cloud platforms (e.g. AWS,
20Microsoft Azure, OIDC identity providers), using native credentials retrieved
21from the current environment without the need to copy, save and manage
22long-lived service account credentials.
23
24Specifically, this is intended to use access tokens acquired using the GCP STS
25token exchange endpoint following the `OAuth 2.0 Token Exchange`_ spec.
26
27.. _OAuth 2.0 Token Exchange: https://tools.ietf.org/html/rfc8693
28"""
29
30import abc
31import datetime
32import json
33
34import six
35
36from google.auth import _helpers
37from google.auth import credentials
38from google.auth import exceptions
39from google.auth import impersonated_credentials
40from google.oauth2 import sts
41from google.oauth2 import utils
42
43# The token exchange grant_type used for exchanging credentials.
44_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
45# The token exchange requested_token_type. This is always an access_token.
46_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
47# Cloud resource manager URL used to retrieve project information.
48_CLOUD_RESOURCE_MANAGER = "https://cloudresourcemanager.googleapis.com/v1/projects/"
49
50
51@six.add_metaclass(abc.ABCMeta)
52class Credentials(credentials.Scoped, credentials.CredentialsWithQuotaProject):
53 """Base class for all external account credentials.
54
55 This is used to instantiate Credentials for exchanging external account
56 credentials for Google access token and authorizing requests to Google APIs.
57 The base class implements the common logic for exchanging external account
58 credentials for Google access tokens.
59 """
60
61 def __init__(
62 self,
63 audience,
64 subject_token_type,
65 token_url,
66 credential_source,
67 service_account_impersonation_url=None,
68 client_id=None,
69 client_secret=None,
70 quota_project_id=None,
71 scopes=None,
72 default_scopes=None,
73 ):
74 """Instantiates an external account credentials object.
75
76 Args:
77 audience (str): The STS audience field.
78 subject_token_type (str): The subject token type.
79 token_url (str): The STS endpoint URL.
80 credential_source (Mapping): The credential source dictionary.
81 service_account_impersonation_url (Optional[str]): The optional service account
82 impersonation generateAccessToken URL.
83 client_id (Optional[str]): The optional client ID.
84 client_secret (Optional[str]): The optional client secret.
85 quota_project_id (Optional[str]): The optional quota project ID.
86 scopes (Optional[Sequence[str]]): Optional scopes to request during the
87 authorization grant.
88 default_scopes (Optional[Sequence[str]]): Default scopes passed by a
89 Google client library. Use 'scopes' for user-defined scopes.
90 Raises:
91 google.auth.exceptions.RefreshError: If the generateAccessToken
92 endpoint returned an error.
93 """
94 super(Credentials, self).__init__()
95 self._audience = audience
96 self._subject_token_type = subject_token_type
97 self._token_url = token_url
98 self._credential_source = credential_source
99 self._service_account_impersonation_url = service_account_impersonation_url
100 self._client_id = client_id
101 self._client_secret = client_secret
102 self._quota_project_id = quota_project_id
103 self._scopes = scopes
104 self._default_scopes = default_scopes
105
106 if self._client_id:
107 self._client_auth = utils.ClientAuthentication(
108 utils.ClientAuthType.basic, self._client_id, self._client_secret
109 )
110 else:
111 self._client_auth = None
112 self._sts_client = sts.Client(self._token_url, self._client_auth)
113
114 if self._service_account_impersonation_url:
115 self._impersonated_credentials = self._initialize_impersonated_credentials()
116 else:
117 self._impersonated_credentials = None
118 self._project_id = None
119
120 @property
121 def requires_scopes(self):
122 """Checks if the credentials requires scopes.
123
124 Returns:
125 bool: True if there are no scopes set otherwise False.
126 """
127 return not self._scopes and not self._default_scopes
128
129 @property
130 def project_number(self):
131 """Optional[str]: The project number corresponding to the workload identity pool."""
132
133 # STS audience pattern:
134 # //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/...
135 components = self._audience.split("/")
136 try:
137 project_index = components.index("projects")
138 if project_index + 1 < len(components):
139 return components[project_index + 1] or None
140 except ValueError:
141 return None
142
143 @_helpers.copy_docstring(credentials.Scoped)
144 def with_scopes(self, scopes, default_scopes=None):
145 return self.__class__(
146 audience=self._audience,
147 subject_token_type=self._subject_token_type,
148 token_url=self._token_url,
149 credential_source=self._credential_source,
150 service_account_impersonation_url=self._service_account_impersonation_url,
151 client_id=self._client_id,
152 client_secret=self._client_secret,
153 quota_project_id=self._quota_project_id,
154 scopes=scopes,
155 default_scopes=default_scopes,
156 )
157
158 @abc.abstractmethod
159 def retrieve_subject_token(self, request):
160 """Retrieves the subject token using the credential_source object.
161
162 Args:
163 request (google.auth.transport.Request): A callable used to make
164 HTTP requests.
165 Returns:
166 str: The retrieved subject token.
167 """
168 # pylint: disable=missing-raises-doc
169 # (pylint doesn't recognize that this is abstract)
170 raise NotImplementedError("retrieve_subject_token must be implemented")
171
172 def get_project_id(self, request):
173 """Retrieves the project ID corresponding to the workload identity pool.
174
175 When not determinable, None is returned.
176
177 This is introduced to support the current pattern of using the Auth library:
178
179 credentials, project_id = google.auth.default()
180
181 The resource may not have permission (resourcemanager.projects.get) to
182 call this API or the required scopes may not be selected:
183 https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes
184
185 Args:
186 request (google.auth.transport.Request): A callable used to make
187 HTTP requests.
188 Returns:
189 Optional[str]: The project ID corresponding to the workload identity pool
190 if determinable.
191 """
192 if self._project_id:
193 # If already retrieved, return the cached project ID value.
194 return self._project_id
195 scopes = self._scopes if self._scopes is not None else self._default_scopes
196 # Scopes are required in order to retrieve a valid access token.
197 if self.project_number and scopes:
198 headers = {}
199 url = _CLOUD_RESOURCE_MANAGER + self.project_number
200 self.before_request(request, "GET", url, headers)
201 response = request(url=url, method="GET", headers=headers)
202
203 response_body = (
204 response.data.decode("utf-8")
205 if hasattr(response.data, "decode")
206 else response.data
207 )
208 response_data = json.loads(response_body)
209
210 if response.status == 200:
211 # Cache result as this field is immutable.
212 self._project_id = response_data.get("projectId")
213 return self._project_id
214
215 return None
216
217 @_helpers.copy_docstring(credentials.Credentials)
218 def refresh(self, request):
219 scopes = self._scopes if self._scopes is not None else self._default_scopes
220 if self._impersonated_credentials:
221 self._impersonated_credentials.refresh(request)
222 self.token = self._impersonated_credentials.token
223 self.expiry = self._impersonated_credentials.expiry
224 else:
225 now = _helpers.utcnow()
226 response_data = self._sts_client.exchange_token(
227 request=request,
228 grant_type=_STS_GRANT_TYPE,
229 subject_token=self.retrieve_subject_token(request),
230 subject_token_type=self._subject_token_type,
231 audience=self._audience,
232 scopes=scopes,
233 requested_token_type=_STS_REQUESTED_TOKEN_TYPE,
234 )
235 self.token = response_data.get("access_token")
236 lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
237 self.expiry = now + lifetime
238
239 @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
240 def with_quota_project(self, quota_project_id):
241 # Return copy of instance with the provided quota project ID.
242 return self.__class__(
243 audience=self._audience,
244 subject_token_type=self._subject_token_type,
245 token_url=self._token_url,
246 credential_source=self._credential_source,
247 service_account_impersonation_url=self._service_account_impersonation_url,
248 client_id=self._client_id,
249 client_secret=self._client_secret,
250 quota_project_id=quota_project_id,
251 scopes=self._scopes,
252 default_scopes=self._default_scopes,
253 )
254
255 def _initialize_impersonated_credentials(self):
256 """Generates an impersonated credentials.
257
258 For more details, see `projects.serviceAccounts.generateAccessToken`_.
259
260 .. _projects.serviceAccounts.generateAccessToken: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
261
262 Returns:
263 impersonated_credentials.Credential: The impersonated credentials
264 object.
265
266 Raises:
267 google.auth.exceptions.RefreshError: If the generateAccessToken
268 endpoint returned an error.
269 """
270 # Return copy of instance with no service account impersonation.
271 source_credentials = self.__class__(
272 audience=self._audience,
273 subject_token_type=self._subject_token_type,
274 token_url=self._token_url,
275 credential_source=self._credential_source,
276 service_account_impersonation_url=None,
277 client_id=self._client_id,
278 client_secret=self._client_secret,
279 quota_project_id=self._quota_project_id,
280 scopes=self._scopes,
281 default_scopes=self._default_scopes,
282 )
283
284 # Determine target_principal.
285 start_index = self._service_account_impersonation_url.rfind("/")
286 end_index = self._service_account_impersonation_url.find(":generateAccessToken")
287 if start_index != -1 and end_index != -1 and start_index < end_index:
288 start_index = start_index + 1
289 target_principal = self._service_account_impersonation_url[
290 start_index:end_index
291 ]
292 else:
293 raise exceptions.RefreshError(
294 "Unable to determine target principal from service account impersonation URL."
295 )
296
297 scopes = self._scopes if self._scopes is not None else self._default_scopes
298 # Initialize and return impersonated credentials.
299 return impersonated_credentials.Credentials(
300 source_credentials=source_credentials,
301 target_principal=target_principal,
302 target_scopes=scopes,
303 quota_project_id=self._quota_project_id,
304 iam_endpoint_override=self._service_account_impersonation_url,
305 )