Skip to content
Open
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
124 changes: 81 additions & 43 deletions packages/google-auth/google/auth/_agent_identity_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,21 @@ def _is_certificate_file_ready(path):
return False


def _is_in_well_known_dir(path):
"""Checks if the given path is inside the well-known Agent Identity directory."""
if not path:
return False
well_known_dir = os.path.dirname(_WELL_KNOWN_CERT_PATH)
try:
real_path = os.path.realpath(path)
real_well_known_dir = os.path.realpath(well_known_dir)
return (
os.path.commonpath([real_well_known_dir, real_path]) == real_well_known_dir
)
except ValueError:
return False


def get_agent_identity_certificate_path():
"""Gets the agent certificate path from the certificate config file.

Expand All @@ -98,16 +113,7 @@ def get_agent_identity_certificate_path():
# config file and the certificate file may experience a brief startup latency.
# For all other paths, we return early to avoid introducing unnecessary startup
# delays.
well_known_dir = os.path.dirname(_WELL_KNOWN_CERT_PATH)
try:
abs_cert_path = os.path.abspath(cert_config_path)
abs_well_known_dir = os.path.abspath(well_known_dir)
should_poll = (
os.path.commonpath([abs_well_known_dir, abs_cert_path])
== abs_well_known_dir
)
except ValueError:
should_poll = False
should_poll = _is_in_well_known_dir(cert_config_path)

return _get_cert_path_with_optional_polling(cert_config_path, should_poll)

Expand Down Expand Up @@ -141,8 +147,9 @@ def _get_cert_path_with_optional_polling(cert_config_path, should_poll):
if _is_certificate_file_ready(cert_path):
return cert_path

# The config was parsed, but the cert file is not ready yet
if not should_poll:
# The config was parsed, but the cert file is not ready yet.
# Only poll if both the config path and cert path are in the well-known directory.
if not (should_poll and _is_in_well_known_dir(cert_path)):
# If polling is disabled, return early.
return None

Expand Down Expand Up @@ -182,7 +189,7 @@ def _get_cert_path_with_optional_polling(cert_config_path, should_poll):
raise exceptions.RefreshError(
"Certificate config or certificate file not found after multiple retries. "
f"Token binding protection is failing. You can turn off this protection by setting "
f"{environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES} to false "
f"{environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN} to false "
"to fall back to unbound tokens."
)

Expand Down Expand Up @@ -221,64 +228,101 @@ def _parse_cert_path_from_config(cert_config_path):
return workload_config["cert_path"]


def get_and_parse_agent_identity_certificate():
def _is_bound_token_opted_out():
"""Returns True only if bound tokens are explicitly disabled via env vars."""
val = os.environ.get(environment_vars.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN)
if val is not None:
return val.lower() == "false"

# Fall back to the deprecated env var for backward compatibility
return (
os.environ.get(
environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES,
"true",
).lower()
== "false"
)


def get_agent_identity_certificate_and_bytes():
Comment thread
nbayati marked this conversation as resolved.
"""Gets and parses the agent identity certificate if not opted out.

Checks if the user has opted out of certificate-bound tokens. If not,
it gets the certificate path, reads the file, and parses it.

Returns:
The parsed certificate object if found and not opted out, otherwise None.
Tuple[Optional[cryptography.x509.Certificate], Optional[bytes]]: A tuple
of (parsed certificate object, certificate bytes) if found and not
opted out, otherwise (None, None).
"""
# If the user has opted out of cert bound tokens, there is no need to
# look up the certificate.
is_opted_out = (
os.environ.get(
environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES,
"true",
).lower()
== "false"
)
if is_opted_out:
return None
if _is_bound_token_opted_out():
return None, None

# Respect explicit opt-out of mTLS / client certs
from google.auth.transport import _mtls_helper

env_override = _mtls_helper._check_use_client_cert_env()
if env_override is False:
return None
return None, None

cert_path = get_agent_identity_certificate_path()
if not cert_path:
return None
return None, None

try:
with open(cert_path, "rb") as cert_file:
cert_bytes = cert_file.read()
except PermissionError as e:
raw_bytes = cert_file.read()
except OSError as e:
warnings.warn(
f"Failed to read agent identity certificate file at {cert_path}: {e}. "
"Token binding protection cannot be enabled. Falling back to unbound tokens."
)
return None
return None, None

cert_blocks = _mtls_helper._CERT_REGEX.findall(raw_bytes)
if not cert_blocks:
warnings.warn(
f"No PEM certificate blocks found in {cert_path}. "
"Token binding protection cannot be enabled. Falling back to unbound tokens."
)
return None, None

return parse_certificate(cert_bytes)
cert_bytes = b"\n".join(block.strip() for block in cert_blocks) + b"\n"
try:
return parse_certificate(cert_bytes), cert_bytes
except ValueError as e:
warnings.warn(
f"Failed to parse agent identity certificate at {cert_path}: {e}. "
"Token binding protection cannot be enabled. Falling back to unbound tokens."
)
return None, None


def parse_certificate(cert_bytes):
"""Parses a PEM-encoded certificate.
"""Validates a PEM-encoded certificate chain and returns the leaf certificate.

Args:
cert_bytes (bytes): The PEM-encoded certificate bytes.

Returns:
cryptography.x509.Certificate: The parsed certificate object.
cryptography.x509.Certificate: The leaf (first) parsed certificate object.

Raises:
ValueError: If no certificates are found or any certificate in the chain
is malformed.
ImportError: If the cryptography library is not installed.
"""
try:
from cryptography import x509
from google.auth.transport import _mtls_helper

