diff --git a/.github/.OwlBot.lock.yaml b/.github/.OwlBot.lock.yaml index 64f82d6bf..0eb02fda4 100644 --- a/.github/.OwlBot.lock.yaml +++ b/.github/.OwlBot.lock.yaml @@ -13,5 +13,5 @@ # limitations under the License. docker: image: gcr.io/cloud-devrel-public-resources/owlbot-python:latest - digest: sha256:bc5eed3804aec2f05fad42aacf973821d9500c174015341f721a984a0825b6fd -# created: 2022-04-21T15:43:16.246106921Z + digest: sha256:9db98b055a7f8bd82351238ccaacfd3cda58cdf73012ab58b8da146368330021 +# created: 2022-07-25T16:02:49.174178716Z diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 06c1e10f1..07b049046 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,7 +5,7 @@ # https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners#codeowners-syntax # The @googleapis/yoshi-python is the default owner for changes in this repo -* @arithmetic1728 @sai-sunder-s @TimurSadykov @googleapis/yoshi-python +* @arithmetic1728 @sai-sunder-s @googleapis/googleapis-auth @googleapis/yoshi-python # The python-samples-reviewers team is the default owner for samples changes /samples/ @googleapis/python-samples-owners diff --git a/.kokoro/continuous/prerelease-deps.cfg b/.kokoro/continuous/prerelease-deps.cfg new file mode 100644 index 000000000..3595fb43f --- /dev/null +++ b/.kokoro/continuous/prerelease-deps.cfg @@ -0,0 +1,7 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Only run this nox session. +env_vars: { + key: "NOX_SESSION" + value: "prerelease_deps" +} diff --git a/.kokoro/presubmit/prerelease-deps.cfg b/.kokoro/presubmit/prerelease-deps.cfg new file mode 100644 index 000000000..3595fb43f --- /dev/null +++ b/.kokoro/presubmit/prerelease-deps.cfg @@ -0,0 +1,7 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Only run this nox session. +env_vars: { + key: "NOX_SESSION" + value: "prerelease_deps" +} diff --git a/.kokoro/test-samples-impl.sh b/.kokoro/test-samples-impl.sh index 8a324c9c7..2c6500cae 100755 --- a/.kokoro/test-samples-impl.sh +++ b/.kokoro/test-samples-impl.sh @@ -33,7 +33,7 @@ export PYTHONUNBUFFERED=1 env | grep KOKORO # Install nox -python3.6 -m pip install --upgrade --quiet nox +python3.9 -m pip install --upgrade --quiet nox # Use secrets acessor service account to get secrets if [[ -f "${KOKORO_GFILE_DIR}/secrets_viewer_service_account.json" ]]; then @@ -76,7 +76,7 @@ for file in samples/**/requirements.txt; do echo "------------------------------------------------------------" # Use nox to execute the tests for the project. - python3.6 -m nox -s "$RUN_TESTS_SESSION" + python3.9 -m nox -s "$RUN_TESTS_SESSION" EXIT=$? # If this is a periodic build, send the test log to the FlakyBot. diff --git a/CHANGELOG.md b/CHANGELOG.md index 685ef1cdc..e08322a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ [1]: https://pypi.org/project/google-auth/#history +## [2.10.0](https://github.com/googleapis/google-auth-library-python/compare/v2.9.1...v2.10.0) (2022-08-05) + + +### Features + +* add integration tests for pluggable auth ([#1073](https://github.com/googleapis/google-auth-library-python/issues/1073)) ([f8d776a](https://github.com/googleapis/google-auth-library-python/commit/f8d776a290270da8c43b0f5ba8e8a1fabfcf4dd3)) +* support for configurable token lifetime ([0dc6a9a](https://github.com/googleapis/google-auth-library-python/commit/0dc6a9a30b994f20ad027bfc3715792aa97bd8af)) +* support for configurable token lifetime ([#1079](https://github.com/googleapis/google-auth-library-python/issues/1079)) ([0dc6a9a](https://github.com/googleapis/google-auth-library-python/commit/0dc6a9a30b994f20ad027bfc3715792aa97bd8af)) + + +### Bug Fixes + +* async certificate decoding ([#1085](https://github.com/googleapis/google-auth-library-python/issues/1085)) ([741c6c6](https://github.com/googleapis/google-auth-library-python/commit/741c6c6f5e2d4e98cbae1e6c7a9bc128c6a97bae)) +* Async system tests were not unwrapping async_generators ([#1086](https://github.com/googleapis/google-auth-library-python/issues/1086)) ([29d248a](https://github.com/googleapis/google-auth-library-python/commit/29d248acaf554c2bdba81c96999371c9e610c6b6)) +* Fix IDTokenCredentials update bug ([#1072](https://github.com/googleapis/google-auth-library-python/issues/1072)) ([b62c25c](https://github.com/googleapis/google-auth-library-python/commit/b62c25ca408f72d86fda35b611edb3d2c6eb4f85)) +* make expiration_time optional in response schema ([#1091](https://github.com/googleapis/google-auth-library-python/issues/1091)) ([032fb8d](https://github.com/googleapis/google-auth-library-python/commit/032fb8d1685a50081974ba85e6ead946f30a1ea8)) +* refactor credential subclass parameters ([#1095](https://github.com/googleapis/google-auth-library-python/issues/1095)) ([8d15f69](https://github.com/googleapis/google-auth-library-python/commit/8d15f69711f38196934eabff5f05be26b3afcbf6)) + ## [2.9.1](https://github.com/googleapis/google-auth-library-python/compare/v2.9.0...v2.9.1) (2022-07-12) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index a09247897..16de58d9d 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -429,24 +429,28 @@ These are all required fields for an error response. The code and message fields will be used by the library as part of the thrown exception. -Response format fields summary: ``version``: The version of the JSON -output. Currently only version 1 is supported. ``success``: The -status of the response. When true, the response must contain the 3rd -party token, token type, and expiration. The executable must also exit -with exit code 0. When false, the response must contain the error code -and message fields and exit with a non-zero value. ``token_type``: -The 3rd party subject token type. Must be -*urn:ietf:params:oauth:token-type:jwt*, -*urn:ietf:params:oauth:token-type:id_token*, or -*urn:ietf:params:oauth:token-type:saml2*. ``id_token``: The 3rd party -OIDC token. ``saml_response``: The 3rd party SAML response. -``expiration_time``: The 3rd party subject token expiration time in -seconds (unix epoch time). ``code``: The error code string. -``message``: The error message. - -All response types must include both the ``version`` and ``success`` -fields. Successful responses must include the ``token_type``, -``expiration_time``, and one of ``id_token`` or ``saml_response``. +Response format fields summary: + +- ``version``: The version of the JSON output. Currently only version 1 is + supported. +- ``success``: The status of the response. + - When true, the response must contain the 3rd party token, token type, and expiration. The executable must also exit with exit code 0. + - When false, the response must contain the error code and message fields and exit with a non-zero value. +- ``token_type``: The 3rd party subject token type. Must be + - *urn:ietf:params:oauth:token-type:jwt* + - *urn:ietf:params:oauth:token-type:id_token* + - *urn:ietf:params:oauth:token-type:saml2* +- ``id_token``: The 3rd party OIDC token. +- ``saml_response``: The 3rd party SAML response. +- ``expiration_time``: The 3rd party subject token expiration time in seconds + (unix epoch time). +- ``code``: The error code string. +- ``message``: The error message. + +All response types must include both the ``version`` and ``success`` fields. +Successful responses must include the ``token_type``, and one of ``id_token`` +or ``saml_response``. +If output file is specified, ``expiration_time`` is mandatory. Error responses must include both the ``code`` and ``message`` fields. The library will populate the following environment variables when the diff --git a/google/auth/aws.py b/google/auth/aws.py index 9df2d35e3..08c94427e 100644 --- a/google/auth/aws.py +++ b/google/auth/aws.py @@ -39,7 +39,6 @@ import hashlib import hmac -import io import json import os import posixpath @@ -352,12 +351,8 @@ def __init__( subject_token_type, token_url, credential_source=None, - service_account_impersonation_url=None, - client_id=None, - client_secret=None, - quota_project_id=None, - scopes=None, - default_scopes=None, + *args, + **kwargs ): """Instantiates an AWS workload external account credentials object. @@ -368,15 +363,8 @@ def __init__( credential_source (Mapping): The credential source dictionary used to provide instructions on how to retrieve external credential to be exchanged for Google access tokens. - 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. + args (List): Optional positional arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. + kwargs (Mapping): Optional keyword arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. Raises: google.auth.exceptions.RefreshError: If an error is encountered during @@ -392,12 +380,8 @@ def __init__( 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, + *args, + **kwargs ) credential_source = credential_source or {} self._environment_id = credential_source.get("environment_id") or "" @@ -748,19 +732,7 @@ def from_info(cls, info, **kwargs): 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"), - **kwargs - ) + return super(Credentials, cls).from_info(info, **kwargs) @classmethod def from_file(cls, filename, **kwargs): @@ -773,6 +745,4 @@ def from_file(cls, filename, **kwargs): Returns: google.auth.aws.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) + return super(Credentials, cls).from_file(filename, **kwargs) diff --git a/google/auth/compute_engine/_metadata.py b/google/auth/compute_engine/_metadata.py index d57c22a15..16c5e2138 100644 --- a/google/auth/compute_engine/_metadata.py +++ b/google/auth/compute_engine/_metadata.py @@ -246,7 +246,7 @@ def get_service_account_token(request, service_account="default", scopes=None): scopes (Optional[Union[str, List[str]]]): Optional string or list of strings with auth scopes. Returns: - Union[str, datetime]: The access token and its expiration. + Tuple[str, datetime]: The access token and its expiration. Raises: google.auth.exceptions.TransportError: if an error occurred while diff --git a/google/auth/external_account.py b/google/auth/external_account.py index 97aca1089..a87f92ea4 100644 --- a/google/auth/external_account.py +++ b/google/auth/external_account.py @@ -30,6 +30,7 @@ import abc import copy import datetime +import io import json import re @@ -70,6 +71,7 @@ def __init__( token_url, credential_source, service_account_impersonation_url=None, + service_account_impersonation_options=None, client_id=None, client_secret=None, quota_project_id=None, @@ -108,6 +110,9 @@ def __init__( self._token_url = token_url self._credential_source = credential_source self._service_account_impersonation_url = service_account_impersonation_url + self._service_account_impersonation_options = ( + service_account_impersonation_options or {} + ) self._client_id = client_id self._client_secret = client_secret self._quota_project_id = quota_project_id @@ -158,6 +163,10 @@ def info(self): "subject_token_type": self._subject_token_type, "token_url": self._token_url, "service_account_impersonation_url": self._service_account_impersonation_url, + "service_account_impersonation": copy.deepcopy( + self._service_account_impersonation_options + ) + or None, "credential_source": copy.deepcopy(self._credential_source), "quota_project_id": self._quota_project_id, "client_id": self._client_id, @@ -250,6 +259,7 @@ def with_scopes(self, scopes, default_scopes=None): token_url=self._token_url, credential_source=self._credential_source, service_account_impersonation_url=self._service_account_impersonation_url, + service_account_impersonation_options=self._service_account_impersonation_options, client_id=self._client_id, client_secret=self._client_secret, quota_project_id=self._quota_project_id, @@ -360,6 +370,7 @@ def with_quota_project(self, quota_project_id): token_url=self._token_url, credential_source=self._credential_source, service_account_impersonation_url=self._service_account_impersonation_url, + service_account_impersonation_options=self._service_account_impersonation_options, client_id=self._client_id, client_secret=self._client_secret, quota_project_id=quota_project_id, @@ -393,6 +404,7 @@ def _initialize_impersonated_credentials(self): token_url=self._token_url, credential_source=self._credential_source, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=self._client_id, client_secret=self._client_secret, quota_project_id=self._quota_project_id, @@ -419,6 +431,9 @@ def _initialize_impersonated_credentials(self): target_scopes=scopes, quota_project_id=self._quota_project_id, iam_endpoint_override=self._service_account_impersonation_url, + lifetime=self._service_account_impersonation_options.get( + "token_lifetime_seconds" + ), ) @staticmethod @@ -468,3 +483,54 @@ def is_valid_url(patterns, url): return False return any(re.compile(p).match(uri.hostname.lower()) for p in patterns) + + @classmethod + def from_info(cls, info, **kwargs): + """Creates a Credentials instance from parsed external account info. + + Args: + info (Mapping[str, str]): The external account info in Google + format. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.identity_pool.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" + ), + service_account_impersonation_options=info.get( + "service_account_impersonation" + ) + or {}, + 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 a Credentials instance from an external account json file. + + Args: + filename (str): The path to the external account json file. + kwargs: Additional arguments to pass to the constructor. + + Returns: + google.auth.identity_pool.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/google/auth/identity_pool.py b/google/auth/identity_pool.py index fb33d7726..5fa9faef9 100644 --- a/google/auth/identity_pool.py +++ b/google/auth/identity_pool.py @@ -56,13 +56,8 @@ def __init__( 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, + *args, + **kwargs ): """Instantiates an external account credentials object from a file/URL. @@ -90,21 +85,8 @@ def __init__( { "file": "/path/to/token/file.txt" } - - 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 identity pool. The underlying principal must still have - serviceusage.services.use IAM permission to use the project for - billing/quota. + args (List): Optional positional arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. + kwargs (Mapping): Optional keyword arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. Raises: google.auth.exceptions.RefreshError: If an error is encountered during @@ -121,13 +103,8 @@ def __init__( 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, + *args, + **kwargs ) if not isinstance(credential_source, Mapping): self._credential_source_file = None @@ -255,20 +232,7 @@ def from_info(cls, info, **kwargs): 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 - ) + return super(Credentials, cls).from_info(info, **kwargs) @classmethod def from_file(cls, filename, **kwargs): @@ -282,6 +246,4 @@ def from_file(cls, filename, **kwargs): google.auth.identity_pool.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) + return super(Credentials, cls).from_file(filename, **kwargs) diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 72e61a1fe..4d0c4f0f1 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -232,7 +232,7 @@ def __init__( self._target_principal = target_principal self._target_scopes = target_scopes self._delegates = delegates - self._lifetime = lifetime + self._lifetime = lifetime or _DEFAULT_TOKEN_LIFETIME_SECS self.token = None self.expiry = _helpers.utcnow() self._quota_project_id = quota_project_id @@ -374,7 +374,7 @@ def __init__( def from_credentials(self, target_credentials, target_audience=None): return self.__class__( - target_credentials=self._target_credentials, + target_credentials=target_credentials, target_audience=target_audience, include_email=self._include_email, quota_project_id=self._quota_project_id, diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index 12cd6240e..42f6bcd81 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -35,7 +35,6 @@ # Python 2.7 compatibility except ImportError: # pragma: NO COVER from collections import Mapping -import io import json import os import subprocess @@ -58,13 +57,8 @@ def __init__( 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, + *args, + **kwargs ): """Instantiates an external account credentials object from a executables. @@ -85,21 +79,8 @@ def __init__( "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. + args (List): Optional positional arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. + kwargs (Mapping): Optional keyword arguments passed into the underlying :meth:`~external_account.Credentials.__init__` method. Raises: google.auth.exceptions.RefreshError: If an error is encountered during @@ -116,13 +97,8 @@ def __init__( 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, + *args, + **kwargs ) if not isinstance(credential_source, Mapping): self._credential_source_executable = None @@ -249,20 +225,7 @@ def from_info(cls, info, **kwargs): 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 - ) + return super(Credentials, cls).from_info(info, **kwargs) @classmethod def from_file(cls, filename, **kwargs): @@ -276,9 +239,7 @@ def from_file(cls, filename, **kwargs): 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) + return super(Credentials, cls).from_file(filename, **kwargs) def _parse_subject_token(self, response): if "version" not in response: @@ -301,11 +262,14 @@ def _parse_subject_token(self, response): response["code"], response["message"] ) ) - if "expiration_time" not in response: + if ( + "expiration_time" not in response + and self._credential_source_executable_output_file + ): raise ValueError( - "The executable response is missing the expiration_time field." + "The executable response must contain an expiration_time for successful responses when an output_file has been specified in the configuration." ) - if response["expiration_time"] < time.time(): + if "expiration_time" in response and response["expiration_time"] < time.time(): raise exceptions.RefreshError( "The token returned by the executable is expired." ) diff --git a/google/auth/version.py b/google/auth/version.py index a6759c20a..bcf2a36f4 100644 --- a/google/auth/version.py +++ b/google/auth/version.py @@ -12,4 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -__version__ = "2.9.1" +__version__ = "2.10.0" diff --git a/google/oauth2/_id_token_async.py b/google/oauth2/_id_token_async.py index 20630e0d4..b90994cdc 100644 --- a/google/oauth2/_id_token_async.py +++ b/google/oauth2/_id_token_async.py @@ -95,7 +95,7 @@ async def _fetch_certs(request, certs_url): data = await response.data.read() - return json.loads(json.dumps(data)) + return json.loads(data.decode("utf-8")) async def verify_token( diff --git a/system_tests/noxfile.py b/system_tests/noxfile.py index e056d4b29..88f2e040e 100644 --- a/system_tests/noxfile.py +++ b/system_tests/noxfile.py @@ -37,6 +37,7 @@ EXPLICIT_CREDENTIALS_ENV = "GOOGLE_APPLICATION_CREDENTIALS" EXPLICIT_PROJECT_ENV = "GOOGLE_CLOUD_PROJECT" EXPECT_PROJECT_ENV = "EXPECT_PROJECT_ID" +ALLOW_PLUGGABLE_ENV = "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES" SKIP_GAE_TEST_ENV = "SKIP_APP_ENGINE_SYSTEM_TEST" GAE_APP_URL_TMPL = "https://{}-dot-{}.appspot.com" @@ -168,7 +169,7 @@ def configure_cloud_sdk(session, application_default_credentials, project=False) # Test sesssions -TEST_DEPENDENCIES_ASYNC = ["aiohttp", "pytest-asyncio", "nest-asyncio"] +TEST_DEPENDENCIES_ASYNC = ["aiohttp", "pytest-asyncio", "nest-asyncio", "mock"] TEST_DEPENDENCIES_SYNC = ["pytest", "requests", "mock"] PYTHON_VERSIONS_ASYNC = ["3.7"] PYTHON_VERSIONS_SYNC = ["2.7", "3.7"] @@ -379,10 +380,11 @@ def mtls_http(session): ) -@nox.session(python=PYTHON_VERSIONS_SYNC) +@nox.session(python=PYTHON_VERSIONS_ASYNC) def external_accounts(session): + session.env[ALLOW_PLUGGABLE_ENV] = "1" session.install( - *TEST_DEPENDENCIES_SYNC, + *TEST_DEPENDENCIES_ASYNC, LIBRARY_DIR, "google-api-python-client", ) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 9baa3acd2..f5652a5a4 100644 Binary files a/system_tests/secrets.tar.enc and b/system_tests/secrets.tar.enc differ diff --git a/system_tests/system_tests_async/conftest.py b/system_tests/system_tests_async/conftest.py index 966909924..8ab2ff029 100644 --- a/system_tests/system_tests_async/conftest.py +++ b/system_tests/system_tests_async/conftest.py @@ -19,6 +19,7 @@ import google.auth.transport.requests import google.auth.transport.urllib3 import pytest +import pytest_asyncio import requests import urllib3 @@ -30,37 +31,37 @@ TOKEN_INFO_URL = "https://www.googleapis.com/oauth2/v3/tokeninfo" -@pytest.fixture +@pytest_asyncio.fixture def service_account_file(): """The full path to a valid service account key file.""" yield sync_conftest.SERVICE_ACCOUNT_FILE -@pytest.fixture +@pytest_asyncio.fixture def impersonated_service_account_file(): """The full path to a valid service account key file.""" yield sync_conftest.IMPERSONATED_SERVICE_ACCOUNT_FILE -@pytest.fixture +@pytest_asyncio.fixture def authorized_user_file(): """The full path to a valid authorized user file.""" yield sync_conftest.AUTHORIZED_USER_FILE -@pytest.fixture +@pytest_asyncio.fixture async def aiohttp_session(): async with aiohttp.ClientSession(auto_decompress=False) as session: yield session -@pytest.fixture(params=["aiohttp"]) +@pytest_asyncio.fixture(params=["aiohttp"]) async def http_request(request, aiohttp_session): """A transport.request object.""" yield aiohttp_requests.Request(aiohttp_session) -@pytest.fixture +@pytest_asyncio.fixture async def token_info(http_request): """Returns a function that obtains OAuth2 token info.""" @@ -85,7 +86,7 @@ async def _token_info(access_token=None, id_token=None): yield _token_info -@pytest.fixture +@pytest_asyncio.fixture async def verify_refresh(http_request): """Returns a function that verifies that credentials can be refreshed.""" diff --git a/system_tests/system_tests_sync/test_external_accounts.py b/system_tests/system_tests_sync/test_external_accounts.py index e24c7b40a..59fbd4bef 100644 --- a/system_tests/system_tests_sync/test_external_accounts.py +++ b/system_tests/system_tests_sync/test_external_accounts.py @@ -32,11 +32,13 @@ # original service account key. +import datetime import json import os import socket from tempfile import NamedTemporaryFile import threading +import time import sys import google.auth @@ -171,6 +173,34 @@ def test_file_based_external_account( }, ) +# This test makes sure that setting a token lifetime works +# for service account impersonation. +def test_file_based_external_account_with_configure_token_lifetime( + oidc_credentials, service_account_info, dns_access +): + with NamedTemporaryFile() as tmpfile: + tmpfile.write(oidc_credentials.token.encode("utf-8")) + tmpfile.flush() + + assert get_project_dns( + dns_access, + { + "type": "external_account", + "audience": _AUDIENCE_OIDC, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + oidc_credentials.service_account_email + ), + "service_account_impersonation": { + "token_lifetime_seconds": 2800, + }, + "credential_source": { + "file": tmpfile.name, + }, + }, + ) + # This test makes sure that setting up an http server to provide credentials # works to allow access to Google resources. @@ -303,3 +333,45 @@ def test_aws_based_external_account( }, }, ) + + +# This test makes sure that setting up an executable to provide credentials +# works to allow access to Google resources. +def test_pluggable_external_account( + oidc_credentials, service_account_info, dns_access +): + now = datetime.datetime.now() + unix_seconds = time.mktime(now.timetuple()) + expiration_time = (unix_seconds + 1 * 60 * 60) * 1000 + credential = { + "success": True, + "version": 1, + "expiration_time": expiration_time, + "token_type": "urn:ietf:params:oauth:token-type:jwt", + "id_token": oidc_credentials.token, + } + + tmpfile = NamedTemporaryFile(delete=True) + with open(tmpfile.name, "w") as f: + f.write("#!/bin/bash\n") + f.write("echo \"{}\"\n".format(json.dumps(credential).replace('"', '\\"'))) + tmpfile.file.close() + + os.chmod(tmpfile.name, 0o777) + assert get_project_dns( + dns_access, + { + "type": "external_account", + "audience": _AUDIENCE_OIDC, + "subject_token_type": "urn:ietf:params:oauth:token-type:jwt", + "token_url": "https://sts.googleapis.com/v1/token", + "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/{}:generateAccessToken".format( + oidc_credentials.service_account_email + ), + "credential_source": { + "executable": { + "command": tmpfile.name, + } + }, + }, + ) diff --git a/testing/requirements.txt b/testing/requirements.txt index df20f96d6..299c8f2ef 100644 --- a/testing/requirements.txt +++ b/testing/requirements.txt @@ -17,4 +17,4 @@ grpcio pytest-asyncio; python_version > '3.0' aioresponses; python_version > '3.0' asynctest; python_version > '3.0' -aiohttp; python_version > '3.0' \ No newline at end of file +aiohttp; python_version > '3.0' diff --git a/tests/test_aws.py b/tests/test_aws.py index d55afa6a8..0a451f3eb 100644 --- a/tests/test_aws.py +++ b/tests/test_aws.py @@ -797,6 +797,7 @@ def test_from_info_full_options(self, mock_init): "subject_token_type": SUBJECT_TOKEN_TYPE, "token_url": TOKEN_URL, "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "quota_project_id": QUOTA_PROJECT_ID, @@ -811,10 +812,12 @@ def test_from_info_full_options(self, mock_init): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, 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(aws.Credentials, "__init__", return_value=None) @@ -835,10 +838,12 @@ def test_from_info_required_options_only(self, mock_init): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=None, client_secret=None, credential_source=self.CREDENTIAL_SOURCE, quota_project_id=None, + workforce_pool_user_project=None, ) @mock.patch.object(aws.Credentials, "__init__", return_value=None) @@ -848,6 +853,7 @@ def test_from_file_full_options(self, mock_init, tmpdir): "subject_token_type": SUBJECT_TOKEN_TYPE, "token_url": TOKEN_URL, "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "quota_project_id": QUOTA_PROJECT_ID, @@ -864,10 +870,12 @@ def test_from_file_full_options(self, mock_init, tmpdir): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, 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(aws.Credentials, "__init__", return_value=None) @@ -889,10 +897,12 @@ def test_from_file_required_options_only(self, mock_init, tmpdir): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=None, client_secret=None, credential_source=self.CREDENTIAL_SOURCE, quota_project_id=None, + workforce_pool_user_project=None, ) def test_constructor_invalid_credential_source(self): diff --git a/tests/test_external_account.py b/tests/test_external_account.py index 067fb59b6..a289b5df9 100644 --- a/tests/test_external_account.py +++ b/tests/test_external_account.py @@ -74,6 +74,7 @@ def __init__( token_url, credential_source, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=None, client_secret=None, quota_project_id=None, @@ -87,6 +88,7 @@ def __init__( token_url=token_url, credential_source=credential_source, service_account_impersonation_url=service_account_impersonation_url, + service_account_impersonation_options=service_account_impersonation_options, client_id=client_id, client_secret=client_secret, quota_project_id=quota_project_id, @@ -166,12 +168,14 @@ def make_credentials( scopes=None, default_scopes=None, service_account_impersonation_url=None, + service_account_impersonation_options={}, ): return CredentialsImpl( audience=cls.AUDIENCE, subject_token_type=cls.SUBJECT_TOKEN_TYPE, token_url=cls.TOKEN_URL, service_account_impersonation_url=service_account_impersonation_url, + service_account_impersonation_options=service_account_impersonation_options, credential_source=cls.CREDENTIAL_SOURCE, client_id=client_id, client_secret=client_secret, @@ -493,6 +497,7 @@ def test_with_scopes_full_options_propagated(self): scopes=self.SCOPES, default_scopes=["default1"], service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, ) with mock.patch.object( @@ -508,6 +513,7 @@ def test_with_scopes_full_options_propagated(self): token_url=self.TOKEN_URL, credential_source=self.CREDENTIAL_SOURCE, service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, quota_project_id=self.QUOTA_PROJECT_ID, @@ -550,6 +556,7 @@ def test_with_quota_project_full_options_propagated(self): scopes=self.SCOPES, default_scopes=["default1"], service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, ) with mock.patch.object( @@ -565,6 +572,7 @@ def test_with_quota_project_full_options_propagated(self): token_url=self.TOKEN_URL, credential_source=self.CREDENTIAL_SOURCE, service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, quota_project_id="project-foo", @@ -614,6 +622,7 @@ def test_info_with_full_options(self): client_secret=CLIENT_SECRET, quota_project_id=self.QUOTA_PROJECT_ID, service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, ) assert credentials.info == { @@ -622,6 +631,7 @@ def test_info_with_full_options(self): "subject_token_type": self.SUBJECT_TOKEN_TYPE, "token_url": self.TOKEN_URL, "service_account_impersonation_url": self.SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, "credential_source": self.CREDENTIAL_SOURCE.copy(), "quota_project_id": self.QUOTA_PROJECT_ID, "client_id": CLIENT_ID, @@ -1733,6 +1743,71 @@ def test_workforce_pool_get_project_id_cloud_resource_manager_success(self): # No additional requests. assert len(request.call_args_list) == 2 + def test_refresh_impersonation_with_lifetime(self): + # Simulate service account access token expires in 2800 seconds. + expire_time = ( + _helpers.utcnow().replace(microsecond=0) + datetime.timedelta(seconds=2800) + ).isoformat("T") + "Z" + expected_expiry = datetime.datetime.strptime(expire_time, "%Y-%m-%dT%H:%M:%SZ") + # STS token exchange request/response. + token_response = self.SUCCESS_RESPONSE.copy() + token_headers = {"Content-Type": "application/x-www-form-urlencoded"} + token_request_data = { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "audience": self.AUDIENCE, + "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", + "subject_token": "subject_token_0", + "subject_token_type": self.SUBJECT_TOKEN_TYPE, + "scope": "https://www.googleapis.com/auth/iam", + } + # Service account impersonation request/response. + 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": self.SCOPES, + "lifetime": "2800s", + } + # Initialize mock request to handle token exchange and service account + # impersonation request. + request = self.make_mock_request( + status=http_client.OK, + data=token_response, + impersonation_status=http_client.OK, + impersonation_data=impersonation_response, + ) + # Initialize credentials with service account impersonation. + credentials = self.make_credentials( + service_account_impersonation_url=self.SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, + scopes=self.SCOPES, + ) + + credentials.refresh(request) + + # Only 2 requests should be processed. + assert len(request.call_args_list) == 2 + # Verify token exchange request parameters. + self.assert_token_request_kwargs( + request.call_args_list[0][1], token_headers, token_request_data + ) + # Verify service account impersonation request parameters. + self.assert_impersonation_request_kwargs( + request.call_args_list[1][1], + impersonation_headers, + impersonation_request_data, + ) + assert credentials.valid + assert credentials.expiry == expected_expiry + assert not credentials.expired + assert credentials.token == impersonation_response["accessToken"] + def test_get_project_id_cloud_resource_manager_error(self): # Simulate resource doesn't have sufficient permissions to access # cloud resource manager. diff --git a/tests/test_identity_pool.py b/tests/test_identity_pool.py index 664c317d0..3f48675e2 100644 --- a/tests/test_identity_pool.py +++ b/tests/test_identity_pool.py @@ -293,6 +293,7 @@ def test_from_info_full_options(self, mock_init): "subject_token_type": SUBJECT_TOKEN_TYPE, "token_url": TOKEN_URL, "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "quota_project_id": QUOTA_PROJECT_ID, @@ -307,6 +308,7 @@ def test_from_info_full_options(self, mock_init): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, credential_source=self.CREDENTIAL_SOURCE_TEXT, @@ -332,6 +334,7 @@ def test_from_info_required_options_only(self, mock_init): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=None, client_secret=None, credential_source=self.CREDENTIAL_SOURCE_TEXT, @@ -358,6 +361,7 @@ def test_from_info_workforce_pool(self, mock_init): subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=None, client_secret=None, credential_source=self.CREDENTIAL_SOURCE_TEXT, @@ -372,6 +376,7 @@ def test_from_file_full_options(self, mock_init, tmpdir): "subject_token_type": SUBJECT_TOKEN_TYPE, "token_url": TOKEN_URL, "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "quota_project_id": QUOTA_PROJECT_ID, @@ -388,6 +393,7 @@ def test_from_file_full_options(self, mock_init, tmpdir): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, credential_source=self.CREDENTIAL_SOURCE_TEXT, @@ -414,6 +420,7 @@ def test_from_file_required_options_only(self, mock_init, tmpdir): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=None, client_secret=None, credential_source=self.CREDENTIAL_SOURCE_TEXT, @@ -441,6 +448,7 @@ def test_from_file_workforce_pool(self, mock_init, tmpdir): subject_token_type=WORKFORCE_SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=None, client_secret=None, credential_source=self.CREDENTIAL_SOURCE_TEXT, diff --git a/tests/test_impersonated_credentials.py b/tests/test_impersonated_credentials.py index f65fb7541..0e4dc08d7 100644 --- a/tests/test_impersonated_credentials.py +++ b/tests/test_impersonated_credentials.py @@ -465,14 +465,17 @@ def test_id_token_from_credential( assert credentials.valid assert not credentials.expired + new_credentials = self.make_credentials(lifetime=None) + id_creds = impersonated_credentials.IDTokenCredentials( credentials, target_audience=target_audience, include_email=True ) - id_creds = id_creds.from_credentials(target_credentials=credentials) + id_creds = id_creds.from_credentials(target_credentials=new_credentials) id_creds.refresh(request) assert id_creds.token == ID_TOKEN_DATA assert id_creds._include_email is True + assert id_creds._target_credentials is new_credentials def test_id_token_with_target_audience( self, mock_donor_credentials, mock_authorizedsession_idtoken diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 61ddabd45..b90c86c3a 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -127,6 +127,7 @@ def test_from_info_full_options(self, mock_init): "subject_token_type": SUBJECT_TOKEN_TYPE, "token_url": TOKEN_URL, "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "quota_project_id": QUOTA_PROJECT_ID, @@ -141,6 +142,7 @@ def test_from_info_full_options(self, mock_init): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, credential_source=self.CREDENTIAL_SOURCE, @@ -166,6 +168,7 @@ def test_from_info_required_options_only(self, mock_init): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=None, client_secret=None, credential_source=self.CREDENTIAL_SOURCE, @@ -180,6 +183,7 @@ def test_from_file_full_options(self, mock_init, tmpdir): "subject_token_type": SUBJECT_TOKEN_TYPE, "token_url": TOKEN_URL, "service_account_impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "service_account_impersonation": {"token_lifetime_seconds": 2800}, "client_id": CLIENT_ID, "client_secret": CLIENT_SECRET, "quota_project_id": QUOTA_PROJECT_ID, @@ -196,6 +200,7 @@ def test_from_file_full_options(self, mock_init, tmpdir): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + service_account_impersonation_options={"token_lifetime_seconds": 2800}, client_id=CLIENT_ID, client_secret=CLIENT_SECRET, credential_source=self.CREDENTIAL_SOURCE, @@ -222,6 +227,7 @@ def test_from_file_required_options_only(self, mock_init, tmpdir): subject_token_type=SUBJECT_TOKEN_TYPE, token_url=TOKEN_URL, service_account_impersonation_url=None, + service_account_impersonation_options={}, client_id=None, client_secret=None, credential_source=self.CREDENTIAL_SOURCE, @@ -630,7 +636,9 @@ def test_retrieve_subject_token_missing_error_code_message(self): ) @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_missing_expiration_time(self): + def test_retrieve_subject_token_without_expiration_time_should_fail_when_output_file_specified( + self + ): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { "version": 1, "success": True, @@ -652,9 +660,64 @@ def test_retrieve_subject_token_missing_expiration_time(self): _ = credentials.retrieve_subject_token(None) assert excinfo.match( - r"The executable response is missing the expiration_time field." + r"The executable response must contain an expiration_time for successful responses when an output_file has been specified in the configuration." ) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_without_expiration_time_should_fail_when_retrieving_from_output_file( + 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} + data = self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN.copy() + data.pop("expiration_time") + + with open(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w") as output_file: + json.dump(data, 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 must contain an expiration_time for successful responses when an output_file has been specified in the configuration." + ) + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_without_expiration_time_should_pass_when_output_file_not_specified( + self + ): + EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": self.EXECUTABLE_OIDC_TOKEN, + } + + CREDENTIAL_SOURCE = { + "executable": {"command": "command", "timeout_millis": 30000} + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE).encode("UTF-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable(credential_source=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_missing_token_type(self): EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE = { diff --git a/tests_async/oauth2/test_id_token.py b/tests_async/oauth2/test_id_token.py index b84e74db2..a52b8b4e0 100644 --- a/tests_async/oauth2/test_id_token.py +++ b/tests_async/oauth2/test_id_token.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import json import os import mock @@ -32,7 +33,9 @@ def make_request(status, data=None): if data is not None: response.data = mock.AsyncMock(spec=["__call__", "read"]) - response.data.read = mock.AsyncMock(spec=["__call__"], return_value=data) + response.data.read = mock.AsyncMock( + spec=["__call__"], return_value=json.dumps(data).encode("utf-8") + ) request = mock.AsyncMock(spec=["transport.Request"]) request.return_value = response