From 5c372a92bf5086f1b63eaa8979fc13c5c0ce9dc9 Mon Sep 17 00:00:00 2001 From: Lingqing Gan Date: Mon, 10 Nov 2025 15:58:56 -0800 Subject: [PATCH 01/16] chore: update secret and fix pytest issue (#1868) --- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes tests/transport/aio/test_sessions.py | 22 ++++++++++++++++------ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index b4502815379a4a9a495cea73f619cbb9fe20f8d3..af9af5e620bb2bc052d49a2c7e1c1c765ea238d1 100644 GIT binary patch literal 10324 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 literal 10324 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 Date: Mon, 10 Nov 2025 16:20:28 -0800 Subject: [PATCH 02/16] test: create and delete temp folder in scripts (#1869) --- scripts/decrypt-secrets.sh | 4 ++++ scripts/encrypt-secrets.sh | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/decrypt-secrets.sh b/scripts/decrypt-secrets.sh index f0ef994ed..7e7f03bdc 100755 --- a/scripts/decrypt-secrets.sh +++ b/scripts/decrypt-secrets.sh @@ -20,6 +20,10 @@ ROOT=$( dirname "$DIR" ) # Work from the project root. cd $ROOT +# Create working directory if not exists. system_tests/data is not tracked by +# Git to prevent the secrets from being leaked online. +mkdir -p system_tests/data + gcloud kms decrypt \ --location=global \ --keyring=ci \ diff --git a/scripts/encrypt-secrets.sh b/scripts/encrypt-secrets.sh index b6521e8f5..fba27fba0 100755 --- a/scripts/encrypt-secrets.sh +++ b/scripts/encrypt-secrets.sh @@ -29,4 +29,6 @@ gcloud kms encrypt \ --plaintext-file=system_tests/secrets.tar \ --ciphertext-file=system_tests/secrets.tar.enc -rm system_tests/secrets.tar \ No newline at end of file +rm system_tests/secrets.tar + +rm system_tests/data \ No newline at end of file From b55aa118ac398d512b241777211685e854ad7312 Mon Sep 17 00:00:00 2001 From: werman Date: Mon, 10 Nov 2025 16:42:24 -0800 Subject: [PATCH 03/16] doc: Custom Credential Suppliers for AWS and Okta. (#1830) Documenting Custom Credential Suppliers for: 1. Aws Workload. 2. Okta Workload. The readme updates for these have already been made: [Link](https://github.com/googleapis/google-auth-library-python/pull/1496/files) --------- Co-authored-by: Chalmer Lowe Co-authored-by: Daniel Sanche --- .../snippets/custom_aws_supplier.py | 117 +++++++++++ .../snippets/custom_okta_supplier.py | 190 ++++++++++++++++++ .../cloud-client/snippets/requirements.txt | 5 +- 3 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 samples/cloud-client/snippets/custom_aws_supplier.py create mode 100644 samples/cloud-client/snippets/custom_okta_supplier.py diff --git a/samples/cloud-client/snippets/custom_aws_supplier.py b/samples/cloud-client/snippets/custom_aws_supplier.py new file mode 100644 index 000000000..ec5bf8a10 --- /dev/null +++ b/samples/cloud-client/snippets/custom_aws_supplier.py @@ -0,0 +1,117 @@ +# Copyright 2025 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 json +import os +import sys + +import boto3 +from dotenv import load_dotenv +from google.auth.aws import Credentials as AwsCredentials +from google.auth.aws import AwsSecurityCredentials, AwsSecurityCredentialsSupplier +from google.auth.exceptions import GoogleAuthError +from google.auth.transport.requests import AuthorizedSession + +load_dotenv() + + +class CustomAwsSupplier(AwsSecurityCredentialsSupplier): + """Custom AWS Security Credentials Supplier.""" + + def __init__(self): + """Initializes the Boto3 session, prioritizing environment variables for region.""" + # Explicitly read the region from the environment first. This ensures that + # a value from a .env file is picked up reliably for local testing. + region = os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") + + # If region is None, Boto3's discovery chain will be used when needed. + self.session = boto3.Session(region_name=region) + self._cached_region = None + print(f"[INFO] CustomAwsSupplier initialized. Region from env: {region}") + + def get_aws_region(self, context, request) -> str: + """Returns the AWS region using Boto3's default provider chain.""" + if self._cached_region: + return self._cached_region + + # Accessing region_name will use the value from the constructor if provided, + # otherwise it triggers Boto3's lazy-loading discovery (e.g., metadata service). + self._cached_region = self.session.region_name + + if not self._cached_region: + print("[ERROR] Boto3 was unable to resolve an AWS region.", file=sys.stderr) + raise GoogleAuthError("Boto3 was unable to resolve an AWS region.") + + print(f"[INFO] Boto3 resolved AWS Region: {self._cached_region}") + return self._cached_region + + def get_aws_security_credentials(self, context, request=None) -> AwsSecurityCredentials: + """Retrieves AWS security credentials using Boto3's default provider chain.""" + aws_credentials = self.session.get_credentials() + if not aws_credentials: + print("[ERROR] Unable to resolve AWS credentials.", file=sys.stderr) + raise GoogleAuthError("Unable to resolve AWS credentials from the provider chain.") + + print(f"[INFO] Resolved AWS Access Key ID: {aws_credentials.access_key}") + + return AwsSecurityCredentials( + access_key_id=aws_credentials.access_key, + secret_access_key=aws_credentials.secret_key, + session_token=aws_credentials.token, + ) + + +def main(): + """Main function to demonstrate the custom AWS supplier.""" + print("--- Starting Script ---") + + gcp_audience = os.getenv("GCP_WORKLOAD_AUDIENCE") + sa_impersonation_url = os.getenv("GCP_SERVICE_ACCOUNT_IMPERSONATION_URL") + gcs_bucket_name = os.getenv("GCS_BUCKET_NAME") + + print(f"GCP_WORKLOAD_AUDIENCE: {gcp_audience}") + print(f"GCS_BUCKET_NAME: {gcs_bucket_name}") + + if not all([gcp_audience, sa_impersonation_url, gcs_bucket_name]): + print("[ERROR] Missing required environment variables.", file=sys.stderr) + raise GoogleAuthError("Missing required environment variables.") + + custom_supplier = CustomAwsSupplier() + + credentials = AwsCredentials( + audience=gcp_audience, + subject_token_type="urn:ietf:params:aws:token-type:aws4_request", + service_account_impersonation_url=sa_impersonation_url, + aws_security_credentials_supplier=custom_supplier, + scopes=['https://www.googleapis.com/auth/devstorage.read_write'], + ) + + bucket_url = f"https://storage.googleapis.com/storage/v1/b/{gcs_bucket_name}" + print(f"Request URL: {bucket_url}") + + authed_session = AuthorizedSession(credentials) + try: + print("Attempting to make authenticated request to Google Cloud Storage...") + res = authed_session.get(bucket_url) + res.raise_for_status() + print("\n--- SUCCESS! ---") + print("Successfully authenticated and retrieved bucket data:") + print(json.dumps(res.json(), indent=2)) + except Exception as e: + print("--- FAILED --- ", file=sys.stderr) + print(e, file=sys.stderr) + exit(1) + + +if __name__ == "__main__": + main() diff --git a/samples/cloud-client/snippets/custom_okta_supplier.py b/samples/cloud-client/snippets/custom_okta_supplier.py new file mode 100644 index 000000000..12f83dcfa --- /dev/null +++ b/samples/cloud-client/snippets/custom_okta_supplier.py @@ -0,0 +1,190 @@ +# Copyright 2025 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 json +import urllib.parse +import os +import time + +import requests +from dotenv import load_dotenv +from google.auth.exceptions import GoogleAuthError +from google.auth.identity_pool import Credentials as IdentityPoolClient +from google.auth.transport.requests import AuthorizedSession + +load_dotenv() + +# Workload Identity Pool Configuration +GCP_WORKLOAD_AUDIENCE = os.getenv("GCP_WORKLOAD_AUDIENCE") +SERVICE_ACCOUNT_IMPERSONATION_URL = os.getenv("GCP_SERVICE_ACCOUNT_IMPERSONATION_URL") +GCS_BUCKET_NAME = os.getenv("GCS_BUCKET_NAME") + +# Okta Configuration +OKTA_DOMAIN = os.getenv("OKTA_DOMAIN") +OKTA_CLIENT_ID = os.getenv("OKTA_CLIENT_ID") +OKTA_CLIENT_SECRET = os.getenv("OKTA_CLIENT_SECRET") + +# Constants +TOKEN_URL = "https://sts.googleapis.com/v1/token" +SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" + + +class OktaClientCredentialsSupplier: + """A custom SubjectTokenSupplier that authenticates with Okta. + + This supplier uses the Client Credentials grant flow for machine-to-machine + (M2M) authentication with Okta. + """ + + def __init__(self, domain, client_id, client_secret): + self.okta_token_url = f"{domain}/oauth2/default/v1/token" + self.client_id = client_id + self.client_secret = client_secret + self.access_token = None + self.expiry_time = 0 + print("OktaClientCredentialsSupplier initialized.") + + def get_subject_token(self, context, request=None) -> str: + """Fetches a new token if the current one is expired or missing. + + Args: + context: The context object, not used in this implementation. + + Returns: + The Okta Access token. + """ + # Check if the current token is still valid (with a 60-second buffer). + is_token_valid = self.access_token and time.time() < self.expiry_time - 60 + + if is_token_valid: + print("[Supplier] Returning cached Okta Access token.") + return self.access_token + + print( + "[Supplier] Token is missing or expired. Fetching new Okta Access token..." + ) + self._fetch_okta_access_token() + return self.access_token + + def _fetch_okta_access_token(self): + """Performs the Client Credentials grant flow with Okta.""" + headers = { + "Content-Type": "application/x-www-form-urlencoded", + "Accept": "application/json", + } + data = { + "grant_type": "client_credentials", + "scope": "gcp.test.read", + } + encoded_data = urllib.parse.urlencode(data) + + try: + response = requests.post( + self.okta_token_url, + headers=headers, + data=encoded_data, + auth=(self.client_id, self.client_secret), + ) + response.raise_for_status() + token_data = response.json() + + if "access_token" in token_data and "expires_in" in token_data: + self.access_token = token_data["access_token"] + self.expiry_time = time.time() + token_data["expires_in"] + print( + f"[Supplier] Successfully received Access Token from Okta. " + f"Expires in {token_data['expires_in']} seconds." + ) + else: + raise GoogleAuthError( + "Access token or expires_in not found in Okta response." + ) + except requests.exceptions.RequestException as e: + print(f"[Supplier] Error fetching token from Okta: {e}") + if e.response: + print(f"[Supplier] Okta response: {e.response.text}") + raise GoogleAuthError( + "Failed to authenticate with Okta using Client Credentials grant." + ) from e + + +def main(): + """Main function to demonstrate the custom Okta supplier. + + TODO(Developer): + 1. Before running this sample, set up your environment variables. You can do + this by creating a .env file in the same directory as this script and + populating it with the following variables: + - GCP_WORKLOAD_AUDIENCE: The audience for the GCP workload identity pool. + - GCP_SERVICE_ACCOUNT_IMPERSONATION_URL: The URL for service account impersonation (optional). + - GCS_BUCKET_NAME: The name of the GCS bucket to access. + - OKTA_DOMAIN: Your Okta domain (e.g., https://dev-12345.okta.com). + - OKTA_CLIENT_ID: The Client ID of your Okta M2M application. + - OKTA_CLIENT_SECRET: The Client Secret of your Okta M2M application. + """ + if not all( + [ + GCP_WORKLOAD_AUDIENCE, + GCS_BUCKET_NAME, + OKTA_DOMAIN, + OKTA_CLIENT_ID, + OKTA_CLIENT_SECRET, + ] + ): + raise GoogleAuthError( + "Missing required environment variables. Please check your .env file." + ) + + # 1. Instantiate the custom supplier with Okta credentials. + okta_supplier = OktaClientCredentialsSupplier( + OKTA_DOMAIN, OKTA_CLIENT_ID, OKTA_CLIENT_SECRET + ) + + # 2. Instantiate an IdentityPoolClient. + client = IdentityPoolClient( + audience=GCP_WORKLOAD_AUDIENCE, + subject_token_type=SUBJECT_TOKEN_TYPE, + token_url=TOKEN_URL, + subject_token_supplier=okta_supplier, + # If you choose to provide explicit scopes: use the `scopes` parameter. + default_scopes=['https://www.googleapis.com/auth/cloud-platform'], + service_account_impersonation_url=SERVICE_ACCOUNT_IMPERSONATION_URL, + ) + + # 3. Construct the URL for the Cloud Storage JSON API. + bucket_url = f"https://storage.googleapis.com/storage/v1/b/{GCS_BUCKET_NAME}" + print(f"[Test] Getting metadata for bucket: {GCS_BUCKET_NAME}...") + print(f"[Test] Request URL: {bucket_url}") + + # 4. Use the client to make an authenticated request. + authed_session = AuthorizedSession(client) + try: + res = authed_session.get(bucket_url) + res.raise_for_status() + print("\n--- SUCCESS! ---") + print("Successfully authenticated and retrieved bucket data:") + print(json.dumps(res.json(), indent=2)) + except requests.exceptions.RequestException as e: + print("\n--- FAILED ---") + print(f"Request failed: {e}") + if e.response: + print(f"Response: {e.response.text}") + exit(1) + except GoogleAuthError as e: + print("\n--- FAILED ---") + print(f"Authentication or request failed: {e}") + exit(1) + + +if __name__ == "__main__": + main() diff --git a/samples/cloud-client/snippets/requirements.txt b/samples/cloud-client/snippets/requirements.txt index 416c56b94..97f256bd8 100644 --- a/samples/cloud-client/snippets/requirements.txt +++ b/samples/cloud-client/snippets/requirements.txt @@ -1,4 +1,7 @@ google-cloud-compute==1.5.1 google-cloud-storage==3.1.0 -google-auth==2.38.0 +google-auth==2.41.1 pytest==7.1.2 +boto3>=1.26.0 +requests==2.32.3 +python-dotenv==1.1.1 \ No newline at end of file From cf6fc3cced78bc1362a7fe596c32ebc9ce03c26b Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Mon, 10 Nov 2025 17:19:57 -0800 Subject: [PATCH 04/16] feat: Add shlex to correctly parse executable commands with spaces (#1855) The `subprocess.run` command was using `.split()` which does not handle quoted paths with spaces correctly. This would cause a `FileNotFoundError` when the path to the executable contained spaces. This change replaces `.split()` with `shlex.split()` to correctly parse the command string. A test case has been added to verify the fix and prevent regressions. This was reported in b/237606033 Co-authored-by: Daniel Sanche --- google/auth/pluggable.py | 5 +++-- tests/test_pluggable.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/google/auth/pluggable.py b/google/auth/pluggable.py index fd349537d..730a72c28 100644 --- a/google/auth/pluggable.py +++ b/google/auth/pluggable.py @@ -37,6 +37,7 @@ from collections import Mapping # type: ignore import json import os +import shlex import subprocess import sys import time @@ -220,7 +221,7 @@ def retrieve_subject_token(self, request): exe_stderr = sys.stdout if self.interactive else subprocess.STDOUT result = subprocess.run( - self._credential_source_executable_command.split(), + shlex.split(self._credential_source_executable_command), timeout=exe_timeout, stdin=exe_stdin, stdout=exe_stdout, @@ -273,7 +274,7 @@ def revoke(self, request): # Run executable result = subprocess.run( - self._credential_source_executable_command.split(), + shlex.split(self._credential_source_executable_command), timeout=self._credential_source_executable_interactive_timeout_millis / 1000, stdout=subprocess.PIPE, diff --git a/tests/test_pluggable.py b/tests/test_pluggable.py index 066920b22..d15ebb88b 100644 --- a/tests/test_pluggable.py +++ b/tests/test_pluggable.py @@ -1239,6 +1239,36 @@ def test_retrieve_subject_token_python_2(self): assert excinfo.match(r"Pluggable auth is only supported for python 3.7+") + @mock.patch.dict(os.environ, {"GOOGLE_EXTERNAL_ACCOUNT_ALLOW_EXECUTABLES": "1"}) + def test_retrieve_subject_token_with_quoted_command(self): + command_with_spaces = '"/path/with spaces/to/executable" "arg with spaces"' + credential_source = { + "executable": {"command": command_with_spaces, "timeout_millis": 30000} + } + + with mock.patch( + "subprocess.run", + return_value=subprocess.CompletedProcess( + args=[], + stdout=json.dumps( + self.EXECUTABLE_SUCCESSFUL_OIDC_RESPONSE_ID_TOKEN + ).encode("UTF-8"), + returncode=0, + ), + ) as mock_run: + credentials = self.make_pluggable(credential_source=credential_source) + subject_token = credentials.retrieve_subject_token(None) + + assert subject_token == self.EXECUTABLE_OIDC_TOKEN + mock_run.assert_called_once_with( + ["/path/with spaces/to/executable", "arg with spaces"], + timeout=30.0, + stdin=None, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + env=mock.ANY, + ) + @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)): From d5638986ca03ee95bfffa9ad821124ed7e903e63 Mon Sep 17 00:00:00 2001 From: kdeniz-git Date: Tue, 11 Nov 2025 09:15:11 -0800 Subject: [PATCH 05/16] =?UTF-8?q?feat:=20Implement=20token=20revocation=20?= =?UTF-8?q?in=20STS=20client=20and=20add=20revoke()=20metho=E2=80=A6=20(#1?= =?UTF-8?q?849)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …d to ExternalAccountAuthorizedUser credentials * Add support for OAuth 2.0 token revocation to the STS client, aligning with the specification in RFC7009. * A new revoke_token method is introduced, which makes a POST request to a revocation endpoint. The underlying request handler has also been updated to correctly process successful but empty HTTP responses, as specified by the standard for revocation. * Building on the STS client's new capabilities, this change exposes a public revoke() method on the ExternalAccountAuthorizedUser credentials class. * This method encapsulates the logic for revoking the refresh token by calling the underlying STS client's revoke_token function. It simplifies the process for client applications, like gcloud, to revoke these specific credentials without needing to interact directly with the STS client. * Unit tests are included to verify successful revocation and to ensure appropriate errors are raised if required fields (like revoke_url) are missing. --------- Co-authored-by: Daniel Sanche Co-authored-by: nbayati <99771966+nbayati@users.noreply.github.com> --- .../auth/external_account_authorized_user.py | 24 ++++++ google/oauth2/sts.py | 35 ++++++-- tests/oauth2/test_sts.py | 84 +++++++++++++++++-- .../test_external_account_authorized_user.py | 44 ++++++++++ 4 files changed, 176 insertions(+), 11 deletions(-) diff --git a/google/auth/external_account_authorized_user.py b/google/auth/external_account_authorized_user.py index f8fbf950b..2594e048f 100644 --- a/google/auth/external_account_authorized_user.py +++ b/google/auth/external_account_authorized_user.py @@ -321,6 +321,30 @@ def _build_trust_boundary_lookup_url(self): universe_domain=self._universe_domain, pool_id=pool_id ) + def revoke(self, request): + """Revokes the refresh token. + + Args: + request (google.auth.transport.Request): The object used to make + HTTP requests. + + Raises: + google.auth.exceptions.OAuthError: If the token could not be + revoked. + """ + if not self._revoke_url or not self._refresh_token_val: + raise exceptions.OAuthError( + "The credentials do not contain the necessary fields to " + "revoke the refresh token. You must specify revoke_url and " + "refresh_token." + ) + + self._sts_client.revoke_token( + request, self._refresh_token_val, "refresh_token", self._revoke_url + ) + self.token = None + self._refresh_token = None + @_helpers.copy_docstring(credentials.Credentials) def get_cred_info(self): if self._cred_file_path: diff --git a/google/oauth2/sts.py b/google/oauth2/sts.py index ad3962735..60d6f83c4 100644 --- a/google/oauth2/sts.py +++ b/google/oauth2/sts.py @@ -57,7 +57,7 @@ def __init__(self, token_exchange_endpoint, client_authentication=None): super(Client, self).__init__(client_authentication) self._token_exchange_endpoint = token_exchange_endpoint - def _make_request(self, request, headers, request_body): + def _make_request(self, request, headers, request_body, url=None): # Initialize request headers. request_headers = _URLENCODED_HEADERS.copy() @@ -69,9 +69,12 @@ def _make_request(self, request, headers, request_body): # Apply OAuth client authentication. self.apply_client_authentication_options(request_headers, request_body) + # Use default token exchange endpoint if no url is provided. + url = url or self._token_exchange_endpoint + # Execute request. response = request( - url=self._token_exchange_endpoint, + url=url, method="POST", headers=request_headers, body=urllib.parse.urlencode(request_body).encode("utf-8"), @@ -87,10 +90,12 @@ def _make_request(self, request, headers, request_body): if response.status != http_client.OK: utils.handle_error_response(response_body) - response_data = json.loads(response_body) + # A successful token revocation returns an empty response body. + if not response_body: + return {} - # Return successful response. - return response_data + # Other successful responses should be valid JSON. + return json.loads(response_body) def exchange_token( self, @@ -174,3 +179,23 @@ def refresh_token(self, request, refresh_token): None, {"grant_type": "refresh_token", "refresh_token": refresh_token}, ) + + def revoke_token(self, request, token, token_type_hint, revoke_url): + """Revokes the provided token based on the RFC7009 spec. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + token (str): The OAuth 2.0 token to revoke. + token_type_hint (str): Hint for the type of token being revoked. + revoke_url (str): The STS endpoint URL for revoking tokens. + + Raises: + google.auth.exceptions.OAuthError: If the token revocation endpoint + returned an error. + """ + request_body = {"token": token} + if token_type_hint: + request_body["token_type_hint"] = token_type_hint + + return self._make_request(request, None, request_body, revoke_url) diff --git a/tests/oauth2/test_sts.py b/tests/oauth2/test_sts.py index e0fb4ae23..e9075e406 100644 --- a/tests/oauth2/test_sts.py +++ b/tests/oauth2/test_sts.py @@ -41,6 +41,9 @@ class TestStsClient(object): ACTOR_TOKEN = "HEADER.ACTOR_TOKEN_PAYLOAD.SIGNATURE" ACTOR_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt" TOKEN_EXCHANGE_ENDPOINT = "https://example.com/token.oauth2" + REVOKE_URL = "https://example.com/revoke.oauth2" + TOKEN_TO_REVOKE = "TOKEN_TO_REVOKE" + TOKEN_TYPE_HINT = "refresh_token" ADDON_HEADERS = {"x-client-version": "0.1.2"} ADDON_OPTIONS = {"additional": {"non-standard": ["options"], "other": "some-value"}} SUCCESS_RESPONSE = { @@ -72,10 +75,13 @@ def make_client(cls, client_auth=None): return sts.Client(cls.TOKEN_EXCHANGE_ENDPOINT, client_auth) @classmethod - def make_mock_request(cls, data, status=http_client.OK): + def make_mock_request(cls, data, status=http_client.OK, use_json=True): response = mock.create_autospec(transport.Response, instance=True) response.status = status - response.data = json.dumps(data).encode("utf-8") + if use_json: + response.data = json.dumps(data).encode("utf-8") + else: + response.data = data.encode("utf-8") request = mock.create_autospec(transport.Request) request.return_value = response @@ -83,10 +89,10 @@ def make_mock_request(cls, data, status=http_client.OK): return request @classmethod - def assert_request_kwargs(cls, request_kwargs, headers, request_data): - """Asserts the request was called with the expected parameters. - """ - assert request_kwargs["url"] == cls.TOKEN_EXCHANGE_ENDPOINT + def assert_request_kwargs(cls, request_kwargs, headers, request_data, url=None): + """Asserts the request was called with the expected parameters.""" + url = url or cls.TOKEN_EXCHANGE_ENDPOINT + assert request_kwargs["url"] == url assert request_kwargs["method"] == "POST" assert request_kwargs["headers"] == headers assert request_kwargs["body"] is not None @@ -447,6 +453,63 @@ def test_refresh_token_failure(self): r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" ) + def test_revoke_token_success(self): + """Test revoke token with successful response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request(data="", status=http_client.OK, use_json=False) + + response = client.revoke_token( + request, self.TOKEN_TO_REVOKE, self.TOKEN_TYPE_HINT, self.REVOKE_URL + ) + + headers = { + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + "Content-Type": "application/x-www-form-urlencoded", + } + request_data = { + "token": self.TOKEN_TO_REVOKE, + "token_type_hint": self.TOKEN_TYPE_HINT, + } + self.assert_request_kwargs( + request.call_args[1], headers, request_data, url=self.REVOKE_URL + ) + assert response == {} + + def test_revoke_token_success_no_hint(self): + """Test revoke token with successful response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request(data="", status=http_client.OK, use_json=False) + + response = client.revoke_token( + request, self.TOKEN_TO_REVOKE, None, self.REVOKE_URL + ) + + headers = { + "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING), + "Content-Type": "application/x-www-form-urlencoded", + } + request_data = {"token": self.TOKEN_TO_REVOKE} + self.assert_request_kwargs( + request.call_args[1], headers, request_data, url=self.REVOKE_URL + ) + assert response == {} + + def test_revoke_token_failure(self): + """Test revoke token with failure response.""" + client = self.make_client(self.CLIENT_AUTH_BASIC) + request = self.make_mock_request( + status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + client.revoke_token( + request, self.TOKEN_TO_REVOKE, self.TOKEN_TYPE_HINT, self.REVOKE_URL + ) + + assert excinfo.match( + r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" + ) + def test__make_request_success(self): """Test base method with successful response.""" client = self.make_client(self.CLIENT_AUTH_BASIC) @@ -478,3 +541,12 @@ def test_make_request_failure(self): assert excinfo.match( r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749" ) + + def test__make_request_empty_response(self): + """Test _make_request with a successful but empty response body.""" + client = self.make_client() + request = self.make_mock_request(data="", status=http_client.OK, use_json=False) + + response = client._make_request(request, {}, {}) + + assert response == {} diff --git a/tests/test_external_account_authorized_user.py b/tests/test_external_account_authorized_user.py index a4e121781..0a54af56d 100644 --- a/tests/test_external_account_authorized_user.py +++ b/tests/test_external_account_authorized_user.py @@ -349,6 +349,50 @@ def test_refresh_without_client_secret(self): request.assert_not_called() + def test_revoke_auth_success(self): + request = self.make_mock_request(status=http_client.OK, data={}) + creds = self.make_credentials(revoke_url=REVOKE_URL) + + creds.revoke(request) + + request.assert_called_once_with( + url=REVOKE_URL, + method="POST", + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Authorization": "Basic " + BASIC_AUTH_ENCODING, + }, + body=("token=" + REFRESH_TOKEN + "&token_type_hint=refresh_token").encode( + "utf-8" + ), + ) + assert creds.token is None + assert creds._refresh_token is None + + def test_revoke_without_revoke_url(self): + request = self.make_mock_request() + creds = self.make_credentials(token=ACCESS_TOKEN) + + with pytest.raises(exceptions.OAuthError) as excinfo: + creds.revoke(request) + + assert excinfo.match( + r"The credentials do not contain the necessary fields to revoke the refresh token. You must specify revoke_url and refresh_token." + ) + + def test_revoke_without_refresh_token(self): + request = self.make_mock_request() + creds = self.make_credentials( + refresh_token=None, token=ACCESS_TOKEN, revoke_url=REVOKE_URL + ) + + with pytest.raises(exceptions.OAuthError) as excinfo: + creds.revoke(request) + + assert excinfo.match( + r"The credentials do not contain the necessary fields to revoke the refresh token. You must specify revoke_url and refresh_token." + ) + def test_info(self): creds = self.make_credentials() info = creds.info From 5b96011a515cffe2bcc837d45d6100b202da7e35 Mon Sep 17 00:00:00 2001 From: Nolan Eastin <80856764+nolanleastin@users.noreply.github.com> Date: Wed, 19 Nov 2025 11:55:36 -0800 Subject: [PATCH 06/16] chore: update secret (#1874) --- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index af9af5e620bb2bc052d49a2c7e1c1c765ea238d1..6a4f1f3081812426117834f30ce315cba1f1535f 100644 GIT binary patch literal 10324 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 From 0387bb95713653d47e846cad3a010eb55ef2db4c Mon Sep 17 00:00:00 2001 From: Nolan Eastin <80856764+nolanleastin@users.noreply.github.com> Date: Wed, 19 Nov 2025 13:37:00 -0800 Subject: [PATCH 07/16] feat: MDS connections use mTLS (#1856) Use mTLS/HTTPS when connecting to MDS **Feature Gating** The `GCE_METADATA_MTLS_MODE` environment variable is introduced, which can be set to strict, none, or default. The `should_use_mds_mtls` function determines whether to use mTLS based on the environment variable and the existence of the certificate files in well-known location ((https://docs.cloud.google.com/compute/docs/metadata/overview#https-mds-certificates). **Description of changes** A custom `MdsMtlsAdapter` is implemented to handle the SSL context for mTLS. MdsMtlsAdapter loads MDS mTLS certificates from well-known location. MdsMtlsAdapter is mounted into the provided request.Session. **Behavior** If mode == none: Continue to use HTTP. If mode == default: Use HTTPS if certificates exist. If HTTPS/mTLS fails, falls back to HTTP. If mode == strict: Use HTTPS always, even if certificates don't exist (will result in error). **Integrating with existing code** compute_engine/_metadata.py: - The metadata server URL construction is now dynamic, supporting both http and https schemes based on whether mTLS is enabled. - ping and get functions are updated to use mTLS when it's enabled. --- google/auth/compute_engine/_metadata.py | 111 ++++++++- google/auth/compute_engine/_mtls.py | 164 ++++++++++++++ google/auth/environment_vars.py | 6 + tests/compute_engine/test__metadata.py | 216 +++++++++++++++--- tests/compute_engine/test__mtls.py | 288 ++++++++++++++++++++++++ 5 files changed, 749 insertions(+), 36 deletions(-) create mode 100644 google/auth/compute_engine/_mtls.py create mode 100644 tests/compute_engine/test__mtls.py diff --git a/google/auth/compute_engine/_metadata.py b/google/auth/compute_engine/_metadata.py index ddbe8ac2f..96f1ff526 100644 --- a/google/auth/compute_engine/_metadata.py +++ b/google/auth/compute_engine/_metadata.py @@ -24,15 +24,23 @@ import os from urllib.parse import urljoin +import requests + from google.auth import _helpers from google.auth import environment_vars from google.auth import exceptions from google.auth import metrics from google.auth import transport from google.auth._exponential_backoff import ExponentialBackoff +from google.auth.compute_engine import _mtls + _LOGGER = logging.getLogger(__name__) +_GCE_DEFAULT_MDS_IP = "169.254.169.254" +_GCE_DEFAULT_HOST = "metadata.google.internal" +_GCE_DEFAULT_MDS_HOSTS = [_GCE_DEFAULT_HOST, _GCE_DEFAULT_MDS_IP] + # Environment variable GCE_METADATA_HOST is originally named # GCE_METADATA_ROOT. For compatibility reasons, here it checks # the new variable first; if not set, the system falls back @@ -40,15 +48,48 @@ _GCE_METADATA_HOST = os.getenv(environment_vars.GCE_METADATA_HOST, None) if not _GCE_METADATA_HOST: _GCE_METADATA_HOST = os.getenv( - environment_vars.GCE_METADATA_ROOT, "metadata.google.internal" + environment_vars.GCE_METADATA_ROOT, _GCE_DEFAULT_HOST + ) + + +def _validate_gce_mds_configured_environment(): + """Validates the GCE metadata server environment configuration for mTLS. + + mTLS is only supported when connecting to the default metadata server hosts. + If we are in strict mode (which requires mTLS), ensure that the metadata host + has not been overridden to a custom value (which means mTLS will fail). + + Raises: + google.auth.exceptions.MutualTLSChannelError: if the environment + configuration is invalid for mTLS. + """ + mode = _mtls._parse_mds_mode() + if mode == _mtls.MdsMtlsMode.STRICT: + # mTLS is only supported when connecting to the default metadata host. + # Raise an exception if we are in strict mode (which requires mTLS) + # but the metadata host has been overridden to a custom MDS. (which means mTLS will fail) + if _GCE_METADATA_HOST not in _GCE_DEFAULT_MDS_HOSTS: + raise exceptions.MutualTLSChannelError( + "Mutual TLS is required, but the metadata host has been overridden. " + "mTLS is only supported when connecting to the default metadata host." + ) + + +def _get_metadata_root(use_mtls: bool): + """Returns the metadata server root URL.""" + + scheme = "https" if use_mtls else "http" + return "{}://{}/computeMetadata/v1/".format(scheme, _GCE_METADATA_HOST) + + +def _get_metadata_ip_root(use_mtls: bool): + """Returns the metadata server IP root URL.""" + scheme = "https" if use_mtls else "http" + return "{}://{}".format( + scheme, os.getenv(environment_vars.GCE_METADATA_IP, _GCE_DEFAULT_MDS_IP) ) -_METADATA_ROOT = "http://{}/computeMetadata/v1/".format(_GCE_METADATA_HOST) -# This is used to ping the metadata server, it avoids the cost of a DNS -# lookup. -_METADATA_IP_ROOT = "http://{}".format( - os.getenv(environment_vars.GCE_METADATA_IP, "169.254.169.254") -) + _METADATA_FLAVOR_HEADER = "metadata-flavor" _METADATA_FLAVOR_VALUE = "Google" _METADATA_HEADERS = {_METADATA_FLAVOR_HEADER: _METADATA_FLAVOR_VALUE} @@ -102,6 +143,33 @@ def detect_gce_residency_linux(): return content.startswith(_GOOGLE) +def _prepare_request_for_mds(request, use_mtls=False) -> None: + """Prepares a request for the metadata server. + + This will check if mTLS should be used and mount the mTLS adapter if needed. + + Args: + request (google.auth.transport.Request): A callable used to make + HTTP requests. + use_mtls (bool): Whether to use mTLS for the request. + + Returns: + google.auth.transport.Request: A request object to use. + If mTLS is enabled, the request will have the mTLS adapter mounted. + Otherwise, the original request will be returned unchanged. + """ + # Only modify the request if mTLS is enabled. + if use_mtls: + # Ensure the request has a session to mount the adapter to. + if not request.session: + request.session = requests.Session() + + adapter = _mtls.MdsMtlsAdapter() + # Mount the adapter for all default GCE metadata hosts. + for host in _GCE_DEFAULT_MDS_HOSTS: + request.session.mount(f"https://{host}/", adapter) + + def ping(request, timeout=_METADATA_DEFAULT_TIMEOUT, retry_count=3): """Checks to see if the metadata server is available. @@ -115,6 +183,8 @@ def ping(request, timeout=_METADATA_DEFAULT_TIMEOUT, retry_count=3): Returns: bool: True if the metadata server is reachable, False otherwise. """ + use_mtls = _mtls.should_use_mds_mtls() + _prepare_request_for_mds(request, use_mtls=use_mtls) # NOTE: The explicit ``timeout`` is a workaround. The underlying # issue is that resolving an unknown host on some networks will take # 20-30 seconds; making this timeout short fixes the issue, but @@ -129,7 +199,10 @@ def ping(request, timeout=_METADATA_DEFAULT_TIMEOUT, retry_count=3): for attempt in backoff: try: response = request( - url=_METADATA_IP_ROOT, method="GET", headers=headers, timeout=timeout + url=_get_metadata_ip_root(use_mtls), + method="GET", + headers=headers, + timeout=timeout, ) metadata_flavor = response.headers.get(_METADATA_FLAVOR_HEADER) @@ -153,7 +226,7 @@ def ping(request, timeout=_METADATA_DEFAULT_TIMEOUT, retry_count=3): def get( request, path, - root=_METADATA_ROOT, + root=None, params=None, recursive=False, retry_count=5, @@ -168,7 +241,8 @@ def get( HTTP requests. path (str): The resource to retrieve. For example, ``'instance/service-accounts/default'``. - root (str): The full path to the metadata server root. + root (Optional[str]): The full path to the metadata server root. If not + provided, the default root will be used. params (Optional[Mapping[str, str]]): A mapping of query parameter keys to values. recursive (bool): Whether to do a recursive query of metadata. See @@ -189,7 +263,24 @@ def get( Raises: google.auth.exceptions.TransportError: if an error occurred while retrieving metadata. + google.auth.exceptions.MutualTLSChannelError: if using mtls and the environment + configuration is invalid for mTLS (for example, the metadata host + has been overridden in strict mTLS mode). + """ + use_mtls = _mtls.should_use_mds_mtls() + # Prepare the request object for mTLS if needed. + # This will create a new request object with the mTLS session. + _prepare_request_for_mds(request, use_mtls=use_mtls) + + if root is None: + root = _get_metadata_root(use_mtls) + + # mTLS is only supported when connecting to the default metadata host. + # If we are in strict mode (which requires mTLS), ensure that the metadata host + # has not been overridden to a non-default host value (which means mTLS will fail). + _validate_gce_mds_configured_environment() + base_url = urljoin(root, path) query_params = {} if params is None else params diff --git a/google/auth/compute_engine/_mtls.py b/google/auth/compute_engine/_mtls.py new file mode 100644 index 000000000..6525dd03e --- /dev/null +++ b/google/auth/compute_engine/_mtls.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 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. +# +"""Mutual TLS for Google Compute Engine metadata server.""" + +from dataclasses import dataclass, field +import enum +import logging +import os +from pathlib import Path +import ssl +from urllib.parse import urlparse, urlunparse + +import requests +from requests.adapters import HTTPAdapter + +from google.auth import environment_vars, exceptions + + +_LOGGER = logging.getLogger(__name__) + +_WINDOWS_OS_NAME = "nt" + +# MDS mTLS certificate paths based on OS. +# Documentation to well known locations can be found at: +# https://cloud.google.com/compute/docs/metadata/overview#https-mds-certificates +_WINDOWS_MTLS_COMPONENTS_BASE_PATH = Path("C:/ProgramData/Google/ComputeEngine") +_MTLS_COMPONENTS_BASE_PATH = Path("/run/google-mds-mtls") + + +def _get_mds_root_crt_path(): + if os.name == _WINDOWS_OS_NAME: + return _WINDOWS_MTLS_COMPONENTS_BASE_PATH / "mds-mtls-root.crt" + else: + return _MTLS_COMPONENTS_BASE_PATH / "root.crt" + + +def _get_mds_client_combined_cert_path(): + if os.name == _WINDOWS_OS_NAME: + return _WINDOWS_MTLS_COMPONENTS_BASE_PATH / "mds-mtls-client.key" + else: + return _MTLS_COMPONENTS_BASE_PATH / "client.key" + + +@dataclass +class MdsMtlsConfig: + ca_cert_path: Path = field( + default_factory=_get_mds_root_crt_path + ) # path to CA certificate + client_combined_cert_path: Path = field( + default_factory=_get_mds_client_combined_cert_path + ) # path to file containing client certificate and key + + +def _certs_exist(mds_mtls_config: MdsMtlsConfig): + """Checks if the mTLS certificates exist.""" + return os.path.exists(mds_mtls_config.ca_cert_path) and os.path.exists( + mds_mtls_config.client_combined_cert_path + ) + + +class MdsMtlsMode(enum.Enum): + """MDS mTLS mode. Used to configure connection behavior when connecting to MDS. + + STRICT: Always use HTTPS/mTLS. If certificates are not found locally, an error will be returned. + NONE: Never use mTLS. Requests will use regular HTTP. + DEFAULT: Use mTLS if certificates are found locally, otherwise use regular HTTP. + """ + + STRICT = "strict" + NONE = "none" + DEFAULT = "default" + + +def _parse_mds_mode(): + """Parses the GCE_METADATA_MTLS_MODE environment variable.""" + mode_str = os.environ.get( + environment_vars.GCE_METADATA_MTLS_MODE, "default" + ).lower() + try: + return MdsMtlsMode(mode_str) + except ValueError: + raise ValueError( + "Invalid value for GCE_METADATA_MTLS_MODE. Must be one of 'strict', 'none', or 'default'." + ) + + +def should_use_mds_mtls(mds_mtls_config: MdsMtlsConfig = MdsMtlsConfig()): + """Determines if mTLS should be used for the metadata server.""" + mode = _parse_mds_mode() + if mode == MdsMtlsMode.STRICT: + if not _certs_exist(mds_mtls_config): + raise exceptions.MutualTLSChannelError( + "mTLS certificates not found in strict mode." + ) + return True + elif mode == MdsMtlsMode.NONE: + return False + else: # Default mode + return _certs_exist(mds_mtls_config) + + +class MdsMtlsAdapter(HTTPAdapter): + """An HTTP adapter that uses mTLS for the metadata server.""" + + def __init__( + self, mds_mtls_config: MdsMtlsConfig = MdsMtlsConfig(), *args, **kwargs + ): + self.ssl_context = ssl.create_default_context() + self.ssl_context.load_verify_locations(cafile=mds_mtls_config.ca_cert_path) + self.ssl_context.load_cert_chain( + certfile=mds_mtls_config.client_combined_cert_path + ) + super(MdsMtlsAdapter, self).__init__(*args, **kwargs) + + def init_poolmanager(self, *args, **kwargs): + kwargs["ssl_context"] = self.ssl_context + return super(MdsMtlsAdapter, self).init_poolmanager(*args, **kwargs) + + def proxy_manager_for(self, *args, **kwargs): + kwargs["ssl_context"] = self.ssl_context + return super(MdsMtlsAdapter, self).proxy_manager_for(*args, **kwargs) + + def send(self, request, **kwargs): + # If we are in strict mode, always use mTLS (no HTTP fallback) + if _parse_mds_mode() == MdsMtlsMode.STRICT: + return super(MdsMtlsAdapter, self).send(request, **kwargs) + + # In default mode, attempt mTLS first, then fallback to HTTP on failure + try: + response = super(MdsMtlsAdapter, self).send(request, **kwargs) + response.raise_for_status() + return response + except ( + ssl.SSLError, + requests.exceptions.SSLError, + requests.exceptions.HTTPError, + ) as e: + _LOGGER.warning( + "mTLS connection to Compute Engine Metadata server failed. " + "Falling back to standard HTTP. Reason: %s", + e, + ) + # Fallback to standard HTTP + parsed_original_url = urlparse(request.url) + http_fallback_url = urlunparse(parsed_original_url._replace(scheme="http")) + request.url = http_fallback_url + + # Use a standard HTTPAdapter for the fallback + http_adapter = HTTPAdapter() + return http_adapter.send(request, **kwargs) diff --git a/google/auth/environment_vars.py b/google/auth/environment_vars.py index e5f3598e8..5da3a7382 100644 --- a/google/auth/environment_vars.py +++ b/google/auth/environment_vars.py @@ -60,6 +60,12 @@ """Environment variable providing an alternate ip:port to be used for ip-only GCE metadata requests.""" +GCE_METADATA_MTLS_MODE = "GCE_METADATA_MTLS_MODE" +"""Environment variable controlling the mTLS behavior for GCE metadata requests. + +Can be one of "strict", "none", or "default". +""" + GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE" """Environment variable controlling whether to use client certificate or not. diff --git a/tests/compute_engine/test__metadata.py b/tests/compute_engine/test__metadata.py index c90bc603a..adb63f667 100644 --- a/tests/compute_engine/test__metadata.py +++ b/tests/compute_engine/test__metadata.py @@ -20,12 +20,14 @@ import mock import pytest # type: ignore +import requests from google.auth import _helpers from google.auth import environment_vars from google.auth import exceptions from google.auth import transport from google.auth.compute_engine import _metadata +from google.auth.transport import requests as google_auth_requests PATH = "instance/service-accounts/default" @@ -104,7 +106,7 @@ def test_ping_success(mock_metrics_header_value): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_IP_ROOT, + url="http://169.254.169.254", headers=MDS_PING_REQUEST_HEADER, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -118,7 +120,7 @@ def test_ping_success_retry(mock_metrics_header_value): request.assert_called_with( method="GET", - url=_metadata._METADATA_IP_ROOT, + url="http://169.254.169.254", headers=MDS_PING_REQUEST_HEADER, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -172,7 +174,7 @@ def test_get_success_json(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH, + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -191,7 +193,7 @@ def test_get_success_json_content_type_charset(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH, + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -211,7 +213,7 @@ def test_get_success_retry(mock_sleep): request.assert_called_with( method="GET", - url=_metadata._METADATA_ROOT + PATH, + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -227,7 +229,7 @@ def test_get_success_text(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH, + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -243,7 +245,9 @@ def test_get_success_params(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH + "?recursive=true", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "?recursive=true", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -258,7 +262,9 @@ def test_get_success_recursive_and_params(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH + "?recursive=true", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "?recursive=true", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -273,7 +279,9 @@ def test_get_success_recursive(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH + "?recursive=true", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "?recursive=true", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -322,6 +330,21 @@ def test_get_success_custom_root_old_variable(): ) +def test_get_success_custom_root(): + request = make_request("{}", headers={"content-type": "application/json"}) + + fake_root = "http://another.metadata.service" + + _metadata.get(request, PATH, root=fake_root) + + request.assert_called_once_with( + method="GET", + url="{}/{}".format(fake_root, PATH), + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + @mock.patch("time.sleep", return_value=None) def test_get_failure(mock_sleep): request = make_request("Metadata error", status=http_client.NOT_FOUND) @@ -333,7 +356,7 @@ def test_get_failure(mock_sleep): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH, + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -346,7 +369,7 @@ def test_get_return_none_for_not_found_error(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH, + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -366,7 +389,7 @@ def test_get_failure_connection_failed(mock_sleep): request.assert_called_with( method="GET", - url=_metadata._METADATA_ROOT + PATH, + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -385,7 +408,7 @@ def test_get_too_many_requests_retryable_error_failure(): request.assert_called_with( method="GET", - url=_metadata._METADATA_ROOT + PATH, + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -402,7 +425,7 @@ def test_get_failure_bad_json(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH, + url="http://metadata.google.internal/computeMetadata/v1/" + PATH, headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -416,7 +439,7 @@ def test_get_project_id(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + "project/project-id", + url="http://metadata.google.internal/computeMetadata/v1/project/project-id", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -432,7 +455,7 @@ def test_get_universe_domain_success(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + "universe/universe-domain", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -446,7 +469,7 @@ def test_get_universe_domain_success_empty_response(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + "universe/universe-domain", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -462,7 +485,7 @@ def test_get_universe_domain_not_found(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + "universe/universe-domain", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -483,7 +506,7 @@ def test_get_universe_domain_retryable_error_failure(): request.assert_called_with( method="GET", - url=_metadata._METADATA_ROOT + "universe/universe-domain", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -526,13 +549,13 @@ def request(self, *args, **kwargs): request_error.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + "universe/universe-domain", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) request_ok.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + "universe/universe-domain", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -552,7 +575,7 @@ def test_get_universe_domain_other_error(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + "universe/universe-domain", + url="http://metadata.google.internal/computeMetadata/v1/universe/universe-domain", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) @@ -574,7 +597,7 @@ def test_get_service_account_token(utcnow, mock_metrics_header_value): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH + "/token", + url="http://metadata.google.internal/computeMetadata/v1/" + PATH + "/token", headers={ "metadata-flavor": "Google", "x-goog-api-client": ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, @@ -601,7 +624,10 @@ def test_get_service_account_token_with_scopes_list(utcnow, mock_metrics_header_ request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH + "/token" + "?scopes=foo%2Cbar", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "/token" + + "?scopes=foo%2Cbar", headers={ "metadata-flavor": "Google", "x-goog-api-client": ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, @@ -630,7 +656,10 @@ def test_get_service_account_token_with_scopes_string( request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH + "/token" + "?scopes=foo%2Cbar", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "/token" + + "?scopes=foo%2Cbar", headers={ "metadata-flavor": "Google", "x-goog-api-client": ACCESS_TOKEN_REQUEST_METRICS_HEADER_VALUE, @@ -651,9 +680,144 @@ def test_get_service_account_info(): request.assert_called_once_with( method="GET", - url=_metadata._METADATA_ROOT + PATH + "/?recursive=true", + url="http://metadata.google.internal/computeMetadata/v1/" + + PATH + + "/?recursive=true", headers=_metadata._METADATA_HEADERS, timeout=_metadata._METADATA_DEFAULT_TIMEOUT, ) assert info[key] == value + + +def test__get_metadata_root_mtls(): + assert ( + _metadata._get_metadata_root(use_mtls=True) + == "https://metadata.google.internal/computeMetadata/v1/" + ) + + +def test__get_metadata_root_no_mtls(): + assert ( + _metadata._get_metadata_root(use_mtls=False) + == "http://metadata.google.internal/computeMetadata/v1/" + ) + + +def test__get_metadata_ip_root_mtls(): + assert _metadata._get_metadata_ip_root(use_mtls=True) == "https://169.254.169.254" + + +def test__get_metadata_ip_root_no_mtls(): + assert _metadata._get_metadata_ip_root(use_mtls=False) == "http://169.254.169.254" + + +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +def test__prepare_request_for_mds_mtls(mock_mds_mtls_adapter): + request = google_auth_requests.Request(mock.create_autospec(requests.Session)) + _metadata._prepare_request_for_mds(request, use_mtls=True) + mock_mds_mtls_adapter.assert_called_once() + assert request.session.mount.call_count == len(_metadata._GCE_DEFAULT_MDS_HOSTS) + + +def test__prepare_request_for_mds_no_mtls(): + request = mock.Mock() + _metadata._prepare_request_for_mds(request, use_mtls=False) + request.session.mount.assert_not_called() + + +@mock.patch("google.auth.metrics.mds_ping", return_value=MDS_PING_METRICS_HEADER_VALUE) +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +@mock.patch("google.auth.compute_engine._mtls.should_use_mds_mtls", return_value=True) +@mock.patch("google.auth.transport.requests.Request") +def test_ping_mtls( + mock_request, mock_should_use_mtls, mock_mds_mtls_adapter, mock_metrics_header_value +): + response = mock.create_autospec(transport.Response, instance=True) + response.status = http_client.OK + response.headers = _metadata._METADATA_HEADERS + mock_request.return_value = response + + assert _metadata.ping(mock_request) + + mock_should_use_mtls.assert_called_once() + mock_mds_mtls_adapter.assert_called_once() + mock_request.assert_called_once_with( + url="https://169.254.169.254", + method="GET", + headers=MDS_PING_REQUEST_HEADER, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +@mock.patch("google.auth.compute_engine._mtls.should_use_mds_mtls", return_value=True) +@mock.patch("google.auth.transport.requests.Request") +def test_get_mtls(mock_request, mock_should_use_mtls, mock_mds_mtls_adapter): + response = mock.create_autospec(transport.Response, instance=True) + response.status = http_client.OK + response.data = _helpers.to_bytes("{}") + response.headers = {"content-type": "application/json"} + mock_request.return_value = response + + _metadata.get(mock_request, "some/path") + + mock_should_use_mtls.assert_called_once() + mock_mds_mtls_adapter.assert_called_once() + mock_request.assert_called_once_with( + url="https://metadata.google.internal/computeMetadata/v1/some/path", + method="GET", + headers=_metadata._METADATA_HEADERS, + timeout=_metadata._METADATA_DEFAULT_TIMEOUT, + ) + + +@pytest.mark.parametrize( + "mds_mode, metadata_host, expect_exception", + [ + (_metadata._mtls.MdsMtlsMode.STRICT, _metadata._GCE_DEFAULT_HOST, False), + (_metadata._mtls.MdsMtlsMode.STRICT, _metadata._GCE_DEFAULT_MDS_IP, False), + (_metadata._mtls.MdsMtlsMode.STRICT, "custom.host", True), + (_metadata._mtls.MdsMtlsMode.NONE, "custom.host", False), + (_metadata._mtls.MdsMtlsMode.DEFAULT, _metadata._GCE_DEFAULT_HOST, False), + (_metadata._mtls.MdsMtlsMode.DEFAULT, _metadata._GCE_DEFAULT_MDS_IP, False), + ], +) +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +def test_validate_gce_mds_configured_environment( + mock_parse_mds_mode, mds_mode, metadata_host, expect_exception +): + mock_parse_mds_mode.return_value = mds_mode + with mock.patch( + "google.auth.compute_engine._metadata._GCE_METADATA_HOST", new=metadata_host + ): + if expect_exception: + with pytest.raises(exceptions.MutualTLSChannelError): + _metadata._validate_gce_mds_configured_environment() + else: + _metadata._validate_gce_mds_configured_environment() + mock_parse_mds_mode.assert_called_once() + + +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +def test__prepare_request_for_mds_mtls_session_exists(mock_mds_mtls_adapter): + mock_session = mock.create_autospec(requests.Session) + request = google_auth_requests.Request(mock_session) + _metadata._prepare_request_for_mds(request, use_mtls=True) + + mock_mds_mtls_adapter.assert_called_once() + assert mock_session.mount.call_count == len(_metadata._GCE_DEFAULT_MDS_HOSTS) + + +@mock.patch("google.auth.compute_engine._mtls.MdsMtlsAdapter") +def test__prepare_request_for_mds_mtls_no_session(mock_mds_mtls_adapter): + request = google_auth_requests.Request(None) + # Explicitly set session to None to avoid a session being created in the Request constructor. + request.session = None + + with mock.patch("requests.Session") as mock_session_class: + _metadata._prepare_request_for_mds(request, use_mtls=True) + + mock_session_class.assert_called_once() + mock_mds_mtls_adapter.assert_called_once() + assert request.session.mount.call_count == len(_metadata._GCE_DEFAULT_MDS_HOSTS) diff --git a/tests/compute_engine/test__mtls.py b/tests/compute_engine/test__mtls.py new file mode 100644 index 000000000..fdd61a07d --- /dev/null +++ b/tests/compute_engine/test__mtls.py @@ -0,0 +1,288 @@ +# -*- coding: utf-8 -*- +# +# Copyright 2024 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. +# + +from pathlib import Path + +import mock +import pytest # type: ignore +import requests + +from google.auth import environment_vars, exceptions +from google.auth.compute_engine import _mtls + + +@pytest.fixture +def mock_mds_mtls_config(): + return _mtls.MdsMtlsConfig( + ca_cert_path=Path("/fake/ca.crt"), + client_combined_cert_path=Path("/fake/client.key"), + ) + + +@mock.patch("os.name", "nt") +def test__MdsMtlsConfig_windows_defaults(): + config = _mtls.MdsMtlsConfig() + assert ( + str(config.ca_cert_path) + == "C:/ProgramData/Google/ComputeEngine/mds-mtls-root.crt" + ) + assert ( + str(config.client_combined_cert_path) + == "C:/ProgramData/Google/ComputeEngine/mds-mtls-client.key" + ) + + +@mock.patch("os.name", "posix") +def test__MdsMtlsConfig_non_windows_defaults(): + config = _mtls.MdsMtlsConfig() + assert str(config.ca_cert_path) == "/run/google-mds-mtls/root.crt" + assert str(config.client_combined_cert_path) == "/run/google-mds-mtls/client.key" + + +def test__parse_mds_mode_default(monkeypatch): + monkeypatch.delenv(environment_vars.GCE_METADATA_MTLS_MODE, raising=False) + assert _mtls._parse_mds_mode() == _mtls.MdsMtlsMode.DEFAULT + + +@pytest.mark.parametrize( + "mode_str, expected_mode", + [ + ("strict", _mtls.MdsMtlsMode.STRICT), + ("none", _mtls.MdsMtlsMode.NONE), + ("default", _mtls.MdsMtlsMode.DEFAULT), + ("STRICT", _mtls.MdsMtlsMode.STRICT), + ], +) +def test__parse_mds_mode_valid(monkeypatch, mode_str, expected_mode): + monkeypatch.setenv(environment_vars.GCE_METADATA_MTLS_MODE, mode_str) + assert _mtls._parse_mds_mode() == expected_mode + + +def test__parse_mds_mode_invalid(monkeypatch): + monkeypatch.setenv(environment_vars.GCE_METADATA_MTLS_MODE, "invalid_mode") + with pytest.raises(ValueError): + _mtls._parse_mds_mode() + + +@mock.patch("os.path.exists") +def test__certs_exist_true(mock_exists, mock_mds_mtls_config): + mock_exists.return_value = True + assert _mtls._certs_exist(mock_mds_mtls_config) is True + + +@mock.patch("os.path.exists") +def test__certs_exist_false(mock_exists, mock_mds_mtls_config): + mock_exists.return_value = False + assert _mtls._certs_exist(mock_mds_mtls_config) is False + + +@pytest.mark.parametrize( + "mtls_mode, certs_exist, expected_result", + [ + ("strict", True, True), + ("strict", False, exceptions.MutualTLSChannelError), + ("none", True, False), + ("none", False, False), + ("default", True, True), + ("default", False, False), + ], +) +@mock.patch("os.path.exists") +def test_should_use_mds_mtls( + mock_exists, monkeypatch, mtls_mode, certs_exist, expected_result +): + monkeypatch.setenv(environment_vars.GCE_METADATA_MTLS_MODE, mtls_mode) + mock_exists.return_value = certs_exist + + if isinstance(expected_result, type) and issubclass(expected_result, Exception): + with pytest.raises(expected_result): + _mtls.should_use_mds_mtls() + else: + assert _mtls.should_use_mds_mtls() is expected_result + + +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_init(mock_ssl_context, mock_mds_mtls_config): + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + mock_ssl_context.assert_called_once() + adapter.ssl_context.load_verify_locations.assert_called_once_with( + cafile=mock_mds_mtls_config.ca_cert_path + ) + adapter.ssl_context.load_cert_chain.assert_called_once_with( + certfile=mock_mds_mtls_config.client_combined_cert_path + ) + + +@mock.patch("ssl.create_default_context") +@mock.patch("requests.adapters.HTTPAdapter.init_poolmanager") +def test_mds_mtls_adapter_init_poolmanager( + mock_init_poolmanager, mock_ssl_context, mock_mds_mtls_config +): + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + mock_init_poolmanager.assert_called_with( + 10, 10, block=False, ssl_context=adapter.ssl_context + ) + + +@mock.patch("ssl.create_default_context") +@mock.patch("requests.adapters.HTTPAdapter.proxy_manager_for") +def test_mds_mtls_adapter_proxy_manager_for( + mock_proxy_manager_for, mock_ssl_context, mock_mds_mtls_config +): + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + adapter.proxy_manager_for("test_proxy") + mock_proxy_manager_for.assert_called_once_with( + "test_proxy", ssl_context=adapter.ssl_context + ) + + +@mock.patch("requests.adapters.HTTPAdapter.send") # Patch the PARENT class method +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_session_request( + mock_ssl_context, mock_super_send, mock_mds_mtls_config +): + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + session = requests.Session() + session.mount("https://", adapter) + + # Setup the parent class send return value + response = requests.Response() + response.status_code = 200 + mock_super_send.return_value = response + + response = session.get("https://fake-mds.com") + + # Assert that the request was successful + assert response.status_code == 200 + mock_super_send.assert_called_once() + + +@mock.patch("requests.adapters.HTTPAdapter.send") +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_success( + mock_ssl_context, mock_parse_mds_mode, mock_super_send, mock_mds_mtls_config +): + """Test the explicit 'happy path' where mTLS succeeds without error.""" + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + # Setup the parent class send return value to be successful (200 OK) + mock_response = requests.Response() + mock_response.status_code = 200 + mock_super_send.return_value = mock_response + + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + + # Call send directly + response = adapter.send(request) + + # Verify we got the response back and no fallback happened + assert response == mock_response + mock_super_send.assert_called_once() + + +@mock.patch("google.auth.compute_engine._mtls.HTTPAdapter") +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_fallback_default_mode( + mock_ssl_context, mock_parse_mds_mode, mock_http_adapter_class, mock_mds_mtls_config +): + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + mock_fallback_send = mock.Mock() + mock_http_adapter_class.return_value.send = mock_fallback_send + + # Simulate SSLError on the super().send() call + with mock.patch( + "requests.adapters.HTTPAdapter.send", side_effect=requests.exceptions.SSLError + ): + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + adapter.send(request) + + # Check that fallback to HTTPAdapter.send occurred + mock_http_adapter_class.assert_called_once() + mock_fallback_send.assert_called_once() + fallback_request = mock_fallback_send.call_args[0][0] + assert fallback_request.url == "http://fake-mds.com/" + + +@mock.patch("google.auth.compute_engine._mtls.HTTPAdapter") +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_fallback_http_error( + mock_ssl_context, mock_parse_mds_mode, mock_http_adapter_class, mock_mds_mtls_config +): + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + mock_fallback_send = mock.Mock() + mock_http_adapter_class.return_value.send = mock_fallback_send + + # Simulate HTTPError on the super().send() call + mock_mtls_response = requests.Response() + mock_mtls_response.status_code = 404 + with mock.patch( + "requests.adapters.HTTPAdapter.send", return_value=mock_mtls_response + ): + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + adapter.send(request) + + # Check that fallback to HTTPAdapter.send occurred + mock_http_adapter_class.assert_called_once() + mock_fallback_send.assert_called_once() + fallback_request = mock_fallback_send.call_args[0][0] + assert fallback_request.url == "http://fake-mds.com/" + + +@mock.patch("requests.adapters.HTTPAdapter.send") +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_no_fallback_other_exception( + mock_ssl_context, mock_parse_mds_mode, mock_http_adapter_send, mock_mds_mtls_config +): + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + # Simulate HTTP exception + with mock.patch( + "requests.adapters.HTTPAdapter.send", + side_effect=requests.exceptions.ConnectionError, + ): + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + with pytest.raises(requests.exceptions.ConnectionError): + adapter.send(request) + + mock_http_adapter_send.assert_not_called() + + +@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode") +@mock.patch("ssl.create_default_context") +def test_mds_mtls_adapter_send_no_fallback_strict_mode( + mock_ssl_context, mock_parse_mds_mode, mock_mds_mtls_config +): + mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.STRICT + adapter = _mtls.MdsMtlsAdapter(mock_mds_mtls_config) + + # Simulate SSLError on the super().send() call + with mock.patch( + "requests.adapters.HTTPAdapter.send", side_effect=requests.exceptions.SSLError + ): + request = requests.Request(method="GET", url="https://fake-mds.com").prepare() + with pytest.raises(requests.exceptions.SSLError): + adapter.send(request) From daabaa714820008565133efdf4d928143b8fe518 Mon Sep 17 00:00:00 2001 From: Daniel Sanche Date: Tue, 25 Nov 2025 13:33:14 -0800 Subject: [PATCH 08/16] chore(tests): update secret (#1876) --- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 6a4f1f3081812426117834f30ce315cba1f1535f..4b1108092b18953009508d6d4932fb35290b90ec 100644 GIT binary patch literal 10324 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 literal 10324 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 Date: Tue, 25 Nov 2025 14:47:19 -0800 Subject: [PATCH 09/16] feat: add ecdsa p-384 support (#1872) GDC (Google Distributed Cloud) needs to support ECDSA-P384 keys for compliance. This change creates an EsSigner and EsVerifier class that is capable of supporting both ECDSA-P256 and ECDSA-P384 keys for backwards compatibility. The EsSigner and EsVerifier classes are plumbed through to the GDC service accounts and are used to both sign and verify JWTs. This implementation was successfully tested against a GDC instance using both ECDSA-P256 and ECDSA-P384 keys. --------- Co-authored-by: Daniel Sanche --- google/auth/_service_account_info.py | 2 +- google/auth/crypt/__init__.py | 17 +- google/auth/crypt/es.py | 221 ++++++++++++++++++++++++++ google/auth/crypt/es256.py | 146 +---------------- google/auth/jwt.py | 15 +- tests/crypt/test_es.py | 173 ++++++++++++++++++++ tests/data/es384_privatekey.pem | 6 + tests/data/es384_public_cert.pem | 15 ++ tests/data/es384_publickey.pem | 5 + tests/data/es384_service_account.json | 9 ++ tests/test__service_account_info.py | 45 ++++-- tests/test_jwt.py | 36 ++++- 12 files changed, 528 insertions(+), 162 deletions(-) create mode 100644 google/auth/crypt/es.py create mode 100644 tests/crypt/test_es.py create mode 100644 tests/data/es384_privatekey.pem create mode 100644 tests/data/es384_public_cert.pem create mode 100644 tests/data/es384_publickey.pem create mode 100644 tests/data/es384_service_account.json diff --git a/google/auth/_service_account_info.py b/google/auth/_service_account_info.py index 6b64adcae..c432080a9 100644 --- a/google/auth/_service_account_info.py +++ b/google/auth/_service_account_info.py @@ -56,7 +56,7 @@ def from_dict(data, require=None, use_rsa_signer=True): if use_rsa_signer: signer = crypt.RSASigner.from_service_account_info(data) else: - signer = crypt.ES256Signer.from_service_account_info(data) + signer = crypt.EsSigner.from_service_account_info(data) return signer diff --git a/google/auth/crypt/__init__.py b/google/auth/crypt/__init__.py index 6d147e706..59519b475 100644 --- a/google/auth/crypt/__init__.py +++ b/google/auth/crypt/__init__.py @@ -40,13 +40,19 @@ from google.auth.crypt import base from google.auth.crypt import rsa +# google.auth.crypt.es depends on the crytpography module which may not be +# successfully imported depending on the system. try: + from google.auth.crypt import es from google.auth.crypt import es256 except ImportError: # pragma: NO COVER + es = None # type: ignore es256 = None # type: ignore -if es256 is not None: # pragma: NO COVER +if es is not None and es256 is not None: # pragma: NO COVER __all__ = [ + "EsSigner", + "EsVerifier", "ES256Signer", "ES256Verifier", "RSASigner", @@ -54,6 +60,11 @@ "Signer", "Verifier", ] + + EsSigner = es.EsSigner + EsVerifier = es.EsVerifier + ES256Signer = es256.ES256Signer + ES256Verifier = es256.ES256Verifier else: # pragma: NO COVER __all__ = ["RSASigner", "RSAVerifier", "Signer", "Verifier"] @@ -65,10 +76,6 @@ RSASigner = rsa.RSASigner RSAVerifier = rsa.RSAVerifier -if es256 is not None: # pragma: NO COVER - ES256Signer = es256.ES256Signer - ES256Verifier = es256.ES256Verifier - def verify_signature(message, signature, certs, verifier_cls=rsa.RSAVerifier): """Verify an RSA or ECDSA cryptographic signature. diff --git a/google/auth/crypt/es.py b/google/auth/crypt/es.py new file mode 100644 index 000000000..f9466af3c --- /dev/null +++ b/google/auth/crypt/es.py @@ -0,0 +1,221 @@ +# Copyright 2017 Google Inc. +# +# 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. + +"""ECDSA verifier and signer that use the ``cryptography`` library. +""" + +from dataclasses import dataclass +from typing import Any, Dict, Optional, Union + +import cryptography.exceptions +from cryptography.hazmat import backends +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature +from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature +import cryptography.x509 + +from google.auth import _helpers +from google.auth.crypt import base + + +_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----" +_BACKEND = backends.default_backend() +_PADDING = padding.PKCS1v15() + + +@dataclass +class _ESAttributes: + """A class that models ECDSA attributes. + + Attributes: + rs_size (int): Size for ASN.1 r and s size. + sha_algo (hashes.HashAlgorithm): Hash algorithm. + algorithm (str): Algorithm name. + """ + + rs_size: int + sha_algo: hashes.HashAlgorithm + algorithm: str + + @classmethod + def from_key( + cls, key: Union[ec.EllipticCurvePublicKey, ec.EllipticCurvePrivateKey] + ): + return cls.from_curve(key.curve) + + @classmethod + def from_curve(cls, curve: ec.EllipticCurve): + # ECDSA raw signature has (r||s) format where r,s are two + # integers of size 32 bytes for P-256 curve and 48 bytes + # for P-384 curve. For P-256 curve, we use SHA256 hash algo, + # and for P-384 curve we use SHA384 algo. + if isinstance(curve, ec.SECP384R1): + return cls(48, hashes.SHA384(), "ES384") + else: + # default to ES256 + return cls(32, hashes.SHA256(), "ES256") + + +class EsVerifier(base.Verifier): + """Verifies ECDSA cryptographic signatures using public keys. + + Args: + public_key ( + cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePublicKey): + The public key used to verify signatures. + """ + + def __init__(self, public_key: ec.EllipticCurvePublicKey) -> None: + self._pubkey = public_key + self._attributes = _ESAttributes.from_key(public_key) + + @_helpers.copy_docstring(base.Verifier) + def verify(self, message: bytes, signature: bytes) -> bool: + # First convert (r||s) raw signature to ASN1 encoded signature. + sig_bytes = _helpers.to_bytes(signature) + if len(sig_bytes) != self._attributes.rs_size * 2: + return False + r = int.from_bytes(sig_bytes[: self._attributes.rs_size], byteorder="big") + s = int.from_bytes(sig_bytes[self._attributes.rs_size :], byteorder="big") + asn1_sig = encode_dss_signature(r, s) + + message = _helpers.to_bytes(message) + try: + self._pubkey.verify(asn1_sig, message, ec.ECDSA(self._attributes.sha_algo)) + return True + except (ValueError, cryptography.exceptions.InvalidSignature): + return False + + @classmethod + def from_string(cls, public_key: Union[str, bytes]) -> "EsVerifier": + """Construct an Verifier instance from a public key or public + certificate string. + + Args: + public_key (Union[str, bytes]): The public key in PEM format or the + x509 public key certificate. + + Returns: + Verifier: The constructed verifier. + + Raises: + ValueError: If the public key can't be parsed. + """ + public_key_data = _helpers.to_bytes(public_key) + + if _CERTIFICATE_MARKER in public_key_data: + cert = cryptography.x509.load_pem_x509_certificate( + public_key_data, _BACKEND + ) + pubkey = cert.public_key() # type: Any + + else: + pubkey = serialization.load_pem_public_key(public_key_data, _BACKEND) + + if not isinstance(pubkey, ec.EllipticCurvePublicKey): + raise TypeError("Expected public key of type EllipticCurvePublicKey") + + return cls(pubkey) + + +class EsSigner(base.Signer, base.FromServiceAccountMixin): + """Signs messages with an ECDSA private key. + + Args: + private_key ( + cryptography.hazmat.primitives.asymmetric.ec.EllipticCurvePrivateKey): + The private key to sign with. + key_id (str): Optional key ID used to identify this private key. This + can be useful to associate the private key with its associated + public key or certificate. + """ + + def __init__( + self, private_key: ec.EllipticCurvePrivateKey, key_id: Optional[str] = None + ) -> None: + self._key = private_key + self._key_id = key_id + self._attributes = _ESAttributes.from_key(private_key) + + @property + def algorithm(self) -> str: + """Name of the algorithm used to sign messages. + Returns: + str: The algorithm name. + """ + return self._attributes.algorithm + + @property # type: ignore + @_helpers.copy_docstring(base.Signer) + def key_id(self) -> Optional[str]: + return self._key_id + + @_helpers.copy_docstring(base.Signer) + def sign(self, message: bytes) -> bytes: + message = _helpers.to_bytes(message) + asn1_signature = self._key.sign(message, ec.ECDSA(self._attributes.sha_algo)) + + # Convert ASN1 encoded signature to (r||s) raw signature. + (r, s) = decode_dss_signature(asn1_signature) + return r.to_bytes(self._attributes.rs_size, byteorder="big") + s.to_bytes( + self._attributes.rs_size, byteorder="big" + ) + + @classmethod + def from_string( + cls, key: Union[bytes, str], key_id: Optional[str] = None + ) -> "EsSigner": + """Construct a RSASigner from a private key in PEM format. + + Args: + key (Union[bytes, str]): Private key in PEM format. + key_id (str): An optional key id used to identify the private key. + + Returns: + google.auth.crypt._cryptography_rsa.RSASigner: The + constructed signer. + + Raises: + ValueError: If ``key`` is not ``bytes`` or ``str`` (unicode). + UnicodeDecodeError: If ``key`` is ``bytes`` but cannot be decoded + into a UTF-8 ``str``. + ValueError: If ``cryptography`` "Could not deserialize key data." + """ + key_bytes = _helpers.to_bytes(key) + private_key = serialization.load_pem_private_key( + key_bytes, password=None, backend=_BACKEND + ) + + if not isinstance(private_key, ec.EllipticCurvePrivateKey): + raise TypeError("Expected private key of type EllipticCurvePrivateKey") + + return cls(private_key, key_id=key_id) + + def __getstate__(self) -> Dict[str, Any]: + """Pickle helper that serializes the _key attribute.""" + state = self.__dict__.copy() + state["_key"] = self._key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + return state + + def __setstate__(self, state: Dict[str, Any]) -> None: + """Pickle helper that deserializes the _key attribute.""" + state["_key"] = serialization.load_pem_private_key(state["_key"], None) + self.__dict__.update(state) diff --git a/google/auth/crypt/es256.py b/google/auth/crypt/es256.py index 820e4becc..e7bda5d3f 100644 --- a/google/auth/crypt/es256.py +++ b/google/auth/crypt/es256.py @@ -15,93 +15,22 @@ """ECDSA (ES256) verifier and signer that use the ``cryptography`` library. """ -from cryptography import utils # type: ignore -import cryptography.exceptions -from cryptography.hazmat import backends -from cryptography.hazmat.primitives import hashes -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import ec -from cryptography.hazmat.primitives.asymmetric import padding -from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature -from cryptography.hazmat.primitives.asymmetric.utils import encode_dss_signature -import cryptography.x509 +from google.auth.crypt.es import EsSigner +from google.auth.crypt.es import EsVerifier -from google.auth import _helpers -from google.auth.crypt import base - -_CERTIFICATE_MARKER = b"-----BEGIN CERTIFICATE-----" -_BACKEND = backends.default_backend() -_PADDING = padding.PKCS1v15() - - -class ES256Verifier(base.Verifier): +class ES256Verifier(EsVerifier): """Verifies ECDSA cryptographic signatures using public keys. Args: - public_key ( - cryptography.hazmat.primitives.asymmetric.ec.ECDSAPublicKey): - The public key used to verify signatures. + public_key (cryptography.hazmat.primitives.asymmetric.ec.ECDSAPublicKey): The public key used to verify + signatures. """ - def __init__(self, public_key): - self._pubkey = public_key - - @_helpers.copy_docstring(base.Verifier) - def verify(self, message, signature): - # First convert (r||s) raw signature to ASN1 encoded signature. - sig_bytes = _helpers.to_bytes(signature) - if len(sig_bytes) != 64: - return False - r = ( - int.from_bytes(sig_bytes[:32], byteorder="big") - if _helpers.is_python_3() - else utils.int_from_bytes(sig_bytes[:32], byteorder="big") - ) - s = ( - int.from_bytes(sig_bytes[32:], byteorder="big") - if _helpers.is_python_3() - else utils.int_from_bytes(sig_bytes[32:], byteorder="big") - ) - asn1_sig = encode_dss_signature(r, s) - - message = _helpers.to_bytes(message) - try: - self._pubkey.verify(asn1_sig, message, ec.ECDSA(hashes.SHA256())) - return True - except (ValueError, cryptography.exceptions.InvalidSignature): - return False - - @classmethod - def from_string(cls, public_key): - """Construct an Verifier instance from a public key or public - certificate string. - - Args: - public_key (Union[str, bytes]): The public key in PEM format or the - x509 public key certificate. - - Returns: - Verifier: The constructed verifier. - - Raises: - ValueError: If the public key can't be parsed. - """ - public_key_data = _helpers.to_bytes(public_key) - - if _CERTIFICATE_MARKER in public_key_data: - cert = cryptography.x509.load_pem_x509_certificate( - public_key_data, _BACKEND - ) - pubkey = cert.public_key() - - else: - pubkey = serialization.load_pem_public_key(public_key_data, _BACKEND) + pass - return cls(pubkey) - -class ES256Signer(base.Signer, base.FromServiceAccountMixin): +class ES256Signer(EsSigner): """Signs messages with an ECDSA private key. Args: @@ -113,63 +42,4 @@ class ES256Signer(base.Signer, base.FromServiceAccountMixin): public key or certificate. """ - def __init__(self, private_key, key_id=None): - self._key = private_key - self._key_id = key_id - - @property # type: ignore - @_helpers.copy_docstring(base.Signer) - def key_id(self): - return self._key_id - - @_helpers.copy_docstring(base.Signer) - def sign(self, message): - message = _helpers.to_bytes(message) - asn1_signature = self._key.sign(message, ec.ECDSA(hashes.SHA256())) - - # Convert ASN1 encoded signature to (r||s) raw signature. - (r, s) = decode_dss_signature(asn1_signature) - return ( - (r.to_bytes(32, byteorder="big") + s.to_bytes(32, byteorder="big")) - if _helpers.is_python_3() - else (utils.int_to_bytes(r, 32) + utils.int_to_bytes(s, 32)) - ) - - @classmethod - def from_string(cls, key, key_id=None): - """Construct a RSASigner from a private key in PEM format. - - Args: - key (Union[bytes, str]): Private key in PEM format. - key_id (str): An optional key id used to identify the private key. - - Returns: - google.auth.crypt._cryptography_rsa.RSASigner: The - constructed signer. - - Raises: - ValueError: If ``key`` is not ``bytes`` or ``str`` (unicode). - UnicodeDecodeError: If ``key`` is ``bytes`` but cannot be decoded - into a UTF-8 ``str``. - ValueError: If ``cryptography`` "Could not deserialize key data." - """ - key = _helpers.to_bytes(key) - private_key = serialization.load_pem_private_key( - key, password=None, backend=_BACKEND - ) - return cls(private_key, key_id=key_id) - - def __getstate__(self): - """Pickle helper that serializes the _key attribute.""" - state = self.__dict__.copy() - state["_key"] = self._key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=serialization.NoEncryption(), - ) - return state - - def __setstate__(self, state): - """Pickle helper that deserializes the _key attribute.""" - state["_key"] = serialization.load_pem_private_key(state["_key"], None) - self.__dict__.update(state) + pass diff --git a/google/auth/jwt.py b/google/auth/jwt.py index 1ebd565d4..9b79f173b 100644 --- a/google/auth/jwt.py +++ b/google/auth/jwt.py @@ -59,17 +59,18 @@ import google.auth.credentials try: - from google.auth.crypt import es256 + from google.auth.crypt import es except ImportError: # pragma: NO COVER - es256 = None # type: ignore + es = None # type: ignore _DEFAULT_TOKEN_LIFETIME_SECS = 3600 # 1 hour in seconds _DEFAULT_MAX_CACHE_SIZE = 10 _ALGORITHM_TO_VERIFIER_CLASS = {"RS256": crypt.RSAVerifier} -_CRYPTOGRAPHY_BASED_ALGORITHMS = frozenset(["ES256"]) +_CRYPTOGRAPHY_BASED_ALGORITHMS = frozenset(["ES256", "ES384"]) -if es256 is not None: # pragma: NO COVER - _ALGORITHM_TO_VERIFIER_CLASS["ES256"] = es256.ES256Verifier # type: ignore +if es is not None: # pragma: NO COVER + _ALGORITHM_TO_VERIFIER_CLASS["ES256"] = es.EsVerifier # type: ignore + _ALGORITHM_TO_VERIFIER_CLASS["ES384"] = es.EsVerifier # type: ignore def encode(signer, payload, header=None, key_id=None): @@ -95,8 +96,8 @@ def encode(signer, payload, header=None, key_id=None): header.update({"typ": "JWT"}) if "alg" not in header: - if es256 is not None and isinstance(signer, es256.ES256Signer): - header.update({"alg": "ES256"}) + if es is not None and isinstance(signer, es.EsSigner): + header.update({"alg": signer.algorithm}) else: header.update({"alg": "RS256"}) diff --git a/tests/crypt/test_es.py b/tests/crypt/test_es.py new file mode 100644 index 000000000..3a62c1413 --- /dev/null +++ b/tests/crypt/test_es.py @@ -0,0 +1,173 @@ +# Copyright 2016 Google Inc. +# +# 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 base64 +import json +import os +import pickle + +from cryptography.hazmat.primitives.asymmetric import ec +import pytest # type: ignore + +from google.auth import _helpers +from google.auth.crypt import base +from google.auth.crypt import es + + +DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + +# To generate es384_privatekey.pem, es384_privatekey.pub, and +# es384_public_cert.pem: +# $ openssl ecparam -genkey -name secp384r1 -noout -out es384_privatekey.pem +# $ openssl ec -in es384_privatekey.pem -pubout -out es384_publickey.pem +# $ openssl req -new -x509 -key es384_privatekey.pem -out \ +# > es384_public_cert.pem + +with open(os.path.join(DATA_DIR, "es384_privatekey.pem"), "rb") as fh: + PRIVATE_KEY_BYTES = fh.read() + PKCS1_KEY_BYTES = PRIVATE_KEY_BYTES + +with open(os.path.join(DATA_DIR, "es384_publickey.pem"), "rb") as fh: + PUBLIC_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es384_public_cert.pem"), "rb") as fh: + PUBLIC_CERT_BYTES = fh.read() + +# RSA keys used to test for type errors in EsVerifier and EsSigner. +with open(os.path.join(DATA_DIR, "privatekey.pem"), "rb") as fh: + RSA_PRIVATE_KEY_BYTES = fh.read() + RSA_PKCS1_KEY_BYTES = RSA_PRIVATE_KEY_BYTES + +with open(os.path.join(DATA_DIR, "privatekey.pub"), "rb") as fh: + RSA_PUBLIC_KEY_BYTES = fh.read() + +SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "es384_service_account.json") + +with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: + SERVICE_ACCOUNT_INFO = json.load(fh) + + +class TestEsVerifier(object): + def test_verify_success(self): + to_sign = b"foo" + signer = es.EsSigner.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_unicode_success(self): + to_sign = u"foo" + signer = es.EsSigner.from_string(PRIVATE_KEY_BYTES) + actual_signature = signer.sign(to_sign) + + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + assert verifier.verify(to_sign, actual_signature) + + def test_verify_failure(self): + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + bad_signature1 = b"" + assert not verifier.verify(b"foo", bad_signature1) + bad_signature2 = b"a" + assert not verifier.verify(b"foo", bad_signature2) + + def test_verify_failure_with_wrong_raw_signature(self): + to_sign = b"foo" + + # This signature has a wrong "r" value in the "(r,s)" raw signature. + wrong_signature = base64.urlsafe_b64decode( + b"m7oaRxUDeYqjZ8qiMwo0PZLTMZWKJLFQREpqce1StMIa_yXQQ-C5WgeIRHW7OqlYSDL0XbUrj_uAw9i-QhfOJQ==" + ) + + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + assert not verifier.verify(to_sign, wrong_signature) + + def test_from_string_pub_key(self): + verifier = es.EsVerifier.from_string(PUBLIC_KEY_BYTES) + assert isinstance(verifier, es.EsVerifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_key_unicode(self): + public_key = _helpers.from_bytes(PUBLIC_KEY_BYTES) + verifier = es.EsVerifier.from_string(public_key) + assert isinstance(verifier, es.EsVerifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_cert(self): + verifier = es.EsVerifier.from_string(PUBLIC_CERT_BYTES) + assert isinstance(verifier, es.EsVerifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_pub_cert_unicode(self): + public_cert = _helpers.from_bytes(PUBLIC_CERT_BYTES) + verifier = es.EsVerifier.from_string(public_cert) + assert isinstance(verifier, es.EsVerifier) + assert isinstance(verifier._pubkey, ec.EllipticCurvePublicKey) + + def test_from_string_type_error(self): + with pytest.raises(TypeError): + es.EsVerifier.from_string(RSA_PUBLIC_KEY_BYTES) + + +class TestEsSigner(object): + def test_from_string_pkcs1(self): + signer = es.EsSigner.from_string(PKCS1_KEY_BYTES) + assert isinstance(signer, es.EsSigner) + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_string_pkcs1_unicode(self): + key_bytes = _helpers.from_bytes(PKCS1_KEY_BYTES) + signer = es.EsSigner.from_string(key_bytes) + assert isinstance(signer, es.EsSigner) + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_string_bogus_key(self): + key_bytes = "bogus-key" + with pytest.raises(ValueError): + es.EsSigner.from_string(key_bytes) + + def test_from_string_type_error(self): + key_bytes = _helpers.from_bytes(RSA_PKCS1_KEY_BYTES) + with pytest.raises(TypeError): + es.EsSigner.from_string(key_bytes) + + def test_from_service_account_info(self): + signer = es.EsSigner.from_service_account_info(SERVICE_ACCOUNT_INFO) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_from_service_account_info_missing_key(self): + with pytest.raises(ValueError) as excinfo: + es.EsSigner.from_service_account_info({}) + + assert excinfo.match(base._JSON_FILE_PRIVATE_KEY) + + def test_from_service_account_file(self): + signer = es.EsSigner.from_service_account_file(SERVICE_ACCOUNT_JSON_FILE) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + def test_pickle(self): + signer = es.EsSigner.from_service_account_file(SERVICE_ACCOUNT_JSON_FILE) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) + + pickled_signer = pickle.dumps(signer) + signer = pickle.loads(pickled_signer) + + assert signer.key_id == SERVICE_ACCOUNT_INFO[base._JSON_FILE_PRIVATE_KEY_ID] + assert isinstance(signer._key, ec.EllipticCurvePrivateKey) diff --git a/tests/data/es384_privatekey.pem b/tests/data/es384_privatekey.pem new file mode 100644 index 000000000..12ff96291 --- /dev/null +++ b/tests/data/es384_privatekey.pem @@ -0,0 +1,6 @@ +-----BEGIN EC PRIVATE KEY----- +MIGkAgEBBDBz1wKJNXd2Rzy52A7F3f9LmLp6KaMUTbL1IT3JaDx1kOp4CUFpI9Zs +rdEx7b7kKQGgBwYFK4EEACKhZANiAATRLiEHuOwLr8bjJnJdYG2mrlWtMEPBHOrm +n7RukR80nV5uAcqt+M319T2togP0tQIe621FUpJq7+Hq0vJJbtI1MPuFSDtpZG04 +5se7BVAw63IPV1EdO6vGXxd5Fay88uU= +-----END EC PRIVATE KEY----- diff --git a/tests/data/es384_public_cert.pem b/tests/data/es384_public_cert.pem new file mode 100644 index 000000000..e8d5d4c68 --- /dev/null +++ b/tests/data/es384_public_cert.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICYzCCAeqgAwIBAgIUeYyowQBkomEoMj72pNh754QlGvAwCgYIKoZIzj0EAwIw +aTELMAkGA1UEBhMCVVMxCzAJBgNVBAgMAkNBMRYwFAYDVQQHDA1Nb3VudGFpbiBW +aWV3MQ8wDQYDVQQKDAZHb29nbGUxDzANBgNVBAsMBkdvb2dsZTETMBEGA1UEAwwK +Z29vZ2xlLmNvbTAeFw0yNTExMTEwMDQzMTlaFw0yNTEyMTEwMDQzMTlaMGkxCzAJ +BgNVBAYTAlVTMQswCQYDVQQIDAJDQTEWMBQGA1UEBwwNTW91bnRhaW4gVmlldzEP +MA0GA1UECgwGR29vZ2xlMQ8wDQYDVQQLDAZHb29nbGUxEzARBgNVBAMMCmdvb2ds +ZS5jb20wdjAQBgcqhkjOPQIBBgUrgQQAIgNiAATRLiEHuOwLr8bjJnJdYG2mrlWt +MEPBHOrmn7RukR80nV5uAcqt+M319T2togP0tQIe621FUpJq7+Hq0vJJbtI1MPuF +SDtpZG045se7BVAw63IPV1EdO6vGXxd5Fay88uWjUzBRMB0GA1UdDgQWBBSRZkxR +63/X4JotxKDRWCI4PwIElDAfBgNVHSMEGDAWgBSRZkxR63/X4JotxKDRWCI4PwIE +lDAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA2cAMGQCMAU+2yy/luLTa+T6 +Jm86i9GiH/lPYdYwZFvwKJFTdj8FJpv7ySN0J80qzWxtBZTCMQIwZO0ZRdv8s7V3 +022yISIujmsPmgj7lvPuDZZaVn1DVYMG3YmBB+cTp+JTqF3x7lN+ +-----END CERTIFICATE----- diff --git a/tests/data/es384_publickey.pem b/tests/data/es384_publickey.pem new file mode 100644 index 000000000..e78ac0f49 --- /dev/null +++ b/tests/data/es384_publickey.pem @@ -0,0 +1,5 @@ +-----BEGIN PUBLIC KEY----- +MHYwEAYHKoZIzj0CAQYFK4EEACIDYgAE0S4hB7jsC6/G4yZyXWBtpq5VrTBDwRzq +5p+0bpEfNJ1ebgHKrfjN9fU9raID9LUCHuttRVKSau/h6tLySW7SNTD7hUg7aWRt +OObHuwVQMOtyD1dRHTurxl8XeRWsvPLl +-----END PUBLIC KEY----- diff --git a/tests/data/es384_service_account.json b/tests/data/es384_service_account.json new file mode 100644 index 000000000..8302344b1 --- /dev/null +++ b/tests/data/es384_service_account.json @@ -0,0 +1,9 @@ +{ + "type":"gdch_service_account", + "format_version":"1", + "project":"mytest", + "private_key_id":"1234567890", + "private_key":"-----BEGIN EC PRIVATE KEY-----\nMIGkAgEBBDAyqgUeNwuUOMCC9Bzyf4uT2rfZyISJFMq3ByfE+ytUbveUd6RtvoCT\nS9cYbmuj06OgBwYFK4EEACKhZANiAATrUB670cjyRUcarD//92jO52Rqo+jKi0x7\nkscWALlC8bx9zED5zpy948FrQhQgb/TLPhunkyTwWe22CzafS8ik5pCZKkWfiJRV\n9IBMJDTMyocCR013qDXKHZOpJ57wAUw=\n-----END EC PRIVATE KEY-----\n", + "name":"mytest", + "token_uri":"https://service-accounts.org.google.com/authenticate" +} diff --git a/tests/test__service_account_info.py b/tests/test__service_account_info.py index be2657074..7e836861e 100644 --- a/tests/test__service_account_info.py +++ b/tests/test__service_account_info.py @@ -23,13 +23,21 @@ DATA_DIR = os.path.join(os.path.dirname(__file__), "data") SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") -GDCH_SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "gdch_service_account.json") +GDCH_SERVICE_ACCOUNT_ES256_JSON_FILE = os.path.join( + DATA_DIR, "gdch_service_account.json" +) +GDCH_SERVICE_ACCOUNT_ES384_JSON_FILE = os.path.join( + DATA_DIR, "es384_service_account.json" +) with open(SERVICE_ACCOUNT_JSON_FILE, "r") as fh: SERVICE_ACCOUNT_INFO = json.load(fh) -with open(GDCH_SERVICE_ACCOUNT_JSON_FILE, "r") as fh: - GDCH_SERVICE_ACCOUNT_INFO = json.load(fh) +with open(GDCH_SERVICE_ACCOUNT_ES256_JSON_FILE, "r") as fh: + GDCH_SERVICE_ACCOUNT_ES256_INFO = json.load(fh) + +with open(GDCH_SERVICE_ACCOUNT_ES384_JSON_FILE, "r") as fh: + GDCH_SERVICE_ACCOUNT_ES384_INFO = json.load(fh) def test_from_dict(): @@ -40,10 +48,19 @@ def test_from_dict(): def test_from_dict_es256_signer(): signer = _service_account_info.from_dict( - GDCH_SERVICE_ACCOUNT_INFO, use_rsa_signer=False + GDCH_SERVICE_ACCOUNT_ES256_INFO, use_rsa_signer=False + ) + assert isinstance(signer, crypt.EsSigner) + assert signer.key_id == GDCH_SERVICE_ACCOUNT_ES256_INFO["private_key_id"] + + +def test_from_dict_es384_signer(): + signer = _service_account_info.from_dict( + GDCH_SERVICE_ACCOUNT_ES384_INFO, use_rsa_signer=False ) - assert isinstance(signer, crypt.ES256Signer) - assert signer.key_id == GDCH_SERVICE_ACCOUNT_INFO["private_key_id"] + assert isinstance(signer, crypt.EsSigner) + assert signer.key_id == GDCH_SERVICE_ACCOUNT_ES384_INFO["private_key_id"] + assert signer.algorithm == "ES384" def test_from_dict_bad_private_key(): @@ -75,8 +92,18 @@ def test_from_filename(): def test_from_filename_es256_signer(): _, signer = _service_account_info.from_filename( - GDCH_SERVICE_ACCOUNT_JSON_FILE, use_rsa_signer=False + GDCH_SERVICE_ACCOUNT_ES256_JSON_FILE, use_rsa_signer=False + ) + + assert isinstance(signer, crypt.EsSigner) + assert signer.key_id == GDCH_SERVICE_ACCOUNT_ES256_INFO["private_key_id"] + + +def test_from_filename_es384_signer(): + _, signer = _service_account_info.from_filename( + GDCH_SERVICE_ACCOUNT_ES384_JSON_FILE, use_rsa_signer=False ) - assert isinstance(signer, crypt.ES256Signer) - assert signer.key_id == GDCH_SERVICE_ACCOUNT_INFO["private_key_id"] + assert isinstance(signer, crypt.EsSigner) + assert signer.key_id == GDCH_SERVICE_ACCOUNT_ES384_INFO["private_key_id"] + assert signer.algorithm == "ES384" diff --git a/tests/test_jwt.py b/tests/test_jwt.py index 28660ea33..a5a904d7d 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -43,6 +43,12 @@ with open(os.path.join(DATA_DIR, "es256_public_cert.pem"), "rb") as fh: EC_PUBLIC_CERT_BYTES = fh.read() +with open(os.path.join(DATA_DIR, "es384_privatekey.pem"), "rb") as fh: + EC384_PRIVATE_KEY_BYTES = fh.read() + +with open(os.path.join(DATA_DIR, "es384_public_cert.pem"), "rb") as fh: + EC384_PUBLIC_CERT_BYTES = fh.read() + SERVICE_ACCOUNT_JSON_FILE = os.path.join(DATA_DIR, "service_account.json") with open(SERVICE_ACCOUNT_JSON_FILE, "rb") as fh: @@ -84,6 +90,11 @@ def es256_signer(): return crypt.ES256Signer.from_string(EC_PRIVATE_KEY_BYTES, "1") +@pytest.fixture +def es384_signer(): + return crypt.EsSigner.from_string(EC384_PRIVATE_KEY_BYTES, "1") + + def test_encode_basic_es256(es256_signer): test_payload = {"test": "value"} encoded = jwt.encode(es256_signer, test_payload) @@ -92,9 +103,19 @@ def test_encode_basic_es256(es256_signer): assert header == {"typ": "JWT", "alg": "ES256", "kid": es256_signer.key_id} +def test_encode_basic_es384(es384_signer): + test_payload = {"test": "value"} + encoded = jwt.encode(es384_signer, test_payload) + header, payload, _, _ = jwt._unverified_decode(encoded) + assert payload == test_payload + assert header == {"typ": "JWT", "alg": "ES384", "kid": es384_signer.key_id} + + @pytest.fixture -def token_factory(signer, es256_signer): - def factory(claims=None, key_id=None, use_es256_signer=False): +def token_factory(signer, es256_signer, es384_signer): + def factory( + claims=None, key_id=None, use_es256_signer=False, use_es384_signer=False + ): now = _helpers.datetime_to_secs(_helpers.utcnow()) payload = { "aud": "audience@example.com", @@ -113,6 +134,8 @@ def factory(claims=None, key_id=None, use_es256_signer=False): if use_es256_signer: return jwt.encode(es256_signer, payload, key_id=key_id) + elif use_es384_signer: + return jwt.encode(es384_signer, payload, key_id=key_id) else: return jwt.encode(signer, payload, key_id=key_id) @@ -158,6 +181,15 @@ def test_decode_valid_es256(token_factory): assert payload["metadata"]["meta"] == "data" +def test_decode_valid_es384(token_factory): + payload = jwt.decode( + token_factory(use_es384_signer=True), certs=EC384_PUBLIC_CERT_BYTES + ) + assert payload["aud"] == "audience@example.com" + assert payload["user"] == "billy bob" + assert payload["metadata"]["meta"] == "data" + + def test_decode_valid_with_audience(token_factory): payload = jwt.decode( token_factory(), certs=PUBLIC_CERT_BYTES, audience="audience@example.com" From 2c374d36a61569b75d11c17fae124d591c52ddc6 Mon Sep 17 00:00:00 2001 From: Daniel Sanche Date: Mon, 1 Dec 2025 12:20:56 -0800 Subject: [PATCH 10/16] chore: update secret (#1879) --- system_tests/secrets.tar.enc | Bin 10324 -> 10324 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/system_tests/secrets.tar.enc b/system_tests/secrets.tar.enc index 4b1108092b18953009508d6d4932fb35290b90ec..d89763db03ebdf5a1bee80f1027afe8cd168a93b 100644 GIT binary patch literal 10324 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 literal 10324 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 From b0993c7edaba505d0fb0628af28760c43034c959 Mon Sep 17 00:00:00 2001 From: Andy Zhao Date: Mon, 1 Dec 2025 13:41:34 -0800 Subject: [PATCH 11/16] fix(auth): Delegate workload cert and key default lookup to helper function (#1877) get_client_ssl_credentials had a bug that defaulted the cert path to CERTIFICATE_CONFIGURATION_DEFAULT_PATH if not explicitly specified. The correct behavior should be to delegate the lookup logic to "_get_workload_cert_and_key" which also takes into account the cert config path set by the env var GOOGLE_API_CERTIFICATE_CONFIG. --------- Co-authored-by: Daniel Sanche --- google/auth/transport/_mtls_helper.py | 13 +++++-------- tests/transport/test__mtls_helper.py | 13 ++++++++++++- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/google/auth/transport/_mtls_helper.py b/google/auth/transport/_mtls_helper.py index 7740f2fe8..7b2b0407f 100644 --- a/google/auth/transport/_mtls_helper.py +++ b/google/auth/transport/_mtls_helper.py @@ -279,7 +279,7 @@ def _run_cert_provider_command(command, expect_encrypted_key=False): def get_client_ssl_credentials( generate_encrypted_key=False, context_aware_metadata_path=CONTEXT_AWARE_METADATA_PATH, - certificate_config_path=CERTIFICATE_CONFIGURATION_DEFAULT_PATH, + certificate_config_path=None, ): """Returns the client side certificate, private key and passphrase. @@ -306,13 +306,10 @@ def get_client_ssl_credentials( the cert, key and passphrase. """ - # 1. Check for certificate config json. - cert_config_path = _check_config_path(certificate_config_path) - if cert_config_path: - # Attempt to retrieve X.509 Workload cert and key. - cert, key = _get_workload_cert_and_key(cert_config_path) - if cert and key: - return True, cert, key, None + # 1. Attempt to retrieve X.509 Workload cert and key. + cert, key = _get_workload_cert_and_key(certificate_config_path) + if cert and key: + return True, cert, key, None # 2. Check for context aware metadata json metadata_path = _check_config_path(context_aware_metadata_path) diff --git a/tests/transport/test__mtls_helper.py b/tests/transport/test__mtls_helper.py index 01d5e3a40..63c742c1f 100644 --- a/tests/transport/test__mtls_helper.py +++ b/tests/transport/test__mtls_helper.py @@ -334,9 +334,15 @@ def test_success_with_certificate_config( assert key == pytest.private_key_bytes assert passphrase is None + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key", autospec=True + ) @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) - def test_success_without_metadata(self, mock_check_config_path): + def test_success_without_metadata( + self, mock_check_config_path, mock_get_workload_cert_and_key + ): mock_check_config_path.return_value = False + mock_get_workload_cert_and_key.return_value = (None, None) has_cert, cert, key, passphrase = _mtls_helper.get_client_ssl_credentials() assert not has_cert assert cert is None @@ -395,12 +401,17 @@ def test_missing_cert_command( ) @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper._get_workload_cert_and_key", autospec=True + ) def test_customize_context_aware_metadata_path( self, + mock_get_workload_cert_and_key, mock_check_config_path, mock_load_json_file, mock_run_cert_provider_command, ): + mock_get_workload_cert_and_key.return_value = (None, None) context_aware_metadata_path = "/path/to/metata/data" mock_check_config_path.return_value = context_aware_metadata_path mock_load_json_file.return_value = {"cert_provider_command": ["command"]} From 3e8a56687fd050ff44a35cc8db0e95e996648173 Mon Sep 17 00:00:00 2001 From: Daniel Sanche Date: Thu, 4 Dec 2025 13:57:12 -0800 Subject: [PATCH 12/16] chore(tests): allow expired secret in system tests (#1883) Allow system tests to pass, even if the secret is found to be expired Long term, we should re-think these tests. But this will unblock work in this repo Context: https://github.com/googleapis/google-auth-library-python/issues/1882 --- system_tests/system_tests_async/test_default.py | 13 +++++++++++-- system_tests/system_tests_sync/test_default.py | 12 ++++++++++-- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/system_tests/system_tests_async/test_default.py b/system_tests/system_tests_async/test_default.py index 32299c059..dfffba28f 100644 --- a/system_tests/system_tests_async/test_default.py +++ b/system_tests/system_tests_async/test_default.py @@ -16,8 +16,11 @@ import pytest from google.auth import _default_async +from google.auth.exceptions import RefreshError + +EXPECT_PROJECT_ID = os.getenv("EXPECT_PROJECT_ID") +CREDENTIALS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "") -EXPECT_PROJECT_ID = os.environ.get("EXPECT_PROJECT_ID") @pytest.mark.asyncio async def test_application_default_credentials(verify_refresh): @@ -26,4 +29,10 @@ async def test_application_default_credentials(verify_refresh): if EXPECT_PROJECT_ID is not None: assert project_id is not None - await verify_refresh(credentials) + try: + await verify_refresh(credentials) + except RefreshError as e: + # allow expired credentials for explicit_authorized_user tests + # TODO: https://github.com/googleapis/google-auth-library-python/issues/1882 + if not CREDENTIALS.endswith("authorized_user.json") or "Token has been expired or revoked" not in str(e): + raise diff --git a/system_tests/system_tests_sync/test_default.py b/system_tests/system_tests_sync/test_default.py index 560ab3284..322c57b62 100644 --- a/system_tests/system_tests_sync/test_default.py +++ b/system_tests/system_tests_sync/test_default.py @@ -15,8 +15,10 @@ import os import google.auth +from google.auth.exceptions import RefreshError -EXPECT_PROJECT_ID = os.environ.get("EXPECT_PROJECT_ID") +EXPECT_PROJECT_ID = os.getenv("EXPECT_PROJECT_ID") +CREDENTIALS = os.getenv("GOOGLE_APPLICATION_CREDENTIALS", "") def test_application_default_credentials(verify_refresh): @@ -25,4 +27,10 @@ def test_application_default_credentials(verify_refresh): if EXPECT_PROJECT_ID is not None: assert project_id is not None - verify_refresh(credentials) + try: + verify_refresh(credentials) + except RefreshError as e: + # allow expired credentials for explicit_authorized_user tests + # TODO: https://github.com/googleapis/google-auth-library-python/issues/1882 + if not CREDENTIALS.endswith("authorized_user.json") or "Token has been expired or revoked" not in str(e): + raise From 78de7907b8bdb7b5510e3c6fa8a3f3721e2436d7 Mon Sep 17 00:00:00 2001 From: Andy Zhao Date: Thu, 4 Dec 2025 14:24:34 -0800 Subject: [PATCH 13/16] fix(auth): Add temporary patch to workload cert logic to accomodate Cloud Run mis-configuration (#1880) This patch adds a fallback logic to look for Cloud Run cert/keys in the well-known location if the cert config contains the exact incorrect cert/key paths AND the incorrect cert/key paths point to non-existent files. Note: This patch will be reverted sometime in Jan 2026, after Cloud Run environment is updated with the correct cert configs. The revert will be tracked by #1881 --- google/auth/transport/_mtls_helper.py | 33 ++++++++++ tests/transport/test__mtls_helper.py | 87 +++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/google/auth/transport/_mtls_helper.py b/google/auth/transport/_mtls_helper.py index 7b2b0407f..f5d6b6724 100644 --- a/google/auth/transport/_mtls_helper.py +++ b/google/auth/transport/_mtls_helper.py @@ -47,6 +47,20 @@ b"-----BEGIN PASSPHRASE-----(.+)-----END PASSPHRASE-----", re.DOTALL ) +# Temporary patch to accomodate incorrect cert config in Cloud Run prod environment. +_WELL_KNOWN_CLOUD_RUN_CERT_PATH = ( + "/var/run/secrets/workload-spiffe-credentials/certificates.pem" +) +_WELL_KNOWN_CLOUD_RUN_KEY_PATH = ( + "/var/run/secrets/workload-spiffe-credentials/private_key.pem" +) +_INCORRECT_CLOUD_RUN_CERT_PATH = ( + "/var/lib/volumes/certificate/workload-certificates/certificates.pem" +) +_INCORRECT_CLOUD_RUN_KEY_PATH = ( + "/var/lib/volumes/certificate/workload-certificates/private_key.pem" +) + def _check_config_path(config_path): """Checks for config file path. If it exists, returns the absolute path with user expansion; @@ -183,6 +197,25 @@ def _get_workload_cert_and_key_paths(config_path): ) key_path = workload["key_path"] + # == BEGIN Temporary Cloud Run PATCH == + # See https://github.com/googleapis/google-auth-library-python/issues/1881 + if (cert_path == _INCORRECT_CLOUD_RUN_CERT_PATH) and ( + key_path == _INCORRECT_CLOUD_RUN_KEY_PATH + ): + if not path.exists(cert_path) and not path.exists(key_path): + _LOGGER.debug( + "Applying Cloud Run certificate path patch. " + "Configured paths not found: %s, %s. " + "Using well-known paths: %s, %s", + cert_path, + key_path, + _WELL_KNOWN_CLOUD_RUN_CERT_PATH, + _WELL_KNOWN_CLOUD_RUN_KEY_PATH, + ) + cert_path = _WELL_KNOWN_CLOUD_RUN_CERT_PATH + key_path = _WELL_KNOWN_CLOUD_RUN_KEY_PATH + # == END Temporary Cloud Run PATCH == + return cert_path, key_path diff --git a/tests/transport/test__mtls_helper.py b/tests/transport/test__mtls_helper.py index 63c742c1f..2a7a524b1 100644 --- a/tests/transport/test__mtls_helper.py +++ b/tests/transport/test__mtls_helper.py @@ -334,6 +334,93 @@ def test_success_with_certificate_config( assert key == pytest.private_key_bytes assert passphrase is None + @mock.patch( + "google.auth.transport._mtls_helper._read_cert_and_key_files", autospec=True + ) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_success_with_certificate_config_cloud_run_patch( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_cert_config_path, + mock_read_cert_and_key_files, + ): + cert_config_path = "/path/to/config" + mock_check_config_path.return_value = cert_config_path + mock_load_json_file.return_value = { + "cert_configs": { + "workload": { + "cert_path": _mtls_helper._INCORRECT_CLOUD_RUN_CERT_PATH, + "key_path": _mtls_helper._INCORRECT_CLOUD_RUN_KEY_PATH, + } + } + } + mock_get_cert_config_path.return_value = cert_config_path + mock_read_cert_and_key_files.return_value = ( + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + has_cert, cert, key, passphrase = _mtls_helper.get_client_ssl_credentials() + assert has_cert + assert cert == pytest.public_cert_bytes + assert key == pytest.private_key_bytes + assert passphrase is None + + mock_read_cert_and_key_files.assert_called_once_with( + _mtls_helper._WELL_KNOWN_CLOUD_RUN_CERT_PATH, + _mtls_helper._WELL_KNOWN_CLOUD_RUN_KEY_PATH, + ) + + @mock.patch("os.path.exists", autospec=True) + @mock.patch( + "google.auth.transport._mtls_helper._read_cert_and_key_files", autospec=True + ) + @mock.patch( + "google.auth.transport._mtls_helper._get_cert_config_path", autospec=True + ) + @mock.patch("google.auth.transport._mtls_helper._load_json_file", autospec=True) + @mock.patch("google.auth.transport._mtls_helper._check_config_path", autospec=True) + def test_success_with_certificate_config_cloud_run_patch_skipped_if_cert_exists( + self, + mock_check_config_path, + mock_load_json_file, + mock_get_cert_config_path, + mock_read_cert_and_key_files, + mock_os_path_exists, + ): + cert_config_path = "/path/to/config" + mock_check_config_path.return_value = cert_config_path + mock_os_path_exists.return_value = True + mock_load_json_file.return_value = { + "cert_configs": { + "workload": { + "cert_path": _mtls_helper._INCORRECT_CLOUD_RUN_CERT_PATH, + "key_path": _mtls_helper._INCORRECT_CLOUD_RUN_KEY_PATH, + } + } + } + mock_get_cert_config_path.return_value = cert_config_path + mock_read_cert_and_key_files.return_value = ( + pytest.public_cert_bytes, + pytest.private_key_bytes, + ) + + has_cert, cert, key, passphrase = _mtls_helper.get_client_ssl_credentials() + assert has_cert + assert cert == pytest.public_cert_bytes + assert key == pytest.private_key_bytes + assert passphrase is None + + mock_read_cert_and_key_files.assert_called_once_with( + _mtls_helper._INCORRECT_CLOUD_RUN_CERT_PATH, + _mtls_helper._INCORRECT_CLOUD_RUN_KEY_PATH, + ) + @mock.patch( "google.auth.transport._mtls_helper._get_workload_cert_and_key", autospec=True ) From e0c3296f471747258f6d98d2d9bfde636358ecde Mon Sep 17 00:00:00 2001 From: nbayati <99771966+nbayati@users.noreply.github.com> Date: Tue, 9 Dec 2025 12:34:43 -0800 Subject: [PATCH 14/16] fix(auth): Use public refresh method for source credentials in ImpersonatedCredentials (#1884) This PR addresses a bug in ImpersonatedCredentials that causes a issues when the source_credential is of a type that does not implement the private _refresh_token method (for example, a custom credential type). --- google/auth/impersonated_credentials.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google/auth/impersonated_credentials.py b/google/auth/impersonated_credentials.py index 334573428..e2724382a 100644 --- a/google/auth/impersonated_credentials.py +++ b/google/auth/impersonated_credentials.py @@ -286,7 +286,7 @@ def _refresh_token(self, request): self._source_credentials.token_state == credentials.TokenState.STALE or self._source_credentials.token_state == credentials.TokenState.INVALID ): - self._source_credentials._refresh_token(request) + self._source_credentials.refresh(request) body = { "delegates": self._delegates, From 0f7097e78f247665b6ef0287d482033f7be2ed6d Mon Sep 17 00:00:00 2001 From: Lingqing Gan Date: Fri, 12 Dec 2025 12:47:51 -0800 Subject: [PATCH 15/16] feat: support Python 3.14 (#1822) Co-authored-by: Anthonios Partheniou --- .kokoro/samples/python3.14/common.cfg | 37 +++++++++++++++++++ .kokoro/samples/python3.14/continuous.cfg | 6 +++ .kokoro/samples/python3.14/periodic-head.cfg | 11 ++++++ .kokoro/samples/python3.14/periodic.cfg | 6 +++ .kokoro/samples/python3.14/presubmit.cfg | 6 +++ noxfile.py | 3 +- samples/cloud-client/snippets/noxfile.py | 2 +- .../cloud-client/snippets/requirements.txt | 4 +- setup.py | 1 + testing/constraints-3.14.txt | 0 .../transport/test_aiohttp_requests.py | 11 ++++-- 11 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 .kokoro/samples/python3.14/common.cfg create mode 100644 .kokoro/samples/python3.14/continuous.cfg create mode 100644 .kokoro/samples/python3.14/periodic-head.cfg create mode 100644 .kokoro/samples/python3.14/periodic.cfg create mode 100644 .kokoro/samples/python3.14/presubmit.cfg create mode 100644 testing/constraints-3.14.txt diff --git a/.kokoro/samples/python3.14/common.cfg b/.kokoro/samples/python3.14/common.cfg new file mode 100644 index 000000000..c82a73a9e --- /dev/null +++ b/.kokoro/samples/python3.14/common.cfg @@ -0,0 +1,37 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +# Build logs will be here +action { + define_artifacts { + regex: "**/*sponge_log.xml" + } +} + +# Specify which tests to run +env_vars: { + key: "RUN_TESTS_SESSION" + value: "unit-3.14" +} + +# Download trampoline resources. +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/trampoline" + +# Download resources for system tests (service account key, etc.) +gfile_resources: "/bigstore/cloud-devrel-kokoro-resources/google-auth-library-python" + +# Use the trampoline script to run in docker. +build_file: "google-auth-library-python/.kokoro/trampoline.sh" + +# Configure the docker image for kokoro-trampoline. +env_vars: { + key: "TRAMPOLINE_IMAGE" + value: "gcr.io/cloud-devrel-kokoro-resources/python-multi" +} +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/google-auth-library-python/.kokoro/build.sh" +} +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/google-auth-library-python/.kokoro/samples-test-setup.sh" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.14/continuous.cfg b/.kokoro/samples/python3.14/continuous.cfg new file mode 100644 index 000000000..a1c8d9759 --- /dev/null +++ b/.kokoro/samples/python3.14/continuous.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/.kokoro/samples/python3.14/periodic-head.cfg b/.kokoro/samples/python3.14/periodic-head.cfg new file mode 100644 index 000000000..83eace873 --- /dev/null +++ b/.kokoro/samples/python3.14/periodic-head.cfg @@ -0,0 +1,11 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} + +env_vars: { + key: "TRAMPOLINE_BUILD_FILE" + value: "github/google-auth-library-python/.kokoro/test-samples-against-head.sh" +} diff --git a/.kokoro/samples/python3.14/periodic.cfg b/.kokoro/samples/python3.14/periodic.cfg new file mode 100644 index 000000000..71cd1e597 --- /dev/null +++ b/.kokoro/samples/python3.14/periodic.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "False" +} diff --git a/.kokoro/samples/python3.14/presubmit.cfg b/.kokoro/samples/python3.14/presubmit.cfg new file mode 100644 index 000000000..a1c8d9759 --- /dev/null +++ b/.kokoro/samples/python3.14/presubmit.cfg @@ -0,0 +1,6 @@ +# Format: //devtools/kokoro/config/proto/build.proto + +env_vars: { + key: "INSTALL_LIBRARY_FROM_SOURCE" + value: "True" +} \ No newline at end of file diff --git a/noxfile.py b/noxfile.py index 728e8c7cc..11f677a3b 100644 --- a/noxfile.py +++ b/noxfile.py @@ -36,7 +36,7 @@ DEFAULT_PYTHON_VERSION = "3.10" # TODO(https://github.com/googleapis/google-auth-library-python/issues/1787): # Remove or restore testing for Python 3.7/3.8 -UNIT_TEST_PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13"] +UNIT_TEST_PYTHON_VERSIONS = ["3.9", "3.10", "3.11", "3.12", "3.13", "3.14"] # Error if a python version is missing nox.options.error_on_missing_interpreters = True @@ -53,6 +53,7 @@ "unit-3.11", "unit-3.12", "unit-3.13", + "unit-3.14", # cover must be last to avoid error `No data to report` "cover", "docs", diff --git a/samples/cloud-client/snippets/noxfile.py b/samples/cloud-client/snippets/noxfile.py index c21466d4f..3cdf3cf3b 100644 --- a/samples/cloud-client/snippets/noxfile.py +++ b/samples/cloud-client/snippets/noxfile.py @@ -60,7 +60,7 @@ ] -@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]) +@nox.session(python=["3.7", "3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]) def unit(session): # constraints_path = str( # CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt" diff --git a/samples/cloud-client/snippets/requirements.txt b/samples/cloud-client/snippets/requirements.txt index 97f256bd8..b5c5cea30 100644 --- a/samples/cloud-client/snippets/requirements.txt +++ b/samples/cloud-client/snippets/requirements.txt @@ -1,7 +1,7 @@ google-cloud-compute==1.5.1 google-cloud-storage==3.1.0 google-auth==2.41.1 -pytest==7.1.2 +pytest==8.4.2 boto3>=1.26.0 requests==2.32.3 -python-dotenv==1.1.1 \ No newline at end of file +python-dotenv==1.1.1 diff --git a/setup.py b/setup.py index 20f79ce66..014b32a95 100644 --- a/setup.py +++ b/setup.py @@ -129,6 +129,7 @@ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", diff --git a/testing/constraints-3.14.txt b/testing/constraints-3.14.txt new file mode 100644 index 000000000..e69de29bb diff --git a/tests_async/transport/test_aiohttp_requests.py b/tests_async/transport/test_aiohttp_requests.py index d00955a7d..e910779a6 100644 --- a/tests_async/transport/test_aiohttp_requests.py +++ b/tests_async/transport/test_aiohttp_requests.py @@ -115,10 +115,11 @@ def make_with_parameter_request(self): http = aiohttp.ClientSession(auto_decompress=False) return aiohttp_requests.Request(http) - def test_unsupported_session(self): + @pytest.mark.asyncio + async def test_unsupported_session(self): http = aiohttp.ClientSession(auto_decompress=True) with pytest.raises(ValueError): - aiohttp_requests.Request(http) + await aiohttp_requests.Request(http) def test_timeout(self): http = mock.create_autospec( @@ -144,11 +145,13 @@ class TestAuthorizedSession(object): TEST_URL = "http://example.com/" method = "GET" - def test_constructor(self): + @pytest.mark.asyncio + async def test_constructor(self): authed_session = aiohttp_requests.AuthorizedSession(mock.sentinel.credentials) assert authed_session.credentials == mock.sentinel.credentials - def test_constructor_with_auth_request(self): + @pytest.mark.asyncio + async def test_constructor_with_auth_request(self): http = mock.create_autospec( aiohttp.ClientSession, instance=True, _auto_decompress=False ) From 262eb9e33d58ffeb536ecd083c22d9fb12c808e6 Mon Sep 17 00:00:00 2001 From: Victor Chudnovsky Date: Mon, 15 Dec 2025 09:35:59 -0800 Subject: [PATCH 16/16] chore: librarian release pull request: 20251212T161150Z (#1888) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR created by the Librarian CLI to initialize a release. Merging this PR will auto trigger a release. Librarian Version: v0.7.0 Language Image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator:latest
google-auth: 2.44.0 ## [2.44.0](https://github.com/googleapis/google-auth-library-python/compare/v2.43.0...v2.44.0) (2025-12-12) ### Features * MDS connections use mTLS (#1856) ([0387bb95](https://github.com/googleapis/google-auth-library-python/commit/0387bb95)) * support Python 3.14 (#1822) ([0f7097e7](https://github.com/googleapis/google-auth-library-python/commit/0f7097e7)) * add ecdsa p-384 support (#1872) ([39c381a5](https://github.com/googleapis/google-auth-library-python/commit/39c381a5)) * Add shlex to correctly parse executable commands with spaces (#1855) ([cf6fc3cc](https://github.com/googleapis/google-auth-library-python/commit/cf6fc3cc)) * Implement token revocation in STS client and add revoke() metho… (#1849) ([d5638986](https://github.com/googleapis/google-auth-library-python/commit/d5638986)) ### Bug Fixes * Add temporary patch to workload cert logic to accomodate Cloud Run mis-configuration (#1880) ([78de7907](https://github.com/googleapis/google-auth-library-python/commit/78de7907)) * Delegate workload cert and key default lookup to helper function (#1877) ([b0993c7e](https://github.com/googleapis/google-auth-library-python/commit/b0993c7e)) * Use public refresh method for source credentials in ImpersonatedCredentials (#1884) ([e0c3296f](https://github.com/googleapis/google-auth-library-python/commit/e0c3296f))
--- .librarian/state.yaml | 2 +- CHANGELOG.md | 18 ++++++++++++++++++ google/auth/version.py | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.librarian/state.yaml b/.librarian/state.yaml index 9b7e2ca09..826e2646e 100644 --- a/.librarian/state.yaml +++ b/.librarian/state.yaml @@ -1,7 +1,7 @@ image: us-central1-docker.pkg.dev/cloud-sdk-librarian-prod/images-prod/python-librarian-generator:latest libraries: - id: google-auth - version: 2.43.0 + version: 2.44.0 last_generated_commit: 102d9f92ac6ed649a61efd9b208e4d1de278e9bb apis: [] source_roots: diff --git a/CHANGELOG.md b/CHANGELOG.md index a71cd68e3..51f0c787a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ [1]: https://pypi.org/project/google-auth/#history +## [2.44.0](https://github.com/googleapis/google-auth-library-python/compare/v2.43.0...v2.44.0) (2025-12-13) + + +### Features + +* support Python 3.14 (#1822) ([0f7097e78f247665b6ef0287d482033f7be2ed6d](https://github.com/googleapis/google-auth-library-python/commit/0f7097e78f247665b6ef0287d482033f7be2ed6d)) +* add ecdsa p-384 support (#1872) ([39c381a5f6881b590025f36d333d12eff8dc60fc](https://github.com/googleapis/google-auth-library-python/commit/39c381a5f6881b590025f36d333d12eff8dc60fc)) +* MDS connections use mTLS (#1856) ([0387bb95713653d47e846cad3a010eb55ef2db4c](https://github.com/googleapis/google-auth-library-python/commit/0387bb95713653d47e846cad3a010eb55ef2db4c)) +* Implement token revocation in STS client and add revoke() metho… (#1849) ([d5638986ca03ee95bfffa9ad821124ed7e903e63](https://github.com/googleapis/google-auth-library-python/commit/d5638986ca03ee95bfffa9ad821124ed7e903e63)) +* Add shlex to correctly parse executable commands with spaces (#1855) ([cf6fc3cced78bc1362a7fe596c32ebc9ce03c26b](https://github.com/googleapis/google-auth-library-python/commit/cf6fc3cced78bc1362a7fe596c32ebc9ce03c26b)) + + +### Bug Fixes + +* Use public refresh method for source credentials in ImpersonatedCredentials (#1884) ([e0c3296f471747258f6d98d2d9bfde636358ecde](https://github.com/googleapis/google-auth-library-python/commit/e0c3296f471747258f6d98d2d9bfde636358ecde)) +* Add temporary patch to workload cert logic to accomodate Cloud Run mis-configuration (#1880) ([78de7907b8bdb7b5510e3c6fa8a3f3721e2436d7](https://github.com/googleapis/google-auth-library-python/commit/78de7907b8bdb7b5510e3c6fa8a3f3721e2436d7)) +* Delegate workload cert and key default lookup to helper function (#1877) ([b0993c7edaba505d0fb0628af28760c43034c959](https://github.com/googleapis/google-auth-library-python/commit/b0993c7edaba505d0fb0628af28760c43034c959)) + ## [2.43.0](https://github.com/googleapis/google-cloud-python/compare/google-auth-v2.42.1...google-auth-v2.43.0) (2025-11-05) diff --git a/google/auth/version.py b/google/auth/version.py index 20f2c8c0a..80d1360d3 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.43.0" +__version__ = "2.44.0"