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