Skip to content

Commit f2805d0

Browse files
committed
feat: Add 3LO auth support to ToolboxTool
1 parent d6d1a70 commit f2805d0

File tree

2 files changed

+47
-75
lines changed

2 files changed

+47
-75
lines changed

packages/toolbox-adk/src/toolbox_adk/tool.py

Lines changed: 45 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,11 @@
2020
from google.adk.tools.base_tool import BaseTool
2121
from google.adk.agents.readonly_context import ReadonlyContext
2222

23+
from google_auth_oauthlib.flow import InstalledAppFlow
24+
from google.auth.transport.requests import Request
25+
from .credentials import CredentialConfig, CredentialType
26+
from .client import USER_TOKEN_CONTEXT_VAR
27+
2328

2429
class ToolboxContext:
2530
"""Context object passed to pre/post hooks."""
@@ -40,35 +45,21 @@ def __init__(
4045
core_tool: CoreToolboxTool,
4146
pre_hook: Optional[Callable[[ToolboxContext], Awaitable[None]]] = None,
4247
post_hook: Optional[Callable[[ToolboxContext], Awaitable[None]]] = None,
48+
auth_config: Optional[CredentialConfig] = None,
4349
):
4450
"""
4551
Args:
4652
core_tool: The underlying toolbox_core.py tool instance.
4753
pre_hook: Async function called before execution. Can modify ctx.arguments.
4854
post_hook: Async function called after execution (finally block). Can inspect ctx.result/error.
55+
auth_config: Credential configuration to handle interactive flows.
4956
"""
5057
# We act as a proxy.
5158
# We need to extract metadata from the core tool to satisfy BaseTool's contract.
52-
# core_tool name, description etc. are available.
53-
54-
# Note: BaseTool in adk-python typically requires tool_name, tool_description etc.
55-
# We map them from the core tool.
56-
# core_tool.tool_def is usually available or we use the wrapper's attributes if exposed.
57-
# In toolbox-core 0.1.0+, tool instance is callable but metadata is also stored.
58-
# Assuming core_tool has typical attributes or we introspect.
59-
# For now, we rely on core_tool's string representation or assume it has name/doc.
6059

6160
name = getattr(core_tool, "__name__", "unknown_tool")
6261
description = getattr(core_tool, "__doc__", "No description provided.") or "No description provided."
6362

