Skip to content

Commit 49ba48b

Browse files
committed
fix: store calendar OAuth credentials after authentication
Signed-off-by: Anas Khan <83116240+anxkhn@users.noreply.github.com>
1 parent 6603ba3 commit 49ba48b

3 files changed

Lines changed: 185 additions & 46 deletions

File tree

samples/python/agents/birthday_planner_adk/calendar_agent/adk_agent_executor.py

Lines changed: 27 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -89,16 +89,10 @@ async def _process_request(
8989
# 2. The function call required authorization.
9090
# Ideally we'd have a way to interpret whether the response is a completion for the
9191
# task or requires follow-up, but I'm not going to bother just yet.
92-
if auth_request_function_call := get_auth_request_function_call(
93-
event
94-
):
92+
if auth_request_function_call := get_auth_request_function_call(event):
9593
# Gather details, then suspend.
96-
auth_details = self._prepare_auth_request(
97-
auth_request_function_call
98-
)
99-
logger.debug(
100-
'Yielding auth required response: %s', auth_details.uri
101-
)
94+
auth_details = self._prepare_auth_request(auth_request_function_call)
95+
logger.debug('Yielding auth required response: %s', auth_details.uri)
10296
await task_updater.update_status(
10397
TaskState.auth_required,
10498
message=new_agent_text_message(
@@ -127,9 +121,7 @@ async def _process_request(
127121

128122
if auth_details:
129123
# After auth is received, we can continue processing this request.
130-
await self._complete_auth_processing(
131-
context, auth_details, task_updater
132-
)
124+
await self._complete_auth_processing(context, auth_details, task_updater)
133125

134126
def _prepare_auth_request(
135127
self, auth_request_function_call: types.FunctionCall
@@ -148,9 +140,7 @@ def _prepare_auth_request(
148140
oauth2_config = auth_config.exchanged_auth_credential.oauth2
149141
base_auth_uri = oauth2_config.auth_uri
150142
if not base_auth_uri:
151-
raise ValueError(
152-
f'Cannot get auth uri from auth config: {auth_config}'
153-
)
143+
raise ValueError(f'Cannot get auth uri from auth config: {auth_config}')
154144
redirect_uri = f'{self._card.url}authenticate'
155145
oauth2_config.redirect_uri = redirect_uri
156146
state_token = oauth2_config.state
@@ -194,9 +184,7 @@ async def _complete_auth_processing(
194184
),
195185
)
196186
del self._awaiting_auth[auth_details.state]
197-
oauth2_config = (
198-
auth_details.auth_config.exchanged_auth_credential.oauth2
199-
)
187+
oauth2_config = auth_details.auth_config.exchanged_auth_credential.oauth2
200188
oauth2_config.auth_response_uri = auth_uri
201189
auth_content = types.UserContent(
202190
parts=[
@@ -210,20 +198,20 @@ async def _complete_auth_processing(
210198
]
211199
)
212200
await self._process_request(auth_content, context, task_updater)
213-
# Extract the stored credential.
214-
if context.call_context and context.call_context.user.is_authenticated:
215-
await self._store_user_auth(
216-
context,
217-
auth_details.auth_config.auth_scheme,
218-
auth_details.auth_config.raw_auth_credential,
219-
)
201+
# Always hoist the session credential. The documented OAuth redirect
202+
# has no JWT, so call_context stays unauthenticated.
203+
await self._store_user_auth(
204+
context,
205+
auth_details.auth_config.auth_scheme,
206+
auth_details.auth_config.raw_auth_credential,
207+
)
220208

221209
async def execute(
222210
self,
223211
context: RequestContext,
224212
event_queue: EventQueue,
225-
):
226-
# Run the agent until either complete or the task is suspended.
213+
) -> None:
214+
"""Run the agent until the task completes or is suspended."""
227215
updater = TaskUpdater(event_queue, context.task_id, context.context_id)
228216
# Immediately notify that the task is submitted.
229217
if not context.current_task:
@@ -238,11 +226,12 @@ async def execute(
238226
)
239227
logger.debug('[Calendar] execute exiting')
240228

241-
async def cancel(self, context: RequestContext, event_queue: EventQueue):
242-
# Ideally: kill any ongoing tasks.
229+
async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
230+
"""Cancel is not supported for the calendar agent."""
243231
raise ServerError(error=UnsupportedOperationError())
244232

245-
async def on_auth_callback(self, state: str, uri: str):
233+
async def on_auth_callback(self, state: str, uri: str) -> None:
234+
"""Resume an in-flight OAuth callback."""
246235
self._awaiting_auth[state].set_result(uri)
247236

248237
async def _upsert_session(self, context: RequestContext) -> Session:
@@ -262,9 +251,9 @@ async def _upsert_session(self, context: RequestContext) -> Session:
262251
return await self._ensure_auth(session)
263252

264253
async def _ensure_auth(self, session: Session) -> Session:
265-
if (
266-
stored_cred := self._credentials.get(session.user_id)
267-
) and not session.state.get(stored_cred.key):
254+
if (stored_cred := self._credentials.get(session.user_id)) and not session.state.get(
255+
stored_cred.key
256+
):
268257
event_action = EventActions(
269258
state_delta={
270259
stored_cred.key: stored_cred.credential,
@@ -301,10 +290,8 @@ async def _store_user_auth(
301290
)
302291
stored_credential = session.state.get(credential_key)
303292
if stored_credential:
304-
self._credentials[context.call_context.user.user_name] = (
305-
StoredCredential(
306-
key=credential_key, credential=stored_credential
307-
)
293+
self._credentials[session.user_id] = StoredCredential(
294+
key=credential_key, credential=stored_credential
308295
)
309296

310297

@@ -321,15 +308,11 @@ def convert_a2a_part_to_genai(part: Part) -> types.Part:
321308
if isinstance(part, FilePart):
322309
if isinstance(part.file, FileWithUri):
323310
return types.Part(
324-
file_data=types.FileData(
325-
file_uri=part.file.uri, mime_type=part.file.mime_type
326-
)
311+
file_data=types.FileData(file_uri=part.file.uri, mime_type=part.file.mime_type)
327312
)
328313
if isinstance(part.file, FileWithBytes):
329314
return types.Part(
330-
inline_data=types.Blob(
331-
data=part.file.bytes, mime_type=part.file.mime_type
332-
)
315+
inline_data=types.Blob(data=part.file.bytes, mime_type=part.file.mime_type)
333316
)
334317
raise ValueError(f'Unsupported file type: {type(part.file)}')
335318
raise ValueError(f'Unsupported part type: {type(part)}')
@@ -390,7 +373,5 @@ def get_auth_config(
390373
if not auth_request_function_call.args or not (
391374
auth_config := auth_request_function_call.args.get('authConfig')
392375
):
393-
raise ValueError(
394-
f'Cannot get auth config from function call: {auth_request_function_call}'
395-
)
376+
raise ValueError(f'Cannot get auth config from function call: {auth_request_function_call}')
396377
return AuthConfig.model_validate(auth_config)

samples/python/agents/birthday_planner_adk/calendar_agent/tests/__init__.py

Whitespace-only changes.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
import asyncio
2+
import importlib.util
3+
import sys
4+
5+
from pathlib import Path
6+
from types import ModuleType
7+
from unittest.mock import AsyncMock, MagicMock, patch
8+
9+
10+
_AGENT_DIR = Path(__file__).resolve().parents[1]
11+
if str(_AGENT_DIR) not in sys.path:
12+
sys.path.insert(0, str(_AGENT_DIR))
13+
14+
15+
class _StubAgentExecutor:
16+
"""Stand-in for a2a.server.agent_execution.AgentExecutor."""
17+
18+
19+
def _ensure_module(name: str) -> ModuleType:
20+
module = sys.modules.get(name)
21+
if module is not None:
22+
return module
23+
module = ModuleType(name)
24+
module.__path__ = []
25+
sys.modules[name] = module
26+
parent_name, _, attr = name.rpartition('.')
27+
if parent_name:
28+
setattr(_ensure_module(parent_name), attr, module)
29+
return module
30+
31+
32+
def _missing(name: str) -> bool:
33+
try:
34+
return importlib.util.find_spec(name) is None
35+
except ModuleNotFoundError:
36+
return True
37+
38+
39+
def _set_attr(module: ModuleType, name: str, value: object) -> None:
40+
if not hasattr(module, name):
41+
setattr(module, name, value)
42+
43+
44+
def _install_import_stubs() -> None:
45+
"""Let this test import the executor without the sample's runtime extras."""
46+
agent_execution = _ensure_module('a2a.server.agent_execution')
47+
_set_attr(agent_execution, 'AgentExecutor', _StubAgentExecutor)
48+
_set_attr(agent_execution, 'RequestContext', MagicMock)
49+
_set_attr(
50+
_ensure_module('a2a.server.agent_execution.context'),
51+
'RequestContext',
52+
agent_execution.RequestContext,
53+
)
54+
_set_attr(_ensure_module('a2a.server.events.event_queue'), 'EventQueue', MagicMock)
55+
_set_attr(_ensure_module('a2a.server.tasks'), 'TaskUpdater', MagicMock)
56+
a2a_types = _ensure_module('a2a.types')
57+
for type_name in (
58+
'AgentCard',
59+
'FilePart',
60+
'FileWithBytes',
61+
'FileWithUri',
62+
'Part',
63+
'TextPart',
64+
'UnsupportedOperationError',
65+
):
66+
_set_attr(a2a_types, type_name, MagicMock)
67+
68+
class _TaskState:
69+
working = 'working'
70+
failed = 'failed'
71+
auth_required = 'auth_required'
72+
73+
a2a_types.TaskState = _TaskState
74+
_set_attr(_ensure_module('a2a.utils.errors'), 'ServerError', Exception)
75+
_set_attr(
76+
_ensure_module('a2a.utils.message'),
77+
'new_agent_text_message',
78+
MagicMock(),
79+
)
80+
if _missing('google.adk'):
81+
_ensure_module('google.adk').Runner = MagicMock
82+
adk_auth = _ensure_module('google.adk.auth')
83+
for type_name in ('AuthConfig', 'AuthCredential', 'AuthScheme'):
84+
setattr(adk_auth, type_name, MagicMock)
85+
adk_events = _ensure_module('google.adk.events')
86+
for type_name in ('Event', 'EventActions'):
87+
setattr(adk_events, type_name, MagicMock)
88+
_ensure_module('google.adk.sessions').Session = MagicMock
89+
_ensure_module(
90+
'google.adk.tools.openapi_tool.openapi_spec_parser.tool_auth_handler'
91+
).ToolContextCredentialStore = MagicMock
92+
if _missing('google.genai'):
93+
_ensure_module('google.genai').types = MagicMock()
94+
95+
96+
_install_import_stubs()
97+
98+
from adk_agent_executor import ADKAgentExecutor, ADKAuthDetails
99+
100+
101+
def test_complete_auth_processing_stores_credential_when_unauthenticated() -> None:
102+
"""OAuth callback without a JWT must still cache the session credential."""
103+
asyncio.run(_run_complete_auth_processing_unauthenticated())
104+
105+
106+
async def _run_complete_auth_processing_unauthenticated() -> None:
107+
runner = MagicMock()
108+
runner.app_name = 'Calendar Agent'
109+
executor = ADKAgentExecutor(runner, MagicMock())
110+
111+
credential = object()
112+
session = MagicMock()
113+
session.user_id = 'anonymous'
114+
session.state = {'calendar-oauth-key': credential}
115+
116+
user = MagicMock()
117+
user.is_authenticated = False
118+
user.user_name = 'jwt-user'
119+
call_context = MagicMock()
120+
call_context.user = user
121+
context = MagicMock()
122+
context.call_context = call_context
123+
context.context_id = 'ctx-unauthenticated'
124+
125+
future = asyncio.get_running_loop().create_future()
126+
future.set_result('http://localhost:10007/authenticate?code=abc&state=xyz')
127+
128+
auth_config = MagicMock()
129+
auth_config.exchanged_auth_credential.oauth2 = MagicMock()
130+
auth_config.auth_scheme = MagicMock()
131+
auth_config.raw_auth_credential = MagicMock()
132+
auth_details = ADKAuthDetails(
133+
state='xyz',
134+
uri='https://accounts.google.com/o/oauth2/auth',
135+
future=future,
136+
auth_config=auth_config,
137+
auth_request_function_call_id='fn-1',
138+
)
139+
executor._awaiting_auth[auth_details.state] = future # noqa: SLF001
140+
141+
task_updater = MagicMock()
142+
task_updater.update_status = AsyncMock()
143+
144+
with (
145+
patch.object(executor, '_process_request', new_callable=AsyncMock),
146+
patch.object(executor, '_upsert_session', new_callable=AsyncMock, return_value=session),
147+
patch('adk_agent_executor.ToolContextCredentialStore') as store_cls,
148+
):
149+
store_cls.return_value.get_credential_key.return_value = 'calendar-oauth-key'
150+
await executor._complete_auth_processing( # noqa: SLF001
151+
context, auth_details, task_updater
152+
)
153+
154+
assert session.user_id in executor._credentials # noqa: S101, SLF001
155+
stored = executor._credentials[session.user_id] # noqa: SLF001
156+
assert stored.key == 'calendar-oauth-key' # noqa: S101
157+
assert stored.credential is credential # noqa: S101
158+
assert user.user_name not in executor._credentials # noqa: S101, SLF001

0 commit comments

Comments
 (0)