blob: e40c6528bd7531033666bd037f754eec920f6186 [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
bojeil-googlef97499c2021-06-09 07:58:25 -070031import copy
bojeil-googled4d7f382021-02-16 12:33:20 -080032import datetime
33import json
bojeil-googlef97499c2021-06-09 07:58:25 -070034import re
bojeil-googled4d7f382021-02-16 12:33:20 -080035
36import six
37
38from google.auth import _helpers
39from google.auth import credentials
40from google.auth import exceptions
41from google.auth import impersonated_credentials
42from google.oauth2 import sts
43from google.oauth2 import utils
44
bojeil-googlef97499c2021-06-09 07:58:25 -070045# External account JSON type identifier.
46_EXTERNAL_ACCOUNT_JSON_TYPE = "external_account"
bojeil-googled4d7f382021-02-16 12:33:20 -080047# The token exchange grant_type used for exchanging credentials.
48_STS_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
49# The token exchange requested_token_type. This is always an access_token.
50_STS_REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
51# Cloud resource manager URL used to retrieve project information.
52_CLOUD_RESOURCE_MANAGER = "https://cloudresourcemanager.googleapis.com/v1/projects/"
53
54
55@six.add_metaclass(abc.ABCMeta)
56class Credentials(credentials.Scoped, credentials.CredentialsWithQuotaProject):
57 """Base class for all external account credentials.
58
59 This is used to instantiate Credentials for exchanging external account
60 credentials for Google access token and authorizing requests to Google APIs.
61 The base class implements the common logic for exchanging external account
62 credentials for Google access tokens.
63 """
64
65 def __init__(
66 self,
67 audience,
68 subject_token_type,
69 token_url,
70 credential_source,
71 service_account_impersonation_url=None,
72 client_id=None,
73 client_secret=None,
74 quota_project_id=None,
75 scopes=None,
76 default_scopes=None,
77 ):
78 """Instantiates an external account credentials object.
79
80 Args:
81 audience (str): The STS audience field.
82 subject_token_type (str): The subject token type.
83 token_url (str): The STS endpoint URL.
84 credential_source (Mapping): The credential source dictionary.
85 service_account_impersonation_url (Optional[str]): The optional service account
86 impersonation generateAccessToken URL.
87 client_id (Optional[str]): The optional client ID.
88 client_secret (Optional[str]): The optional client secret.
89 quota_project_id (Optional[str]): The optional quota project ID.
90 scopes (Optional[Sequence[str]]): Optional scopes to request during the
91 authorization grant.
92 default_scopes (Optional[Sequence[str]]): Default scopes passed by a
93 Google client library. Use 'scopes' for user-defined scopes.
94 Raises:
95 google.auth.exceptions.RefreshError: If the generateAccessToken
96 endpoint returned an error.
97 """
98 super(Credentials, self).__init__()
99 self._audience = audience
100 self._subject_token_type = subject_token_type
101 self._token_url = token_url
102 self._credential_source = credential_source
103 self._service_account_impersonation_url = service_account_impersonation_url
104 self._client_id = client_id
105 self._client_secret = client_secret
106 self._quota_project_id = quota_project_id
107 self._scopes = scopes
108 self._default_scopes = default_scopes
109
110 if self._client_id:
111 self._client_auth = utils.ClientAuthentication(
112 utils.ClientAuthType.basic, self._client_id, self._client_secret
113 )
114 else:
115 self._client_auth = None
116 self._sts_client = sts.Client(self._token_url, self._client_auth)
117
118 if self._service_account_impersonation_url:
119 self._impersonated_credentials = self._initialize_impersonated_credentials()
120 else:
121 self._impersonated_credentials = None
122 self._project_id = None
123
124 @property
bojeil-googlef97499c2021-06-09 07:58:25 -0700125 def info(self):
126 """Generates the dictionary representation of the current credentials.
127
128 Returns:
129 Mapping: The dictionary representation of the credentials. This is the
130 reverse of "from_info" defined on the subclasses of this class. It is
131 useful for serializing the current credentials so it can deserialized
132 later.
133 """
134 config_info = {
135 "type": _EXTERNAL_ACCOUNT_JSON_TYPE,
136 "audience": self._audience,
137 "subject_token_type": self._subject_token_type,
138 "token_url": self._token_url,
139 "service_account_impersonation_url": self._service_account_impersonation_url,
140 "credential_source": copy.deepcopy(self._credential_source),
141 "quota_project_id": self._quota_project_id,
142 "client_id": self._client_id,
143 "client_secret": self._client_secret,
144 }
145 # Remove None fields in the info dictionary.
146 for k, v in dict(config_info).items():
147 if v is None:
148 del config_info[k]
149
150 return config_info
151
152 @property
153 def service_account_email(self):
154 """Returns the service account email if service account impersonation is used.
155
156 Returns:
157 Optional[str]: The service account email if impersonation is used. Otherwise
158 None is returned.
159 """
160 if self._service_account_impersonation_url:
161 # Parse email from URL. The formal looks as follows:
162 # https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
163 url = self._service_account_impersonation_url
164 start_index = url.rfind("/")
165 end_index = url.find(":generateAccessToken")
166 if start_index != -1 and end_index != -1 and start_index < end_index:
167 start_index = start_index + 1
168 return url[start_index:end_index]
169 return None
170
171 @property
172 def is_user(self):
173 """Returns whether the credentials represent a user (True) or workload (False).
174 Workloads behave similarly to service accounts. Currently workloads will use
175 service account impersonation but will eventually not require impersonation.
176 As a result, this property is more reliable than the service account email
177 property in determining if the credentials represent a user or workload.
178
179 Returns:
180 bool: True if the credentials represent a user. False if they represent a
181 workload.
182 """
183 # If service account impersonation is used, the credentials will always represent a
184 # service account.
185 if self._service_account_impersonation_url:
186 return False
187 # Workforce pools representing users have the following audience format:
188 # //iam.googleapis.com/locations/$location/workforcePools/$poolId/providers/$providerId
189 p = re.compile(r"//iam\.googleapis\.com/locations/[^/]+/workforcePools/")
190 if p.match(self._audience):
191 return True
192 return False
193
194 @property
bojeil-googled4d7f382021-02-16 12:33:20 -0800195 def requires_scopes(self):
196 """Checks if the credentials requires scopes.
197
198 Returns:
199 bool: True if there are no scopes set otherwise False.
200 """
201 return not self._scopes and not self._default_scopes
202
203 @property
204 def project_number(self):
205 """Optional[str]: The project number corresponding to the workload identity pool."""
206
207 # STS audience pattern:
208 # //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/...
209 components = self._audience.split("/")
210 try:
211 project_index = components.index("projects")
212 if project_index + 1 < len(components):
213 return components[project_index + 1] or None
214 except ValueError:
215 return None
216
217 @_helpers.copy_docstring(credentials.Scoped)
218 def with_scopes(self, scopes, default_scopes=None):
219 return self.__class__(
220 audience=self._audience,
221 subject_token_type=self._subject_token_type,
222 token_url=self._token_url,
223 credential_source=self._credential_source,
224 service_account_impersonation_url=self._service_account_impersonation_url,
225 client_id=self._client_id,
226 client_secret=self._client_secret,
227 quota_project_id=self._quota_project_id,
228 scopes=scopes,
229 default_scopes=default_scopes,
230 )
231
232 @abc.abstractmethod
233 def retrieve_subject_token(self, request):
234 """Retrieves the subject token using the credential_source object.
235
236 Args:
237 request (google.auth.transport.Request): A callable used to make
238 HTTP requests.
239 Returns:
240 str: The retrieved subject token.
241 """
242 # pylint: disable=missing-raises-doc
243 # (pylint doesn't recognize that this is abstract)
244 raise NotImplementedError("retrieve_subject_token must be implemented")
245
246 def get_project_id(self, request):
247 """Retrieves the project ID corresponding to the workload identity pool.
248
249 When not determinable, None is returned.
250
251 This is introduced to support the current pattern of using the Auth library:
252
253 credentials, project_id = google.auth.default()
254
255 The resource may not have permission (resourcemanager.projects.get) to
256 call this API or the required scopes may not be selected:
257 https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes
258
259 Args:
260 request (google.auth.transport.Request): A callable used to make
261 HTTP requests.
262 Returns:
263 Optional[str]: The project ID corresponding to the workload identity pool
264 if determinable.
265 """
266 if self._project_id:
267 # If already retrieved, return the cached project ID value.
268 return self._project_id
269 scopes = self._scopes if self._scopes is not None else self._default_scopes
270 # Scopes are required in order to retrieve a valid access token.
271 if self.project_number and scopes:
272 headers = {}
273 url = _CLOUD_RESOURCE_MANAGER + self.project_number
274 self.before_request(request, "GET", url, headers)
275 response = request(url=url, method="GET", headers=headers)
276
277 response_body = (
278 response.data.decode("utf-8")
279 if hasattr(response.data, "decode")
280 else response.data
281 )
282 response_data = json.loads(response_body)
283
284 if response.status == 200:
285 # Cache result as this field is immutable.
286 self._project_id = response_data.get("projectId")
287 return self._project_id
288
289 return None
290
291 @_helpers.copy_docstring(credentials.Credentials)
292 def refresh(self, request):
293 scopes = self._scopes if self._scopes is not None else self._default_scopes
294 if self._impersonated_credentials:
295 self._impersonated_credentials.refresh(request)
296 self.token = self._impersonated_credentials.token
297 self.expiry = self._impersonated_credentials.expiry
298 else:
299 now = _helpers.utcnow()
300 response_data = self._sts_client.exchange_token(
301 request=request,
302 grant_type=_STS_GRANT_TYPE,
303 subject_token=self.retrieve_subject_token(request),
304 subject_token_type=self._subject_token_type,
305 audience=self._audience,
306 scopes=scopes,
307 requested_token_type=_STS_REQUESTED_TOKEN_TYPE,
308 )
309 self.token = response_data.get("access_token")
310 lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
311 self.expiry = now + lifetime
312
313 @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
314 def with_quota_project(self, quota_project_id):
315 # Return copy of instance with the provided quota project ID.
316 return self.__class__(
317 audience=self._audience,
318 subject_token_type=self._subject_token_type,
319 token_url=self._token_url,
320 credential_source=self._credential_source,
321 service_account_impersonation_url=self._service_account_impersonation_url,
322 client_id=self._client_id,
323 client_secret=self._client_secret,
324 quota_project_id=quota_project_id,
325 scopes=self._scopes,
326 default_scopes=self._default_scopes,
327 )
328
329 def _initialize_impersonated_credentials(self):
330 """Generates an impersonated credentials.
331
332 For more details, see `projects.serviceAccounts.generateAccessToken`_.
333
334 .. _projects.serviceAccounts.generateAccessToken: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
335
336 Returns:
337 impersonated_credentials.Credential: The impersonated credentials
338 object.
339
340 Raises:
341 google.auth.exceptions.RefreshError: If the generateAccessToken
342 endpoint returned an error.
343 """
344 # Return copy of instance with no service account impersonation.
345 source_credentials = self.__class__(
346 audience=self._audience,
347 subject_token_type=self._subject_token_type,
348 token_url=self._token_url,
349 credential_source=self._credential_source,
350 service_account_impersonation_url=None,
351 client_id=self._client_id,
352 client_secret=self._client_secret,
353 quota_project_id=self._quota_project_id,
354 scopes=self._scopes,
355 default_scopes=self._default_scopes,
356 )
357
358 # Determine target_principal.
bojeil-googlef97499c2021-06-09 07:58:25 -0700359 target_principal = self.service_account_email
360 if not target_principal:
bojeil-googled4d7f382021-02-16 12:33:20 -0800361 raise exceptions.RefreshError(
362 "Unable to determine target principal from service account impersonation URL."
363 )
364
365 scopes = self._scopes if self._scopes is not None else self._default_scopes
366 # Initialize and return impersonated credentials.
367 return impersonated_credentials.Credentials(
368 source_credentials=source_credentials,
369 target_principal=target_principal,
370 target_scopes=scopes,
371 quota_project_id=self._quota_project_id,
372 iam_endpoint_override=self._service_account_impersonation_url,
373 )