chore: Add base credential service interface (WIP)

PiperOrigin-RevId: 771358480
This commit is contained in:
Xiang (Sean) Zhou
2025-06-13 23:24:30 -07:00
committed by Copybara-Service
parent b51a1f45fd
commit 8ebf229c47
7 changed files with 175 additions and 91 deletions
@@ -27,6 +27,8 @@ from google.adk.auth import AuthCredential
from google.adk.auth import AuthCredentialTypes from google.adk.auth import AuthCredentialTypes
from google.adk.auth import OAuth2Auth from google.adk.auth import OAuth2Auth
from google.adk.tools import ToolContext from google.adk.tools import ToolContext
from google.adk.tools.authenticated_tool.base_authenticated_tool import AuthenticatedFunctionTool
from google.adk.tools.authenticated_tool.credentials_store import ToolContextCredentialsStore
from google.adk.tools.google_api_tool import CalendarToolset from google.adk.tools.google_api_tool import CalendarToolset
from google.auth.transport.requests import Request from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials from google.oauth2.credentials import Credentials
@@ -56,6 +58,7 @@ def list_calendar_events(
end_time: str, end_time: str,
limit: int, limit: int,
tool_context: ToolContext, tool_context: ToolContext,
credential: AuthCredential,
) -> list[dict]: ) -> list[dict]:
"""Search for calendar events. """Search for calendar events.
@@ -80,84 +83,11 @@ def list_calendar_events(
Returns: Returns:
list[dict]: A list of events that match the search criteria. list[dict]: A list of events that match the search criteria.
""" """
creds = None
# Check if the tokes were already in the session state, which means the user creds = Credentials(
# has already gone through the OAuth flow and successfully authenticated and token=credential.oauth2.access_token,
# authorized the tool to access their calendar. refresh_token=credential.oauth2.refresh_token,
if "calendar_tool_tokens" in tool_context.state: )
creds = Credentials.from_authorized_user_info(
tool_context.state["calendar_tool_tokens"], SCOPES
)
if not creds or not creds.valid:
# If the access token is expired, refresh it with the refresh token.
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
else:
auth_scheme = OAuth2(
flows=OAuthFlows(
authorizationCode=OAuthFlowAuthorizationCode(
authorizationUrl="https://accounts.google.com/o/oauth2/auth",
tokenUrl="https://oauth2.googleapis.com/token",
scopes={
"https://www.googleapis.com/auth/calendar": (
"See, edit, share, and permanently delete all the"
" calendars you can access using Google Calendar"
)
},
)
)
)
auth_credential = AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id=oauth_client_id, client_secret=oauth_client_secret
),
)
# If the user has not gone through the OAuth flow before, or the refresh
# token also expired, we need to ask users to go through the OAuth flow.
# First we check whether the user has just gone through the OAuth flow and
# Oauth response is just passed back.
auth_response = tool_context.get_auth_response(
AuthConfig(
auth_scheme=auth_scheme, raw_auth_credential=auth_credential
)
)
if auth_response:
# ADK exchanged the access token already for us
access_token = auth_response.oauth2.access_token
refresh_token = auth_response.oauth2.refresh_token
creds = Credentials(
token=access_token,
refresh_token=refresh_token,
token_uri=auth_scheme.flows.authorizationCode.tokenUrl,
client_id=oauth_client_id,
client_secret=oauth_client_secret,
scopes=list(auth_scheme.flows.authorizationCode.scopes.keys()),
)
else:
# If there are no auth response which means the user has not gone
# through the OAuth flow yet, we need to ask users to go through the
# OAuth flow.
tool_context.request_credential(
AuthConfig(
auth_scheme=auth_scheme,
raw_auth_credential=auth_credential,
)
)
# The return value is optional and could be any dict object. It will be
# wrapped in a dict with key as 'result' and value as the return value
# if the object returned is not a dict. This response will be passed
# to LLM to generate a user friendly message. e.g. LLM will tell user:
# "I need your authorization to access your calendar. Please authorize
# me so I can check your meetings for today."
return "Need User Authorization to access their calendar."
# We store the access token and refresh token in the session state for the
# next runs. This is just an example. On production, a tool should store
# those credentials in some secure store or properly encrypt it before store
# it in the session state.
tool_context.state["calendar_tool_tokens"] = json.loads(creds.to_json())
service = build("calendar", "v3", credentials=creds) service = build("calendar", "v3", credentials=creds)
events_result = ( events_result = (
@@ -208,6 +138,38 @@ root_agent = Agent(
Currnet time: {_time} Currnet time: {_time}
""", """,
tools=[list_calendar_events, calendar_toolset], tools=[
AuthenticatedFunctionTool(
func=list_calendar_events,
auth_config=AuthConfig(
auth_scheme=OAuth2(
flows=OAuthFlows(
authorizationCode=OAuthFlowAuthorizationCode(
authorizationUrl=(
"https://accounts.google.com/o/oauth2/auth"
),
tokenUrl="https://oauth2.googleapis.com/token",
scopes={
"https://www.googleapis.com/auth/calendar": (
"See, edit, share, and permanently delete"
" all the calendars you can access using"
" Google Calendar"
)
},
)
)
),
raw_auth_credential=AuthCredential(
auth_type=AuthCredentialTypes.OAUTH2,
oauth2=OAuth2Auth(
client_id=oauth_client_id,
client_secret=oauth_client_secret,
),
),
),
credential_store=ToolContextCredentialsStore(),
),
calendar_toolset,
],
before_agent_callback=update_time, before_agent_callback=update_time,
) )
+2 -2
View File
@@ -49,7 +49,7 @@ class AuthHandler:
def parse_and_store_auth_response(self, state: State) -> None: def parse_and_store_auth_response(self, state: State) -> None:
credential_key = "temp:" + self.auth_config.get_credential_key() credential_key = "temp:" + self.auth_config.credential_key
state[credential_key] = self.auth_config.exchanged_auth_credential state[credential_key] = self.auth_config.exchanged_auth_credential
if not isinstance( if not isinstance(
@@ -67,7 +67,7 @@ class AuthHandler:
raise ValueError("auth_scheme is empty.") raise ValueError("auth_scheme is empty.")
def get_auth_response(self, state: State) -> AuthCredential: def get_auth_response(self, state: State) -> AuthCredential:
credential_key = "temp:" + self.auth_config.get_credential_key() credential_key = "temp:" + self.auth_config.credential_key
return state.get(credential_key, None) return state.get(credential_key, None)
def generate_auth_request(self) -> AuthConfig: def generate_auth_request(self) -> AuthConfig:
+20 -4
View File
@@ -14,6 +14,10 @@
from __future__ import annotations from __future__ import annotations
from typing import Optional
from typing_extensions import deprecated
from .auth_credential import AuthCredential from .auth_credential import AuthCredential
from .auth_credential import BaseModelWithConfig from .auth_credential import BaseModelWithConfig
from .auth_schemes import AuthScheme from .auth_schemes import AuthScheme
@@ -45,11 +49,23 @@ class AuthConfig(BaseModelWithConfig):
this field to guide the user through the OAuth2 flow and fill auth response in this field to guide the user through the OAuth2 flow and fill auth response in
this field""" this field"""
credential_key: Optional[str] = None
"""A user specified key used to load and save this credential in a credential
service.
"""
def __init__(self, **data):
super().__init__(**data)
if self.credential_key:
return
self.credential_key = self.get_credential_key()
@deprecated("This method is deprecated. Use credential_key instead.")
def get_credential_key(self): def get_credential_key(self):
"""Generates a hash key based on auth_scheme and raw_auth_credential. This """Builds a hash key based on auth_scheme and raw_auth_credential used to
hash key can be used to store / retrieve exchanged_auth_credential in a save / load this credential to / from a credentials service.
credentials store.
""" """
auth_scheme = self.auth_scheme auth_scheme = self.auth_scheme
if auth_scheme.model_extra: if auth_scheme.model_extra:
@@ -62,7 +78,7 @@ class AuthConfig(BaseModelWithConfig):
) )
auth_credential = self.raw_auth_credential auth_credential = self.raw_auth_credential
if auth_credential.model_extra: if auth_credential and auth_credential.model_extra:
auth_credential = auth_credential.model_copy(deep=True) auth_credential = auth_credential.model_copy(deep=True)
auth_credential.model_extra.clear() auth_credential.model_extra.clear()
credential_name = ( credential_name = (
@@ -0,0 +1,13 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
@@ -0,0 +1,75 @@
# Copyright 2025 Google LLC
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
from __future__ import annotations
from abc import ABC
from abc import abstractmethod
from typing import Optional
from ...tools.tool_context import ToolContext
from ...utils.feature_decorator import working_in_progress
from ..auth_credential import AuthCredential
from ..auth_tool import AuthConfig
@working_in_progress("Implementation are in progress. Don't use it for now.")
class BaseCredentialService(ABC):
"""Abstract class for Service that loads / saves tool credentials from / to
the backend credential store."""
@abstractmethod
async def load_credential(
self,
auth_config: AuthConfig,
tool_context: ToolContext,
) -> Optional[AuthCredential]:
"""
Loads the credential by auth config and current tool context from the
backend credential store.
Args:
auth_config: The auth config which contains the auth scheme and auth
credential information. auth_config.get_credential_key will be used to
build the key to load the credential.
tool_context: The context of the current invocation when the tool is
trying to load the credential.
Returns:
Optional[AuthCredential]: the credential saved in the store.
"""
@abstractmethod
async def save_credential(
self,
auth_config: AuthConfig,
tool_context: ToolContext,
) -> None:
"""
Saves the exchanged_auth_credential in auth config to the backend credential
store.
Args:
auth_config: The auth config which contains the auth scheme and auth
credential information. auth_config.get_credential_key will be used to
build the key to save the credential.
tool_context: The context of the current invocation when the tool is
trying to save the credential.
Returns:
None
"""
+22 -4
View File
@@ -68,10 +68,28 @@ def auth_config(oauth2_auth_scheme, oauth2_credentials):
) )
def test_get_credential_key(auth_config): @pytest.fixture
def auth_config_with_key(oauth2_auth_scheme, oauth2_credentials):
"""Create an AuthConfig for testing."""
return AuthConfig(
auth_scheme=oauth2_auth_scheme,
raw_auth_credential=oauth2_credentials,
credential_key="test_key",
)
def test_custom_credential_key(auth_config_with_key):
"""Test using custom credential key."""
key = auth_config_with_key.credential_key
assert key == "test_key"
def test_credential_key(auth_config):
"""Test generating a unique credential key.""" """Test generating a unique credential key."""
key = auth_config.get_credential_key() key = auth_config.credential_key
assert key.startswith("adk_oauth2_") assert key.startswith("adk_oauth2_")
assert "_oauth2_" in key assert "_oauth2_" in key
@@ -80,8 +98,8 @@ def test_get_credential_key_with_extras(auth_config):
"""Test generating a key when model_extra exists.""" """Test generating a key when model_extra exists."""
# Add model_extra to test cleanup # Add model_extra to test cleanup
original_key = auth_config.get_credential_key() original_key = auth_config.credential_key
key = auth_config.get_credential_key() key = auth_config.credential_key
auth_config.auth_scheme.model_extra["extra_field"] = "value" auth_config.auth_scheme.model_extra["extra_field"] = "value"
auth_config.raw_auth_credential.model_extra["extra_field"] = "value" auth_config.raw_auth_credential.model_extra["extra_field"] = "value"
+3 -3
View File
@@ -387,7 +387,7 @@ class TestGetAuthResponse:
state = MockState() state = MockState()
# Store a credential in the state # Store a credential in the state
credential_key = auth_config.get_credential_key() credential_key = auth_config.credential_key
state["temp:" + credential_key] = oauth2_credentials_with_auth_uri state["temp:" + credential_key] = oauth2_credentials_with_auth_uri
result = handler.get_auth_response(state) result = handler.get_auth_response(state)
@@ -418,7 +418,7 @@ class TestParseAndStoreAuthResponse:
handler.parse_and_store_auth_response(state) handler.parse_and_store_auth_response(state)
credential_key = auth_config.get_credential_key() credential_key = auth_config.credential_key
assert ( assert (
state["temp:" + credential_key] == auth_config.exchanged_auth_credential state["temp:" + credential_key] == auth_config.exchanged_auth_credential
) )
@@ -436,7 +436,7 @@ class TestParseAndStoreAuthResponse:
handler.parse_and_store_auth_response(state) handler.parse_and_store_auth_response(state)
credential_key = auth_config_with_exchanged.get_credential_key() credential_key = auth_config_with_exchanged.credential_key
assert state["temp:" + credential_key] == mock_exchange_token.return_value assert state["temp:" + credential_key] == mock_exchange_token.return_value
assert mock_exchange_token.called assert mock_exchange_token.called