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
18 changes: 5 additions & 13 deletions src/google/adk/auth/auth_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,7 @@ def _is_exchangeable(self, credential: AuthCredential | None) -> bool:
):
return False
oauth2 = credential.oauth2 if credential else None
return bool(
oauth2
and not oauth2.access_token
and oauth2.client_id
and oauth2.client_secret
)
return bool(oauth2 and not oauth2.access_token and oauth2.client_id)

def _read_stored_credential(
self, state: State
Expand Down Expand Up @@ -290,14 +285,11 @@ def _generate_auth_request(self) -> AuthConfig:
credential_key=self.auth_config.credential_key,
)

# Check for client_id and client_secret
if (
not self.auth_config.raw_auth_credential.oauth2.client_id
or not self.auth_config.raw_auth_credential.oauth2.client_secret
):
# Public clients (Azure AD B2C, PKCE) have a client_id and no secret.
if not self.auth_config.raw_auth_credential.oauth2.client_id:
raise ValueError(
f"Auth Scheme {self.auth_config.auth_scheme.type_} requires both"
" client_id and client_secret in auth_credential.oauth2."
f"Auth Scheme {self.auth_config.auth_scheme.type_} requires"
" client_id in auth_credential.oauth2."
)

# Generate new auth URI
Expand Down
15 changes: 13 additions & 2 deletions src/google/adk/auth/oauth2_credential_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,18 +83,29 @@ def create_oauth2_session(
not auth_credential
or not auth_credential.oauth2
or not auth_credential.oauth2.client_id
or not auth_credential.oauth2.client_secret
):
return None, None

# Public clients have no client_secret. The model default is
# client_secret_basic, which would send an empty Basic header. RFC 6749
# token endpoint auth method "none" is the public-client value.
token_endpoint_auth_method: str | None = (
auth_credential.oauth2.token_endpoint_auth_method
)
if (
not auth_credential.oauth2.client_secret
and token_endpoint_auth_method == "client_secret_basic"
):
token_endpoint_auth_method = "none"

# Scope is intentionally omitted: token exchange and refresh don't require
# it per RFC 6749, and some providers reject it on these requests.
session = OAuth2Session(
auth_credential.oauth2.client_id,
auth_credential.oauth2.client_secret,
redirect_uri=auth_credential.oauth2.redirect_uri,
state=auth_credential.oauth2.state,
token_endpoint_auth_method=auth_credential.oauth2.token_endpoint_auth_method,
token_endpoint_auth_method=token_endpoint_auth_method,
code_challenge_method=auth_credential.oauth2.code_challenge_method,
default_timeout=_TOKEN_REQUEST_TIMEOUT_SECONDS,
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -295,11 +295,6 @@ def _request_credential(self) -> None:
"OAuth2 credentials client_id is missing."
)

if not self.auth_credential.oauth2.client_secret:
raise AuthCredentialMissingError(
"OAuth2 credentials client_secret is missing."
)

self.tool_context.request_credential(self._build_auth_config())
return None

Expand Down
100 changes: 96 additions & 4 deletions tests/unittests/auth/test_auth_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -514,7 +514,7 @@ def test_auth_uri_in_raw_credential(
)

def test_missing_client_credentials(self, oauth2_auth_scheme):
"""Test when client_id or client_secret is missing."""
"""Test when client_id is missing."""
bad_credential = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(redirect_uri="https://example.com/callback"),
Expand All @@ -530,11 +530,37 @@ def test_missing_client_credentials(self, oauth2_auth_scheme):
)
handler = AuthHandler(config)

with pytest.raises(
ValueError, match="requires both client_id and client_secret"
):
with pytest.raises(ValueError, match="requires client_id"):
handler.generate_auth_request()

@patch("google.adk.auth.auth_handler.AuthHandler.generate_auth_uri")
def test_public_client_without_client_secret(
self, mock_generate_auth_uri, oauth2_auth_scheme
):
"""Public clients can start the auth request with client_id only."""
public_credential = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id="public-client",
redirect_uri="https://example.com/callback",
),
)
mock_generate_auth_uri.return_value = public_credential.model_copy(
deep=True
)
config = AuthConfig(
auth_scheme=oauth2_auth_scheme,
raw_auth_credential=public_credential,
exchanged_auth_credential=public_credential.model_copy(deep=True),
)
handler = AuthHandler(config)

result = handler.generate_auth_request()

mock_generate_auth_uri.assert_called_once()
assert result.raw_auth_credential.oauth2.client_id == "public-client"
assert result.raw_auth_credential.oauth2.client_secret is None

@patch("google.adk.auth.auth_handler.AuthHandler.generate_auth_uri")
def test_generate_new_auth_uri(self, mock_generate_auth_uri, auth_config):
"""Test generating a new auth URI."""
Expand Down Expand Up @@ -743,6 +769,43 @@ def test_reattaches_configured_client_for_exchange(
assert state[credential_key].oauth2.access_token == "mock_access_token"
assert state[credential_key].oauth2.client_secret is None

@patch("google.adk.auth.oauth2_credential_util.OAuth2Session")
def test_get_auth_response_exchanges_public_client(
self, mock_oauth2_session, oauth2_auth_scheme
):
"""Public clients exchange an auth code with client_id only."""
public = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id="public-client",
redirect_uri="https://example.com/callback",
),
)
stored = public.model_copy(deep=True)
stored.oauth2.auth_code = "public-auth-code"
stored.oauth2.auth_response_uri = (
"https://example.com/callback?code=public-auth-code"
)
config = AuthConfig(
auth_scheme=oauth2_auth_scheme,
raw_auth_credential=public,
exchanged_auth_credential=stored,
)
mock_client = Mock()
mock_oauth2_session.return_value = mock_client
mock_client.fetch_token.return_value = OAuth2Token(
{"access_token": "public_access_token"}
)
state = MockState()
state["temp:" + config.credential_key] = stored