return x509.load_pem_x509_certificate(cert_bytes)
cert_blocks = _mtls_helper._CERT_REGEX.findall(cert_bytes)
if not cert_blocks:
return x509.load_pem_x509_certificate(cert_bytes)
certs = [x509.load_pem_x509_certificate(block) for block in cert_blocks]
return certs[0]
except ImportError as e:
raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e

Expand Down Expand Up @@ -350,8 +394,9 @@ def calculate_certificate_fingerprint(cert):
def should_request_bound_token(cert):
"""Determines if a bound token should be requested.

This is based on the GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES
environment variable and whether the certificate is an agent identity cert.
This is based on the GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN env var
(falls back to the deprecated GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES
if unset) and whether the certificate is an agent identity cert.

Args:
cert (cryptography.x509.Certificate): The parsed certificate object.
Expand All @@ -360,14 +405,7 @@ def should_request_bound_token(cert):
bool: True if a bound token should be requested, False otherwise.
"""
is_agent_cert = _is_agent_identity_certificate(cert)
is_opted_in = (
os.environ.get(
environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES,
"true",
).lower()
== "true"
)
if not (is_agent_cert and is_opted_in):
if not is_agent_cert or _is_bound_token_opted_out():
return False

# Respect explicit opt-out of mTLS / client certs
Expand Down
17 changes: 3 additions & 14 deletions packages/google-auth/google/auth/aio/transport/sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
import logging
import time
from typing import Mapping, Optional, TYPE_CHECKING, Union
import urllib.parse
import warnings

from google.auth import _exponential_backoff, exceptions
Expand All @@ -43,11 +42,6 @@
ClientTimeout = None

_LOGGER = logging.getLogger(__name__)
_MTLS_URL_PREFIXES = [
"mtls.googleapis.com",
"mtls.sandbox.googleapis.com",
"p.googleapis.com",
]

# Tracks the internal aiohttp installation and usage
try:
Expand Down Expand Up @@ -371,15 +365,10 @@ async def request(
)

async def _recover_auth_state():
is_mtls_endpoint = False
if self._is_mtls:
hostname = urllib.parse.urlsplit(url).hostname
if hostname:
is_mtls_endpoint = any(
hostname == prefix
or hostname.endswith("." + prefix)
for prefix in _MTLS_URL_PREFIXES
)
is_mtls_endpoint = (
google.auth.transport._mtls_helper.is_mtls_endpoint(url)
)
# Snapshot the stale certificate state BEFORE acquiring the lock.
# This represents the cert that caused the 401 rejection.
if is_mtls_endpoint:
Expand Down
65 changes: 50 additions & 15 deletions packages/google-auth/google/auth/compute_engine/_metadata.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@

import requests

