From 63ad5e9b99a1e48c369fb4d32b5cf98f73f6e447 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Tue, 1 Mar 2022 14:14:51 -0800 Subject: [PATCH 01/48] feat: Add Pluggable auth support (#988) * Port identity pool credentials * access_token retrieved * -> pluggable * Update pluggable.py * Create test_pluggable.py * Unit tests * Address pr issues --- google/auth/_default.py | 6 + google/auth/pluggable.py | 273 +++++++++++++++++++++ tests/test_pluggable.py | 511 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 790 insertions(+) create mode 100644 google/auth/pluggable.py create mode 100644 tests/test_pluggable.py diff --git a/google/auth/_default.py b/google/auth/_default.py index 34edda046..68d7cc1a1 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -325,6 +325,12 @@ def _get_external_account_credentials( credentials = aws.Credentials.from_info( info, scopes=scopes, default_scopes=default_scopes ) + elif info.get("credential_source").get("executable") is not None: + from google.auth import pluggable + + credentials = pluggable.Credentials.from_info( + info, scopes=scopes, default_scopes=default_scopes + ) else: try: # Check if configuration corresponds to an Identity Pool credentials. diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py new file mode 100644 index 000000000..d440079f8 --- /dev/null +++ b/google/auth/pluggable.py @@ -0,0 +1,273 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Pluggable Credentials. + +This module provides credentials to access Google Cloud resources from on-prem +or non-Google Cloud platforms which support external credentials (e.g. OIDC ID +tokens) retrieved from local file locations or local servers. This includes +Microsoft Azure and OIDC identity providers (e.g. K8s workloads registered with +Hub with Hub workload identity enabled). + +These credentials are recommended over the use of service account credentials +in on-prem/non-Google Cloud platforms as they do not involve the management of +long-live service account private keys. + +Pluggable Credentials are initialized using external_account arguments which +are typically loaded from third-party executables. Unlike other +credentials that can be initialized with a list of explicit arguments, secrets +or credentials, external account clients use the environment and hints/guidelines +provided by the external_account JSON file to retrieve credentials and exchange +them for Google access tokens. +""" + +try: + from collections.abc import Mapping +# Python 2.7 compatibility +except ImportError: # pragma: NO COVER + from collections import Mapping +import io +import json +import os +import subprocess + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import external_account + +# External account JSON type identifier. +EXECUTABLE_SUPPORTED_MAX_VERSION = 1 + +class Credentials(external_account.Credentials): + """External account credentials sourced from executables.""" + + def __init__( + self, + audience, + subject_token_type, + token_url, + credential_source, + service_account_impersonation_url=None, + client_id=None, + client_secret=None, + quota_project_id=None, + scopes=None, + default_scopes=None, + workforce_pool_user_project=None, + ): + """Instantiates an external account credentials object from a executables. + + Args: + audience (str): The STS audience field. + subject_token_type (str): The subject token type. + token_url (str): The STS endpoint URL. + credential_source (Mapping): The credential source dictionary used to + provide instructions on how to retrieve external credential to be + exchanged for Google access tokens. + + Example credential_source for pluggable credential:: + + { + "executable": { + "command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2", + "timeout_millis": 5000, + "output_file": "/path/to/generated/cached/credentials" + } + } + + service_account_impersonation_url (Optional[str]): The optional service account + impersonation getAccessToken URL. + client_id (Optional[str]): The optional client ID. + client_secret (Optional[str]): The optional client secret. + quota_project_id (Optional[str]): The optional quota project ID. + scopes (Optional[Sequence[str]]): Optional scopes to request during the + authorization grant. + default_scopes (Optional[Sequence[str]]): Default scopes passed by a + Google client library. Use 'scopes' for user-defined scopes. + workforce_pool_user_project (Optona[str]): The optional workforce pool user + project number when the credential corresponds to a workforce pool and not + a workload Pluggable. The underlying principal must still have + serviceusage.services.use IAM permission to use the project for + billing/quota. + + Raises: + google.auth.exceptions.RefreshError: If an error is encountered during + access token retrieval logic. + ValueError: For invalid parameters. + + .. note:: Typically one of the helper constructors + :meth:`from_file` or + :meth:`from_info` are used instead of calling the constructor directly. + """ + + super(Credentials, self).__init__( + audience=audience, + subject_token_type=subject_token_type, + token_url=token_url, + credential_source=credential_source, + service_account_impersonation_url=service_account_impersonation_url, + client_id=client_id, + client_secret=client_secret, + quota_project_id=quota_project_id, + scopes=scopes, + default_scopes=default_scopes, + workforce_pool_user_project=workforce_pool_user_project, + ) + if not isinstance(credential_source, Mapping): + self._credential_source_executable = None + raise ValueError( + "Missing credential_source. The credential_source is not a dict." + ) + else: + self._credential_source_executable = credential_source.get("executable") + if not self._credential_source_executable: + raise ValueError( + "Missing credential_source. An 'executable' must be provided." + ) + self._credential_source_executable_command = self._credential_source_executable.get("command") + self._credential_source_executable_timeout_millis = self._credential_source_executable.get("timeout_millis") + self._credential_source_executable_output_file = self._credential_source_executable.get("output_file") + + # environment_id is only supported in AWS or dedicated future external + # account credentials. + if "environment_id" in credential_source: + raise ValueError( + "Invalid Pluggable credential_source field 'environment_id'" + ) + + if not self._credential_source_executable_command: + raise ValueError( + "Missing command. Executable command must be provided." + ) + if not self._credential_source_executable_timeout_millis: + raise ValueError( + "Missing timeout_millis. Executable timeout millis must be provided." + ) + + @_helpers.copy_docstring(external_account.Credentials) + def retrieve_subject_token(self, request): + env_allow_executables = os.environ.get('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES') + if env_allow_executables != '1': + raise ValueError( + "Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run." + ) + + # Inject env vars + original_audience = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE") + os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience + original_subject_token_type = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE") + os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type + original_interactive = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE") + os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = "0" # Always set to 0 until interactive mode is implemented. + original_service_account_impersonation_url = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL") + if self._service_account_impersonation_url is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] = self._service_account_impersonation_url + original_credential_source_executable_output_file = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") + if self._credential_source_executable_output_file is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] = self._credential_source_executable_output_file + + result = subprocess.run(self._credential_source_executable_command.split(), timeout=self._credential_source_executable_timeout_millis/1000, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + + # Reset env vars + if original_audience is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = original_audience + else: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] + if original_subject_token_type is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self.original_subject_token_type + else: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] + if original_interactive is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = original_interactive + else: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] + if original_service_account_impersonation_url is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] = original_service_account_impersonation_url + elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL") is not None: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] + if original_credential_source_executable_output_file is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] = original_credential_source_executable_output_file + elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") is not None: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] + + if result.returncode != 0: + raise exceptions.RefreshError( + "Executable exited with non-zero return code {}. Error: {}".format(result.returncode, result.stdout) + ) + else: + data = result.stdout.decode('utf-8') + response = json.loads(data) + if not response['success']: + raise exceptions.RefreshError( + "Executable returned unsuccessful response: {}.".format(response) + ) + elif response['version'] > EXECUTABLE_SUPPORTED_MAX_VERSION: + raise exceptions.RefreshError( + "Executable returned unsupported version {}.".format(response['version']) + ) + elif response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token": # OIDC + return response["id_token"] + elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML + return response["saml_response"] + else: + raise exceptions.RefreshError( + "Executable returned unsupported token type." + ) + + @classmethod + def from_info(cls, info, **kwargs): + """Creates a Pluggable Credentials instance from parsed external account info. + + Args: + info (Mapping[str, str]): The Pluggable external account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.pluggable.Credentials: The constructed + credentials. + + Raises: + ValueError: For invalid parameters. + """ + return cls( + audience=info.get("audience"), + subject_token_type=info.get("subject_token_type"), + token_url=info.get("token_url"), + service_account_impersonation_url=info.get( + "service_account_impersonation_url" + ), + client_id=info.get("client_id"), + client_secret=info.get("client_secret"), + credential_source=info.get("credential_source"), + quota_project_id=info.get("quota_project_id"), + workforce_pool_user_project=info.get("workforce_pool_user_project"), + **kwargs + ) + + @classmethod + def from_file(cls, filename, **kwargs): + """Creates an Pluggable Credentials instance from an external account json file. + + Args: + filename (str): The path to the Pluggable external account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.pluggable.Credentials: The constructed + credentials. + """ + with io.open(filename, "r", encoding="utf-8") as json_file: + data = json.load(json_file) + return cls.from_info(data, **kwargs) diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py new file mode 100644 index 000000000..b9d6e9f27 --- /dev/null +++ b/tests/test_pluggable.py @@ -0,0 +1,511 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import json +import os + +import mock +import pytest # type: ignore +import subprocess +import pytest_subprocess +from six.moves import http_client +from six.moves import urllib + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import identity_pool +from google.auth import pluggable +from google.auth import transport + + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password". +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" +SERVICE_ACCOUNT_EMAIL = "service-1234@service-name.iam.gserviceaccount.com" +SERVICE_ACCOUNT_IMPERSONATION_URL = ( + "https://us-east1-iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:generateAccessToken".format(SERVICE_ACCOUNT_EMAIL) +) +QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" +SCOPES = ["scope1", "scope2"] +SUBJECT_TOKEN_FIELD_NAME = "access_token" + +TOKEN_URL = "https://sts.googleapis.com/v1/token" +SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" +AUDIENCE = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID" + +class TestCredentials(object): + CREDENTIAL_SOURCE_EXECUTABLE_COMMAND = "/fake/external/excutable --arg1=value1 --arg2=value2" + CREDENTIAL_SOURCE_EXECUTABLE = { + "command": CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "timeout_millis": 5000, + "output_file": "/fake/output/file" + } + CREDENTIAL_SOURCE = { + "executable": CREDENTIAL_SOURCE_EXECUTABLE + } + EXECUTABLE_OIDC_TOKEN = "FAKE_ID_TOKEN" + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999 + } + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:jwt", + "id_token": EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999 + } + EXECUTABLE_SAML_TOKEN = "FAKE_SAML_RESPONSE" + EXECUTABLE_SUCCESSFUL_SAML_RESPONSE = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:saml2", + "saml_response": EXECUTABLE_SAML_TOKEN, + "expiration_time": 1620433341 + } + EXECUTABLE_FAILED_RESPONSE = { + "version": 1, + "success": False, + "code": "401", + "message": "Permission denied. Caller not authorized" + } + CREDENTIAL_URL = "http://fakeurl.com" + + @classmethod + def make_mock_response(cls, status, data): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + if isinstance(data, dict): + response.data = json.dumps(data).encode("utf-8") + else: + response.data = data + return response + + @classmethod + def make_mock_request( + cls, token_status=http_client.OK, token_data=None, *extra_requests + ): + responses = [] + responses.append(cls.make_mock_response(token_status, token_data)) + + while len(extra_requests) > 0: + # If service account impersonation is requested, mock the expected response. + status, data, extra_requests = ( + extra_requests[0], + extra_requests[1], + extra_requests[2:], + ) + responses.append(cls.make_mock_response(status, data)) + + request = mock.create_autospec(transport.Request) + request.side_effect = responses + + return request + + @classmethod + def assert_credential_request_kwargs( + cls, request_kwargs, headers, url=CREDENTIAL_URL + ): + assert request_kwargs["url"] == url + assert request_kwargs["method"] == "GET" + assert request_kwargs["headers"] == headers + assert request_kwargs.get("body", None) is None + + @classmethod + def assert_token_request_kwargs( + cls, request_kwargs, headers, request_data, token_url=TOKEN_URL + ): + assert request_kwargs["url"] == token_url + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) + assert len(body_tuples) == len(request_data.keys()) + for (k, v) in body_tuples: + assert v.decode("utf-8") == request_data[k.decode("utf-8")] + + @classmethod + def assert_impersonation_request_kwargs( + cls, + request_kwargs, + headers, + request_data, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + ): + assert request_kwargs["url"] == service_account_impersonation_url + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_json = json.loads(request_kwargs["body"].decode("utf-8")) + assert body_json == request_data + + @classmethod + def assert_underlying_credentials_refresh( + cls, + credentials, + audience, + subject_token, + subject_token_type, + token_url, + service_account_impersonation_url=None, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=None, + credential_data=None, + scopes=None, + default_scopes=None, + workforce_pool_user_project=None, + ): + """Utility to assert that a credentials are initialized with the expected + attributes by calling refresh functionality and confirming response matches + expected one and that the underlying requests were populated with the + expected parameters. + """ + # STS token exchange request/response. + token_response = cls.SUCCESS_RESPONSE.copy() + token_headers = {"Content-Type": "application/x-www-form-urlencoded"} + if basic_auth_encoding: + token_headers["Authorization"] = "Basic " + basic_auth_encoding + + if service_account_impersonation_url: + token_scopes = "https://www.googleapis.com/auth/iam" + else: + token_scopes = " ".join(used_scopes or []) + + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": audience, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": token_scopes, + "subject_token": subject_token, + "subject_token_type": subject_token_type, + } + if workforce_pool_user_project: + token_request_data["options"] = urllib.parse.quote( + json.dumps({"userProject": workforce_pool_user_project}) + ) + + if service_account_impersonation_url: + # Service account impersonation request/response. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + } + impersonation_request_data = { + "delegates": None, + "scope": used_scopes, + "lifetime": "3600s", + } + + # Initialize mock request to handle token retrieval, token exchange and + # service account impersonation request. + requests = [] + if credential_data: + requests.append((http_client.OK, credential_data)) + + token_request_index = len(requests) + requests.append((http_client.OK, token_response)) + + if service_account_impersonation_url: + impersonation_request_index = len(requests) + requests.append((http_client.OK, impersonation_response)) + + request = cls.make_mock_request(*[el for req in requests for el in req]) + + credentials.refresh(request) + + assert len(request.call_args_list) == len(requests) + if credential_data: + cls.assert_credential_request_kwargs(request.call_args_list[0][1], None) + # Verify token exchange request parameters. + cls.assert_token_request_kwargs( + request.call_args_list[token_request_index][1], + token_headers, + token_request_data, + token_url, + ) + # Verify service account impersonation request parameters if the request + # is processed. + if service_account_impersonation_url: + cls.assert_impersonation_request_kwargs( + request.call_args_list[impersonation_request_index][1], + impersonation_headers, + impersonation_request_data, + service_account_impersonation_url, + ) + assert credentials.token == impersonation_response["accessToken"] + else: + assert credentials.token == token_response["access_token"] + assert credentials.quota_project_id == quota_project_id + assert credentials.scopes == scopes + assert credentials.default_scopes == default_scopes + + @classmethod + def make_pluggable( + cls, + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + client_id=None, + client_secret=None, + quota_project_id=None, + scopes=None, + default_scopes=None, + service_account_impersonation_url=None, + credential_source=None, + workforce_pool_user_project=None, + ): + return pluggable.Credentials( + audience=audience, + subject_token_type=subject_token_type, + token_url=TOKEN_URL, + service_account_impersonation_url=service_account_impersonation_url, + credential_source=credential_source, + client_id=client_id, + client_secret=client_secret, + quota_project_id=quota_project_id, + scopes=scopes, + default_scopes=default_scopes, + workforce_pool_user_project=workforce_pool_user_project, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_info_full_options(self, mock_init): + credentials = pluggable.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE, + } + ) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_info_required_options_only(self, mock_init): + credentials = pluggable.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + ) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=None, + workforce_pool_user_project=None, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_file_full_options(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = pluggable.Credentials.from_file(str(config_file)) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_file_required_options_only(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = pluggable.Credentials.from_file(str(config_file)) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=None, + workforce_pool_user_project=None, + ) + + def test_constructor_invalid_options(self): + credential_source = {"unsupported": "value"} + + with pytest.raises(ValueError) as excinfo: + self.make_pluggable(credential_source=credential_source) + + assert excinfo.match(r"Missing credential_source") + + def test_constructor_invalid_options_environment_id(self): + credential_source = {"executable": self.CREDENTIAL_SOURCE_EXECUTABLE, "environment_id": "aws1"} + + with pytest.raises(ValueError) as excinfo: + self.make_pluggable(credential_source=credential_source) + + assert excinfo.match( + r"Invalid Pluggable credential_source field 'environment_id'" + ) + + def test_constructor_invalid_credential_source(self): + with pytest.raises(ValueError) as excinfo: + self.make_pluggable(credential_source="non-dict") + + assert excinfo.match(r"Missing credential_source") + + def test_info_with_credential_source(self): + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_oidc_id_token(self, fp): + fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN)) + + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_oidc_jwt(self, fp): + fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT)) + + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_saml(self, fp): + fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE)) + + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_SAML_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_failed(self, fp): + fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_FAILED_RESPONSE)) + + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executable returned unsuccessful response") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_invalid_version(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2 = { + "version": 2, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999 + } + + fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2)) + + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executable returned unsupported version") \ No newline at end of file From f36e5e86bf0a4d682dca4102752063956dbda373 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 2 Mar 2022 17:10:45 -0800 Subject: [PATCH 02/48] feat: Add file caching (#990) * Add file cache * feat: add output file cache support --- google/auth/pluggable.py | 53 +++++++++++++++++++++++----------- tests/test_pluggable.py | 61 ++++++++++++++++++++++++++++++++++++---- 2 files changed, 92 insertions(+), 22 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index d440079f8..1c28ab511 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -41,6 +41,7 @@ import json import os import subprocess +import time from google.auth import _helpers from google.auth import exceptions @@ -163,6 +164,17 @@ def retrieve_subject_token(self, request): "Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run." ) + # Check output file + if self._credential_source_executable_output_file is not None: + try: + with open(self._credential_source_executable_output_file) as output_file: + response = json.load(output_file) + subject_token = self._parse_subject_token(response) + except: + pass + else: + return subject_token + # Inject env vars original_audience = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE") os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience @@ -208,23 +220,8 @@ def retrieve_subject_token(self, request): else: data = result.stdout.decode('utf-8') response = json.loads(data) - if not response['success']: - raise exceptions.RefreshError( - "Executable returned unsuccessful response: {}.".format(response) - ) - elif response['version'] > EXECUTABLE_SUPPORTED_MAX_VERSION: - raise exceptions.RefreshError( - "Executable returned unsupported version {}.".format(response['version']) - ) - elif response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token": # OIDC - return response["id_token"] - elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML - return response["saml_response"] - else: - raise exceptions.RefreshError( - "Executable returned unsupported token type." - ) - + return self._parse_subject_token(response) + @classmethod def from_info(cls, info, **kwargs): """Creates a Pluggable Credentials instance from parsed external account info. @@ -271,3 +268,25 @@ def from_file(cls, filename, **kwargs): with io.open(filename, "r", encoding="utf-8") as json_file: data = json.load(json_file) return cls.from_info(data, **kwargs) + + def _parse_subject_token(self, response): + if not response['success']: + raise exceptions.RefreshError( + "Executable returned unsuccessful response: {}.".format(response) + ) + elif response['version'] > EXECUTABLE_SUPPORTED_MAX_VERSION: + raise exceptions.RefreshError( + "Executable returned unsupported version {}.".format(response['version']) + ) + elif response['expiration_time'] < time.time(): + raise exceptions.RefreshError( + "The token returned by the executable is expired." + ) + elif response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token": # OIDC + return response["id_token"] + elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML + return response["saml_response"] + else: + raise exceptions.RefreshError( + "Executable returned unsupported token type." + ) \ No newline at end of file diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index b9d6e9f27..318f0d9ab 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -49,10 +49,11 @@ class TestCredentials(object): CREDENTIAL_SOURCE_EXECUTABLE_COMMAND = "/fake/external/excutable --arg1=value1 --arg2=value2" + CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "fake_output_file" CREDENTIAL_SOURCE_EXECUTABLE = { "command": CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, "timeout_millis": 5000, - "output_file": "/fake/output/file" + "output_file": CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE } CREDENTIAL_SOURCE = { "executable": CREDENTIAL_SOURCE_EXECUTABLE @@ -78,7 +79,7 @@ class TestCredentials(object): "success": True, "token_type": "urn:ietf:params:oauth:token-type:saml2", "saml_response": EXECUTABLE_SAML_TOKEN, - "expiration_time": 1620433341 + "expiration_time": 9999999999 } EXECUTABLE_FAILED_RESPONSE = { "version": 1, @@ -488,7 +489,20 @@ def test_retrieve_subject_token_failed(self, fp): subject_token = credentials.retrieve_subject_token(None) assert excinfo.match(r"Executable returned unsuccessful response") - + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) + def test_retrieve_subject_token_not_allowd(self, fp): + fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN)) + + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE + ) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executables need to be explicitly allowed") + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_invalid_version(self, fp): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2 = { @@ -498,7 +512,7 @@ def test_retrieve_subject_token_invalid_version(self, fp): "id_token": self.EXECUTABLE_OIDC_TOKEN, "expiration_time": 9999999999 } - + fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2)) credentials = self.make_pluggable( @@ -508,4 +522,41 @@ def test_retrieve_subject_token_invalid_version(self, fp): with pytest.raises(exceptions.RefreshError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"Executable returned unsupported version") \ No newline at end of file + assert excinfo.match(r"Executable returned unsupported version") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_expired_token(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED= { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 0 + } + + fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED)) + + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The token returned by the executable is expired") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_file_cache(self, fp): + with open(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, 'w') as output_file: + json.dump(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN, output_file) + + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE + ) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + if os.path.exists(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE): + os.remove(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) From 28cfefc4d21555d21048e98fb76b95519e8199b3 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Thu, 17 Mar 2022 16:20:18 +0000 Subject: [PATCH 03/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/pluggable.py | 107 ++++++++++++++++++++++++------------- tests/test_pluggable.py | 111 +++++++++++++++++++++------------------ 2 files changed, 131 insertions(+), 87 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 1c28ab511..b84d12984 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -50,6 +50,7 @@ # External account JSON type identifier. EXECUTABLE_SUPPORTED_MAX_VERSION = 1 + class Credentials(external_account.Credentials): """External account credentials sourced from executables.""" @@ -136,9 +137,15 @@ def __init__( raise ValueError( "Missing credential_source. An 'executable' must be provided." ) - self._credential_source_executable_command = self._credential_source_executable.get("command") - self._credential_source_executable_timeout_millis = self._credential_source_executable.get("timeout_millis") - self._credential_source_executable_output_file = self._credential_source_executable.get("output_file") + self._credential_source_executable_command = self._credential_source_executable.get( + "command" + ) + self._credential_source_executable_timeout_millis = self._credential_source_executable.get( + "timeout_millis" + ) + self._credential_source_executable_output_file = self._credential_source_executable.get( + "output_file" + ) # environment_id is only supported in AWS or dedicated future external # account credentials. @@ -148,9 +155,7 @@ def __init__( ) if not self._credential_source_executable_command: - raise ValueError( - "Missing command. Executable command must be provided." - ) + raise ValueError("Missing command. Executable command must be provided.") if not self._credential_source_executable_timeout_millis: raise ValueError( "Missing timeout_millis. Executable timeout millis must be provided." @@ -158,16 +163,20 @@ def __init__( @_helpers.copy_docstring(external_account.Credentials) def retrieve_subject_token(self, request): - env_allow_executables = os.environ.get('GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES') - if env_allow_executables != '1': + env_allow_executables = os.environ.get( + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES" + ) + if env_allow_executables != "1": raise ValueError( "Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run." ) - + # Check output file if self._credential_source_executable_output_file is not None: try: - with open(self._credential_source_executable_output_file) as output_file: + with open( + self._credential_source_executable_output_file + ) as output_file: response = json.load(output_file) subject_token = self._parse_subject_token(response) except: @@ -181,15 +190,30 @@ def retrieve_subject_token(self, request): original_subject_token_type = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE") os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type original_interactive = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE") - os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = "0" # Always set to 0 until interactive mode is implemented. - original_service_account_impersonation_url = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL") + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE" + ] = "0" # Always set to 0 until interactive mode is implemented. + original_service_account_impersonation_url = os.getenv( + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" + ) if self._service_account_impersonation_url is not None: - os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] = self._service_account_impersonation_url - original_credential_source_executable_output_file = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" + ] = self._service_account_impersonation_url + original_credential_source_executable_output_file = os.getenv( + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" + ) if self._credential_source_executable_output_file is not None: - os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] = self._credential_source_executable_output_file - - result = subprocess.run(self._credential_source_executable_command.split(), timeout=self._credential_source_executable_timeout_millis/1000, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" + ] = self._credential_source_executable_output_file + + result = subprocess.run( + self._credential_source_executable_command.split(), + timeout=self._credential_source_executable_timeout_millis / 1000, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) # Reset env vars if original_audience is not None: @@ -197,7 +221,9 @@ def retrieve_subject_token(self, request): else: del os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] if original_subject_token_type is not None: - os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self.original_subject_token_type + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE" + ] = self.original_subject_token_type else: del os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] if original_interactive is not None: @@ -205,23 +231,29 @@ def retrieve_subject_token(self, request): else: del os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] if original_service_account_impersonation_url is not None: - os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] = original_service_account_impersonation_url + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" + ] = original_service_account_impersonation_url elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL") is not None: del os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] if original_credential_source_executable_output_file is not None: - os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] = original_credential_source_executable_output_file + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" + ] = original_credential_source_executable_output_file elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") is not None: del os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] - + if result.returncode != 0: raise exceptions.RefreshError( - "Executable exited with non-zero return code {}. Error: {}".format(result.returncode, result.stdout) + "Executable exited with non-zero return code {}. Error: {}".format( + result.returncode, result.stdout + ) ) else: - data = result.stdout.decode('utf-8') + data = result.stdout.decode("utf-8") response = json.loads(data) return self._parse_subject_token(response) - + @classmethod def from_info(cls, info, **kwargs): """Creates a Pluggable Credentials instance from parsed external account info. @@ -270,23 +302,26 @@ def from_file(cls, filename, **kwargs): return cls.from_info(data, **kwargs) def _parse_subject_token(self, response): - if not response['success']: - raise exceptions.RefreshError( - "Executable returned unsuccessful response: {}.".format(response) - ) - elif response['version'] > EXECUTABLE_SUPPORTED_MAX_VERSION: + if not response["success"]: raise exceptions.RefreshError( - "Executable returned unsupported version {}.".format(response['version']) + "Executable returned unsuccessful response: {}.".format(response) ) - elif response['expiration_time'] < time.time(): + elif response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION: + raise exceptions.RefreshError( + "Executable returned unsupported version {}.".format( + response["version"] + ) + ) + elif response["expiration_time"] < time.time(): raise exceptions.RefreshError( "The token returned by the executable is expired." ) - elif response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token": # OIDC + elif ( + response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" + or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token" + ): # OIDC return response["id_token"] - elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML + elif response["token_type"] == "urn:ietf:params:oauth:token-type:saml2": # SAML return response["saml_response"] else: - raise exceptions.RefreshError( - "Executable returned unsupported token type." - ) \ No newline at end of file + raise exceptions.RefreshError("Executable returned unsupported token type.") diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 318f0d9ab..1617426a8 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -47,31 +47,32 @@ SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" AUDIENCE = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID" + class TestCredentials(object): - CREDENTIAL_SOURCE_EXECUTABLE_COMMAND = "/fake/external/excutable --arg1=value1 --arg2=value2" + CREDENTIAL_SOURCE_EXECUTABLE_COMMAND = ( + "/fake/external/excutable --arg1=value1 --arg2=value2" + ) CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "fake_output_file" CREDENTIAL_SOURCE_EXECUTABLE = { "command": CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, "timeout_millis": 5000, - "output_file": CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE - } - CREDENTIAL_SOURCE = { - "executable": CREDENTIAL_SOURCE_EXECUTABLE + "output_file": CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, } + CREDENTIAL_SOURCE = {"executable": CREDENTIAL_SOURCE_EXECUTABLE} EXECUTABLE_OIDC_TOKEN = "FAKE_ID_TOKEN" EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN = { "version": 1, "success": True, "token_type": "urn:ietf:params:oauth:token-type:id_token", "id_token": EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999 + "expiration_time": 9999999999, } EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT = { "version": 1, "success": True, "token_type": "urn:ietf:params:oauth:token-type:jwt", "id_token": EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999 + "expiration_time": 9999999999, } EXECUTABLE_SAML_TOKEN = "FAKE_SAML_RESPONSE" EXECUTABLE_SUCCESSFUL_SAML_RESPONSE = { @@ -79,13 +80,13 @@ class TestCredentials(object): "success": True, "token_type": "urn:ietf:params:oauth:token-type:saml2", "saml_response": EXECUTABLE_SAML_TOKEN, - "expiration_time": 9999999999 + "expiration_time": 9999999999, } EXECUTABLE_FAILED_RESPONSE = { "version": 1, "success": False, "code": "401", - "message": "Permission denied. Caller not authorized" + "message": "Permission denied. Caller not authorized", } CREDENTIAL_URL = "http://fakeurl.com" @@ -293,7 +294,7 @@ def make_pluggable( default_scopes=default_scopes, workforce_pool_user_project=workforce_pool_user_project, ) - + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) def test_from_info_full_options(self, mock_init): credentials = pluggable.Credentials.from_info( @@ -413,7 +414,10 @@ def test_constructor_invalid_options(self): assert excinfo.match(r"Missing credential_source") def test_constructor_invalid_options_environment_id(self): - credential_source = {"executable": self.CREDENTIAL_SOURCE_EXECUTABLE, "environment_id": "aws1"} + credential_source = { + "executable": self.CREDENTIAL_SOURCE_EXECUTABLE, + "environment_id": "aws1", + } with pytest.raises(ValueError) as excinfo: self.make_pluggable(credential_source=credential_source) @@ -443,48 +447,52 @@ def test_info_with_credential_source(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_oidc_id_token(self, fp): - fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN)) - - credentials = self.make_pluggable( - credential_source=self.CREDENTIAL_SOURCE + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), ) + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + subject_token = credentials.retrieve_subject_token(None) assert subject_token == self.EXECUTABLE_OIDC_TOKEN @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_oidc_jwt(self, fp): - fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT)) - - credentials = self.make_pluggable( - credential_source=self.CREDENTIAL_SOURCE + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT), ) + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + subject_token = credentials.retrieve_subject_token(None) assert subject_token == self.EXECUTABLE_OIDC_TOKEN - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_saml(self, fp): - fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE)) - - credentials = self.make_pluggable( - credential_source=self.CREDENTIAL_SOURCE + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE), ) + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + subject_token = credentials.retrieve_subject_token(None) assert subject_token == self.EXECUTABLE_SAML_TOKEN @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_failed(self, fp): - fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_FAILED_RESPONSE)) - - credentials = self.make_pluggable( - credential_source=self.CREDENTIAL_SOURCE + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_FAILED_RESPONSE), ) + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + with pytest.raises(exceptions.RefreshError) as excinfo: subject_token = credentials.retrieve_subject_token(None) @@ -492,12 +500,13 @@ def test_retrieve_subject_token_failed(self, fp): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) def test_retrieve_subject_token_not_allowd(self, fp): - fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN)) - - credentials = self.make_pluggable( - credential_source=self.CREDENTIAL_SOURCE + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), ) + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + with pytest.raises(ValueError) as excinfo: subject_token = credentials.retrieve_subject_token(None) @@ -510,15 +519,16 @@ def test_retrieve_subject_token_invalid_version(self, fp): "success": True, "token_type": "urn:ietf:params:oauth:token-type:id_token", "id_token": self.EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999 + "expiration_time": 9999999999, } - fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2)) - - credentials = self.make_pluggable( - credential_source=self.CREDENTIAL_SOURCE + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2), ) + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + with pytest.raises(exceptions.RefreshError) as excinfo: subject_token = credentials.retrieve_subject_token(None) @@ -526,37 +536,36 @@ def test_retrieve_subject_token_invalid_version(self, fp): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_expired_token(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED= { + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED = { "version": 1, "success": True, "token_type": "urn:ietf:params:oauth:token-type:id_token", "id_token": self.EXECUTABLE_OIDC_TOKEN, - "expiration_time": 0 + "expiration_time": 0, } - - fp.register(self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED)) - - credentials = self.make_pluggable( - credential_source=self.CREDENTIAL_SOURCE + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED), ) + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + with pytest.raises(exceptions.RefreshError) as excinfo: subject_token = credentials.retrieve_subject_token(None) assert excinfo.match(r"The token returned by the executable is expired") - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_file_cache(self, fp): - with open(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, 'w') as output_file: + with open(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: json.dump(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN, output_file) - - credentials = self.make_pluggable( - credential_source=self.CREDENTIAL_SOURCE - ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) subject_token = credentials.retrieve_subject_token(None) assert subject_token == self.EXECUTABLE_OIDC_TOKEN - + if os.path.exists(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE): os.remove(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) From d7f4980e3c7cf8cf5096405cdde5bcd308edbc17 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Fri, 18 Mar 2022 10:49:43 -0700 Subject: [PATCH 04/48] Update pluggable.py --- google/auth/pluggable.py | 97 +++++++++++++++++++--------------------- 1 file changed, 47 insertions(+), 50 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index b84d12984..dc6d52750 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -13,23 +13,22 @@ # limitations under the License. """Pluggable Credentials. - -This module provides credentials to access Google Cloud resources from on-prem -or non-Google Cloud platforms which support external credentials (e.g. OIDC ID -tokens) retrieved from local file locations or local servers. This includes -Microsoft Azure and OIDC identity providers (e.g. K8s workloads registered with -Hub with Hub workload identity enabled). - -These credentials are recommended over the use of service account credentials -in on-prem/non-Google Cloud platforms as they do not involve the management of -long-live service account private keys. - Pluggable Credentials are initialized using external_account arguments which are typically loaded from third-party executables. Unlike other credentials that can be initialized with a list of explicit arguments, secrets or credentials, external account clients use the environment and hints/guidelines provided by the external_account JSON file to retrieve credentials and exchange -them for Google access tokens. +them for Google access tokens. + +Example credential_source for pluggable credential:: + + { + "executable": { + "command": "/path/to/get/credentials.sh --arg1=value1 --arg2=value2", + "timeout_millis": 5000, + "output_file": "/path/to/generated/cached/credentials" + } + } """ try: @@ -47,10 +46,9 @@ from google.auth import exceptions from google.auth import external_account -# External account JSON type identifier. +# The max supported executable spec version. EXECUTABLE_SUPPORTED_MAX_VERSION = 1 - class Credentials(external_account.Credentials): """External account credentials sourced from executables.""" @@ -78,7 +76,7 @@ def __init__( provide instructions on how to retrieve external credential to be exchanged for Google access tokens. - Example credential_source for pluggable credential:: + Example credential_source for pluggable credential: { "executable": { @@ -126,40 +124,36 @@ def __init__( default_scopes=default_scopes, workforce_pool_user_project=workforce_pool_user_project, ) + if workforce_pool_user_project is not None: + raise ValueError( + "Pluggable auth doesn't support Workforce poolyet." + ) if not isinstance(credential_source, Mapping): self._credential_source_executable = None raise ValueError( "Missing credential_source. The credential_source is not a dict." ) - else: - self._credential_source_executable = credential_source.get("executable") - if not self._credential_source_executable: - raise ValueError( - "Missing credential_source. An 'executable' must be provided." - ) - self._credential_source_executable_command = self._credential_source_executable.get( - "command" - ) - self._credential_source_executable_timeout_millis = self._credential_source_executable.get( - "timeout_millis" - ) - self._credential_source_executable_output_file = self._credential_source_executable.get( - "output_file" + self._credential_source_executable = credential_source.get("executable") + if not self._credential_source_executable: + raise ValueError( + "Missing credential_source. An 'executable' must be provided." ) - - # environment_id is only supported in AWS or dedicated future external - # account credentials. - if "environment_id" in credential_source: - raise ValueError( - "Invalid Pluggable credential_source field 'environment_id'" - ) + self._credential_source_executable_command = self._credential_source_executable.get( + "command" + ) + self._credential_source_executable_timeout_millis = self._credential_source_executable.get( + "timeout_millis" + ) + self._credential_source_executable_output_file = self._credential_source_executable.get( + "output_file" + ) if not self._credential_source_executable_command: raise ValueError("Missing command. Executable command must be provided.") if not self._credential_source_executable_timeout_millis: - raise ValueError( - "Missing timeout_millis. Executable timeout millis must be provided." - ) + self._credential_source_executable_timeout_millis = 30 * 1000 + elif self._credential_source_executable_timeout_millis < 0 or self._credential_source_executable_timeout_millis > 120: + raise ValueError("Timeout must be between 0 and 120 seconds.") @_helpers.copy_docstring(external_account.Credentials) def retrieve_subject_token(self, request): @@ -171,20 +165,21 @@ def retrieve_subject_token(self, request): "Executables need to be explicitly allowed (set GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES to '1') to run." ) - # Check output file + # Check output file. if self._credential_source_executable_output_file is not None: try: with open( self._credential_source_executable_output_file ) as output_file: response = json.load(output_file) + # If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again. subject_token = self._parse_subject_token(response) except: pass else: return subject_token - # Inject env vars + # Inject env vars. original_audience = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE") os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience original_subject_token_type = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE") @@ -199,7 +194,7 @@ def retrieve_subject_token(self, request): if self._service_account_impersonation_url is not None: os.environ[ "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" - ] = self._service_account_impersonation_url + ] = self.service_account_email() original_credential_source_executable_output_file = os.getenv( "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" ) @@ -215,7 +210,7 @@ def retrieve_subject_token(self, request): stderr=subprocess.STDOUT, ) - # Reset env vars + # Reset env vars. if original_audience is not None: os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = original_audience else: @@ -302,21 +297,23 @@ def from_file(cls, filename, **kwargs): return cls.from_info(data, **kwargs) def _parse_subject_token(self, response): - if not response["success"]: - raise exceptions.RefreshError( - "Executable returned unsuccessful response: {}.".format(response) - ) - elif response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION: + if response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION: raise exceptions.RefreshError( "Executable returned unsupported version {}.".format( response["version"] ) ) - elif response["expiration_time"] < time.time(): + if not response["success"] or not response["success"]: + if not response["code"] or not response["message"]: + raise ValueError("Code and message are required in the response.") + raise exceptions.RefreshError( + "Executable returned unsuccessful response: code: {}, message: {}.".format(response["code"], response["message"]) + ) + if response["expiration_time"] < time.time(): raise exceptions.RefreshError( "The token returned by the executable is expired." ) - elif ( + if ( response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token" ): # OIDC From 709217043a14778fdf44ecff406729622cb80731 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Sat, 19 Mar 2022 15:09:35 +0000 Subject: [PATCH 05/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/pluggable.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index dc6d52750..3b9ce3133 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -49,6 +49,7 @@ # The max supported executable spec version. EXECUTABLE_SUPPORTED_MAX_VERSION = 1 + class Credentials(external_account.Credentials): """External account credentials sourced from executables.""" @@ -125,9 +126,7 @@ def __init__( workforce_pool_user_project=workforce_pool_user_project, ) if workforce_pool_user_project is not None: - raise ValueError( - "Pluggable auth doesn't support Workforce poolyet." - ) + raise ValueError("Pluggable auth doesn't support Workforce poolyet.") if not isinstance(credential_source, Mapping): self._credential_source_executable = None raise ValueError( @@ -152,7 +151,10 @@ def __init__( raise ValueError("Missing command. Executable command must be provided.") if not self._credential_source_executable_timeout_millis: self._credential_source_executable_timeout_millis = 30 * 1000 - elif self._credential_source_executable_timeout_millis < 0 or self._credential_source_executable_timeout_millis > 120: + elif ( + self._credential_source_executable_timeout_millis < 0 + or self._credential_source_executable_timeout_millis > 120 + ): raise ValueError("Timeout must be between 0 and 120 seconds.") @_helpers.copy_docstring(external_account.Credentials) @@ -307,7 +309,9 @@ def _parse_subject_token(self, response): if not response["code"] or not response["message"]: raise ValueError("Code and message are required in the response.") raise exceptions.RefreshError( - "Executable returned unsuccessful response: code: {}, message: {}.".format(response["code"], response["message"]) + "Executable returned unsuccessful response: code: {}, message: {}.".format( + response["code"], response["message"] + ) ) if response["expiration_time"] < time.time(): raise exceptions.RefreshError( From 5246463b0a4aaa6769985a210d49097515617260 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Tue, 22 Mar 2022 18:11:07 -0700 Subject: [PATCH 06/48] Update pluggable.py --- google/auth/pluggable.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 3b9ce3133..c19b1d83c 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -126,7 +126,7 @@ def __init__( workforce_pool_user_project=workforce_pool_user_project, ) if workforce_pool_user_project is not None: - raise ValueError("Pluggable auth doesn't support Workforce poolyet.") + raise ValueError("Workforce Pools are not yet supported with Pluggable Auth.") if not isinstance(credential_source, Mapping): self._credential_source_executable = None raise ValueError( @@ -152,7 +152,7 @@ def __init__( if not self._credential_source_executable_timeout_millis: self._credential_source_executable_timeout_millis = 30 * 1000 elif ( - self._credential_source_executable_timeout_millis < 0 + self._credential_source_executable_timeout_millis < 5 or self._credential_source_executable_timeout_millis > 120 ): raise ValueError("Timeout must be between 0 and 120 seconds.") @@ -177,7 +177,7 @@ def retrieve_subject_token(self, request): # If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again. subject_token = self._parse_subject_token(response) except: - pass + raise exceptions.RefreshError("Failed to load token from the output file of the executable. Please ensure your configuration is correct, remove the output file then try again.") else: return subject_token @@ -305,7 +305,7 @@ def _parse_subject_token(self, response): response["version"] ) ) - if not response["success"] or not response["success"]: + if not response["success"]: if not response["code"] or not response["message"]: raise ValueError("Code and message are required in the response.") raise exceptions.RefreshError( From 62246d6d6fe73985eb182b5c460e2eed8c002d0c Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Wed, 23 Mar 2022 01:12:37 +0000 Subject: [PATCH 07/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/pluggable.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index c19b1d83c..5b4ed213e 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -126,7 +126,9 @@ def __init__( workforce_pool_user_project=workforce_pool_user_project, ) if workforce_pool_user_project is not None: - raise ValueError("Workforce Pools are not yet supported with Pluggable Auth.") + raise ValueError( + "Workforce Pools are not yet supported with Pluggable Auth." + ) if not isinstance(credential_source, Mapping): self._credential_source_executable = None raise ValueError( @@ -177,7 +179,9 @@ def retrieve_subject_token(self, request): # If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again. subject_token = self._parse_subject_token(response) except: - raise exceptions.RefreshError("Failed to load token from the output file of the executable. Please ensure your configuration is correct, remove the output file then try again.") + raise exceptions.RefreshError( + "Failed to load token from the output file of the executable. Please ensure your configuration is correct, remove the output file then try again." + ) else: return subject_token From d0e42945a3643ee302c11fbefc209f635ac0027d Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Tue, 22 Mar 2022 18:48:41 -0700 Subject: [PATCH 08/48] Update setup.py --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 22f627b99..8ade05202 100644 --- a/setup.py +++ b/setup.py @@ -38,6 +38,7 @@ ], "pyopenssl": "pyopenssl>=20.0.0", "reauth": "pyu2f>=0.1.5", + "pytest_subprocess>=1.4.1", } with io.open("README.rst", "r") as fh: From 661827149189a86d0b8b7a5a7e858a1381d1e694 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 23 Mar 2022 09:55:28 -0700 Subject: [PATCH 09/48] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8ade05202..61e5f1bdd 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ ], "pyopenssl": "pyopenssl>=20.0.0", "reauth": "pyu2f>=0.1.5", - "pytest_subprocess>=1.4.1", + "pytest_subprocess>=1.4.1" } with io.open("README.rst", "r") as fh: From 1f38c4567b8b1523a8bf82495c4d3152daf8fc4f Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 23 Mar 2022 10:11:34 -0700 Subject: [PATCH 10/48] Update setup.py --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 61e5f1bdd..d6711ff2a 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,7 @@ ], "pyopenssl": "pyopenssl>=20.0.0", "reauth": "pyu2f>=0.1.5", - "pytest_subprocess>=1.4.1" + "pytest_subprocess": "pytest_subprocess>=1.4.1", } with io.open("README.rst", "r") as fh: From be1cfc9c6e62ebfb19193a3b00aaea8eae99c274 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 23 Mar 2022 12:35:01 -0700 Subject: [PATCH 11/48] pytest_subprocess --- setup.py | 1 - testing/requirements.txt | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index d6711ff2a..22f627b99 100644 --- a/setup.py +++ b/setup.py @@ -38,7 +38,6 @@ ], "pyopenssl": "pyopenssl>=20.0.0", "reauth": "pyu2f>=0.1.5", - "pytest_subprocess": "pytest_subprocess>=1.4.1", } with io.open("README.rst", "r") as fh: diff --git a/testing/requirements.txt b/testing/requirements.txt index df20f96d6..09aef97b9 100644 --- a/testing/requirements.txt +++ b/testing/requirements.txt @@ -13,6 +13,7 @@ urllib3 cryptography responses grpcio +pytest_subprocess # Async Dependencies pytest-asyncio; python_version > '3.0' aioresponses; python_version > '3.0' From 97bd2099eaa73fc068a656067eefccded8100cf5 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 23 Mar 2022 23:13:58 -0700 Subject: [PATCH 12/48] timeout --- google/auth/pluggable.py | 7 ++----- tests/test_pluggable.py | 15 +-------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 5b4ed213e..2342d4cbb 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -153,11 +153,8 @@ def __init__( raise ValueError("Missing command. Executable command must be provided.") if not self._credential_source_executable_timeout_millis: self._credential_source_executable_timeout_millis = 30 * 1000 - elif ( - self._credential_source_executable_timeout_millis < 5 - or self._credential_source_executable_timeout_millis > 120 - ): - raise ValueError("Timeout must be between 0 and 120 seconds.") + elif self._credential_source_executable_timeout_millis < 5 * 1000 or self._credential_source_executable_timeout_millis > 120 * 1000: + raise ValueError("Timeout must be between 5 and 120 seconds.") @_helpers.copy_docstring(external_account.Credentials) def retrieve_subject_token(self, request): diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 1617426a8..f942dda29 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -55,7 +55,7 @@ class TestCredentials(object): CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "fake_output_file" CREDENTIAL_SOURCE_EXECUTABLE = { "command": CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, - "timeout_millis": 5000, + "timeout_millis": 30000, "output_file": CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, } CREDENTIAL_SOURCE = {"executable": CREDENTIAL_SOURCE_EXECUTABLE} @@ -413,19 +413,6 @@ def test_constructor_invalid_options(self): assert excinfo.match(r"Missing credential_source") - def test_constructor_invalid_options_environment_id(self): - credential_source = { - "executable": self.CREDENTIAL_SOURCE_EXECUTABLE, - "environment_id": "aws1", - } - - with pytest.raises(ValueError) as excinfo: - self.make_pluggable(credential_source=credential_source) - - assert excinfo.match( - r"Invalid Pluggable credential_source field 'environment_id'" - ) - def test_constructor_invalid_credential_source(self): with pytest.raises(ValueError) as excinfo: self.make_pluggable(credential_source="non-dict") From 139bac90863a582afeb2ab938bb8e2e4534cdc23 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 23 Mar 2022 23:15:02 -0700 Subject: [PATCH 13/48] Update pluggable.py --- google/auth/pluggable.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 2342d4cbb..452b1a26f 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -176,9 +176,7 @@ def retrieve_subject_token(self, request): # If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again. subject_token = self._parse_subject_token(response) except: - raise exceptions.RefreshError( - "Failed to load token from the output file of the executable. Please ensure your configuration is correct, remove the output file then try again." - ) + pass else: return subject_token From 33c76670deb3e2de8afd9687e0ae7077d20a4dfc Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 23 Mar 2022 23:19:51 -0700 Subject: [PATCH 14/48] env --- google/auth/pluggable.py | 42 ++-------------------------------------- 1 file changed, 2 insertions(+), 40 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 452b1a26f..6750fb17e 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -181,17 +181,6 @@ def retrieve_subject_token(self, request): return subject_token # Inject env vars. - original_audience = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE") - os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience - original_subject_token_type = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE") - os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type - original_interactive = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE") - os.environ[ - "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE" - ] = "0" # Always set to 0 until interactive mode is implemented. - original_service_account_impersonation_url = os.getenv( - "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" - ) if self._service_account_impersonation_url is not None: os.environ[ "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" @@ -203,42 +192,15 @@ def retrieve_subject_token(self, request): os.environ[ "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" ] = self._credential_source_executable_output_file - + env = os.environ.copy() result = subprocess.run( self._credential_source_executable_command.split(), timeout=self._credential_source_executable_timeout_millis / 1000, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + env = env, ) - # Reset env vars. - if original_audience is not None: - os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = original_audience - else: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] - if original_subject_token_type is not None: - os.environ[ - "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE" - ] = self.original_subject_token_type - else: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] - if original_interactive is not None: - os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = original_interactive - else: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] - if original_service_account_impersonation_url is not None: - os.environ[ - "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" - ] = original_service_account_impersonation_url - elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL") is not None: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] - if original_credential_source_executable_output_file is not None: - os.environ[ - "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" - ] = original_credential_source_executable_output_file - elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") is not None: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] - if result.returncode != 0: raise exceptions.RefreshError( "Executable exited with non-zero return code {}. Error: {}".format( From 3894c258aaa24f5527c9b626aa55a86e6fbbb862 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Thu, 24 Mar 2022 06:41:59 +0000 Subject: [PATCH 15/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/pluggable.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 6750fb17e..aceb3982e 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -153,7 +153,10 @@ def __init__( raise ValueError("Missing command. Executable command must be provided.") if not self._credential_source_executable_timeout_millis: self._credential_source_executable_timeout_millis = 30 * 1000 - elif self._credential_source_executable_timeout_millis < 5 * 1000 or self._credential_source_executable_timeout_millis > 120 * 1000: + elif ( + self._credential_source_executable_timeout_millis < 5 * 1000 + or self._credential_source_executable_timeout_millis > 120 * 1000 + ): raise ValueError("Timeout must be between 5 and 120 seconds.") @_helpers.copy_docstring(external_account.Credentials) @@ -198,7 +201,7 @@ def retrieve_subject_token(self, request): timeout=self._credential_source_executable_timeout_millis / 1000, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, - env = env, + env=env, ) if result.returncode != 0: From 9c233dc51291d0541f29afe54cc515363dbdf80c Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Thu, 24 Mar 2022 10:26:20 -0700 Subject: [PATCH 16/48] Update _default.py --- google/auth/_default.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google/auth/_default.py b/google/auth/_default.py index 68d7cc1a1..29af16bc1 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -317,7 +317,7 @@ def _get_external_account_credentials( google.auth.exceptions.DefaultCredentialsError: if the info dictionary is in the wrong format or is missing required information. """ - # There are currently 2 types of external_account credentials. + # There are currently 3 types of external_account credentials. if info.get("subject_token_type") == _AWS_SUBJECT_TOKEN_TYPE: # Check if configuration corresponds to an AWS credentials. from google.auth import aws @@ -325,7 +325,7 @@ def _get_external_account_credentials( credentials = aws.Credentials.from_info( info, scopes=scopes, default_scopes=default_scopes ) - elif info.get("credential_source").get("executable") is not None: + elif info.get("credential_source") is not None and info.get("credential_source").get("executable") is not None: from google.auth import pluggable credentials = pluggable.Credentials.from_info( From 72ae073a0360126626702a071912b7b301887769 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Thu, 24 Mar 2022 17:28:09 +0000 Subject: [PATCH 17/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/_default.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/google/auth/_default.py b/google/auth/_default.py index 29af16bc1..d0fb4f569 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -325,7 +325,10 @@ def _get_external_account_credentials( credentials = aws.Credentials.from_info( info, scopes=scopes, default_scopes=default_scopes ) - elif info.get("credential_source") is not None and info.get("credential_source").get("executable") is not None: + elif ( + info.get("credential_source") is not None + and info.get("credential_source").get("executable") is not None + ): from google.auth import pluggable credentials = pluggable.Credentials.from_info( From d51fd90dc230afaf4294fab933682214dc1f559d Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Thu, 24 Mar 2022 10:53:19 -0700 Subject: [PATCH 18/48] Update requirements.txt --- testing/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/requirements.txt b/testing/requirements.txt index 09aef97b9..ae9bcd49b 100644 --- a/testing/requirements.txt +++ b/testing/requirements.txt @@ -13,7 +13,7 @@ urllib3 cryptography responses grpcio -pytest_subprocess +pytest-subprocess; python_version > '3.6' # Async Dependencies pytest-asyncio; python_version > '3.0' aioresponses; python_version > '3.0' From b0b9ad236264d7102785dc21b26132dc60e63fe6 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Thu, 24 Mar 2022 16:04:38 -0700 Subject: [PATCH 19/48] Update _default.py --- google/auth/_default.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/google/auth/_default.py b/google/auth/_default.py index d0fb4f569..62a4c579b 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -325,15 +325,6 @@ def _get_external_account_credentials( credentials = aws.Credentials.from_info( info, scopes=scopes, default_scopes=default_scopes ) - elif ( - info.get("credential_source") is not None - and info.get("credential_source").get("executable") is not None - ): - from google.auth import pluggable - - credentials = pluggable.Credentials.from_info( - info, scopes=scopes, default_scopes=default_scopes - ) else: try: # Check if configuration corresponds to an Identity Pool credentials. From 67127100d1cbd647ca6e6adf99b5def7e3c959ce Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Mon, 28 Mar 2022 17:04:27 -0700 Subject: [PATCH 20/48] Update pluggable.py --- google/auth/pluggable.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index aceb3982e..807103b76 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -150,7 +150,7 @@ def __init__( ) if not self._credential_source_executable_command: - raise ValueError("Missing command. Executable command must be provided.") + raise ValueError("Missing command field. Executable command must be provided.") if not self._credential_source_executable_timeout_millis: self._credential_source_executable_timeout_millis = 30 * 1000 elif ( @@ -184,18 +184,20 @@ def retrieve_subject_token(self, request): return subject_token # Inject env vars. + env = os.environ.copy() + env["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience + env["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type + env[ + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE" + ] = "0" # Always set to 0 until interactive mode is implemented. if self._service_account_impersonation_url is not None: - os.environ[ + env[ "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" ] = self.service_account_email() - original_credential_source_executable_output_file = os.getenv( - "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" - ) if self._credential_source_executable_output_file is not None: - os.environ[ + env[ "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" ] = self._credential_source_executable_output_file - env = os.environ.copy() result = subprocess.run( self._credential_source_executable_command.split(), timeout=self._credential_source_executable_timeout_millis / 1000, @@ -263,7 +265,7 @@ def from_file(cls, filename, **kwargs): return cls.from_info(data, **kwargs) def _parse_subject_token(self, response): - if response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION: + if "version" in response and response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION: raise exceptions.RefreshError( "Executable returned unsupported version {}.".format( response["version"] From 29ac7553772f989c48b39b19da36c3ac22d3ae7b Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Mon, 4 Apr 2022 22:02:31 -0700 Subject: [PATCH 21/48] Update pluggable.py --- google/auth/pluggable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 807103b76..9f838a8e1 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -20,7 +20,7 @@ provided by the external_account JSON file to retrieve credentials and exchange them for Google access tokens. -Example credential_source for pluggable credential:: +Example credential_source for pluggable credential: { "executable": { From 1abed38d8a79d144ca31a85ba55faf167128c6e6 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Mon, 4 Apr 2022 22:38:20 -0700 Subject: [PATCH 22/48] Update pluggable.py --- google/auth/pluggable.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 9f838a8e1..f95bdf12a 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -176,12 +176,16 @@ def retrieve_subject_token(self, request): self._credential_source_executable_output_file ) as output_file: response = json.load(output_file) - # If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again. - subject_token = self._parse_subject_token(response) except: pass else: - return subject_token + try: + # If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again. + subject_token = self._parse_subject_token(response) + except: + raise + else: + return subject_token # Inject env vars. env = os.environ.copy() From d276d52730241d93e25d8bbe3c92736bd72f725c Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Mon, 4 Apr 2022 23:36:32 -0700 Subject: [PATCH 23/48] Update test_pluggable.py --- tests/test_pluggable.py | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index f942dda29..ab6850ba4 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -483,7 +483,7 @@ def test_retrieve_subject_token_failed(self, fp): with pytest.raises(exceptions.RefreshError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"Executable returned unsuccessful response") + assert excinfo.match(r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized.") @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) def test_retrieve_subject_token_not_allowd(self, fp): @@ -519,7 +519,7 @@ def test_retrieve_subject_token_invalid_version(self, fp): with pytest.raises(exceptions.RefreshError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"Executable returned unsupported version") + assert excinfo.match(r"Executable returned unsupported version.") @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_expired_token(self, fp): @@ -541,7 +541,7 @@ def test_retrieve_subject_token_expired_token(self, fp): with pytest.raises(exceptions.RefreshError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"The token returned by the executable is expired") + assert excinfo.match(r"The token returned by the executable is expired.") @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_file_cache(self, fp): @@ -556,3 +556,25 @@ def test_retrieve_subject_token_file_cache(self, fp): if os.path.exists(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE): os.remove(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_unsupported_token_type(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "token_type": "unsupported_token_type", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executable returned unsupported token type.") \ No newline at end of file From c9d304de9c8820e5ab78bb09cd384b274dceabe1 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Tue, 5 Apr 2022 17:21:46 -0700 Subject: [PATCH 24/48] format validations --- google/auth/pluggable.py | 14 +++-- tests/test_pluggable.py | 109 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 118 insertions(+), 5 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index f95bdf12a..2acd67c2f 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -269,24 +269,32 @@ def from_file(cls, filename, **kwargs): return cls.from_info(data, **kwargs) def _parse_subject_token(self, response): - if "version" in response and response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION: + if not "version" in response: + raise ValueError("The executable response is missing the version field.") + if response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION: raise exceptions.RefreshError( "Executable returned unsupported version {}.".format( response["version"] ) ) + if not "success" in response: + raise ValueError("The executable response is missing the success field.") if not response["success"]: - if not response["code"] or not response["message"]: - raise ValueError("Code and message are required in the response.") + if "code" not in response or "message" not in response: + raise ValueError("Error code and message fields are required in the response.") raise exceptions.RefreshError( "Executable returned unsuccessful response: code: {}, message: {}.".format( response["code"], response["message"] ) ) + if not "expiration_time" in response: + raise ValueError("The executable response is missing the expiration_time field.") if response["expiration_time"] < time.time(): raise exceptions.RefreshError( "The token returned by the executable is expired." ) + if not "token_type" in response: + raise ValueError("The executable response is missing the token_type field.") if ( response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" or response["token_type"] == "urn:ietf:params:oauth:token-type:id_token" diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index ab6850ba4..55f037b29 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -437,6 +437,7 @@ def test_retrieve_subject_token_oidc_id_token(self, fp): fp.register( self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), + ) credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) @@ -520,7 +521,7 @@ def test_retrieve_subject_token_invalid_version(self, fp): subject_token = credentials.retrieve_subject_token(None) assert excinfo.match(r"Executable returned unsupported version.") - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_expired_token(self, fp): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED = { @@ -577,4 +578,108 @@ def test_retrieve_subject_token_unsupported_token_type(self, fp): with pytest.raises(exceptions.RefreshError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"Executable returned unsupported token type.") \ No newline at end of file + assert excinfo.match(r"Executable returned unsupported token type.") + + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_version(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The executable response is missing the version field.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_success(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The executable response is missing the success field.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_error_code_message(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": False, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Error code and message fields are required in the response.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_expiration_time(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The executable response is missing the expiration_time field.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_token_type(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The executable response is missing the token_type field.") \ No newline at end of file From ac6c36072084a440c234a9465b35462bd52378b3 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Tue, 19 Apr 2022 15:13:09 -0700 Subject: [PATCH 25/48] Update _default.py --- google/auth/_default.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/google/auth/_default.py b/google/auth/_default.py index 62a4c579b..d0fb4f569 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -325,6 +325,15 @@ def _get_external_account_credentials( credentials = aws.Credentials.from_info( info, scopes=scopes, default_scopes=default_scopes ) + elif ( + info.get("credential_source") is not None + and info.get("credential_source").get("executable") is not None + ): + from google.auth import pluggable + + credentials = pluggable.Credentials.from_info( + info, scopes=scopes, default_scopes=default_scopes + ) else: try: # Check if configuration corresponds to an Identity Pool credentials. From decb4124a5a4bd544316e7b03047048f8726f883 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Wed, 20 Apr 2022 04:34:33 +0000 Subject: [PATCH 26/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/pluggable.py | 12 +++++++++--- tests/test_pluggable.py | 35 +++++++++++++++++++---------------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 2acd67c2f..76f088d24 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -150,7 +150,9 @@ def __init__( ) if not self._credential_source_executable_command: - raise ValueError("Missing command field. Executable command must be provided.") + raise ValueError( + "Missing command field. Executable command must be provided." + ) if not self._credential_source_executable_timeout_millis: self._credential_source_executable_timeout_millis = 30 * 1000 elif ( @@ -281,14 +283,18 @@ def _parse_subject_token(self, response): raise ValueError("The executable response is missing the success field.") if not response["success"]: if "code" not in response or "message" not in response: - raise ValueError("Error code and message fields are required in the response.") + raise ValueError( + "Error code and message fields are required in the response." + ) raise exceptions.RefreshError( "Executable returned unsuccessful response: code: {}, message: {}.".format( response["code"], response["message"] ) ) if not "expiration_time" in response: - raise ValueError("The executable response is missing the expiration_time field.") + raise ValueError( + "The executable response is missing the expiration_time field." + ) if response["expiration_time"] < time.time(): raise exceptions.RefreshError( "The token returned by the executable is expired." diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 55f037b29..2945c375b 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -437,7 +437,6 @@ def test_retrieve_subject_token_oidc_id_token(self, fp): fp.register( self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), - ) credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) @@ -484,7 +483,9 @@ def test_retrieve_subject_token_failed(self, fp): with pytest.raises(exceptions.RefreshError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized.") + assert excinfo.match( + r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." + ) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) def test_retrieve_subject_token_not_allowd(self, fp): @@ -521,7 +522,7 @@ def test_retrieve_subject_token_invalid_version(self, fp): subject_token = credentials.retrieve_subject_token(None) assert excinfo.match(r"Executable returned unsupported version.") - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_expired_token(self, fp): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED = { @@ -579,8 +580,7 @@ def test_retrieve_subject_token_unsupported_token_type(self, fp): subject_token = credentials.retrieve_subject_token(None) assert excinfo.match(r"Executable returned unsupported token type.") - - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_missing_version(self, fp): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { @@ -601,7 +601,7 @@ def test_retrieve_subject_token_missing_version(self, fp): subject_token = credentials.retrieve_subject_token(None) assert excinfo.match(r"The executable response is missing the version field.") - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_missing_success(self, fp): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { @@ -622,13 +622,10 @@ def test_retrieve_subject_token_missing_success(self, fp): subject_token = credentials.retrieve_subject_token(None) assert excinfo.match(r"The executable response is missing the success field.") - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_missing_error_code_message(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { - "version": 1, - "success": False, - } + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = {"version": 1, "success": False} fp.register( self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), @@ -640,8 +637,10 @@ def test_retrieve_subject_token_missing_error_code_message(self, fp): with pytest.raises(ValueError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"Error code and message fields are required in the response.") - + assert excinfo.match( + r"Error code and message fields are required in the response." + ) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_missing_expiration_time(self, fp): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { @@ -661,8 +660,10 @@ def test_retrieve_subject_token_missing_expiration_time(self, fp): with pytest.raises(ValueError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"The executable response is missing the expiration_time field.") - + assert excinfo.match( + r"The executable response is missing the expiration_time field." + ) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_missing_token_type(self, fp): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { @@ -682,4 +683,6 @@ def test_retrieve_subject_token_missing_token_type(self, fp): with pytest.raises(ValueError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"The executable response is missing the token_type field.") \ No newline at end of file + assert excinfo.match( + r"The executable response is missing the token_type field." + ) From 1c9b6db25c683663ed4b71ab0ab39946fce8f6eb Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Tue, 19 Apr 2022 23:18:59 -0700 Subject: [PATCH 27/48] Update requirements.txt --- testing/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/requirements.txt b/testing/requirements.txt index ae9bcd49b..ff4ab0056 100644 --- a/testing/requirements.txt +++ b/testing/requirements.txt @@ -13,7 +13,7 @@ urllib3 cryptography responses grpcio -pytest-subprocess; python_version > '3.6' +pytest-subprocess # Async Dependencies pytest-asyncio; python_version > '3.0' aioresponses; python_version > '3.0' From a7efb54399678d6af65e7ea26990b284b2f445c3 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Tue, 19 Apr 2022 23:36:59 -0700 Subject: [PATCH 28/48] Revert "Update requirements.txt" This reverts commit 1c9b6db25c683663ed4b71ab0ab39946fce8f6eb. --- testing/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/requirements.txt b/testing/requirements.txt index ff4ab0056..ae9bcd49b 100644 --- a/testing/requirements.txt +++ b/testing/requirements.txt @@ -13,7 +13,7 @@ urllib3 cryptography responses grpcio -pytest-subprocess +pytest-subprocess; python_version > '3.6' # Async Dependencies pytest-asyncio; python_version > '3.0' aioresponses; python_version > '3.0' From 1c08483586007e4caf1a36f2c9cbf2a45d403ee0 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 20 Apr 2022 00:15:15 -0700 Subject: [PATCH 29/48] Revert "Update _default.py" This reverts commit ac6c36072084a440c234a9465b35462bd52378b3. --- google/auth/_default.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/google/auth/_default.py b/google/auth/_default.py index dc54c44b6..638083f9a 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -325,15 +325,6 @@ def _get_external_account_credentials( credentials = aws.Credentials.from_info( info, scopes=scopes, default_scopes=default_scopes ) - elif ( - info.get("credential_source") is not None - and info.get("credential_source").get("executable") is not None - ): - from google.auth import pluggable - - credentials = pluggable.Credentials.from_info( - info, scopes=scopes, default_scopes=default_scopes - ) else: try: # Check if configuration corresponds to an Identity Pool credentials. From adc6779a5399c4aa52634fbc279b0e74783bec7c Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 20 Apr 2022 00:15:24 -0700 Subject: [PATCH 30/48] Revert "Revert "Update _default.py"" This reverts commit 1c08483586007e4caf1a36f2c9cbf2a45d403ee0. --- google/auth/_default.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/google/auth/_default.py b/google/auth/_default.py index 638083f9a..dc54c44b6 100644 --- a/google/auth/_default.py +++ b/google/auth/_default.py @@ -325,6 +325,15 @@ def _get_external_account_credentials( credentials = aws.Credentials.from_info( info, scopes=scopes, default_scopes=default_scopes ) + elif ( + info.get("credential_source") is not None + and info.get("credential_source").get("executable") is not None + ): + from google.auth import pluggable + + credentials = pluggable.Credentials.from_info( + info, scopes=scopes, default_scopes=default_scopes + ) else: try: # Check if configuration corresponds to an Identity Pool credentials. From 889bf32fdc2fc7191226bc744e9b832507bcbf90 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Thu, 28 Apr 2022 18:17:01 -0700 Subject: [PATCH 31/48] Raise output format error but retry parsing token if `success` is 0 --- google/auth/pluggable.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 76f088d24..e8db043df 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -184,8 +184,10 @@ def retrieve_subject_token(self, request): try: # If the cached response is expired, _parse_subject_token will raise an error which will be ignored and we will call the executable again. subject_token = self._parse_subject_token(response) - except: + except ValueError: raise + except exceptions.RefreshError: + pass else: return subject_token @@ -219,9 +221,14 @@ def retrieve_subject_token(self, request): ) ) else: - data = result.stdout.decode("utf-8") - response = json.loads(data) - return self._parse_subject_token(response) + try: + data = result.stdout.decode("utf-8") + response = json.loads(data) + subject_token = self._parse_subject_token(response) + except: + raise + else: + return subject_token @classmethod def from_info(cls, info, **kwargs): From e9db21cf3b5902a985e655b5e21b7466f2ff8b60 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Fri, 29 Apr 2022 01:18:47 +0000 Subject: [PATCH 32/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/pluggable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index e8db043df..dd53dfdf8 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -226,7 +226,7 @@ def retrieve_subject_token(self, request): response = json.loads(data) subject_token = self._parse_subject_token(response) except: - raise + raise else: return subject_token From e1edbb62ead57a64b172ffc23313a15c1a70fefd Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Mon, 2 May 2022 23:20:35 -0700 Subject: [PATCH 33/48] Update requirements.txt --- testing/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/requirements.txt b/testing/requirements.txt index ae9bcd49b..786e30029 100644 --- a/testing/requirements.txt +++ b/testing/requirements.txt @@ -13,7 +13,7 @@ urllib3 cryptography responses grpcio -pytest-subprocess; python_version > '3.6' +pytest-subprocess; python_version > '3.5' # Async Dependencies pytest-asyncio; python_version > '3.0' aioresponses; python_version > '3.0' From 74beba9405564a5b764af8718c49e640d9b84c5f Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Mon, 2 May 2022 23:52:52 -0700 Subject: [PATCH 34/48] Delete test_pluggable.py --- tests/test_pluggable.py | 688 ---------------------------------------- 1 file changed, 688 deletions(-) delete mode 100644 tests/test_pluggable.py diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py deleted file mode 100644 index 2945c375b..000000000 --- a/tests/test_pluggable.py +++ /dev/null @@ -1,688 +0,0 @@ -# Copyright 2022 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import datetime -import json -import os - -import mock -import pytest # type: ignore -import subprocess -import pytest_subprocess -from six.moves import http_client -from six.moves import urllib - -from google.auth import _helpers -from google.auth import exceptions -from google.auth import identity_pool -from google.auth import pluggable -from google.auth import transport - - -CLIENT_ID = "username" -CLIENT_SECRET = "password" -# Base64 encoding of "username:password". -BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" -SERVICE_ACCOUNT_EMAIL = "service-1234@service-name.iam.gserviceaccount.com" -SERVICE_ACCOUNT_IMPERSONATION_URL = ( - "https://us-east1-iamcredentials.googleapis.com/v1/projects/-" - + "/serviceAccounts/{}:generateAccessToken".format(SERVICE_ACCOUNT_EMAIL) -) -QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" -SCOPES = ["scope1", "scope2"] -SUBJECT_TOKEN_FIELD_NAME = "access_token" - -TOKEN_URL = "https://sts.googleapis.com/v1/token" -SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" -AUDIENCE = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID" - - -class TestCredentials(object): - CREDENTIAL_SOURCE_EXECUTABLE_COMMAND = ( - "/fake/external/excutable --arg1=value1 --arg2=value2" - ) - CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "fake_output_file" - CREDENTIAL_SOURCE_EXECUTABLE = { - "command": CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, - "timeout_millis": 30000, - "output_file": CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, - } - CREDENTIAL_SOURCE = {"executable": CREDENTIAL_SOURCE_EXECUTABLE} - EXECUTABLE_OIDC_TOKEN = "FAKE_ID_TOKEN" - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN = { - "version": 1, - "success": True, - "token_type": "urn:ietf:params:oauth:token-type:id_token", - "id_token": EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999, - } - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT = { - "version": 1, - "success": True, - "token_type": "urn:ietf:params:oauth:token-type:jwt", - "id_token": EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999, - } - EXECUTABLE_SAML_TOKEN = "FAKE_SAML_RESPONSE" - EXECUTABLE_SUCCESSFUL_SAML_RESPONSE = { - "version": 1, - "success": True, - "token_type": "urn:ietf:params:oauth:token-type:saml2", - "saml_response": EXECUTABLE_SAML_TOKEN, - "expiration_time": 9999999999, - } - EXECUTABLE_FAILED_RESPONSE = { - "version": 1, - "success": False, - "code": "401", - "message": "Permission denied. Caller not authorized", - } - CREDENTIAL_URL = "http://fakeurl.com" - - @classmethod - def make_mock_response(cls, status, data): - response = mock.create_autospec(transport.Response, instance=True) - response.status = status - if isinstance(data, dict): - response.data = json.dumps(data).encode("utf-8") - else: - response.data = data - return response - - @classmethod - def make_mock_request( - cls, token_status=http_client.OK, token_data=None, *extra_requests - ): - responses = [] - responses.append(cls.make_mock_response(token_status, token_data)) - - while len(extra_requests) > 0: - # If service account impersonation is requested, mock the expected response. - status, data, extra_requests = ( - extra_requests[0], - extra_requests[1], - extra_requests[2:], - ) - responses.append(cls.make_mock_response(status, data)) - - request = mock.create_autospec(transport.Request) - request.side_effect = responses - - return request - - @classmethod - def assert_credential_request_kwargs( - cls, request_kwargs, headers, url=CREDENTIAL_URL - ): - assert request_kwargs["url"] == url - assert request_kwargs["method"] == "GET" - assert request_kwargs["headers"] == headers - assert request_kwargs.get("body", None) is None - - @classmethod - def assert_token_request_kwargs( - cls, request_kwargs, headers, request_data, token_url=TOKEN_URL - ): - assert request_kwargs["url"] == token_url - assert request_kwargs["method"] == "POST" - assert request_kwargs["headers"] == headers - assert request_kwargs["body"] is not None - body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) - assert len(body_tuples) == len(request_data.keys()) - for (k, v) in body_tuples: - assert v.decode("utf-8") == request_data[k.decode("utf-8")] - - @classmethod - def assert_impersonation_request_kwargs( - cls, - request_kwargs, - headers, - request_data, - service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, - ): - assert request_kwargs["url"] == service_account_impersonation_url - assert request_kwargs["method"] == "POST" - assert request_kwargs["headers"] == headers - assert request_kwargs["body"] is not None - body_json = json.loads(request_kwargs["body"].decode("utf-8")) - assert body_json == request_data - - @classmethod - def assert_underlying_credentials_refresh( - cls, - credentials, - audience, - subject_token, - subject_token_type, - token_url, - service_account_impersonation_url=None, - basic_auth_encoding=None, - quota_project_id=None, - used_scopes=None, - credential_data=None, - scopes=None, - default_scopes=None, - workforce_pool_user_project=None, - ): - """Utility to assert that a credentials are initialized with the expected - attributes by calling refresh functionality and confirming response matches - expected one and that the underlying requests were populated with the - expected parameters. - """ - # STS token exchange request/response. - token_response = cls.SUCCESS_RESPONSE.copy() - token_headers = {"Content-Type": "application/x-www-form-urlencoded"} - if basic_auth_encoding: - token_headers["Authorization"] = "Basic " + basic_auth_encoding - - if service_account_impersonation_url: - token_scopes = "https://www.googleapis.com/auth/iam" - else: - token_scopes = " ".join(used_scopes or []) - - token_request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": audience, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "scope": token_scopes, - "subject_token": subject_token, - "subject_token_type": subject_token_type, - } - if workforce_pool_user_project: - token_request_data["options"] = urllib.parse.quote( - json.dumps({"userProject": workforce_pool_user_project}) - ) - - if service_account_impersonation_url: - # Service account impersonation request/response. - expire_time = ( - _helpers.utcnow().replace(microsecond=0) - + datetime.timedelta(seconds=3600) - ).isoformat("T") + "Z" - impersonation_response = { - "accessToken": "SA_ACCESS_TOKEN", - "expireTime": expire_time, - } - impersonation_headers = { - "Content-Type": "application/json", - "authorization": "Bearer {}".format(token_response["access_token"]), - } - impersonation_request_data = { - "delegates": None, - "scope": used_scopes, - "lifetime": "3600s", - } - - # Initialize mock request to handle token retrieval, token exchange and - # service account impersonation request. - requests = [] - if credential_data: - requests.append((http_client.OK, credential_data)) - - token_request_index = len(requests) - requests.append((http_client.OK, token_response)) - - if service_account_impersonation_url: - impersonation_request_index = len(requests) - requests.append((http_client.OK, impersonation_response)) - - request = cls.make_mock_request(*[el for req in requests for el in req]) - - credentials.refresh(request) - - assert len(request.call_args_list) == len(requests) - if credential_data: - cls.assert_credential_request_kwargs(request.call_args_list[0][1], None) - # Verify token exchange request parameters. - cls.assert_token_request_kwargs( - request.call_args_list[token_request_index][1], - token_headers, - token_request_data, - token_url, - ) - # Verify service account impersonation request parameters if the request - # is processed. - if service_account_impersonation_url: - cls.assert_impersonation_request_kwargs( - request.call_args_list[impersonation_request_index][1], - impersonation_headers, - impersonation_request_data, - service_account_impersonation_url, - ) - assert credentials.token == impersonation_response["accessToken"] - else: - assert credentials.token == token_response["access_token"] - assert credentials.quota_project_id == quota_project_id - assert credentials.scopes == scopes - assert credentials.default_scopes == default_scopes - - @classmethod - def make_pluggable( - cls, - audience=AUDIENCE, - subject_token_type=SUBJECT_TOKEN_TYPE, - client_id=None, - client_secret=None, - quota_project_id=None, - scopes=None, - default_scopes=None, - service_account_impersonation_url=None, - credential_source=None, - workforce_pool_user_project=None, - ): - return pluggable.Credentials( - audience=audience, - subject_token_type=subject_token_type, - token_url=TOKEN_URL, - service_account_impersonation_url=service_account_impersonation_url, - credential_source=credential_source, - client_id=client_id, - client_secret=client_secret, - quota_project_id=quota_project_id, - scopes=scopes, - default_scopes=default_scopes, - workforce_pool_user_project=workforce_pool_user_project, - ) - - @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) - def test_from_info_full_options(self, mock_init): - credentials = pluggable.Credentials.from_info( - { - "audience": AUDIENCE, - "subject_token_type": SUBJECT_TOKEN_TYPE, - "token_url": TOKEN_URL, - "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "quota_project_id": QUOTA_PROJECT_ID, - "credential_source": self.CREDENTIAL_SOURCE, - } - ) - - # Confirm pluggable.Credentials instantiated with expected attributes. - assert isinstance(credentials, pluggable.Credentials) - mock_init.assert_called_once_with( - audience=AUDIENCE, - subject_token_type=SUBJECT_TOKEN_TYPE, - token_url=TOKEN_URL, - service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - credential_source=self.CREDENTIAL_SOURCE, - quota_project_id=QUOTA_PROJECT_ID, - workforce_pool_user_project=None, - ) - - @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) - def test_from_info_required_options_only(self, mock_init): - credentials = pluggable.Credentials.from_info( - { - "audience": AUDIENCE, - "subject_token_type": SUBJECT_TOKEN_TYPE, - "token_url": TOKEN_URL, - "credential_source": self.CREDENTIAL_SOURCE, - } - ) - - # Confirm pluggable.Credentials instantiated with expected attributes. - assert isinstance(credentials, pluggable.Credentials) - mock_init.assert_called_once_with( - audience=AUDIENCE, - subject_token_type=SUBJECT_TOKEN_TYPE, - token_url=TOKEN_URL, - service_account_impersonation_url=None, - client_id=None, - client_secret=None, - credential_source=self.CREDENTIAL_SOURCE, - quota_project_id=None, - workforce_pool_user_project=None, - ) - - @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) - def test_from_file_full_options(self, mock_init, tmpdir): - info = { - "audience": AUDIENCE, - "subject_token_type": SUBJECT_TOKEN_TYPE, - "token_url": TOKEN_URL, - "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, - "client_id": CLIENT_ID, - "client_secret": CLIENT_SECRET, - "quota_project_id": QUOTA_PROJECT_ID, - "credential_source": self.CREDENTIAL_SOURCE, - } - config_file = tmpdir.join("config.json") - config_file.write(json.dumps(info)) - credentials = pluggable.Credentials.from_file(str(config_file)) - - # Confirm pluggable.Credentials instantiated with expected attributes. - assert isinstance(credentials, pluggable.Credentials) - mock_init.assert_called_once_with( - audience=AUDIENCE, - subject_token_type=SUBJECT_TOKEN_TYPE, - token_url=TOKEN_URL, - service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, - client_id=CLIENT_ID, - client_secret=CLIENT_SECRET, - credential_source=self.CREDENTIAL_SOURCE, - quota_project_id=QUOTA_PROJECT_ID, - workforce_pool_user_project=None, - ) - - @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) - def test_from_file_required_options_only(self, mock_init, tmpdir): - info = { - "audience": AUDIENCE, - "subject_token_type": SUBJECT_TOKEN_TYPE, - "token_url": TOKEN_URL, - "credential_source": self.CREDENTIAL_SOURCE, - } - config_file = tmpdir.join("config.json") - config_file.write(json.dumps(info)) - credentials = pluggable.Credentials.from_file(str(config_file)) - - # Confirm pluggable.Credentials instantiated with expected attributes. - assert isinstance(credentials, pluggable.Credentials) - mock_init.assert_called_once_with( - audience=AUDIENCE, - subject_token_type=SUBJECT_TOKEN_TYPE, - token_url=TOKEN_URL, - service_account_impersonation_url=None, - client_id=None, - client_secret=None, - credential_source=self.CREDENTIAL_SOURCE, - quota_project_id=None, - workforce_pool_user_project=None, - ) - - def test_constructor_invalid_options(self): - credential_source = {"unsupported": "value"} - - with pytest.raises(ValueError) as excinfo: - self.make_pluggable(credential_source=credential_source) - - assert excinfo.match(r"Missing credential_source") - - def test_constructor_invalid_credential_source(self): - with pytest.raises(ValueError) as excinfo: - self.make_pluggable(credential_source="non-dict") - - assert excinfo.match(r"Missing credential_source") - - def test_info_with_credential_source(self): - credentials = self.make_pluggable( - credential_source=self.CREDENTIAL_SOURCE.copy() - ) - - assert credentials.info == { - "type": "external_account", - "audience": AUDIENCE, - "subject_token_type": SUBJECT_TOKEN_TYPE, - "token_url": TOKEN_URL, - "credential_source": self.CREDENTIAL_SOURCE, - } - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_oidc_id_token(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - subject_token = credentials.retrieve_subject_token(None) - - assert subject_token == self.EXECUTABLE_OIDC_TOKEN - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_oidc_jwt(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - subject_token = credentials.retrieve_subject_token(None) - - assert subject_token == self.EXECUTABLE_OIDC_TOKEN - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_saml(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - subject_token = credentials.retrieve_subject_token(None) - - assert subject_token == self.EXECUTABLE_SAML_TOKEN - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_failed(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_FAILED_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match( - r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." - ) - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) - def test_retrieve_subject_token_not_allowd(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match(r"Executables need to be explicitly allowed") - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_invalid_version(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2 = { - "version": 2, - "success": True, - "token_type": "urn:ietf:params:oauth:token-type:id_token", - "id_token": self.EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999, - } - - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match(r"Executable returned unsupported version.") - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_expired_token(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED = { - "version": 1, - "success": True, - "token_type": "urn:ietf:params:oauth:token-type:id_token", - "id_token": self.EXECUTABLE_OIDC_TOKEN, - "expiration_time": 0, - } - - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match(r"The token returned by the executable is expired.") - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_file_cache(self, fp): - with open(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: - json.dump(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN, output_file) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - subject_token = credentials.retrieve_subject_token(None) - - assert subject_token == self.EXECUTABLE_OIDC_TOKEN - - if os.path.exists(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE): - os.remove(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_unsupported_token_type(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { - "version": 1, - "success": True, - "token_type": "unsupported_token_type", - "id_token": self.EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999, - } - - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match(r"Executable returned unsupported token type.") - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_version(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { - "success": True, - "token_type": "urn:ietf:params:oauth:token-type:id_token", - "id_token": self.EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999, - } - - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match(r"The executable response is missing the version field.") - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_success(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { - "version": 1, - "token_type": "urn:ietf:params:oauth:token-type:id_token", - "id_token": self.EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999, - } - - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match(r"The executable response is missing the success field.") - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_error_code_message(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = {"version": 1, "success": False} - - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match( - r"Error code and message fields are required in the response." - ) - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_expiration_time(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { - "version": 1, - "success": True, - "token_type": "urn:ietf:params:oauth:token-type:id_token", - "id_token": self.EXECUTABLE_OIDC_TOKEN, - } - - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match( - r"The executable response is missing the expiration_time field." - ) - - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_token_type(self, fp): - EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { - "version": 1, - "success": True, - "id_token": self.EXECUTABLE_OIDC_TOKEN, - "expiration_time": 9999999999, - } - - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match( - r"The executable response is missing the token_type field." - ) From ac697a2258abe797f95ebb80c06f08ae22f4ea39 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Tue, 3 May 2022 23:07:29 -0700 Subject: [PATCH 35/48] Revert "Delete test_pluggable.py" This reverts commit 74beba9405564a5b764af8718c49e640d9b84c5f. --- tests/test_pluggable.py | 688 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 688 insertions(+) create mode 100644 tests/test_pluggable.py diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py new file mode 100644 index 000000000..2945c375b --- /dev/null +++ b/tests/test_pluggable.py @@ -0,0 +1,688 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import datetime +import json +import os + +import mock +import pytest # type: ignore +import subprocess +import pytest_subprocess +from six.moves import http_client +from six.moves import urllib + +from google.auth import _helpers +from google.auth import exceptions +from google.auth import identity_pool +from google.auth import pluggable +from google.auth import transport + + +CLIENT_ID = "username" +CLIENT_SECRET = "password" +# Base64 encoding of "username:password". +BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ=" +SERVICE_ACCOUNT_EMAIL = "service-1234@service-name.iam.gserviceaccount.com" +SERVICE_ACCOUNT_IMPERSONATION_URL = ( + "https://us-east1-iamcredentials.googleapis.com/v1/projects/-" + + "/serviceAccounts/{}:generateAccessToken".format(SERVICE_ACCOUNT_EMAIL) +) +QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" +SCOPES = ["scope1", "scope2"] +SUBJECT_TOKEN_FIELD_NAME = "access_token" + +TOKEN_URL = "https://sts.googleapis.com/v1/token" +SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" +AUDIENCE = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID" + + +class TestCredentials(object): + CREDENTIAL_SOURCE_EXECUTABLE_COMMAND = ( + "/fake/external/excutable --arg1=value1 --arg2=value2" + ) + CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "fake_output_file" + CREDENTIAL_SOURCE_EXECUTABLE = { + "command": CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "timeout_millis": 30000, + "output_file": CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + CREDENTIAL_SOURCE = {"executable": CREDENTIAL_SOURCE_EXECUTABLE} + EXECUTABLE_OIDC_TOKEN = "FAKE_ID_TOKEN" + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:jwt", + "id_token": EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + EXECUTABLE_SAML_TOKEN = "FAKE_SAML_RESPONSE" + EXECUTABLE_SUCCESSFUL_SAML_RESPONSE = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:saml2", + "saml_response": EXECUTABLE_SAML_TOKEN, + "expiration_time": 9999999999, + } + EXECUTABLE_FAILED_RESPONSE = { + "version": 1, + "success": False, + "code": "401", + "message": "Permission denied. Caller not authorized", + } + CREDENTIAL_URL = "http://fakeurl.com" + + @classmethod + def make_mock_response(cls, status, data): + response = mock.create_autospec(transport.Response, instance=True) + response.status = status + if isinstance(data, dict): + response.data = json.dumps(data).encode("utf-8") + else: + response.data = data + return response + + @classmethod + def make_mock_request( + cls, token_status=http_client.OK, token_data=None, *extra_requests + ): + responses = [] + responses.append(cls.make_mock_response(token_status, token_data)) + + while len(extra_requests) > 0: + # If service account impersonation is requested, mock the expected response. + status, data, extra_requests = ( + extra_requests[0], + extra_requests[1], + extra_requests[2:], + ) + responses.append(cls.make_mock_response(status, data)) + + request = mock.create_autospec(transport.Request) + request.side_effect = responses + + return request + + @classmethod + def assert_credential_request_kwargs( + cls, request_kwargs, headers, url=CREDENTIAL_URL + ): + assert request_kwargs["url"] == url + assert request_kwargs["method"] == "GET" + assert request_kwargs["headers"] == headers + assert request_kwargs.get("body", None) is None + + @classmethod + def assert_token_request_kwargs( + cls, request_kwargs, headers, request_data, token_url=TOKEN_URL + ): + assert request_kwargs["url"] == token_url + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) + assert len(body_tuples) == len(request_data.keys()) + for (k, v) in body_tuples: + assert v.decode("utf-8") == request_data[k.decode("utf-8")] + + @classmethod + def assert_impersonation_request_kwargs( + cls, + request_kwargs, + headers, + request_data, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + ): + assert request_kwargs["url"] == service_account_impersonation_url + assert request_kwargs["method"] == "POST" + assert request_kwargs["headers"] == headers + assert request_kwargs["body"] is not None + body_json = json.loads(request_kwargs["body"].decode("utf-8")) + assert body_json == request_data + + @classmethod + def assert_underlying_credentials_refresh( + cls, + credentials, + audience, + subject_token, + subject_token_type, + token_url, + service_account_impersonation_url=None, + basic_auth_encoding=None, + quota_project_id=None, + used_scopes=None, + credential_data=None, + scopes=None, + default_scopes=None, + workforce_pool_user_project=None, + ): + """Utility to assert that a credentials are initialized with the expected + attributes by calling refresh functionality and confirming response matches + expected one and that the underlying requests were populated with the + expected parameters. + """ + # STS token exchange request/response. + token_response = cls.SUCCESS_RESPONSE.copy() + token_headers = {"Content-Type": "application/x-www-form-urlencoded"} + if basic_auth_encoding: + token_headers["Authorization"] = "Basic " + basic_auth_encoding + + if service_account_impersonation_url: + token_scopes = "https://www.googleapis.com/auth/iam" + else: + token_scopes = " ".join(used_scopes or []) + + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": audience, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "scope": token_scopes, + "subject_token": subject_token, + "subject_token_type": subject_token_type, + } + if workforce_pool_user_project: + token_request_data["options"] = urllib.parse.quote( + json.dumps({"userProject": workforce_pool_user_project}) + ) + + if service_account_impersonation_url: + # Service account impersonation request/response. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + + datetime.timedelta(seconds=3600) + ).isoformat("T") + "Z" + impersonation_response = { + "accessToken": "SA_ACCESS_TOKEN", + "expireTime": expire_time, + } + impersonation_headers = { + "Content-Type": "application/json", + "authorization": "Bearer {}".format(token_response["access_token"]), + } + impersonation_request_data = { + "delegates": None, + "scope": used_scopes, + "lifetime": "3600s", + } + + # Initialize mock request to handle token retrieval, token exchange and + # service account impersonation request. + requests = [] + if credential_data: + requests.append((http_client.OK, credential_data)) + + token_request_index = len(requests) + requests.append((http_client.OK, token_response)) + + if service_account_impersonation_url: + impersonation_request_index = len(requests) + requests.append((http_client.OK, impersonation_response)) + + request = cls.make_mock_request(*[el for req in requests for el in req]) + + credentials.refresh(request) + + assert len(request.call_args_list) == len(requests) + if credential_data: + cls.assert_credential_request_kwargs(request.call_args_list[0][1], None) + # Verify token exchange request parameters. + cls.assert_token_request_kwargs( + request.call_args_list[token_request_index][1], + token_headers, + token_request_data, + token_url, + ) + # Verify service account impersonation request parameters if the request + # is processed. + if service_account_impersonation_url: + cls.assert_impersonation_request_kwargs( + request.call_args_list[impersonation_request_index][1], + impersonation_headers, + impersonation_request_data, + service_account_impersonation_url, + ) + assert credentials.token == impersonation_response["accessToken"] + else: + assert credentials.token == token_response["access_token"] + assert credentials.quota_project_id == quota_project_id + assert credentials.scopes == scopes + assert credentials.default_scopes == default_scopes + + @classmethod + def make_pluggable( + cls, + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + client_id=None, + client_secret=None, + quota_project_id=None, + scopes=None, + default_scopes=None, + service_account_impersonation_url=None, + credential_source=None, + workforce_pool_user_project=None, + ): + return pluggable.Credentials( + audience=audience, + subject_token_type=subject_token_type, + token_url=TOKEN_URL, + service_account_impersonation_url=service_account_impersonation_url, + credential_source=credential_source, + client_id=client_id, + client_secret=client_secret, + quota_project_id=quota_project_id, + scopes=scopes, + default_scopes=default_scopes, + workforce_pool_user_project=workforce_pool_user_project, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_info_full_options(self, mock_init): + credentials = pluggable.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE, + } + ) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_info_required_options_only(self, mock_init): + credentials = pluggable.Credentials.from_info( + { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + ) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=None, + workforce_pool_user_project=None, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_file_full_options(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "client_id": CLIENT_ID, + "client_secret": CLIENT_SECRET, + "quota_project_id": QUOTA_PROJECT_ID, + "credential_source": self.CREDENTIAL_SOURCE, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = pluggable.Credentials.from_file(str(config_file)) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + client_id=CLIENT_ID, + client_secret=CLIENT_SECRET, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=QUOTA_PROJECT_ID, + workforce_pool_user_project=None, + ) + + @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) + def test_from_file_required_options_only(self, mock_init, tmpdir): + info = { + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(info)) + credentials = pluggable.Credentials.from_file(str(config_file)) + + # Confirm pluggable.Credentials instantiated with expected attributes. + assert isinstance(credentials, pluggable.Credentials) + mock_init.assert_called_once_with( + audience=AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + service_account_impersonation_url=None, + client_id=None, + client_secret=None, + credential_source=self.CREDENTIAL_SOURCE, + quota_project_id=None, + workforce_pool_user_project=None, + ) + + def test_constructor_invalid_options(self): + credential_source = {"unsupported": "value"} + + with pytest.raises(ValueError) as excinfo: + self.make_pluggable(credential_source=credential_source) + + assert excinfo.match(r"Missing credential_source") + + def test_constructor_invalid_credential_source(self): + with pytest.raises(ValueError) as excinfo: + self.make_pluggable(credential_source="non-dict") + + assert excinfo.match(r"Missing credential_source") + + def test_info_with_credential_source(self): + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE.copy() + ) + + assert credentials.info == { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": SUBJECT_TOKEN_TYPE, + "token_url": TOKEN_URL, + "credential_source": self.CREDENTIAL_SOURCE, + } + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_oidc_id_token(self, fp): + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_oidc_jwt(self, fp): + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_saml(self, fp): + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_SAML_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_failed(self, fp): + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_FAILED_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) + def test_retrieve_subject_token_not_allowd(self, fp): + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executables need to be explicitly allowed") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_invalid_version(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2 = { + "version": 2, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executable returned unsupported version.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_expired_token(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 0, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The token returned by the executable is expired.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_file_cache(self, fp): + with open(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: + json.dump(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN, output_file) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + if os.path.exists(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE): + os.remove(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_unsupported_token_type(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "token_type": "unsupported_token_type", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Executable returned unsupported token type.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_version(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The executable response is missing the version field.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_success(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"The executable response is missing the success field.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_error_code_message(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = {"version": 1, "success": False} + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Error code and message fields are required in the response." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_expiration_time(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"The executable response is missing the expiration_time field." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_missing_token_type(self, fp): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + + fp.register( + self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), + ) + + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"The executable response is missing the token_type field." + ) From ce79682048849f155362f88f78b7e4c47b0ffd6f Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 4 May 2022 16:26:48 -0700 Subject: [PATCH 36/48] Update pluggable.py --- google/auth/pluggable.py | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index dd53dfdf8..e094695b8 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -206,23 +206,20 @@ def retrieve_subject_token(self, request): env[ "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" ] = self._credential_source_executable_output_file - result = subprocess.run( - self._credential_source_executable_command.split(), - timeout=self._credential_source_executable_timeout_millis / 1000, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - env=env, - ) - - if result.returncode != 0: + try: + result = subprocess.check_output( + self._credential_source_executable_command.split(), + stderr=subprocess.STDOUT, + ) + except subprocess.CalledProcessError as e: raise exceptions.RefreshError( "Executable exited with non-zero return code {}. Error: {}".format( - result.returncode, result.stdout + e.returncode, e.output ) ) else: try: - data = result.stdout.decode("utf-8") + data = result.decode("utf-8") response = json.loads(data) subject_token = self._parse_subject_token(response) except: From 78b2f83fa933017e6fba574e16c1207e9bace75f Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 4 May 2022 16:41:28 -0700 Subject: [PATCH 37/48] Update pluggable.py --- google/auth/pluggable.py | 49 +++++++++++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index e094695b8..686951e98 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -192,20 +192,29 @@ def retrieve_subject_token(self, request): return subject_token # Inject env vars. - env = os.environ.copy() - env["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience - env["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type - env[ + original_audience = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE") + os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience + original_subject_token_type = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE") + os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type + original_interactive = os.getenv("GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE") + os.environ[ "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE" ] = "0" # Always set to 0 until interactive mode is implemented. + original_service_account_impersonation_url = os.getenv( + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" + ) if self._service_account_impersonation_url is not None: - env[ + os.environ[ "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" ] = self.service_account_email() + original_credential_source_executable_output_file = os.getenv( + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" + ) if self._credential_source_executable_output_file is not None: - env[ + os.environ[ "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" ] = self._credential_source_executable_output_file + try: result = subprocess.check_output( self._credential_source_executable_command.split(), @@ -226,6 +235,34 @@ def retrieve_subject_token(self, request): raise else: return subject_token + finally: + # Reset env vars. + if original_audience is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = original_audience + else: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] + if original_subject_token_type is not None: + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE" + ] = self.original_subject_token_type + else: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] + if original_interactive is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = original_interactive + else: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] + if original_service_account_impersonation_url is not None: + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" + ] = original_service_account_impersonation_url + elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL") is not None: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] + if original_credential_source_executable_output_file is not None: + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" + ] = original_credential_source_executable_output_file + elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") is not None: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] @classmethod def from_info(cls, info, **kwargs): From 74afd44f5f22bba9e8157820078aeb1afb1b0df8 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Wed, 4 May 2022 23:43:10 +0000 Subject: [PATCH 38/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/pluggable.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 686951e98..350616335 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -214,7 +214,7 @@ def retrieve_subject_token(self, request): os.environ[ "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" ] = self._credential_source_executable_output_file - + try: result = subprocess.check_output( self._credential_source_executable_command.split(), From 9a4a5188eec155f6e9f479bfad6f2fd19aa0c1fb Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 4 May 2022 17:45:52 -0700 Subject: [PATCH 39/48] pytest-subprocess --- testing/requirements.txt | 1 - tests/test_pluggable.py | 223 +++++++++++++++------------------------ 2 files changed, 85 insertions(+), 139 deletions(-) diff --git a/testing/requirements.txt b/testing/requirements.txt index 786e30029..df20f96d6 100644 --- a/testing/requirements.txt +++ b/testing/requirements.txt @@ -13,7 +13,6 @@ urllib3 cryptography responses grpcio -pytest-subprocess; python_version > '3.5' # Async Dependencies pytest-asyncio; python_version > '3.0' aioresponses; python_version > '3.0' diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 2945c375b..83db18e27 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -19,7 +19,6 @@ import mock import pytest # type: ignore import subprocess -import pytest_subprocess from six.moves import http_client from six.moves import urllib @@ -433,76 +432,56 @@ def test_info_with_credential_source(self): } @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_oidc_id_token(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), - ) + def test_retrieve_subject_token_oidc_id_token(self): + with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + subject_token = credentials.retrieve_subject_token(None) - subject_token = credentials.retrieve_subject_token(None) - - assert subject_token == self.EXECUTABLE_OIDC_TOKEN + assert subject_token == self.EXECUTABLE_OIDC_TOKEN @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_oidc_jwt(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + def test_retrieve_subject_token_oidc_jwt(self): + with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - subject_token = credentials.retrieve_subject_token(None) + subject_token = credentials.retrieve_subject_token(None) - assert subject_token == self.EXECUTABLE_OIDC_TOKEN + assert subject_token == self.EXECUTABLE_OIDC_TOKEN @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_saml(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE), - ) + def test_retrieve_subject_token_saml(self): + with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - subject_token = credentials.retrieve_subject_token(None) + subject_token = credentials.retrieve_subject_token(None) - assert subject_token == self.EXECUTABLE_SAML_TOKEN + assert subject_token == self.EXECUTABLE_SAML_TOKEN @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_failed(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_FAILED_RESPONSE), - ) + def test_retrieve_subject_token_failed(self): + with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_FAILED_RESPONSE).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match( - r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." - ) + assert excinfo.match( + r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." + ) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) - def test_retrieve_subject_token_not_allowd(self, fp): - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + def test_retrieve_subject_token_not_allowd(self): + with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"Executables need to be explicitly allowed") + assert excinfo.match(r"Executables need to be explicitly allowed") @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_invalid_version(self, fp): + def test_retrieve_subject_token_invalid_version(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2 = { "version": 2, "success": True, @@ -511,20 +490,16 @@ def test_retrieve_subject_token_invalid_version(self, fp): "expiration_time": 9999999999, } - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2), - ) + with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"Executable returned unsupported version.") + assert excinfo.match(r"Executable returned unsupported version.") @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_expired_token(self, fp): + def test_retrieve_subject_token_expired_token(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED = { "version": 1, "success": True, @@ -533,20 +508,16 @@ def test_retrieve_subject_token_expired_token(self, fp): "expiration_time": 0, } - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"The token returned by the executable is expired.") + assert excinfo.match(r"The token returned by the executable is expired.") @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_file_cache(self, fp): + def test_retrieve_subject_token_file_cache(self): with open(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: json.dump(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN, output_file) @@ -560,7 +531,7 @@ def test_retrieve_subject_token_file_cache(self, fp): os.remove(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_unsupported_token_type(self, fp): + def test_retrieve_subject_token_unsupported_token_type(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { "version": 1, "success": True, @@ -569,20 +540,16 @@ def test_retrieve_subject_token_unsupported_token_type(self, fp): "expiration_time": 9999999999, } - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) + with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + with pytest.raises(exceptions.RefreshError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match(r"Executable returned unsupported token type.") + assert excinfo.match(r"Executable returned unsupported token type.") @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_version(self, fp): + def test_retrieve_subject_token_missing_version(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { "success": True, "token_type": "urn:ietf:params:oauth:token-type:id_token", @@ -590,59 +557,47 @@ def test_retrieve_subject_token_missing_version(self, fp): "expiration_time": 9999999999, } - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) + with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"The executable response is missing the version field.") + assert excinfo.match(r"The executable response is missing the version field.") @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_success(self, fp): + def test_retrieve_subject_token_missing_success(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { "version": 1, "token_type": "urn:ietf:params:oauth:token-type:id_token", "id_token": self.EXECUTABLE_OIDC_TOKEN, "expiration_time": 9999999999, } + + with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match(r"The executable response is missing the success field.") + assert excinfo.match(r"The executable response is missing the success field.") @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_error_code_message(self, fp): + def test_retrieve_subject_token_missing_error_code_message(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = {"version": 1, "success": False} - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) - - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match( - r"Error code and message fields are required in the response." - ) + assert excinfo.match( + r"Error code and message fields are required in the response." + ) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_expiration_time(self, fp): + def test_retrieve_subject_token_missing_expiration_time(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { "version": 1, "success": True, @@ -650,22 +605,18 @@ def test_retrieve_subject_token_missing_expiration_time(self, fp): "id_token": self.EXECUTABLE_OIDC_TOKEN, } - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) + with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match( - r"The executable response is missing the expiration_time field." - ) + assert excinfo.match( + r"The executable response is missing the expiration_time field." + ) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_token_type(self, fp): + def test_retrieve_subject_token_missing_token_type(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { "version": 1, "success": True, @@ -673,16 +624,12 @@ def test_retrieve_subject_token_missing_token_type(self, fp): "expiration_time": 9999999999, } - fp.register( - self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND.split(), - stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE), - ) + with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + with pytest.raises(ValueError) as excinfo: + subject_token = credentials.retrieve_subject_token(None) - with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) - - assert excinfo.match( - r"The executable response is missing the token_type field." - ) + assert excinfo.match( + r"The executable response is missing the token_type field." + ) From ac27e4ac0ad13eb3dc791fa7e1e4bef09ec7fe38 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Thu, 5 May 2022 00:47:47 +0000 Subject: [PATCH 40/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- tests/test_pluggable.py | 99 ++++++++++++++++++++++++++++++++++------- 1 file changed, 83 insertions(+), 16 deletions(-) diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 83db18e27..b394bc28b 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -433,7 +433,12 @@ def test_info_with_credential_source(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_oidc_id_token(self): - with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) subject_token = credentials.retrieve_subject_token(None) @@ -442,7 +447,12 @@ def test_retrieve_subject_token_oidc_id_token(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_oidc_jwt(self): - with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT + ).encode("UTF-8"), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) subject_token = credentials.retrieve_subject_token(None) @@ -451,7 +461,12 @@ def test_retrieve_subject_token_oidc_jwt(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_saml(self): - with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE).encode( + "UTF-8" + ), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) subject_token = credentials.retrieve_subject_token(None) @@ -460,7 +475,10 @@ def test_retrieve_subject_token_saml(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_failed(self): - with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_FAILED_RESPONSE).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(self.EXECUTABLE_FAILED_RESPONSE).encode("UTF-8"), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(exceptions.RefreshError) as excinfo: @@ -472,7 +490,12 @@ def test_retrieve_subject_token_failed(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) def test_retrieve_subject_token_not_allowd(self): - with mock.patch('subprocess.check_output', return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: @@ -490,7 +513,12 @@ def test_retrieve_subject_token_invalid_version(self): "expiration_time": 9999999999, } - with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps( + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_VERSION_2 + ).encode("UTF-8"), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(exceptions.RefreshError) as excinfo: @@ -508,7 +536,12 @@ def test_retrieve_subject_token_expired_token(self): "expiration_time": 0, } - with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_EXPIRED).encode( + "UTF-8" + ), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(exceptions.RefreshError) as excinfo: @@ -540,7 +573,12 @@ def test_retrieve_subject_token_unsupported_token_type(self): "expiration_time": 9999999999, } - with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode( + "UTF-8" + ), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(exceptions.RefreshError) as excinfo: @@ -557,13 +595,20 @@ def test_retrieve_subject_token_missing_version(self): "expiration_time": 9999999999, } - with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode( + "UTF-8" + ), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"The executable response is missing the version field.") + assert excinfo.match( + r"The executable response is missing the version field." + ) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_missing_success(self): @@ -573,20 +618,32 @@ def test_retrieve_subject_token_missing_success(self): "id_token": self.EXECUTABLE_OIDC_TOKEN, "expiration_time": 9999999999, } - - with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode( + "UTF-8" + ), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: subject_token = credentials.retrieve_subject_token(None) - assert excinfo.match(r"The executable response is missing the success field.") + assert excinfo.match( + r"The executable response is missing the success field." + ) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_missing_error_code_message(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = {"version": 1, "success": False} - with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode( + "UTF-8" + ), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: @@ -605,7 +662,12 @@ def test_retrieve_subject_token_missing_expiration_time(self): "id_token": self.EXECUTABLE_OIDC_TOKEN, } - with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode( + "UTF-8" + ), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: @@ -624,7 +686,12 @@ def test_retrieve_subject_token_missing_token_type(self): "expiration_time": 9999999999, } - with mock.patch('subprocess.check_output', return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode('UTF-8')): + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode( + "UTF-8" + ), + ): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: From d71587e77bf5b398346c79b4e980f9aecec928e8 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 4 May 2022 21:39:31 -0700 Subject: [PATCH 41/48] lint --- google/auth/pluggable.py | 14 +++++++------- tests/test_pluggable.py | 22 ++++++++++------------ 2 files changed, 17 insertions(+), 19 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 350616335..fef50eb05 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -18,7 +18,7 @@ credentials that can be initialized with a list of explicit arguments, secrets or credentials, external account clients use the environment and hints/guidelines provided by the external_account JSON file to retrieve credentials and exchange -them for Google access tokens. +them for Google access tokens. Example credential_source for pluggable credential: @@ -178,7 +178,7 @@ def retrieve_subject_token(self, request): self._credential_source_executable_output_file ) as output_file: response = json.load(output_file) - except: + except Exception as e: pass else: try: @@ -231,7 +231,7 @@ def retrieve_subject_token(self, request): data = result.decode("utf-8") response = json.loads(data) subject_token = self._parse_subject_token(response) - except: + except Exception as e: raise else: return subject_token @@ -312,7 +312,7 @@ def from_file(cls, filename, **kwargs): return cls.from_info(data, **kwargs) def _parse_subject_token(self, response): - if not "version" in response: + if "version" not in response: raise ValueError("The executable response is missing the version field.") if response["version"] > EXECUTABLE_SUPPORTED_MAX_VERSION: raise exceptions.RefreshError( @@ -320,7 +320,7 @@ def _parse_subject_token(self, response): response["version"] ) ) - if not "success" in response: + if "success" not in response: raise ValueError("The executable response is missing the success field.") if not response["success"]: if "code" not in response or "message" not in response: @@ -332,7 +332,7 @@ def _parse_subject_token(self, response): response["code"], response["message"] ) ) - if not "expiration_time" in response: + if "expiration_time" not in response: raise ValueError( "The executable response is missing the expiration_time field." ) @@ -340,7 +340,7 @@ def _parse_subject_token(self, response): raise exceptions.RefreshError( "The token returned by the executable is expired." ) - if not "token_type" in response: + if "token_type" not in response: raise ValueError("The executable response is missing the token_type field.") if ( response["token_type"] == "urn:ietf:params:oauth:token-type:jwt" diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index b394bc28b..6b60bab10 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -18,13 +18,11 @@ import mock import pytest # type: ignore -import subprocess from six.moves import http_client from six.moves import urllib from google.auth import _helpers from google.auth import exceptions -from google.auth import identity_pool from google.auth import pluggable from google.auth import transport @@ -482,7 +480,7 @@ def test_retrieve_subject_token_failed(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match( r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." @@ -499,7 +497,7 @@ def test_retrieve_subject_token_not_allowd(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match(r"Executables need to be explicitly allowed") @@ -522,7 +520,7 @@ def test_retrieve_subject_token_invalid_version(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match(r"Executable returned unsupported version.") @@ -545,7 +543,7 @@ def test_retrieve_subject_token_expired_token(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match(r"The token returned by the executable is expired.") @@ -582,7 +580,7 @@ def test_retrieve_subject_token_unsupported_token_type(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(exceptions.RefreshError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match(r"Executable returned unsupported token type.") @@ -604,7 +602,7 @@ def test_retrieve_subject_token_missing_version(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match( r"The executable response is missing the version field." @@ -628,7 +626,7 @@ def test_retrieve_subject_token_missing_success(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match( r"The executable response is missing the success field." @@ -647,7 +645,7 @@ def test_retrieve_subject_token_missing_error_code_message(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match( r"Error code and message fields are required in the response." @@ -671,7 +669,7 @@ def test_retrieve_subject_token_missing_expiration_time(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match( r"The executable response is missing the expiration_time field." @@ -695,7 +693,7 @@ def test_retrieve_subject_token_missing_token_type(self): credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: - subject_token = credentials.retrieve_subject_token(None) + _ = credentials.retrieve_subject_token(None) assert excinfo.match( r"The executable response is missing the token_type field." From 3d40268f83407f949a7aa65cfd624f76373d548d Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Wed, 4 May 2022 22:32:50 -0700 Subject: [PATCH 42/48] Update pluggable.py --- google/auth/pluggable.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index fef50eb05..aa9e84883 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -178,7 +178,7 @@ def retrieve_subject_token(self, request): self._credential_source_executable_output_file ) as output_file: response = json.load(output_file) - except Exception as e: + except Exception: pass else: try: @@ -231,7 +231,7 @@ def retrieve_subject_token(self, request): data = result.decode("utf-8") response = json.loads(data) subject_token = self._parse_subject_token(response) - except Exception as e: + except Exception: raise else: return subject_token From 8bfae0a274d5c332b5b8cfbf8b2b94a73e5352a9 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Thu, 5 May 2022 15:37:56 -0700 Subject: [PATCH 43/48] nox cover nox cover --- google/auth/pluggable.py | 66 ++++---- tests/test__default.py | 25 +++ tests/test_pluggable.py | 347 ++++++++++++++++++--------------------- 3 files changed, 218 insertions(+), 220 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index aa9e84883..9bcd59bea 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -125,10 +125,6 @@ def __init__( default_scopes=default_scopes, workforce_pool_user_project=workforce_pool_user_project, ) - if workforce_pool_user_project is not None: - raise ValueError( - "Workforce Pools are not yet supported with Pluggable Auth." - ) if not isinstance(credential_source, Mapping): self._credential_source_executable = None raise ValueError( @@ -206,7 +202,7 @@ def retrieve_subject_token(self, request): if self._service_account_impersonation_url is not None: os.environ[ "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" - ] = self.service_account_email() + ] = self.service_account_email original_credential_source_executable_output_file = os.getenv( "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" ) @@ -233,36 +229,36 @@ def retrieve_subject_token(self, request): subject_token = self._parse_subject_token(response) except Exception: raise - else: - return subject_token - finally: - # Reset env vars. - if original_audience is not None: - os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = original_audience - else: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] - if original_subject_token_type is not None: - os.environ[ - "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE" - ] = self.original_subject_token_type - else: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] - if original_interactive is not None: - os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = original_interactive - else: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] - if original_service_account_impersonation_url is not None: - os.environ[ - "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" - ] = original_service_account_impersonation_url - elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL") is not None: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] - if original_credential_source_executable_output_file is not None: - os.environ[ - "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" - ] = original_credential_source_executable_output_file - elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") is not None: - del os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] + + # Reset env vars. + if original_audience is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = original_audience + else: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] + if original_subject_token_type is not None: + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE" + ] = original_subject_token_type + else: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] + if original_interactive is not None: + os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = original_interactive + else: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] + if original_service_account_impersonation_url is not None: + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" + ] = original_service_account_impersonation_url + elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL") is not None: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL"] + if original_credential_source_executable_output_file is not None: + os.environ[ + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" + ] = original_credential_source_executable_output_file + elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") is not None: + del os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] + + return subject_token @classmethod def from_info(cls, info, **kwargs): diff --git a/tests/test__default.py b/tests/test__default.py index ed64bc723..de9cedfbb 100644 --- a/tests/test__default.py +++ b/tests/test__default.py @@ -27,6 +27,7 @@ from google.auth import exceptions from google.auth import external_account from google.auth import identity_pool +from google.auth import pluggable from google.auth import impersonated_credentials from google.oauth2 import service_account import google.oauth2.credentials @@ -72,6 +73,17 @@ "token_url": TOKEN_URL, "credential_source": {"file": SUBJECT_TOKEN_TEXT_FILE}, } +PLUGGABLE_DATA = { + "type": "external_account", + "audience": AUDIENCE, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": TOKEN_URL, + "credential_source": { + "executable": { + "command": "command" + } + }, +} AWS_DATA = { "type": "external_account", "audience": AUDIENCE, @@ -1140,3 +1152,16 @@ def test_default_impersonated_service_account_set_both_scopes_and_default_scopes credentials, _ = _default.default(scopes=scopes, default_scopes=default_scopes) assert credentials._target_scopes == scopes + +@EXTERNAL_ACCOUNT_GET_PROJECT_ID_PATCH +def test_load_credentials_from_external_account_pluggable( + get_project_id, tmpdir +): + config_file = tmpdir.join("config.json") + config_file.write(json.dumps(PLUGGABLE_DATA)) + credentials, project_id = _default.load_credentials_from_file(str(config_file)) + + assert isinstance(credentials, pluggable.Credentials) + # Since no scopes are specified, the project ID cannot be determined. + assert project_id is None + assert get_project_id.called \ No newline at end of file diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 6b60bab10..f2f57a9f7 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -15,6 +15,7 @@ import datetime import json import os +import subprocess import mock import pytest # type: ignore @@ -41,6 +42,7 @@ SUBJECT_TOKEN_FIELD_NAME = "access_token" TOKEN_URL = "https://sts.googleapis.com/v1/token" +SERVICE_ACCOUNT_IMPERSONATION_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/byoid-test@cicpclientproj.iam.gserviceaccount.com:generateAccessToken" SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" AUDIENCE = "//iam.googleapis.com/projects/123456/locations/global/workloadIdentityPools/POOL_ID/providers/PROVIDER_ID" @@ -87,183 +89,6 @@ class TestCredentials(object): } CREDENTIAL_URL = "http://fakeurl.com" - @classmethod - def make_mock_response(cls, status, data): - response = mock.create_autospec(transport.Response, instance=True) - response.status = status - if isinstance(data, dict): - response.data = json.dumps(data).encode("utf-8") - else: - response.data = data - return response - - @classmethod - def make_mock_request( - cls, token_status=http_client.OK, token_data=None, *extra_requests - ): - responses = [] - responses.append(cls.make_mock_response(token_status, token_data)) - - while len(extra_requests) > 0: - # If service account impersonation is requested, mock the expected response. - status, data, extra_requests = ( - extra_requests[0], - extra_requests[1], - extra_requests[2:], - ) - responses.append(cls.make_mock_response(status, data)) - - request = mock.create_autospec(transport.Request) - request.side_effect = responses - - return request - - @classmethod - def assert_credential_request_kwargs( - cls, request_kwargs, headers, url=CREDENTIAL_URL - ): - assert request_kwargs["url"] == url - assert request_kwargs["method"] == "GET" - assert request_kwargs["headers"] == headers - assert request_kwargs.get("body", None) is None - - @classmethod - def assert_token_request_kwargs( - cls, request_kwargs, headers, request_data, token_url=TOKEN_URL - ): - assert request_kwargs["url"] == token_url - assert request_kwargs["method"] == "POST" - assert request_kwargs["headers"] == headers - assert request_kwargs["body"] is not None - body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) - assert len(body_tuples) == len(request_data.keys()) - for (k, v) in body_tuples: - assert v.decode("utf-8") == request_data[k.decode("utf-8")] - - @classmethod - def assert_impersonation_request_kwargs( - cls, - request_kwargs, - headers, - request_data, - service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, - ): - assert request_kwargs["url"] == service_account_impersonation_url - assert request_kwargs["method"] == "POST" - assert request_kwargs["headers"] == headers - assert request_kwargs["body"] is not None - body_json = json.loads(request_kwargs["body"].decode("utf-8")) - assert body_json == request_data - - @classmethod - def assert_underlying_credentials_refresh( - cls, - credentials, - audience, - subject_token, - subject_token_type, - token_url, - service_account_impersonation_url=None, - basic_auth_encoding=None, - quota_project_id=None, - used_scopes=None, - credential_data=None, - scopes=None, - default_scopes=None, - workforce_pool_user_project=None, - ): - """Utility to assert that a credentials are initialized with the expected - attributes by calling refresh functionality and confirming response matches - expected one and that the underlying requests were populated with the - expected parameters. - """ - # STS token exchange request/response. - token_response = cls.SUCCESS_RESPONSE.copy() - token_headers = {"Content-Type": "application/x-www-form-urlencoded"} - if basic_auth_encoding: - token_headers["Authorization"] = "Basic " + basic_auth_encoding - - if service_account_impersonation_url: - token_scopes = "https://www.googleapis.com/auth/iam" - else: - token_scopes = " ".join(used_scopes or []) - - token_request_data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "audience": audience, - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "scope": token_scopes, - "subject_token": subject_token, - "subject_token_type": subject_token_type, - } - if workforce_pool_user_project: - token_request_data["options"] = urllib.parse.quote( - json.dumps({"userProject": workforce_pool_user_project}) - ) - - if service_account_impersonation_url: - # Service account impersonation request/response. - expire_time = ( - _helpers.utcnow().replace(microsecond=0) - + datetime.timedelta(seconds=3600) - ).isoformat("T") + "Z" - impersonation_response = { - "accessToken": "SA_ACCESS_TOKEN", - "expireTime": expire_time, - } - impersonation_headers = { - "Content-Type": "application/json", - "authorization": "Bearer {}".format(token_response["access_token"]), - } - impersonation_request_data = { - "delegates": None, - "scope": used_scopes, - "lifetime": "3600s", - } - - # Initialize mock request to handle token retrieval, token exchange and - # service account impersonation request. - requests = [] - if credential_data: - requests.append((http_client.OK, credential_data)) - - token_request_index = len(requests) - requests.append((http_client.OK, token_response)) - - if service_account_impersonation_url: - impersonation_request_index = len(requests) - requests.append((http_client.OK, impersonation_response)) - - request = cls.make_mock_request(*[el for req in requests for el in req]) - - credentials.refresh(request) - - assert len(request.call_args_list) == len(requests) - if credential_data: - cls.assert_credential_request_kwargs(request.call_args_list[0][1], None) - # Verify token exchange request parameters. - cls.assert_token_request_kwargs( - request.call_args_list[token_request_index][1], - token_headers, - token_request_data, - token_url, - ) - # Verify service account impersonation request parameters if the request - # is processed. - if service_account_impersonation_url: - cls.assert_impersonation_request_kwargs( - request.call_args_list[impersonation_request_index][1], - impersonation_headers, - impersonation_request_data, - service_account_impersonation_url, - ) - assert credentials.token == impersonation_response["accessToken"] - else: - assert credentials.token == token_response["access_token"] - assert credentials.quota_project_id == quota_project_id - assert credentials.scopes == scopes - assert credentials.default_scopes == default_scopes - @classmethod def make_pluggable( cls, @@ -429,7 +254,7 @@ def test_info_with_credential_source(self): "credential_source": self.CREDENTIAL_SOURCE, } - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1", "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE": "original_audience", "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE": "original_token_type", "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE": "0", "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL": "original_impersonated_email", "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE": "original_output_file"}) def test_retrieve_subject_token_oidc_id_token(self): with mock.patch( "subprocess.check_output", @@ -437,7 +262,7 @@ def test_retrieve_subject_token_oidc_id_token(self): self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN ).encode("UTF-8"), ): - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + credentials = self.make_pluggable(audience=AUDIENCE,service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, credential_source=self.CREDENTIAL_SOURCE) subject_token = credentials.retrieve_subject_token(None) @@ -451,7 +276,7 @@ def test_retrieve_subject_token_oidc_jwt(self): self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT ).encode("UTF-8"), ): - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + credentials = self.make_pluggable(audience=AUDIENCE,service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, credential_source=self.CREDENTIAL_SOURCE) subject_token = credentials.retrieve_subject_token(None) @@ -549,18 +374,105 @@ def test_retrieve_subject_token_expired_token(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_file_cache(self): - with open(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "actual_output_file" + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "timeout_millis": 30000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + with open(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: json.dump(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN, output_file) - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + credentials = self.make_pluggable(credential_source=ACTUAL_CREDENTIAL_SOURCE) subject_token = credentials.retrieve_subject_token(None) - assert subject_token == self.EXECUTABLE_OIDC_TOKEN - if os.path.exists(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE): - os.remove(self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_no_file_cache(self): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "timeout_millis": 30000, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN).encode( + "UTF-8" + ), + ): + credentials = self.make_pluggable(credential_source=ACTUAL_CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_file_cache_value_error_report(self): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "actual_output_file" + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "timeout_millis": 30000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + ACTUAL_EXECUTABLE_RESPONSE= { + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + with open(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: + json.dump(ACTUAL_EXECUTABLE_RESPONSE, output_file) + + credentials = self.make_pluggable(credential_source=ACTUAL_CREDENTIAL_SOURCE) + + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + assert excinfo.match( + r"The executable response is missing the version field." + ) + + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_file_cache_refresh_error_retry(self): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "actual_output_file" + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "timeout_millis": 30000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + ACTUAL_EXECUTABLE_RESPONSE= { + "version": 2, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + "expiration_time": 9999999999, + } + with open(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: + json.dump(ACTUAL_EXECUTABLE_RESPONSE, output_file) + + with mock.patch( + "subprocess.check_output", + return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN).encode( + "UTF-8" + ), + ): + credentials = self.make_pluggable(credential_source=ACTUAL_CREDENTIAL_SOURCE) + + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_unsupported_token_type(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { @@ -698,3 +610,68 @@ def test_retrieve_subject_token_missing_token_type(self): assert excinfo.match( r"The executable response is missing the token_type field." ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_workforce_pool_user_project_not_supported(self): + with pytest.raises(ValueError) as excinfo: + credentials = self.make_pluggable(workforce_pool_user_project="fake_workforce_pool_user_project") + + assert excinfo.match( + r"workforce_pool_user_project should not be set for non-workforce pool credentials" + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_missing_command(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = {"executable": + {"timeout_millis": 30000, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE,}} + credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Missing command field. Executable command must be provided." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_timeout_small(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = {"executable": + {"command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "timeout_millis": 5000-1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE,}} + credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Timeout must be between 5 and 120 seconds." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_timeout_large(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = {"executable": + {"command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "timeout_millis": 120000+1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE,}} + credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Timeout must be between 5 and 120 seconds." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_executable_fail(self): + with mock.patch( + "subprocess.check_output", + ) as subprocess_mock: + subprocess_mock.side_effect = subprocess.CalledProcessError(returncode=1,cmd='') + credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Executable exited with non-zero return code 1. Error: None" + ) \ No newline at end of file From 245b610191ef656c2f2077112d5facb25e24e5be Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Sun, 8 May 2022 09:13:39 +0000 Subject: [PATCH 44/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- google/auth/pluggable.py | 2 +- tests/test__default.py | 13 ++-- tests/test_pluggable.py | 127 ++++++++++++++++++++++++--------------- 3 files changed, 82 insertions(+), 60 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 9bcd59bea..ebfb2c585 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -257,7 +257,7 @@ def retrieve_subject_token(self, request): ] = original_credential_source_executable_output_file elif os.getenv("GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE") is not None: del os.environ["GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE"] - + return subject_token @classmethod diff --git a/tests/test__default.py b/tests/test__default.py index de9cedfbb..d41744db5 100644 --- a/tests/test__default.py +++ b/tests/test__default.py @@ -78,11 +78,7 @@ "audience": AUDIENCE, "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", "token_url": TOKEN_URL, - "credential_source": { - "executable": { - "command": "command" - } - }, + "credential_source": {"executable": {"command": "command"}}, } AWS_DATA = { "type": "external_account", @@ -1153,10 +1149,9 @@ def test_default_impersonated_service_account_set_both_scopes_and_default_scopes credentials, _ = _default.default(scopes=scopes, default_scopes=default_scopes) assert credentials._target_scopes == scopes + @EXTERNAL_ACCOUNT_GET_PROJECT_ID_PATCH -def test_load_credentials_from_external_account_pluggable( - get_project_id, tmpdir -): +def test_load_credentials_from_external_account_pluggable(get_project_id, tmpdir): config_file = tmpdir.join("config.json") config_file.write(json.dumps(PLUGGABLE_DATA)) credentials, project_id = _default.load_credentials_from_file(str(config_file)) @@ -1164,4 +1159,4 @@ def test_load_credentials_from_external_account_pluggable( assert isinstance(credentials, pluggable.Credentials) # Since no scopes are specified, the project ID cannot be determined. assert project_id is None - assert get_project_id.called \ No newline at end of file + assert get_project_id.called diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index f2f57a9f7..116f9505e 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -254,7 +254,17 @@ def test_info_with_credential_source(self): "credential_source": self.CREDENTIAL_SOURCE, } - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1", "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE": "original_audience", "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE": "original_token_type", "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE": "0", "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL": "original_impersonated_email", "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE": "original_output_file"}) + @mock.patch.dict( + os.environ, + { + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1", + "GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE": "original_audience", + "GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE": "original_token_type", + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE": "0", + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL": "original_impersonated_email", + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE": "original_output_file", + }, + ) def test_retrieve_subject_token_oidc_id_token(self): with mock.patch( "subprocess.check_output", @@ -262,7 +272,11 @@ def test_retrieve_subject_token_oidc_id_token(self): self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN ).encode("UTF-8"), ): - credentials = self.make_pluggable(audience=AUDIENCE,service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, credential_source=self.CREDENTIAL_SOURCE) + credentials = self.make_pluggable( + audience=AUDIENCE, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + credential_source=self.CREDENTIAL_SOURCE, + ) subject_token = credentials.retrieve_subject_token(None) @@ -276,7 +290,11 @@ def test_retrieve_subject_token_oidc_jwt(self): self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT ).encode("UTF-8"), ): - credentials = self.make_pluggable(audience=AUDIENCE,service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, credential_source=self.CREDENTIAL_SOURCE) + credentials = self.make_pluggable( + audience=AUDIENCE, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + credential_source=self.CREDENTIAL_SOURCE, + ) subject_token = credentials.retrieve_subject_token(None) @@ -401,16 +419,18 @@ def test_retrieve_subject_token_no_file_cache(self): with mock.patch( "subprocess.check_output", - return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN).encode( - "UTF-8" - ), + return_value=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), ): - credentials = self.make_pluggable(credential_source=ACTUAL_CREDENTIAL_SOURCE) + credentials = self.make_pluggable( + credential_source=ACTUAL_CREDENTIAL_SOURCE + ) subject_token = credentials.retrieve_subject_token(None) - + assert subject_token == self.EXECUTABLE_OIDC_TOKEN - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_file_cache_value_error_report(self): ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "actual_output_file" @@ -420,7 +440,7 @@ def test_retrieve_subject_token_file_cache_value_error_report(self): "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, } ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} - ACTUAL_EXECUTABLE_RESPONSE= { + ACTUAL_EXECUTABLE_RESPONSE = { "success": True, "token_type": "urn:ietf:params:oauth:token-type:id_token", "id_token": self.EXECUTABLE_OIDC_TOKEN, @@ -428,18 +448,16 @@ def test_retrieve_subject_token_file_cache_value_error_report(self): } with open(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: json.dump(ACTUAL_EXECUTABLE_RESPONSE, output_file) - + credentials = self.make_pluggable(credential_source=ACTUAL_CREDENTIAL_SOURCE) with pytest.raises(ValueError) as excinfo: _ = credentials.retrieve_subject_token(None) - assert excinfo.match( - r"The executable response is missing the version field." - ) - + assert excinfo.match(r"The executable response is missing the version field.") + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_file_cache_refresh_error_retry(self): ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = "actual_output_file" @@ -449,7 +467,7 @@ def test_retrieve_subject_token_file_cache_refresh_error_retry(self): "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, } ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} - ACTUAL_EXECUTABLE_RESPONSE= { + ACTUAL_EXECUTABLE_RESPONSE = { "version": 2, "success": True, "token_type": "urn:ietf:params:oauth:token-type:id_token", @@ -461,18 +479,20 @@ def test_retrieve_subject_token_file_cache_refresh_error_retry(self): with mock.patch( "subprocess.check_output", - return_value=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN).encode( - "UTF-8" - ), + return_value=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), ): - credentials = self.make_pluggable(credential_source=ACTUAL_CREDENTIAL_SOURCE) + credentials = self.make_pluggable( + credential_source=ACTUAL_CREDENTIAL_SOURCE + ) subject_token = credentials.retrieve_subject_token(None) assert subject_token == self.EXECUTABLE_OIDC_TOKEN - + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_unsupported_token_type(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { @@ -614,59 +634,66 @@ def test_retrieve_subject_token_missing_token_type(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_workforce_pool_user_project_not_supported(self): with pytest.raises(ValueError) as excinfo: - credentials = self.make_pluggable(workforce_pool_user_project="fake_workforce_pool_user_project") + credentials = self.make_pluggable( + workforce_pool_user_project="fake_workforce_pool_user_project" + ) assert excinfo.match( r"workforce_pool_user_project should not be set for non-workforce pool credentials" ) - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_credential_source_missing_command(self): with pytest.raises(ValueError) as excinfo: - CREDENTIAL_SOURCE = {"executable": - {"timeout_millis": 30000, - "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE,}} + CREDENTIAL_SOURCE = { + "executable": { + "timeout_millis": 30000, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) _ = credentials.retrieve_subject_token(None) assert excinfo.match( r"Missing command field. Executable command must be provided." ) - + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_credential_source_timeout_small(self): with pytest.raises(ValueError) as excinfo: - CREDENTIAL_SOURCE = {"executable": - {"command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, - "timeout_millis": 5000-1, - "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE,}} + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "timeout_millis": 5000 - 1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) _ = credentials.retrieve_subject_token(None) - assert excinfo.match( - r"Timeout must be between 5 and 120 seconds." - ) - + assert excinfo.match(r"Timeout must be between 5 and 120 seconds.") + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_credential_source_timeout_large(self): with pytest.raises(ValueError) as excinfo: - CREDENTIAL_SOURCE = {"executable": - {"command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, - "timeout_millis": 120000+1, - "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE,}} + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "timeout_millis": 120000 + 1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) _ = credentials.retrieve_subject_token(None) - assert excinfo.match( - r"Timeout must be between 5 and 120 seconds." - ) - + assert excinfo.match(r"Timeout must be between 5 and 120 seconds.") + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_executable_fail(self): - with mock.patch( - "subprocess.check_output", - ) as subprocess_mock: - subprocess_mock.side_effect = subprocess.CalledProcessError(returncode=1,cmd='') + with mock.patch("subprocess.check_output") as subprocess_mock: + subprocess_mock.side_effect = subprocess.CalledProcessError( + returncode=1, cmd="" + ) credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) with pytest.raises(exceptions.RefreshError) as excinfo: @@ -674,4 +701,4 @@ def test_retrieve_subject_token_executable_fail(self): assert excinfo.match( r"Executable exited with non-zero return code 1. Error: None" - ) \ No newline at end of file + ) From 25b5446db5c73a79bcec1692175b2e17ab800cd2 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Sun, 8 May 2022 03:22:26 -0700 Subject: [PATCH 45/48] lint --- tests/test__default.py | 2 +- tests/test_pluggable.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test__default.py b/tests/test__default.py index d41744db5..4e7eeb84e 100644 --- a/tests/test__default.py +++ b/tests/test__default.py @@ -27,8 +27,8 @@ from google.auth import exceptions from google.auth import external_account from google.auth import identity_pool -from google.auth import pluggable from google.auth import impersonated_credentials +from google.auth import pluggable from google.oauth2 import service_account import google.oauth2.credentials diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 116f9505e..a86015d7d 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -12,20 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. -import datetime +# import datetime import json import os import subprocess import mock import pytest # type: ignore -from six.moves import http_client -from six.moves import urllib +# from six.moves import http_client +# from six.moves import urllib -from google.auth import _helpers +# from google.auth import _helpers from google.auth import exceptions from google.auth import pluggable -from google.auth import transport +# from google.auth import transport CLIENT_ID = "username" @@ -634,7 +634,7 @@ def test_retrieve_subject_token_missing_token_type(self): @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_workforce_pool_user_project_not_supported(self): with pytest.raises(ValueError) as excinfo: - credentials = self.make_pluggable( + _ = self.make_pluggable( workforce_pool_user_project="fake_workforce_pool_user_project" ) From 34126ef195ea06dceb8120e2c90d876d59daa722 Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Sun, 8 May 2022 03:29:31 -0700 Subject: [PATCH 46/48] Update test_pluggable.py --- tests/test_pluggable.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index a86015d7d..dd1929e74 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -631,17 +631,6 @@ def test_retrieve_subject_token_missing_token_type(self): r"The executable response is missing the token_type field." ) - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_workforce_pool_user_project_not_supported(self): - with pytest.raises(ValueError) as excinfo: - _ = self.make_pluggable( - workforce_pool_user_project="fake_workforce_pool_user_project" - ) - - assert excinfo.match( - r"workforce_pool_user_project should not be set for non-workforce pool credentials" - ) - @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_credential_source_missing_command(self): with pytest.raises(ValueError) as excinfo: @@ -652,7 +641,6 @@ def test_credential_source_missing_command(self): } } credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) - _ = credentials.retrieve_subject_token(None) assert excinfo.match( r"Missing command field. Executable command must be provided." @@ -669,7 +657,6 @@ def test_credential_source_timeout_small(self): } } credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) - _ = credentials.retrieve_subject_token(None) assert excinfo.match(r"Timeout must be between 5 and 120 seconds.") @@ -684,7 +671,6 @@ def test_credential_source_timeout_large(self): } } credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) - _ = credentials.retrieve_subject_token(None) assert excinfo.match(r"Timeout must be between 5 and 120 seconds.") From 15d7a79abc5038095f1be43ca3e4aea5f536b9e6 Mon Sep 17 00:00:00 2001 From: Owl Bot Date: Sun, 8 May 2022 10:31:25 +0000 Subject: [PATCH 47/48] =?UTF-8?q?=F0=9F=A6=89=20Updates=20from=20OwlBot=20?= =?UTF-8?q?post-processor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --- tests/test_pluggable.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index dd1929e74..190bd6418 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -19,12 +19,14 @@ import mock import pytest # type: ignore + # from six.moves import http_client # from six.moves import urllib # from google.auth import _helpers from google.auth import exceptions from google.auth import pluggable + # from google.auth import transport From 1e5a89a806dfc13a7ef790ffb2ad0f978b83318c Mon Sep 17 00:00:00 2001 From: Chuan Ren Date: Sun, 8 May 2022 11:38:42 -0700 Subject: [PATCH 48/48] Update test_pluggable.py --- tests/test_pluggable.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index dd1929e74..6ec3b77bc 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -640,7 +640,7 @@ def test_credential_source_missing_command(self): "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, } } - credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) assert excinfo.match( r"Missing command field. Executable command must be provided." @@ -656,7 +656,7 @@ def test_credential_source_timeout_small(self): "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, } } - credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) assert excinfo.match(r"Timeout must be between 5 and 120 seconds.") @@ -670,7 +670,7 @@ def test_credential_source_timeout_large(self): "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, } } - credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) assert excinfo.match(r"Timeout must be between 5 and 120 seconds.")