Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions packages/google-auth/google/auth/_default.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,9 +391,10 @@ def _get_gae_credentials():
def _get_gce_credentials(request=None, quota_project_id=None):
"""Gets credentials and project ID from the GCE Metadata Service."""
# Ping requires a transport, but we want application default credentials
# to require no arguments. So, we'll use the _http_client transport which
# uses http.client. This is only acceptable because the metadata server
# doesn't do SSL and never requires proxies.
# to require no arguments.
# We'll use the requests transport if present, otherwise we'll fall back to
# _http_client transport. If TLS is enabled, http_client will fail to communicate
# with the metadata server. That case will be handled by compute_engine._mtls.

# While this library is normally bundled with compute_engine, there are
# some cases where it's not available, so we tolerate ImportError.
Expand All @@ -405,7 +406,11 @@ def _get_gce_credentials(request=None, quota_project_id=None):
return None, None

if request is None:
request = google.auth.transport._http_client.Request()
try:
# use requests transport if available for TLS support
request = google.auth.transport.requests.Request()
except ImportError:
request = google.auth.transport._http_client.Request()

if _metadata.is_on_gce(request=request):
# Get the project ID.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@
import os
from urllib.parse import urljoin

import requests
try:
import requests as requests_lib
except ImportError: # pragma: NO COVER
requests_lib = None # type: ignore[assignment]

from google.auth import _helpers
from google.auth import environment_vars
Expand Down Expand Up @@ -174,10 +177,10 @@ def _prepare_request_for_mds(request, use_mtls=False) -> None:

"""
# Only modify the request if mTLS is enabled, and request supports sessions.
if use_mtls and hasattr(request, "session"):
if use_mtls and requests_lib and hasattr(request, "session"):
# Ensure the request has a session to mount the adapter to.
if not request.session:
request.session = requests.Session()
request.session = requests_lib.Session()

adapter = _mtls.MdsMtlsAdapter()
# Mount the adapter for all default GCE metadata hosts.
Expand Down
113 changes: 64 additions & 49 deletions packages/google-auth/google/auth/compute_engine/_mtls.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@
import ssl
from urllib.parse import urlparse, urlunparse

import requests
from requests.adapters import HTTPAdapter
try:
import requests
from requests.adapters import HTTPAdapter

_HAS_REQUESTS = True
except ImportError: # pragma: NO COVER
_HAS_REQUESTS = False

from google.auth import environment_vars, exceptions

Expand Down Expand Up @@ -102,6 +107,10 @@ 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 _HAS_REQUESTS:
raise exceptions.MutualTLSChannelError(
"The requests library is required for mTLS. Install it with `google-auth[requests]`"
)
if not _certs_exist(mds_mtls_config):
raise exceptions.MutualTLSChannelError(
"mTLS certificates not found in strict mode."
Expand All @@ -110,55 +119,61 @@ def should_use_mds_mtls(mds_mtls_config: MdsMtlsConfig = MdsMtlsConfig()):
elif mode == MdsMtlsMode.NONE:
return False
else: # Default mode
if not _HAS_REQUESTS:
return False
return _certs_exist(mds_mtls_config)


class MdsMtlsAdapter(HTTPAdapter):
"""An HTTP adapter that uses mTLS for the metadata server."""
if _HAS_REQUESTS:

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,
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
)
# 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)
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)
19 changes: 14 additions & 5 deletions packages/google-auth/noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,14 +115,14 @@ def mypy(session):


@nox.session(python=ALL_PYTHON)
@nox.parametrize(["install_deprecated_extras"], (True, False))
def unit(session, install_deprecated_extras):
@nox.parametrize(["install_extras"], (True, False))
def unit(session, install_extras):
# Install all test dependencies, then install this package in-place.

if session.python in ("3.7",):
session.skip("Python 3.7 is no longer supported")
min_py, max_py = UNIT_TEST_PYTHON_VERSIONS[0], UNIT_TEST_PYTHON_VERSIONS[-1]
if not install_deprecated_extras and session.python not in (min_py, max_py):
if not install_extras and session.python not in (min_py, max_py):
# only run double tests on first and last supported versions
session.skip(
f"Extended tests only run on boundary Python versions ({min_py}, {max_py}) to reduce CI load."
Expand All @@ -131,13 +131,22 @@ def unit(session, install_deprecated_extras):
constraints_path = str(
CURRENT_DIRECTORY / "testing" / f"constraints-{session.python}.txt"
)
extras_str = "testing"
if install_deprecated_extras:

if install_extras:
extras_str = "testing"
# rsa and oauth2client were both archived and support dropped,
# but we still test old code paths
session.install("oauth2client")
extras_str += ",rsa"
else:
# Install testing dependencies explicitly without requests
extras_str = "testing_minimal"

session.install("-e", f".[{extras_str}]", "-c", constraints_path)

if not install_extras:
# Verify requests is actually not installed
session.run("python", "-c", "import requests", success_codes=[1])
session.run(
"pytest",
f"--junitxml=unit_{session.python}_sponge_log.xml",
Expand Down
14 changes: 10 additions & 4 deletions packages/google-auth/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
rsa_extra_require = ["rsa>=3.1.4,<5"]

# Unit test requirements.
testing_extra_require = [
testing_minimal_require = [
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1735): Remove `grpcio` from testing requirements once an extra is added for `grpcio` dependency.
"grpcio",
"flask",
Expand All @@ -59,14 +59,19 @@
*reauth_extra_require,
"responses",
*urllib3_extra_require,
# Async Dependencies
*aiohttp_extra_require,
"aioresponses",
"pytest-asyncio",
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1665): Remove the pinned version of pyopenssl
# once `TestDecryptPrivateKey::test_success` is updated to remove the deprecated `OpenSSL.crypto.sign` and
# `OpenSSL.crypto.verify` methods. See: https://www.pyopenssl.org/en/latest/changelog.html#id3.
"pyopenssl < 24.3.0",
]

# Testing with requests/aiohttp
testing_extra_require = [
*testing_minimal_require,
*requests_extra_require,
*aiohttp_extra_require,
"aioresponses",
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1722): `test_aiohttp_requests` depend on
# aiohttp < 3.10.0 which is a bug. Investigate and remove the pinned aiohttp version.
"aiohttp < 3.10.0",
Expand All @@ -82,6 +87,7 @@
"reauth": reauth_extra_require,
"requests": requests_extra_require,
"testing": testing_extra_require,
"testing_minimal": testing_minimal_require,
"urllib3": urllib3_extra_require,
"rsa": rsa_extra_require,
# TODO(https://github.com/googleapis/google-auth-library-python/issues/1735): Add an extra for `grpcio` dependency.
Expand Down
22 changes: 22 additions & 0 deletions packages/google-auth/tests/compute_engine/test__metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -969,3 +969,25 @@ def test__prepare_request_for_mds_mtls_http_request(mock_mds_mtls_adapter):
_metadata._prepare_request_for_mds(request, use_mtls=True)

assert mock_mds_mtls_adapter.call_count == 0


@mock.patch("google.auth.compute_engine._mtls._HAS_REQUESTS", False)
@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode")
def test_get_without_requests_default(mock_parse_mds_mode):
mock_parse_mds_mode.return_value = _metadata._mtls.MdsMtlsMode.DEFAULT
request = make_request("foobar", headers={"content-type": "text/plain"})

result = _metadata.get(request, PATH)
assert result == "foobar"
# Ensure no session is created since requests is missing
assert not hasattr(request, "session")


@mock.patch("google.auth.compute_engine._mtls._HAS_REQUESTS", False)
@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode")
def test_get_without_requests_strict(mock_parse_mds_mode):
mock_parse_mds_mode.return_value = _metadata._mtls.MdsMtlsMode.STRICT
request = make_request("foobar", headers={"content-type": "text/plain"})

with pytest.raises(exceptions.MutualTLSChannelError, match="requests library"):
_metadata.get(request, PATH)
22 changes: 22 additions & 0 deletions packages/google-auth/tests/compute_engine/test__mtls.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,3 +286,25 @@ def test_mds_mtls_adapter_send_no_fallback_strict_mode(
request = requests.Request(method="GET", url="https://fake-mds.com").prepare()
with pytest.raises(requests.exceptions.SSLError):
adapter.send(request)


@mock.patch("google.auth.compute_engine._mtls._HAS_REQUESTS", False)
@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode")
def test_should_use_mds_mtls_without_requests_strict(mock_parse_mds_mode):
mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.STRICT
with pytest.raises(exceptions.MutualTLSChannelError, match="requests library"):
_mtls.should_use_mds_mtls()


@mock.patch("google.auth.compute_engine._mtls._HAS_REQUESTS", False)
@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode")
def test_should_use_mds_mtls_without_requests_default(mock_parse_mds_mode):
mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.DEFAULT
assert _mtls.should_use_mds_mtls() is False


@mock.patch("google.auth.compute_engine._mtls._HAS_REQUESTS", False)
@mock.patch("google.auth.compute_engine._mtls._parse_mds_mode")
def test_should_use_mds_mtls_without_requests_none(mock_parse_mds_mode):
mock_parse_mds_mode.return_value = _mtls.MdsMtlsMode.NONE
assert _mtls.should_use_mds_mtls() is False
Loading