from google.auth import _agent_identity_utils
from google.auth import _helpers
from google.auth import environment_vars
from google.auth import exceptions
Expand Down Expand Up @@ -255,6 +256,8 @@ def get(
headers=None,
return_none_for_not_found_error=False,
timeout=_METADATA_DEFAULT_TIMEOUT,
method="GET",
body=None,
):
"""Fetch a resource from the metadata server.

Expand All @@ -276,6 +279,8 @@ def get(
return_none_for_not_found_error (Optional[bool]): If True, returns None
for 404 error instead of throwing an exception.
timeout (int): How long to wait, in seconds for the metadata server to respond.
method (str): The HTTP method to use for the request. Defaults to "GET".
Comment thread
nbayati marked this conversation as resolved.
body (Optional[bytes]): The HTTP request body payload to send. Defaults to None.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to raise a ValueError (or similar) here to "exit early" if a body is specified byt the method is GET. While I think technically valid to include a body in GET requests (most often I think the body just gets ignored), it may lead a caller to think it is getting a bound token when in reality it isn't?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! that's a great suggestion! Done!


Returns:
Union[Mapping, str]: If the metadata server returns JSON, a mapping of
Expand All @@ -288,8 +293,12 @@ def get(
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).
ValueError: if a request body is specified with the GET method.

"""
if body is not None and method.upper() == "GET":
raise ValueError("Request body cannot be specified with GET method.")

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.
Expand Down Expand Up @@ -319,9 +328,15 @@ def get(
last_exception = None
for attempt in backoff:
try:
response = request(
url=url, method="GET", headers=headers_to_use, timeout=timeout
)
kwargs = {
"url": url,
"method": method,
"headers": headers_to_use,
"timeout": timeout,
}
if body is not None:
kwargs["body"] = body
response = request(**kwargs)
if response.status in transport.DEFAULT_RETRYABLE_STATUS_CODES:
_LOGGER.warning(
"Compute Engine Metadata server unavailable on "
Expand Down Expand Up @@ -465,6 +480,32 @@ def get_service_account_info(request, service_account="default"):
return get(request, path, params={"recursive": "true"})


def _get_token_request_params(metrics_header_value):
"""Returns (method, body, headers) for a metadata server token request.

Defaults to a standard GET request with the x-goog-api-client metrics header.
Upgrades to a POST request with a JSON certificate_chain body and
Content-Type header if an Agent Identity certificate is present and bound
tokens are enabled.

Args:
metrics_header_value (str): Value for the x-goog-api-client header.

Returns:
Tuple[str, Optional[bytes], Mapping[str, str]]: A tuple of
(HTTP method, request body bytes, request headers).
"""
headers = {metrics.API_CLIENT_HEADER: metrics_header_value}
cert, cert_bytes = _agent_identity_utils.get_agent_identity_certificate_and_bytes()
if cert and _agent_identity_utils.should_request_bound_token(cert):
headers["Content-Type"] = "application/json"
body = json.dumps({"certificate_chain": cert_bytes.decode("utf-8")}).encode(
"utf-8"
)
return "POST", body, headers
return "GET", None, headers


def get_service_account_token(request, service_account="default", scopes=None):
"""Get the OAuth 2.0 access token for a service account.

Expand All @@ -483,26 +524,20 @@ def get_service_account_token(request, service_account="default", scopes=None):
google.auth.exceptions.TransportError: if an error occurred while
retrieving metadata.
"""
from google.auth import _agent_identity_utils

params = {}
if scopes:
if not isinstance(scopes, str):
scopes = ",".join(scopes)
params["scopes"] = scopes

cert = _agent_identity_utils.get_and_parse_agent_identity_certificate()
if cert:
if _agent_identity_utils.should_request_bound_token(cert):
fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(cert)
params["bindCertificateFingerprint"] = fingerprint

metrics_header = {
metrics.API_CLIENT_HEADER: metrics.token_request_access_token_mds()
}
method, body, headers = _get_token_request_params(
metrics.token_request_access_token_mds()
)

path = "instance/service-accounts/{0}/token".format(service_account)
token_json = get(request, path, params=params, headers=metrics_header)
token_json = get(
request, path, params=params, headers=headers, method=method, body=body
)
token_expiry = _helpers.utcnow() + datetime.timedelta(
seconds=token_json["expires_in"]
)
Expand Down
14 changes: 10 additions & 4 deletions packages/google-auth/google/auth/compute_engine/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,11 +528,17 @@ def _call_metadata_identity_endpoint(self, request):
try:
path = "instance/service-accounts/default/identity"
params = {"audience": self._target_audience, "format": "full"}
metrics_header = {
metrics.API_CLIENT_HEADER: metrics.token_request_id_token_mds()
}
method, body, headers = _metadata._get_token_request_params(
metrics.token_request_id_token_mds()
)

id_token = _metadata.get(
request, path, params=params, headers=metrics_header
request,
path,
params=params,
headers=headers,
method=method,
body=body,
)
except exceptions.TransportError as caught_exc:
new_exc = exceptions.RefreshError(caught_exc)
Expand Down
Loading
Loading