blob: 1f3034ac35339eff63fca3b5dd8b3e70eaa20941 [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 }
bojeil-googlea5e6b652021-06-09 12:27:21 -0700145 return {key: value for key, value in config_info.items() if value is not None}
bojeil-googlef97499c2021-06-09 07:58:25 -0700146
147 @property
148 def service_account_email(self):
149 """Returns the service account email if service account impersonation is used.
150
151 Returns:
152 Optional[str]: The service account email if impersonation is used. Otherwise
153 None is returned.
154 """
155 if self._service_account_impersonation_url:
156 # Parse email from URL. The formal looks as follows:
157 # https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/name@project-id.iam.gserviceaccount.com:generateAccessToken
158 url = self._service_account_impersonation_url
159 start_index = url.rfind("/")
160 end_index = url.find(":generateAccessToken")
161 if start_index != -1 and end_index != -1 and start_index < end_index:
162 start_index = start_index + 1
163 return url[start_index:end_index]
164 return None
165
166 @property
167 def is_user(self):
168 """Returns whether the credentials represent a user (True) or workload (False).
169 Workloads behave similarly to service accounts. Currently workloads will use
170 service account impersonation but will eventually not require impersonation.
171 As a result, this property is more reliable than the service account email
172 property in determining if the credentials represent a user or workload.
173
174 Returns:
175 bool: True if the credentials represent a user. False if they represent a
176 workload.
177 """
178 # If service account impersonation is used, the credentials will always represent a
179 # service account.
180 if self._service_account_impersonation_url:
181 return False
182 # Workforce pools representing users have the following audience format:
183 # //iam.googleapis.com/locations/$location/workforcePools/$poolId/providers/$providerId
184 p = re.compile(r"//iam\.googleapis\.com/locations/[^/]+/workforcePools/")
185 if p.match(self._audience):
186 return True
187 return False
188
189 @property
bojeil-googled4d7f382021-02-16 12:33:20 -0800190 def requires_scopes(self):
191 """Checks if the credentials requires scopes.
192
193 Returns:
194 bool: True if there are no scopes set otherwise False.
195 """
196 return not self._scopes and not self._default_scopes
197
198 @property
199 def project_number(self):
200 """Optional[str]: The project number corresponding to the workload identity pool."""
201
202 # STS audience pattern:
203 # //iam.googleapis.com/projects/$PROJECT_NUMBER/locations/...
204 components = self._audience.split("/")
205 try:
206 project_index = components.index("projects")
207 if project_index + 1 < len(components):
208 return components[project_index + 1] or None
209 except ValueError:
210 return None
211
212 @_helpers.copy_docstring(credentials.Scoped)
213 def with_scopes(self, scopes, default_scopes=None):
214 return self.__class__(
215 audience=self._audience,
216 subject_token_type=self._subject_token_type,
217 token_url=self._token_url,
218 credential_source=self._credential_source,
219 service_account_impersonation_url=self._service_account_impersonation_url,
220 client_id=self._client_id,
221 client_secret=self._client_secret,
222 quota_project_id=self._quota_project_id,
223 scopes=scopes,
224 default_scopes=default_scopes,
225 )
226
227 @abc.abstractmethod
228 def retrieve_subject_token(self, request):
229 """Retrieves the subject token using the credential_source object.
230
231 Args:
232 request (google.auth.transport.Request): A callable used to make
233 HTTP requests.
234 Returns:
235 str: The retrieved subject token.
236 """
237 # pylint: disable=missing-raises-doc
238 # (pylint doesn't recognize that this is abstract)
239 raise NotImplementedError("retrieve_subject_token must be implemented")
240
241 def get_project_id(self, request):
242 """Retrieves the project ID corresponding to the workload identity pool.
243
244 When not determinable, None is returned.
245
246 This is introduced to support the current pattern of using the Auth library:
247
248 credentials, project_id = google.auth.default()
249
250 The resource may not have permission (resourcemanager.projects.get) to
251 call this API or the required scopes may not be selected:
252 https://cloud.google.com/resource-manager/reference/rest/v1/projects/get#authorization-scopes
253
254 Args:
255 request (google.auth.transport.Request): A callable used to make
256 HTTP requests.
257 Returns:
258 Optional[str]: The project ID corresponding to the workload identity pool
259 if determinable.
260 """
261 if self._project_id:
262 # If already retrieved, return the cached project ID value.
263 return self._project_id
264 scopes = self._scopes if self._scopes is not None else self._default_scopes
265 # Scopes are required in order to retrieve a valid access token.
266 if self.project_number and scopes:
267 headers = {}
268 url = _CLOUD_RESOURCE_MANAGER + self.project_number
269 self.before_request(request, "GET", url, headers)
270 response = request(url=url, method="GET", headers=headers)
271
272 response_body = (
273 response.data.decode("utf-8")
274 if hasattr(response.data, "decode")
275 else response.data
276 )
277 response_data = json.loads(response_body)
278
279 if response.status == 200:
280 # Cache result as this field is immutable.
281 self._project_id = response_data.get("projectId")
282 return self._project_id
283
284 return None
285
286 @_helpers.copy_docstring(credentials.Credentials)
287 def refresh(self, request):
288 scopes = self._scopes if self._scopes is not None else self._default_scopes
289 if self._impersonated_credentials:
290 self._impersonated_credentials.refresh(request)
291 self.token = self._impersonated_credentials.token
292 self.expiry = self._impersonated_credentials.expiry
293 else:
294 now = _helpers.utcnow()
295 response_data = self._sts_client.exchange_token(
296 request=request,
297 grant_type=_STS_GRANT_TYPE,
298 subject_token=self.retrieve_subject_token(request),
299 subject_token_type=self._subject_token_type,
300 audience=self._audience,
301 scopes=scopes,
302 requested_token_type=_STS_REQUESTED_TOKEN_TYPE,
303 )
304 self.token = response_data.get("access_token")
305 lifetime = datetime.timedelta(seconds=response_data.get("expires_in"))
306 self.expiry = now + lifetime
307
308 @_helpers.copy_docstring(credentials.CredentialsWithQuotaProject)
309 def with_quota_project(self, quota_project_id):
310 # Return copy of instance with the provided quota project ID.
311 return self.__class__(
312 audience=self._audience,
313 subject_token_type=self._subject_token_type,
314 token_url=self._token_url,
315 credential_source=self._credential_source,
316 service_account_impersonation_url=self._service_account_impersonation_url,
317 client_id=self._client_id,
318 client_secret=self._client_secret,
319 quota_project_id=quota_project_id,
320 scopes=self._scopes,
321 default_scopes=self._default_scopes,
322 )
323
324 def _initialize_impersonated_credentials(self):
325 """Generates an impersonated credentials.
326
327 For more details, see `projects.serviceAccounts.generateAccessToken`_.
328
329 .. _projects.serviceAccounts.generateAccessToken: https://cloud.google.com/iam/docs/reference/credentials/rest/v1/projects.serviceAccounts/generateAccessToken
330
331 Returns:
332 impersonated_credentials.Credential: The impersonated credentials
333 object.
334
335 Raises:
336 google.auth.exceptions.RefreshError: If the generateAccessToken
337 endpoint returned an error.
338 """
339 # Return copy of instance with no service account impersonation.
340 source_credentials = self.__class__(
341 audience=self._audience,
342 subject_token_type=self._subject_token_type,
343 token_url=self._token_url,
344 credential_source=self._credential_source,
345 service_account_impersonation_url=None,
346 client_id=self._client_id,
347 client_secret=self._client_secret,
348 quota_project_id=self._quota_project_id,
349 scopes=self._scopes,
350 default_scopes=self._default_scopes,
351 )
352
353 # Determine target_principal.
bojeil-googlef97499c2021-06-09 07:58:25 -0700354 target_principal = self.service_account_email
355 if not target_principal:
bojeil-googled4d7f382021-02-16 12:33:20 -0800356 raise exceptions.RefreshError(
357 "Unable to determine target principal from service account impersonation URL."
358 )
359
360 scopes = self._scopes if self._scopes is not None else self._default_scopes
361 # Initialize and return impersonated credentials.
362 return impersonated_credentials.Credentials(
363 source_credentials=source_credentials,
364 target_principal=target_principal,
365 target_scopes=scopes,
366 quota_project_id=self._quota_project_id,
367 iam_endpoint_override=self._service_account_impersonation_url,
368 )