Skip to content

Commit ffe41f0

Browse files
GWealecopybara-github
authored andcommitted
fix: use mTLS endpoint for Google OAuth2 token requests
When a client certificate is configured, OAuth2 token exchange and refresh now present the certificate and target the *.mtls.googleapis.com endpoint so Context-Aware Access / token binding is honored. Gated to *.googleapis.com token endpoints with a client cert available; third-party providers and non-cert environments are unchanged. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 941319195
1 parent 99ba8ce commit ffe41f0

4 files changed

Lines changed: 311 additions & 19 deletions

File tree

src/google/adk/auth/oauth2_credential_util.py

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from authlib.oauth2.rfc6749 import OAuth2Token
2323
from fastapi.openapi.models import OAuth2
2424

25+
from ..utils import _mtls_utils
2526
from ..utils.feature_decorator import experimental
2627
from .auth_credential import AuthCredential
2728
from .auth_schemes import AuthScheme
@@ -83,18 +84,28 @@ def create_oauth2_session(
8384

8485
# Scope is intentionally omitted: token exchange and refresh don't require
8586
# it per RFC 6749, and some providers reject it on these requests.
86-
return (
87-
OAuth2Session(
88-
auth_credential.oauth2.client_id,
89-
auth_credential.oauth2.client_secret,
90-
redirect_uri=auth_credential.oauth2.redirect_uri,
91-
state=auth_credential.oauth2.state,
92-
token_endpoint_auth_method=auth_credential.oauth2.token_endpoint_auth_method,
93-
code_challenge_method=auth_credential.oauth2.code_challenge_method,
94-
),
95-
token_endpoint,
87+
session = OAuth2Session(
88+
auth_credential.oauth2.client_id,
89+
auth_credential.oauth2.client_secret,
90+
redirect_uri=auth_credential.oauth2.redirect_uri,
91+
state=auth_credential.oauth2.state,
92+
token_endpoint_auth_method=auth_credential.oauth2.token_endpoint_auth_method,
93+
code_challenge_method=auth_credential.oauth2.code_challenge_method,
9694
)
9795

96+
# When a client certificate is configured, route Google token requests through
97+
# the mTLS endpoint and present the cert so Context-Aware Access / token
98+
# binding is honored. Non-Google providers and non-cert environments keep the
99+
# existing behavior.
100+
if (
101+
_mtls_utils.is_non_mtls_googleapis_endpoint(token_endpoint)
102+
and _mtls_utils.use_client_cert_effective()
103+
):
104+
if _mtls_utils.configure_session_for_mtls(session):
105+
token_endpoint = _mtls_utils.effective_googleapis_endpoint(token_endpoint)
106+
107+
return session, token_endpoint
108+
98109

99110
@experimental
100111
def update_credential_with_tokens(

src/google/adk/utils/_mtls_utils.py

Lines changed: 92 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,22 @@
1717
from __future__ import annotations
1818

1919
import enum
20+
import logging
2021
import os
22+
from typing import TYPE_CHECKING
23+
from urllib.parse import urlsplit
24+
from urllib.parse import urlunsplit
2125

2226
from google.auth.transport import mtls
2327

28+
if TYPE_CHECKING:
29+
import requests
30+
31+
logger = logging.getLogger("google_adk." + __name__)
32+
33+
_GOOGLEAPIS_SUFFIX = ".googleapis.com"
34+
_MTLS_GOOGLEAPIS_SUFFIX = ".mtls.googleapis.com"
35+
2436

2537
class MtlsEndpoint(enum.Enum):
2638
"""Enum for the mTLS endpoint setting."""
@@ -30,10 +42,21 @@ class MtlsEndpoint(enum.Enum):
3042
NEVER = "never"
3143

3244

45+
def _mtls_endpoint_setting() -> MtlsEndpoint:
46+
"""Returns the GOOGLE_API_USE_MTLS_ENDPOINT setting, defaulting to AUTO."""
47+
setting = os.getenv(
48+
"GOOGLE_API_USE_MTLS_ENDPOINT", MtlsEndpoint.AUTO.value
49+
).lower()
50+
try:
51+
return MtlsEndpoint(setting)
52+
except ValueError:
53+
return MtlsEndpoint.AUTO
54+
55+
3356
def use_client_cert_effective() -> bool:
3457
"""Returns whether client certificate should be used for mTLS."""
3558
try:
36-
return mtls.should_use_client_cert()
59+
return bool(mtls.should_use_client_cert())
3760
except (ImportError, AttributeError):
3861
return (
3962
os.getenv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false").lower()
@@ -53,16 +76,76 @@ def get_api_endpoint(
5376
mtls_template: Template for mTLS regional endpoint (e.g.
5477
"secretmanager.{location}.rep.mtls.googleapis.com").
5578
"""
56-
use_mtls_endpoint_str = os.getenv(
57-
"GOOGLE_API_USE_MTLS_ENDPOINT", MtlsEndpoint.AUTO.value
58-
).lower()
59-
try:
60-
use_mtls_endpoint = MtlsEndpoint(use_mtls_endpoint_str)
61-
except ValueError:
62-
use_mtls_endpoint = MtlsEndpoint.AUTO
63-
79+
use_mtls_endpoint = _mtls_endpoint_setting()
6480
if (use_mtls_endpoint == MtlsEndpoint.ALWAYS) or (
6581
use_mtls_endpoint == MtlsEndpoint.AUTO and use_client_cert_effective()
6682
):
6783
return mtls_template.format(location=location)
6884
return default_template.format(location=location)
85+
86+
87+
def is_non_mtls_googleapis_endpoint(url: str) -> bool:
88+
"""Returns whether url points at a *.googleapis.com host without the mTLS infix."""
89+
if not url:
90+
return False
91+
host = urlsplit(url).hostname or ""
92+
return (
93+
host.endswith(_GOOGLEAPIS_SUFFIX) and _MTLS_GOOGLEAPIS_SUFFIX not in host
94+
)
95+
96+
97+
def effective_googleapis_endpoint(url: str) -> str:
98+
"""Rewrites a *.googleapis.com url to its .mtls.googleapis.com variant.
99+
100+
Honors GOOGLE_API_USE_MTLS_ENDPOINT=never as an opt-out. Hosts that are not
101+
googleapis.com hosts, or are already mTLS hosts, are returned unchanged so
102+
non-Google providers are never affected.
103+
"""
104+
if not is_non_mtls_googleapis_endpoint(url):
105+
return url
106+
if _mtls_endpoint_setting() == MtlsEndpoint.NEVER:
107+
return url
108+
parsed = urlsplit(url)
109+
host = parsed.hostname or ""
110+
new_host = host[: -len(_GOOGLEAPIS_SUFFIX)] + _MTLS_GOOGLEAPIS_SUFFIX
111+
netloc = f"{new_host}:{parsed.port}" if parsed.port else new_host
112+
return urlunsplit(
113+
(parsed.scheme, netloc, parsed.path, parsed.query, parsed.fragment)
114+
)
115+
116+
117+
def configure_session_for_mtls(session: requests.Session) -> bool:
118+
"""Mounts a mutual-TLS adapter on a requests session when a client cert exists.
119+
120+
authlib's OAuth2Session is a requests.Session but not a google-auth
121+
AuthorizedSession, so it lacks configure_mtls_channel(). This replicates that
122+
method's effect: load the application-default client certificate and mount an
123+
adapter that presents it on https connections.
124+
125+
Returns True if a client certificate was found and the adapter was mounted.
126+
"""
127+
try:
128+
from google.auth import exceptions as ga_exceptions
129+
from google.auth.transport import _mtls_helper
130+
from google.auth.transport.requests import _MutualTlsAdapter
131+
except ImportError:
132+
return False
133+
134+
cert_source = (
135+
mtls.default_client_cert_source()
136+
if mtls.has_default_client_cert_source()
137+
else None
138+
)
139+
try:
140+
is_mtls, cert, key = _mtls_helper.get_client_cert_and_key(cert_source)
141+
except (ImportError, ga_exceptions.GoogleAuthError) as e:
142+
logger.warning(
143+
"Could not load client certificate for mTLS; falling back to non-mTLS"
144+
" token request: %s",
145+
e,
146+
)
147+
return False
148+
149+
if is_mtls:
150+
session.mount("https://", _MutualTlsAdapter(cert, key))
151+
return bool(is_mtls)

tests/unittests/auth/test_oauth2_credential_util.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import time
1616
from typing import Optional
1717
from unittest.mock import Mock
18+
from unittest.mock import patch
1819

1920
from authlib.oauth2.rfc6749 import OAuth2Token
2021
from fastapi.openapi.models import OAuth2
@@ -151,6 +152,104 @@ def test_create_oauth2_session_missing_credentials(self):
151152
assert client is None
152153
assert token_endpoint is None
153154

155+
def _google_openid_scheme(self) -> OpenIdConnectWithConfig:
156+
"""OpenID Connect scheme that uses Google's OAuth2 token endpoint."""
157+
return OpenIdConnectWithConfig(
158+
type_="openIdConnect",
159+
openId_connect_url=(
160+
"https://accounts.google.com/.well-known/openid_configuration"
161+
),
162+
authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
163+
token_endpoint="https://oauth2.googleapis.com/token",
164+
scopes=["openid"],
165+
)
166+
167+
@patch.dict("os.environ", {}, clear=True)
168+
@patch("google.adk.utils._mtls_utils.configure_session_for_mtls")
169+
@patch("google.adk.utils._mtls_utils.use_client_cert_effective")
170+
def test_create_oauth2_session_google_endpoint_uses_mtls(
171+
self, mock_use_cert, mock_configure
172+
):
173+
"""Google token endpoint is switched to mTLS when a cert is mounted."""
174+
mock_use_cert.return_value = True
175+
mock_configure.return_value = True
176+
credential = create_oauth2_auth_credential(
177+
auth_type=AuthCredentialTypes.OAUTH2
178+
)
179+
180+
client, token_endpoint = create_oauth2_session(
181+
self._google_openid_scheme(), credential
182+
)
183+
184+
assert client is not None
185+
assert token_endpoint == "https://oauth2.mtls.googleapis.com/token"
186+
mock_configure.assert_called_once_with(client)
187+
188+
@patch.dict("os.environ", {}, clear=True)
189+
@patch("google.adk.utils._mtls_utils.configure_session_for_mtls")
190+
@patch("google.adk.utils._mtls_utils.use_client_cert_effective")
191+
def test_create_oauth2_session_google_endpoint_no_cert_keeps_plain(
192+
self, mock_use_cert, mock_configure
193+
):
194+
"""Without a client cert the plain Google endpoint is kept."""
195+
mock_use_cert.return_value = False
196+
credential = create_oauth2_auth_credential(
197+
auth_type=AuthCredentialTypes.OAUTH2
198+
)
199+
200+
_, token_endpoint = create_oauth2_session(
201+
self._google_openid_scheme(), credential
202+
)
203+
204+
assert token_endpoint == "https://oauth2.googleapis.com/token"
205+
mock_configure.assert_not_called()
206+
207+
@patch.dict("os.environ", {}, clear=True)
208+
@patch("google.adk.utils._mtls_utils.configure_session_for_mtls")
209+
@patch("google.adk.utils._mtls_utils.use_client_cert_effective")
210+
def test_create_oauth2_session_cert_unavailable_keeps_plain(
211+
self, mock_use_cert, mock_configure
212+
):
213+
"""If the adapter cannot be mounted, the endpoint is not switched."""
214+
mock_use_cert.return_value = True
215+
mock_configure.return_value = False
216+
credential = create_oauth2_auth_credential(
217+
auth_type=AuthCredentialTypes.OAUTH2
218+
)
219+
220+
client, token_endpoint = create_oauth2_session(
221+
self._google_openid_scheme(), credential
222+
)
223+
224+
assert token_endpoint == "https://oauth2.googleapis.com/token"
225+
mock_configure.assert_called_once_with(client)
226+
227+
@patch.dict("os.environ", {}, clear=True)
228+
@patch("google.adk.utils._mtls_utils.configure_session_for_mtls")
229+
@patch("google.adk.utils._mtls_utils.use_client_cert_effective")
230+
def test_create_oauth2_session_non_google_endpoint_skips_mtls(
231+
self, mock_use_cert, mock_configure
232+
):
233+
"""Non-Google providers are never switched to an mTLS endpoint."""
234+
mock_use_cert.return_value = True
235+
credential = create_oauth2_auth_credential(
236+
auth_type=AuthCredentialTypes.OAUTH2
237+
)
238+
scheme = OpenIdConnectWithConfig(
239+
type_="openIdConnect",
240+
openId_connect_url=(
241+
"https://example.com/.well-known/openid_configuration"
242+
),
243+
authorization_endpoint="https://example.com/auth",
244+
token_endpoint="https://example.com/token",
245+
scopes=["openid"],
246+
)
247+
248+
_, token_endpoint = create_oauth2_session(scheme, credential)
249+
250+
assert token_endpoint == "https://example.com/token"
251+
mock_configure.assert_not_called()
252+
154253
@pytest.mark.parametrize(
155254
"token_endpoint_auth_method, expected_auth_method",
156255
[

0 commit comments

Comments
 (0)