From 44a189fc6185bf33e9d5609cf8d57a846cd98aaf Mon Sep 17 00:00:00 2001 From: Jin Date: Wed, 28 Sep 2022 10:29:19 -0700 Subject: [PATCH] feat: implement pluggable auth interactive mode (#1131) For interactive mode: 1. Always using output to read the result. 2. Make `expiration_time` optional for all mode. 3. Implement interactive mode run executable 4. Implement `revoke()` function. 5. Refactor tests Co-authored-by: Leo <39062083+lsirac@users.noreply.github.com> Co-authored-by: Carl Lundin <108372512+clundin25@users.noreply.github.com> --- docs/user-guide.rst | 10 +- google/auth/pluggable.py | 244 +++++++++++++----- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes tests/test_pluggable.py | 468 +++++++++++++++++++++++++++-------- 4 files changed, 554 insertions(+), 168 deletions(-) diff --git a/docs/user-guide.rst b/docs/user-guide.rst index e689b11c6..682b58a76 100644 --- a/docs/user-guide.rst +++ b/docs/user-guide.rst @@ -434,8 +434,10 @@ 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. + - 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* @@ -450,7 +452,9 @@ Response format fields summary: 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. +``expiration_time`` is optional. If the output file does not contain the +``expiration_time`` field, the response will be considered expired and the +executable will be called. 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/pluggable.py b/google/auth/pluggable.py index 42f6bcd81..6be8222c1 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -38,6 +38,7 @@ import json import os import subprocess +import sys import time from google.auth import _helpers @@ -47,6 +48,14 @@ # The max supported executable spec version. EXECUTABLE_SUPPORTED_MAX_VERSION = 1 +EXECUTABLE_TIMEOUT_MILLIS_DEFAULT = 30 * 1000 # 30 seconds +EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND = 5 * 1000 # 5 seconds +EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND = 120 * 1000 # 2 minutes + +EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_DEFAULT = 5 * 60 * 1000 # 5 minutes +EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND = 5 * 60 * 1000 # 5 minutes +EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND = 30 * 60 * 1000 # 30 minutes + class Credentials(external_account.Credentials): """External account credentials sourced from executables.""" @@ -92,6 +101,7 @@ def __init__( :meth:`from_info` are used instead of calling the constructor directly. """ + self.interactive = kwargs.pop("interactive", False) super(Credentials, self).__init__( audience=audience, subject_token_type=subject_token_type, @@ -116,37 +126,51 @@ def __init__( self._credential_source_executable_timeout_millis = self._credential_source_executable.get( "timeout_millis" ) + self._credential_source_executable_interactive_timeout_millis = self._credential_source_executable.get( + "interactive_timeout_millis" + ) self._credential_source_executable_output_file = self._credential_source_executable.get( "output_file" ) + self._tokeninfo_username = kwargs.get("tokeninfo_username", "") # dummy value if not self._credential_source_executable_command: 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 + self._credential_source_executable_timeout_millis = ( + EXECUTABLE_TIMEOUT_MILLIS_DEFAULT + ) elif ( - self._credential_source_executable_timeout_millis < 5 * 1000 - or self._credential_source_executable_timeout_millis > 120 * 1000 + self._credential_source_executable_timeout_millis + < EXECUTABLE_TIMEOUT_MILLIS_LOWER_BOUND + or self._credential_source_executable_timeout_millis + > EXECUTABLE_TIMEOUT_MILLIS_UPPER_BOUND ): raise ValueError("Timeout must be between 5 and 120 seconds.") + if not self._credential_source_executable_interactive_timeout_millis: + self._credential_source_executable_interactive_timeout_millis = ( + EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_DEFAULT + ) + elif ( + self._credential_source_executable_interactive_timeout_millis + < EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_LOWER_BOUND + or self._credential_source_executable_interactive_timeout_millis + > EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_UPPER_BOUND + ): + raise ValueError("Interactive timeout must be between 5 and 30 minutes.") + @_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." - ) + self._validate_running_mode() # Check output file. if self._credential_source_executable_output_file is not None: try: with open( - self._credential_source_executable_output_file + self._credential_source_executable_output_file, encoding="utf-8" ) as output_file: response = json.load(output_file) except Exception: @@ -155,6 +179,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) + if ( + "expiration_time" not in response + ): # Always treat missing expiration_time as expired and proceed to executable run. + raise exceptions.RefreshError except ValueError: raise except exceptions.RefreshError: @@ -169,46 +197,102 @@ def retrieve_subject_token(self, request): # 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: - env[ - "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" - ] = self.service_account_email - if self._credential_source_executable_output_file is not None: - env[ - "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" - ] = self._credential_source_executable_output_file + self._inject_env_variables(env) + env["GOOGLE_EXTERNAL_ACCOUNT_REVOKE"] = "0" - try: - 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: - raise exceptions.RefreshError( - "Executable exited with non-zero return code {}. Error: {}".format( - result.returncode, result.stdout - ) + # Run executable. + exe_timeout = ( + self._credential_source_executable_interactive_timeout_millis / 1000 + if self.interactive + else self._credential_source_executable_timeout_millis / 1000 + ) + exe_stdin = sys.stdin if self.interactive else None + exe_stdout = sys.stdout if self.interactive else subprocess.PIPE + exe_stderr = sys.stdout if self.interactive else subprocess.STDOUT + + result = subprocess.run( + self._credential_source_executable_command.split(), + timeout=exe_timeout, + stdin=exe_stdin, + stdout=exe_stdout, + stderr=exe_stderr, + env=env, + ) + if result.returncode != 0: + raise exceptions.RefreshError( + "Executable exited with non-zero return code {}. Error: {}".format( + result.returncode, result.stdout ) - except Exception: - raise - else: - try: - data = result.stdout.decode("utf-8") - response = json.loads(data) - subject_token = self._parse_subject_token(response) - except Exception: - raise + ) + + # Handle executable output. + response = json.loads(result.stdout.decode("utf-8")) if result.stdout else None + if not response and self._credential_source_executable_output_file is not None: + response = json.load( + open(self._credential_source_executable_output_file, encoding="utf-8") + ) + subject_token = self._parse_subject_token(response) return subject_token + def revoke(self, request): + """Revokes the subject token using the credential_source object. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + Raises: + google.auth.exceptions.RefreshError: If the executable revocation + not properly executed. + + """ + if not self.interactive: + raise ValueError("Revoke is only enabled under interactive mode.") + self._validate_running_mode() + + if not _helpers.is_python_3(): + raise exceptions.RefreshError( + "Pluggable auth is only supported for python 3.6+" + ) + + # Inject variables + env = os.environ.copy() + self._inject_env_variables(env) + env["GOOGLE_EXTERNAL_ACCOUNT_REVOKE"] = "1" + + # Run executable + result = subprocess.run( + self._credential_source_executable_command.split(), + timeout=self._credential_source_executable_interactive_timeout_millis + / 1000, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=env, + ) + + if result.returncode != 0: + raise exceptions.RefreshError( + "Auth revoke failed on executable. Exit with non-zero return code {}. Error: {}".format( + result.returncode, result.stdout + ) + ) + + response = json.loads(result.stdout.decode("utf-8")) + self._validate_revoke_response(response) + + @property + def external_account_id(self): + """Returns the external account identifier. + + When service account impersonation is used the identifier is the service + account email. + + Without service account impersonation, this returns None, unless it is + being used by the Google Cloud CLI which populates this field. + """ + + return self.service_account_email or self._tokeninfo_username + @classmethod def from_info(cls, info, **kwargs): """Creates a Pluggable Credentials instance from parsed external account info. @@ -241,17 +325,23 @@ def from_file(cls, filename, **kwargs): """ return super(Credentials, cls).from_file(filename, **kwargs) + def _inject_env_variables(self, env): + env["GOOGLE_EXTERNAL_ACCOUNT_AUDIENCE"] = self._audience + env["GOOGLE_EXTERNAL_ACCOUNT_TOKEN_TYPE"] = self._subject_token_type + env["GOOGLE_EXTERNAL_ACCOUNT_ID"] = self.external_account_id + env["GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE"] = "1" if self.interactive else "0" + + if self._service_account_impersonation_url is not None: + env[ + "GOOGLE_EXTERNAL_ACCOUNT_IMPERSONATED_EMAIL" + ] = self.service_account_email + if self._credential_source_executable_output_file is not None: + env[ + "GOOGLE_EXTERNAL_ACCOUNT_OUTPUT_FILE" + ] = self._credential_source_executable_output_file + def _parse_subject_token(self, 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( - "Executable returned unsupported version {}.".format( - response["version"] - ) - ) - if "success" not in response: - raise ValueError("The executable response is missing the success field.") + self._validate_response_schema(response) if not response["success"]: if "code" not in response or "message" not in response: raise ValueError( @@ -262,13 +352,6 @@ def _parse_subject_token(self, response): response["code"], response["message"] ) ) - if ( - "expiration_time" not in response - and self._credential_source_executable_output_file - ): - raise ValueError( - "The executable response must contain an expiration_time for successful responses when an output_file has been specified in the configuration." - ) if "expiration_time" in response and response["expiration_time"] < time.time(): raise exceptions.RefreshError( "The token returned by the executable is expired." @@ -284,3 +367,38 @@ def _parse_subject_token(self, response): return response["saml_response"] else: raise exceptions.RefreshError("Executable returned unsupported token type.") + + def _validate_revoke_response(self, response): + self._validate_response_schema(response) + if not response["success"]: + raise exceptions.RefreshError("Revoke failed with unsuccessful response.") + + def _validate_response_schema(self, 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( + "Executable returned unsupported version {}.".format( + response["version"] + ) + ) + + if "success" not in response: + raise ValueError("The executable response is missing the success field.") + + def _validate_running_mode(self): + 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." + ) + + if self.interactive and not self._credential_source_executable_output_file: + raise ValueError( + "An output_file must be specified in the credential configuration for interactive mode." + ) + + if self.interactive and not self.is_workforce_pool: + raise ValueError("Interactive mode is only enabled for workforce pool.") diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 61917177f498741b2b4673c9d27c20e3a48ecb55..2bef7d971ed407a5c67431d403624e65b2c28877 100644 GIT binary patch literal 10324 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 diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 80cde7972..293d5c6ed 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -26,6 +26,7 @@ # from google.auth import _helpers from google.auth import exceptions from google.auth import pluggable +from tests.test__default import WORKFORCE_AUDIENCE # from google.auth import transport @@ -44,7 +45,6 @@ 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" @@ -57,6 +57,7 @@ class TestCredentials(object): CREDENTIAL_SOURCE_EXECUTABLE = { "command": CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, "timeout_millis": 30000, + "interactive_timeout_millis": 300000, "output_file": CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, } CREDENTIAL_SOURCE = {"executable": CREDENTIAL_SOURCE_EXECUTABLE} @@ -68,6 +69,12 @@ class TestCredentials(object): "id_token": EXECUTABLE_OIDC_TOKEN, "expiration_time": 9999999999, } + EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_ID_TOKEN = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:id_token", + "id_token": EXECUTABLE_OIDC_TOKEN, + } EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT = { "version": 1, "success": True, @@ -75,6 +82,12 @@ class TestCredentials(object): "id_token": EXECUTABLE_OIDC_TOKEN, "expiration_time": 9999999999, } + EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_JWT = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:jwt", + "id_token": EXECUTABLE_OIDC_TOKEN, + } EXECUTABLE_SAML_TOKEN = "FAKE_SAML_RESPONSE" EXECUTABLE_SUCCESSFUL_SAML_RESPONSE = { "version": 1, @@ -83,6 +96,12 @@ class TestCredentials(object): "saml_response": EXECUTABLE_SAML_TOKEN, "expiration_time": 9999999999, } + EXECUTABLE_SUCCESSFUL_SAML_NO_EXPIRATION_TIME_RESPONSE = { + "version": 1, + "success": True, + "token_type": "urn:ietf:params:oauth:token-type:saml2", + "saml_response": EXECUTABLE_SAML_TOKEN, + } EXECUTABLE_FAILED_RESPONSE = { "version": 1, "success": False, @@ -104,6 +123,7 @@ def make_pluggable( service_account_impersonation_url=None, credential_source=None, workforce_pool_user_project=None, + interactive=None, ): return pluggable.Credentials( audience=audience, @@ -117,6 +137,7 @@ def make_pluggable( scopes=scopes, default_scopes=default_scopes, workforce_pool_user_project=workforce_pool_user_project, + interactive=interactive, ) @mock.patch.object(pluggable.Credentials, "__init__", return_value=None) @@ -262,77 +283,134 @@ 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", - }, - ) - def test_retrieve_subject_token_oidc_id_token(self): - with mock.patch( - "subprocess.run", - return_value=subprocess.CompletedProcess( - args=[], - stdout=json.dumps( + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_successfully(self, tmpdir): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( + "actual_output_file" + ) + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "interactive_timeout_millis": 300000, + "output_file": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + ACTUAL_CREDENTIAL_SOURCE = {"executable": ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE} + + testData = { + "subject_token_oidc_id_token": { + "stdout": json.dumps( self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN ).encode("UTF-8"), - returncode=0, - ), - ): - credentials = self.make_pluggable( - audience=AUDIENCE, - service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, - credential_source=self.CREDENTIAL_SOURCE, - ) + "impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "file_content": self.EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_ID_TOKEN, + "expect_token": self.EXECUTABLE_OIDC_TOKEN, + }, + "subject_token_oidc_id_token_interacitve_mode": { + "audience": WORKFORCE_AUDIENCE, + "file_content": self.EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_ID_TOKEN, + "interactive": True, + "expect_token": self.EXECUTABLE_OIDC_TOKEN, + }, + "subject_token_oidc_jwt": { + "stdout": json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT + ).encode("UTF-8"), + "impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "file_content": self.EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_JWT, + "expect_token": self.EXECUTABLE_OIDC_TOKEN, + }, + "subject_token_oidc_jwt_interactive_mode": { + "audience": WORKFORCE_AUDIENCE, + "file_content": self.EXECUTABLE_SUCCESSFUL_OIDC_NO_EXPIRATION_TIME_RESPONSE_JWT, + "interactive": True, + "expect_token": self.EXECUTABLE_OIDC_TOKEN, + }, + "subject_token_saml": { + "stdout": json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE).encode( + "UTF-8" + ), + "impersonation_url": SERVICE_ACCOUNT_IMPERSONATION_URL, + "file_content": self.EXECUTABLE_SUCCESSFUL_SAML_NO_EXPIRATION_TIME_RESPONSE, + "expect_token": self.EXECUTABLE_SAML_TOKEN, + }, + "subject_token_saml_interactive_mode": { + "audience": WORKFORCE_AUDIENCE, + "file_content": self.EXECUTABLE_SUCCESSFUL_SAML_NO_EXPIRATION_TIME_RESPONSE, + "interactive": True, + "expect_token": self.EXECUTABLE_SAML_TOKEN, + }, + } - subject_token = credentials.retrieve_subject_token(None) + for data in testData.values(): + with open( + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, "w" + ) as output_file: + json.dump(data.get("file_content"), output_file) - assert subject_token == self.EXECUTABLE_OIDC_TOKEN + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], stdout=data.get("stdout"), returncode=0 + ), + ): + credentials = self.make_pluggable( + audience=data.get("audience", AUDIENCE), + service_account_impersonation_url=data.get("impersonation_url"), + credential_source=ACTUAL_CREDENTIAL_SOURCE, + interactive=data.get("interactive", False), + ) + subject_token = credentials.retrieve_subject_token(None) + assert subject_token == data.get("expect_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_oidc_jwt(self): + def test_retrieve_subject_token_saml(self): with mock.patch( "subprocess.run", return_value=subprocess.CompletedProcess( args=[], - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_JWT).encode( + stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE).encode( "UTF-8" ), returncode=0, ), ): - credentials = self.make_pluggable( - audience=AUDIENCE, - service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, - 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 + assert subject_token == self.EXECUTABLE_SAML_TOKEN @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) - def test_retrieve_subject_token_saml(self): + def test_retrieve_subject_token_saml_interactive_mode(self, tmpdir): + + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( + "actual_output_file" + ) + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "interactive_timeout_millis": 300000, + "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_SAML_NO_EXPIRATION_TIME_RESPONSE, output_file + ) + with mock.patch( "subprocess.run", - return_value=subprocess.CompletedProcess( - args=[], - stdout=json.dumps(self.EXECUTABLE_SUCCESSFUL_SAML_RESPONSE).encode( - "UTF-8" - ), - returncode=0, - ), + return_value=subprocess.CompletedProcess(args=[], returncode=0), ): - credentials = self.make_pluggable(credential_source=self.CREDENTIAL_SOURCE) + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=ACTUAL_CREDENTIAL_SOURCE, + interactive=True, + ) subject_token = credentials.retrieve_subject_token(None) assert subject_token == self.EXECUTABLE_SAML_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_failed(self): @@ -353,6 +431,46 @@ def test_retrieve_subject_token_failed(self): r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." ) + @mock.patch.dict( + os.environ, + { + "GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1", + "GOOGLE_EXTERNAL_ACCOUNT_INTERACTIVE": "1", + }, + ) + def test_retrieve_subject_token_failed_interactive_mode(self, tmpdir): + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( + "actual_output_file" + ) + ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE = { + "command": "command", + "interactive_timeout_millis": 300000, + "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", encoding="utf-8" + ) as output_file: + json.dump(self.EXECUTABLE_FAILED_RESPONSE, output_file) + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess(args=[], returncode=0), + ): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=ACTUAL_CREDENTIAL_SOURCE, + interactive=True, + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"Executable returned unsuccessful response: code: 401, message: Permission denied. Caller not authorized." + ) + os.remove(ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) def test_retrieve_subject_token_not_allowd(self): with mock.patch( @@ -641,63 +759,6 @@ def test_retrieve_subject_token_missing_error_code_message(self): 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_without_expiration_time_should_fail_when_output_file_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, - } - - 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=self.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." - ) - - @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, tmpdir - ): - ACTUAL_CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE = tmpdir.join( - "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, @@ -767,6 +828,36 @@ def test_credential_source_missing_command(self): r"Missing command field. Executable command must be provided." ) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_missing_output_interactive_mode(self): + CREDENTIAL_SOURCE = { + "executable": {"command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND} + } + credentials = self.make_pluggable( + credential_source=CREDENTIAL_SOURCE, interactive=True + ) + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match( + r"An output_file must be specified in the credential configuration for interactive mode." + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_timeout_missing_will_use_default_timeout_value(self): + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert ( + credentials._credential_source_executable_timeout_millis + == pluggable.EXECUTABLE_TIMEOUT_MILLIS_DEFAULT + ) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_credential_source_timeout_small(self): with pytest.raises(ValueError) as excinfo: @@ -795,6 +886,51 @@ def test_credential_source_timeout_large(self): 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_interactive_timeout_missing_will_use_default_interactive_timeout_value( + self + ): + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + credentials = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert ( + credentials._credential_source_executable_interactive_timeout_millis + == pluggable.EXECUTABLE_INTERACTIVE_TIMEOUT_MILLIS_DEFAULT + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_interactive_timeout_small(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "interactive_timeout_millis": 30000 - 1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert excinfo.match(r"Interactive timeout must be between 5 and 30 minutes.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_credential_source_interactive_timeout_large(self): + with pytest.raises(ValueError) as excinfo: + CREDENTIAL_SOURCE = { + "executable": { + "command": self.CREDENTIAL_SOURCE_EXECUTABLE_COMMAND, + "interactive_timeout_millis": 1800000 + 1, + "output_file": self.CREDENTIAL_SOURCE_EXECUTABLE_OUTPUT_FILE, + } + } + _ = self.make_pluggable(credential_source=CREDENTIAL_SOURCE) + + assert excinfo.match(r"Interactive timeout must be between 5 and 30 minutes.") + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_executable_fail(self): with mock.patch( @@ -812,6 +948,120 @@ def test_retrieve_subject_token_executable_fail(self): r"Executable exited with non-zero return code 1. Error: None" ) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_non_workforce_fail_interactive_mode(self): + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE, interactive=True + ) + with pytest.raises(ValueError) as excinfo: + _ = credentials.retrieve_subject_token(None) + + assert excinfo.match(r"Interactive mode is only enabled for workforce pool.") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_executable_fail_interactive_mode(self): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], stdout=None, returncode=1 + ), + ): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=self.CREDENTIAL_SOURCE, + interactive=True, + ) + + 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" + ) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "0"}) + def test_revoke_failed_executable_not_allowed(self): + credentials = self.make_pluggable( + credential_source=self.CREDENTIAL_SOURCE, interactive=True + ) + with pytest.raises(ValueError) as excinfo: + _ = credentials.revoke(None) + + assert excinfo.match(r"Executables need to be explicitly allowed") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_revoke_failed(self): + testData = { + "non_interactive_mode": { + "interactive": False, + "expectErrType": ValueError, + "expectErrPattern": r"Revoke is only enabled under interactive mode.", + }, + "executable_failed": { + "returncode": 1, + "expectErrType": exceptions.RefreshError, + "expectErrPattern": r"Auth revoke failed on executable.", + }, + "response_validation_missing_version": { + "response": {}, + "expectErrType": ValueError, + "expectErrPattern": r"The executable response is missing the version field.", + }, + "response_validation_invalid_version": { + "response": {"version": 2}, + "expectErrType": exceptions.RefreshError, + "expectErrPattern": r"Executable returned unsupported version.", + }, + "response_validation_missing_success": { + "response": {"version": 1}, + "expectErrType": ValueError, + "expectErrPattern": r"The executable response is missing the success field.", + }, + "response_validation_failed_with_success_field_is_false": { + "response": {"version": 1, "success": False}, + "expectErrType": exceptions.RefreshError, + "expectErrPattern": r"Revoke failed with unsuccessful response.", + }, + } + for data in testData.values(): + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(data.get("response")).encode("UTF-8"), + returncode=data.get("returncode", 0), + ), + ): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + credential_source=self.CREDENTIAL_SOURCE, + interactive=data.get("interactive", True), + ) + + with pytest.raises(data.get("expectErrType")) as excinfo: + _ = credentials.revoke(None) + + assert excinfo.match(data.get("expectErrPattern")) + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_revoke_successfully(self): + ACTUAL_RESPONSE = {"version": 1, "success": True} + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps(ACTUAL_RESPONSE).encode("utf-8"), + returncode=0, + ), + ): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=self.CREDENTIAL_SOURCE, + interactive=True, + ) + _ = credentials.revoke(None) + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) def test_retrieve_subject_token_python_2(self): with mock.patch("sys.version_info", (2, 7)): @@ -821,3 +1071,17 @@ def test_retrieve_subject_token_python_2(self): _ = credentials.retrieve_subject_token(None) assert excinfo.match(r"Pluggable auth is only supported for python 3.6+") + + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_revoke_subject_token_python_2(self): + with mock.patch("sys.version_info", (2, 7)): + credentials = self.make_pluggable( + audience=WORKFORCE_AUDIENCE, + credential_source=self.CREDENTIAL_SOURCE, + interactive=True, + ) + + with pytest.raises(exceptions.RefreshError) as excinfo: + _ = credentials.revoke(None) + + assert excinfo.match(r"Pluggable auth is only supported for python 3.6+")