bojeil-google | d4d7f38 | 2021-02-16 12:33:20 -0800 | [diff] [blame^] | 1 | # 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 | |
| 17 | This module provides credentials that exchange workload identity pool external |
| 18 | credentials for Google access tokens. This facilitates accessing Google Cloud |
| 19 | Platform resources from on-prem and non-Google Cloud platforms (e.g. AWS, |
| 20 | Microsoft Azure, OIDC identity providers), using native credentials retrieved |
| 21 | from the current environment without the need to copy, save and manage |
| 22 | long-lived service account credentials. |
| 23 | |
| 24 | Specifically, this is intended to use access tokens acquired using the GCP STS |
| 25 | token 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 | |
| 30 | import abc |
| 31 | import datetime |
| 32 | import json |
| 33 | |
| 34 | import six |
| 35 | |
| 36 | from google.auth import _helpers |
| 37 | from google.auth import credentials |
| 38 | from google.auth import exceptions |
| 39 | from google.auth import impersonated_credentials |
| 40 | from google.oauth2 import sts |
| 41 | from 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) |
| 52 | class 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 | ) |