64-
# toolbox_core tools might not expose explicit args schema in a way BaseTool expects
65-
# (dict of name -> type). ADK tools usually define `tool_args` as a dict.
66-
# We might need to inspect the signature if not explicitly available.
67-
# For this implementation, we allow dynamic arguments similar to FunctionTool.
68-
# If BaseTool enforces providing `tool_args`, we might need to introspect.
69-
# However, BaseTool logic in adk-python often derives it.
70-
# We'll call super with minimal known info or defaults.
71-
7263
super().__init__(
7364
name=name,
7465
description=description,
@@ -78,6 +69,8 @@ def __init__(
7869
self._core_tool = core_tool
7970
self._pre_hook = pre_hook
8071
self._post_hook = post_hook
72+
self._auth_config = auth_config
73+
self._user_creds: Optional[Any] = None
8174

8275
@override
8376
async def run_async(
@@ -93,75 +86,52 @@ async def run_async(
9386
await self._pre_hook(ctx)
9487

9588
# 2. ADK Auth Integration (3LO)
96-
# We check if we need to inject a user token managed by ADK.
97-
# This typically happens if the toolset was configured with USER_IDENTITY.
98-
# We don't know the configuration here explicitly unless passed,
99-
# BUT the convention is: if we can get a token from tool_context, use it.
100-
# Or better: The client (ToolboxClient) didn't set auth headers for USER_IDENTITY,
101-
# so we must do it here.
102-
103-
# How do we know if we logic requires it?
104-
# We can try to fetch; if it exists, we assume we should use it?
105-
# Or we rely on the fact that `core_tool` instance itself might have bound auth getters?
106-
# Actually, toolbox-core tools support `add_auth_token_getters`.
107-
# We need to bridge ADK's `get_auth_response` to toolbox-core's getter.
108-
109-
# ADK 3LO flow:
110-
# response = tool_context.get_auth_response()
111-
# if not response:
112-
# tool_context.request_credential(...)
113-
# return "Please authenticate..."
114-
# else:
115-
# token = response.token
116-
117-
# If we have a way to detect 3LO requirement, we trigger it.
118-
# For now, we'll try to retrieve an auth response if one is potentially available.
119-
# But `toolbox-core` tool will fail if it needs auth and doesn't get it.
120-
# The proper pattern is: wrap the core tool with a dynamically injected getter
121-
# that calls into ADK logic.
122-
123-
# However, `toolbox-core`'s `auth_token_getters` expects a callable.
124-
# Be careful: `tool_context` is available here in `run_async`, but `auth_token_getters`
125-
# are usually bound locally.
126-
127-
# We'll define a token getter that closes over `tool_context`.
128-
# We'll define a token getter that closes over `tool_context` if needed in future.
129-
# For now, unused.
89+
# Check if USER_IDENTITY is configured
90+
reset_token = None
91+
if self._auth_config and self._auth_config.type == CredentialType.USER_IDENTITY:
92+
# Handle interactive flow if credentials are missing or expired
93+
if not self._user_creds or not self._user_creds.valid:
94+
if self._user_creds and self._user_creds.expired and self._user_creds.refresh_token:
95+
try:
96+
self._user_creds.refresh(Request())
97+
except Exception:
98+
self._user_creds = None
99+
100+
if not self._user_creds:
101+
# Trigger flow
102+
if not (self._auth_config.client_id and self._auth_config.client_secret):
103+
raise ValueError("USER_IDENTITY requires client_id and client_secret")
104+
105+
config = {
106+
"installed": {
107+
"client_id": self._auth_config.client_id,
108+
"client_secret": self._auth_config.client_secret,
109+
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
110+
"token_uri": "https://oauth2.googleapis.com/token",
111+
}
112+
}
113+
scopes = self._auth_config.scopes or ["https://www.googleapis.com/auth/cloud-platform"]
114+
115+
flow = InstalledAppFlow.from_client_config(config, scopes=scopes)
116+
self._user_creds = flow.run_local_server(port=0)
117+
118+
# Inject token into ContextVar
119+
if self._user_creds:
120+
reset_token = USER_TOKEN_CONTEXT_VAR.set(self._user_creds.token)
130121

131-
# Let's try to get a token if available, but fundamentally, ADK expects the tool to return
132-
# a request for credentials if missing.
133-
auth_response = tool_context.get_auth_response()
134-
135-
# NOTE: We can't easily tell if this specific tool *needs* user auth without metadata.
136-
# Strategy: We assume that if the tool fails with an auth error, we might fallback?
137-
# OR we rely on configuration.
138-
# Ideally, `ToolboxToolset` would have configured this tool with a special "trigger" if needed.
139-
140-
# For now, we will proceed to execute.
141-
# If `USER_IDENTITY` was configured, we might have injected a special marker or getter.
142-
# Revisit this logic if explicit "Force Auth" flag is needed on the tool.
143-
144122
try:
145123
# Execute the core tool
146-
# mapping: toolbox-core expects (arg1=..., arg2=...)
147-
# We unpack ctx.arguments
148-
149-
# For 3LO support: We really need to know if we should be requesting credentials.
150-
# For this MVP, we will rely on static auth or pre-existing headers.
151-
# If we want to support 3LO properly, we need to handle the `tool_context.request_credential` flow.
152-
# We'll check if `auth_token` argument is present or if we have a bound getter.
153-
154-
# We execute:
155124
result = await self._core_tool(**ctx.arguments)
156125

157126
ctx.result = result
158127
return result
159128

160129
except Exception as e:
161-
# TODO: Inspect e for Auth errors to trigger 3LO if configured?
162130
ctx.error = e
163131
raise e
164132
finally:
133+
if reset_token:
134+
USER_TOKEN_CONTEXT_VAR.reset(reset_token)
165135
if self._post_hook:
166136
await self._post_hook(ctx)
167137

@@ -172,5 +142,6 @@ def bind_params(self, bounded_params: Dict[str, Any]) -> 'ToolboxTool':
172142
return ToolboxTool(
173143
core_tool=new_core_tool,
174144
pre_hook=self._pre_hook,
175-
post_hook=self._post_hook
145+
post_hook=self._post_hook,
146+
auth_config=self._auth_config
176147
)

packages/toolbox-adk/src/toolbox_adk/toolset.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,8 @@ async def get_tools(
9898
ToolboxTool(
9999
core_tool=t,
100100
pre_hook=self._pre_hook,
101-
post_hook=self._post_hook
101+
post_hook=self._post_hook,
102+
auth_config=self._client.credential_config
102103
)
103104
for t in tools
104105
]

0 commit comments

Comments
 (0)