result = AuthHandler(config).get_auth_response(state)

assert result.oauth2.access_token == "public_access_token"
assert mock_oauth2_session.call_args[0][0] == "public-client"
assert mock_oauth2_session.call_args[0][1] is None
assert mock_oauth2_session.return_value.fetch_token.called


class TestParseAndStoreAuthResponse:
"""Tests for the parse_and_store_auth_response method."""
Expand Down Expand Up @@ -786,6 +849,35 @@ async def test_oauth_scheme(
assert state["temp:" + credential_key] == mock_exchange_token.return_value
assert mock_exchange_token.called

@patch("google.adk.auth.auth_handler.AuthHandler.exchange_auth_token")
@pytest.mark.asyncio
async def test_oauth_scheme_public_client(
self, mock_exchange_token, oauth2_auth_scheme
):
"""Public clients still exchange an auth code (no client_secret)."""
public = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id="public-client",
redirect_uri="https://example.com/callback",
),
)
exchanged = public.model_copy(deep=True)
exchanged.oauth2.auth_code = "public-auth-code"
config = AuthConfig(
auth_scheme=oauth2_auth_scheme,
raw_auth_credential=public,
exchanged_auth_credential=exchanged,
)
mock_exchange_token.return_value = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(access_token="exchanged_token"),
)

await AuthHandler(config).parse_and_store_auth_response(MockState())

assert mock_exchange_token.called

@pytest.mark.asyncio
async def test_empty_credential_key_raises_error(self, oauth2_auth_scheme):
"""Test that ValueError is raised when credential_key is empty."""
Expand Down
34 changes: 30 additions & 4 deletions tests/unittests/auth/test_oauth2_credential_util.py
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ def test_create_oauth2_session_invalid_scheme(self):
assert client is None
assert token_endpoint is None

def test_create_oauth2_session_missing_credentials(self):
"""Test create_oauth2_session with missing credentials."""
def test_create_oauth2_session_missing_client_id(self):
"""Test create_oauth2_session with missing client_id."""
scheme = OpenIdConnectWithConfig(
type_="openIdConnect",
openId_connect_url=(
Expand All @@ -142,8 +142,7 @@ def test_create_oauth2_session_missing_credentials(self):
credential = AuthCredential(
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
oauth2=OAuth2Auth(
client_id="test_client_id",
# Missing client_secret
client_secret="test_client_secret",
),
)

Expand All @@ -152,6 +151,33 @@ def test_create_oauth2_session_missing_credentials(self):
assert client is None
assert token_endpoint is None

def test_create_oauth2_session_public_client_without_secret(self):
"""Public clients have a client_id and no client_secret."""
scheme = OpenIdConnectWithConfig(
type_="openIdConnect",
openId_connect_url=(
"https://example.com/.well-known/openid_configuration"
),
authorization_endpoint="https://example.com/auth",
token_endpoint="https://example.com/token",
scopes=["openid"],
)
credential = AuthCredential(
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
oauth2=OAuth2Auth(
client_id="public-client",
redirect_uri="https://app/cb",
),
)

client, token_endpoint = create_oauth2_session(scheme, credential)

assert client is not None
assert token_endpoint == "https://example.com/token"
assert client.client_id == "public-client"
assert client.client_secret is None
assert client.token_endpoint_auth_method == "none"

def _google_openid_scheme(self) -> OpenIdConnectWithConfig:
"""OpenID Connect scheme that uses Google's OAuth2 token endpoint."""
return OpenIdConnectWithConfig(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,68 @@ def openid_connect_credential():
return credential


@pytest.mark.asyncio
async def test_openid_connect_public_client_without_secret(
openid_connect_scheme,
):
public_credential = AuthCredential(
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
oauth2=OAuth2Auth(
client_id='public-client',
redirect_uri='https://app/cb',
),
)
tool_context = create_mock_tool_context()
handler = ToolAuthHandler(
tool_context,
openid_connect_scheme,
public_credential,
)
result = await handler.prepare_auth_credentials()
assert result.state == 'pending'
assert result.auth_credential == public_credential


@pytest.mark.asyncio
async def test_openid_connect_public_client_exchanges_auth_response(
openid_connect_scheme, monkeypatch
):
public_credential = AuthCredential(
auth_type=AuthCredentialTypes.OPEN_ID_CONNECT,
oauth2=OAuth2Auth(
client_id='public-client',
redirect_uri='https://app/cb',
),
)
stored = public_credential.model_copy(deep=True)
stored.oauth2.auth_code = 'public-auth-code'
stored.oauth2.auth_response_uri = 'https://app/cb?code=public-auth-code'

tool_context = create_mock_tool_context()
handler = ToolAuthHandler(
tool_context,
openid_connect_scheme,
public_credential,
)
auth_config = handler._build_auth_config()
tool_context.state['temp:' + auth_config.credential_key] = stored

mock_client = MagicMock()
mock_client.fetch_token.return_value = {
'access_token': 'public_access_token',
'token_type': 'bearer',
}
monkeypatch.setattr(
'google.adk.auth.oauth2_credential_util.OAuth2Session',
lambda *args, **kwargs: mock_client,
)

result = await handler.prepare_auth_credentials()
assert result.state == 'done'
assert result.auth_credential.auth_type == AuthCredentialTypes.HTTP
assert result.auth_credential.http.credentials.token == 'public_access_token'


@pytest.mark.asyncio
async def test_openid_connect_no_auth_response(
openid_connect_scheme, openid_connect_credential
Expand Down