Skip to content

Commit 3368f27

Browse files
feat: Add helper methods for asynchronous x.509 certificate discovery (#1956)
This PR introduces the google.auth.aio.transport.mtls module, providing asynchronous helper methods for mTLS certificate discovery [go/caa:x509-async-support](http://goto.google.com/caa:x509-async-support). These helpers are designed to be for x.509 certs discovery and non-blocking, ensuring that disk I/O operations are async during mTLS handshake process. Plus, added unit tests respectively for the helper functions. Please note: Only x.509 creds are in scope of this project currently. Context aware or ECP credentials are not in scope of this project currently. Next Steps: Will create a followup PR will that will utilize these helpers to implement `configure_mtls_channel` within the `AsyncAuthorizedSession` class. --------- Signed-off-by: Radhika Agrawal <agrawalradhika@google.com>
1 parent 89fc6f2 commit 3368f27

File tree

6 files changed

+304
-16
lines changed

6 files changed

+304
-16
lines changed
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Helper functions for mTLS in async for discovery of certs.
17+
"""
18+
19+
import asyncio
20+
import logging
21+
22+
from google.auth import exceptions
23+
import google.auth.transport._mtls_helper
24+
import google.auth.transport.mtls
25+
26+
_LOGGER = logging.getLogger(__name__)
27+
28+
29+
async def _run_in_executor(func, *args):
30+
"""Run a blocking function in an executor to avoid blocking the event loop.
31+
32+
This implements the non-blocking execution strategy for disk I/O operations.
33+
"""
34+
try:
35+
# For python versions 3.9 and newer versions
36+
return await asyncio.to_thread(func, *args)
37+
except AttributeError:
38+
# Fallback for older Python versions
39+
loop = asyncio.get_running_loop()
40+
return await loop.run_in_executor(None, func, *args)
41+
42+
43+
def default_client_cert_source():
44+
"""Get a callback which returns the default client SSL credentials.
45+
46+
Returns:
47+
Awaitable[Callable[[], [bytes, bytes]]]: A callback which returns the default
48+
client certificate bytes and private key bytes, both in PEM format.
49+
50+
Raises:
51+
google.auth.exceptions.DefaultClientCertSourceError: If the default
52+
client SSL credentials don't exist or are malformed.
53+
"""
54+
if not google.auth.transport.mtls.has_default_client_cert_source(
55+
include_context_aware=False
56+
):
57+
raise exceptions.MutualTLSChannelError(
58+
"Default client cert source doesn't exist"
59+
)
60+
61+
async def callback():
62+
try:
63+
_, cert_bytes, key_bytes = await get_client_cert_and_key()
64+
except (OSError, RuntimeError, ValueError) as caught_exc:
65+
new_exc = exceptions.MutualTLSChannelError(caught_exc)
66+
raise new_exc from caught_exc
67+
68+
return cert_bytes, key_bytes
69+
70+
return callback
71+
72+
73+
async def get_client_ssl_credentials(
74+
certificate_config_path=None,
75+
):
76+
"""Returns the client side certificate, private key and passphrase.
77+
78+
We look for certificates and keys with the following order of priority:
79+
1. Certificate and key specified by certificate_config.json.
80+
Currently, only X.509 workload certificates are supported.
81+
82+
Args:
83+
certificate_config_path (str): The certificate_config.json file path.
84+
85+
Returns:
86+
Tuple[bool, bytes, bytes, bytes]:
87+
A boolean indicating if cert, key and passphrase are obtained, the
88+
cert bytes and key bytes both in PEM format, and passphrase bytes.
89+
90+
Raises:
91+
google.auth.exceptions.ClientCertError: if problems occurs when getting
92+
the cert, key and passphrase.
93+
"""
94+
95+
# Attempt to retrieve X.509 Workload cert and key.
96+
cert, key = await _run_in_executor(
97+
google.auth.transport._mtls_helper._get_workload_cert_and_key,
98+
certificate_config_path,
99+
False,
100+
)
101+
102+
if cert and key:
103+
return True, cert, key, None
104+
105+
return False, None, None, None
106+
107+
108+
async def get_client_cert_and_key(client_cert_callback=None):
109+
"""Returns the client side certificate and private key. The function first
110+
tries to get certificate and key from client_cert_callback; if the callback
111+
is None or doesn't provide certificate and key, the function tries application
112+
default SSL credentials.
113+
114+
Args:
115+
client_cert_callback (Optional[Callable[[], (bytes, bytes)]]): An
116+
optional callback which returns client certificate bytes and private
117+
key bytes both in PEM format.
118+
119+
Returns:
120+
Tuple[bool, bytes, bytes]:
121+
A boolean indicating if cert and key are obtained, the cert bytes
122+
and key bytes both in PEM format.
123+
124+
Raises:
125+
google.auth.exceptions.ClientCertError: if problems occurs when getting
126+
the cert and key.
127+
"""
128+
if client_cert_callback:
129+
result = client_cert_callback()
130+
try:
131+
cert, key = await result
132+
except TypeError:
133+
cert, key = result
134+
return True, cert, key
135+
136+
has_cert, cert, key, _ = await get_client_ssl_credentials()
137+
return has_cert, cert, key

packages/google-auth/google/auth/transport/_mtls_helper.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@
2525
from google.auth import exceptions
2626

2727
CONTEXT_AWARE_METADATA_PATH = "~/.secureConnect/context_aware_metadata.json"
28+
29+
# Default gcloud config path, to be used with path.expanduser for cross-platform compatibility.
2830
CERTIFICATE_CONFIGURATION_DEFAULT_PATH = "~/.config/gcloud/certificate_config.json"
2931
_CERT_PROVIDER_COMMAND = "cert_provider_command"
3032
_CERT_REGEX = re.compile(
@@ -103,14 +105,18 @@ def _load_json_file(path):
103105
return json_data
104106

105107

106-
def _get_workload_cert_and_key(certificate_config_path=None):
108+
def _get_workload_cert_and_key(
109+
certificate_config_path=None, include_context_aware=True
110+
):
107111
"""Read the workload identity cert and key files specified in the certificate config provided.
108112
If no config path is provided, check the environment variable: "GOOGLE_API_CERTIFICATE_CONFIG"
109113
first, then the well known gcloud location: "~/.config/gcloud/certificate_config.json".
110114
111115
Args:
112116
certificate_config_path (string): The certificate config path. If no path is provided,
113117
the environment variable will be checked first, then the well known gcloud location.
118+
include_context_aware (bool): If context aware metadata path should be checked for the
119+
SecureConnect mTLS configuration.
114120
115121
Returns:
116122
Tuple[Optional[bytes], Optional[bytes]]: client certificate bytes in PEM format and key
@@ -121,15 +127,17 @@ def _get_workload_cert_and_key(certificate_config_path=None):
121127
the certificate or key information.
122128
"""
123129

124-
cert_path, key_path = _get_workload_cert_and_key_paths(certificate_config_path)
130+
cert_path, key_path = _get_workload_cert_and_key_paths(
131+
certificate_config_path, include_context_aware
132+
)
125133

126134
if cert_path is None and key_path is None:
127135
return None, None
128136

129137
return _read_cert_and_key_files(cert_path, key_path)
130138

131139

132-
def _get_cert_config_path(certificate_config_path=None):
140+
def _get_cert_config_path(certificate_config_path=None, include_context_aware=True):
133141
"""Get the certificate configuration path based on the following order:
134142
135143
1: Explicit override, if set
@@ -141,6 +149,8 @@ def _get_cert_config_path(certificate_config_path=None):
141149
Args:
142150
certificate_config_path (string): The certificate config path. If provided, the well known
143151
location and environment variable will be ignored.
152+
include_context_aware (bool): If context aware metadata path should be checked for the
153+
SecureConnect mTLS configuration.
144154
145155
Returns:
146156
The absolute path of the certificate config file, and None if the file does not exist.
@@ -155,7 +165,7 @@ def _get_cert_config_path(certificate_config_path=None):
155165
environment_vars.CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH,
156166
None,
157167
)
158-
if env_path is not None and env_path != "":
168+
if include_context_aware and env_path is not None and env_path != "":
159169
certificate_config_path = env_path
160170
else:
161171
certificate_config_path = CERTIFICATE_CONFIGURATION_DEFAULT_PATH
@@ -166,8 +176,8 @@ def _get_cert_config_path(certificate_config_path=None):
166176
return certificate_config_path
167177

168178

169-
def _get_workload_cert_and_key_paths(config_path):
170-
absolute_path = _get_cert_config_path(config_path)
179+
def _get_workload_cert_and_key_paths(config_path, include_context_aware=True):
180+
absolute_path = _get_cert_config_path(config_path, include_context_aware)
171181
if absolute_path is None:
172182
return None, None
173183

packages/google-auth/google/auth/transport/mtls.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,19 @@
2020
from google.auth.transport import _mtls_helper
2121

2222

23-
def has_default_client_cert_source():
23+
def has_default_client_cert_source(include_context_aware=True):
2424
"""Check if default client SSL credentials exists on the device.
2525
26+
Args:
27+
include_context_aware (bool): include_context_aware indicates if context_aware
28+
path location will be checked or should it be skipped.
29+
2630
Returns:
2731
bool: indicating if the default client cert source exists.
2832
"""
2933
if (
30-
_mtls_helper._check_config_path(_mtls_helper.CONTEXT_AWARE_METADATA_PATH)
34+
include_context_aware
35+
and _mtls_helper._check_config_path(_mtls_helper.CONTEXT_AWARE_METADATA_PATH)
3136
is not None
3237
):
3338
return True
@@ -58,7 +63,7 @@ def default_client_cert_source():
5863
google.auth.exceptions.DefaultClientCertSourceError: If the default
5964
client SSL credentials don't exist or are malformed.
6065
"""
61-
if not has_default_client_cert_source():
66+
if not has_default_client_cert_source(include_context_aware=True):
6267
raise exceptions.MutualTLSChannelError(
6368
"Default client cert source doesn't exist"
6469
)
@@ -94,7 +99,7 @@ def default_client_encrypted_cert_source(cert_path, key_path):
9499
google.auth.exceptions.DefaultClientCertSourceError: If any problem
95100
occurs when loading or saving the client certificate and key.
96101
"""
97-
if not has_default_client_cert_source():
102+
if not has_default_client_cert_source(include_context_aware=True):
98103
raise exceptions.MutualTLSChannelError(
99104
"Default client encrypted cert source doesn't exist"
100105
)

packages/google-auth/system_tests/system_tests_sync/test_service_account.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,12 +41,12 @@ def test_refresh_success(http_request, credentials, token_info):
4141

4242
assert info["email"] == credentials.service_account_email
4343
info_scopes = _helpers.string_to_scopes(info["scope"])
44-
assert set(info_scopes) == set(
44+
assert set(info_scopes).issubset(set(
4545
[
4646
"https://www.googleapis.com/auth/userinfo.email",
4747
"https://www.googleapis.com/auth/userinfo.profile",
4848
]
49-
)
49+
))
5050

5151
def test_iam_signer(http_request, credentials):
5252
credentials = credentials.with_scopes(

0 commit comments

Comments
 (0)