Skip to content

Commit 8083d53

Browse files
committed
Serialize credential lifecycle by key
1 parent fabf0fd commit 8083d53

2 files changed

Lines changed: 172 additions & 44 deletions

File tree

src/google/adk/auth/credential_manager.py

Lines changed: 75 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,13 @@
1414

1515
from __future__ import annotations
1616

17+
import asyncio
1718
from collections.abc import Sequence
1819
import logging
1920
import threading
2021
from typing import Optional
22+
from weakref import WeakKeyDictionary
23+
from weakref import WeakValueDictionary
2124

2225
from fastapi.openapi.models import OAuth2
2326

@@ -102,6 +105,27 @@ class CredentialManager:
102105

103106
_auth_provider_registry = AuthProviderRegistry()
104107
_registry_lock = threading.Lock()
108+
_credential_locks_lock = threading.Lock()
109+
_credential_locks: WeakKeyDictionary[
110+
asyncio.AbstractEventLoop, WeakValueDictionary[str, asyncio.Lock]
111+
] = WeakKeyDictionary()
112+
113+
def _get_credential_lock(self) -> asyncio.Lock:
114+
"""Returns the event-loop-local lock for this credential."""
115+
loop = asyncio.get_running_loop()
116+
credential_key = getattr(self._auth_config, "credential_key", None) or (
117+
f"auth_config:{id(self._auth_config)}"
118+
)
119+
with self._credential_locks_lock:
120+
locks_for_loop = self._credential_locks.get(loop)
121+
if locks_for_loop is None:
122+
locks_for_loop = WeakValueDictionary()
123+
self._credential_locks[loop] = locks_for_loop
124+
lock = locks_for_loop.get(credential_key)
125+
if lock is None:
126+
lock = asyncio.Lock()
127+
locks_for_loop[credential_key] = lock
128+
return lock
105129

106130
@classmethod
107131
def register_auth_provider(cls, provider: BaseAuthProvider) -> None:
@@ -227,51 +251,58 @@ async def get_auth_credential(
227251
return None
228252
return provided_credential
229253

230-
# Step 1: Validate credential configuration
231-
await self._validate_credential()
232-
233-
# Step 2: Check if credential is already ready (no processing needed)
234-
raw_auth_credential = self._auth_config.raw_auth_credential
235-
if self._is_credential_ready() and raw_auth_credential is not None:
236-
# Return a copy to avoid leaking mutations across invocations/users when
237-
# tools share a long-lived AuthConfig instance.
238-
return raw_auth_credential.model_copy(deep=True)
239-
240-
# Step 3: Try to load existing processed credential
241-
credential = await self._load_existing_credential(context)
242-
243-
# Step 4: If no existing credential, load from auth response
244-
# TODO instead of load from auth response, we can store auth response in
245-
# credential service.
246-
was_from_auth_response = False
247-
if not credential:
248-
credential = await self._load_from_auth_response(context)
249-
was_from_auth_response = True
250-
251-
# Step 5: If still no credential available, check if client credentials
252-
if not credential:
253-
# For client credentials flow, use raw credentials directly
254-
if self._is_client_credentials_flow():
255-
# Exchange/refresh steps may mutate the credential object in-place, so
256-
# do not operate on the shared tool config.
257-
credential = self._auth_config.raw_auth_credential.model_copy(deep=True)
258-
else:
259-
# For authorization code flow, return None to trigger user authorization
260-
return None
261-
262-
# Step 6: Exchange credential if needed (e.g., service account to access token)
263-
credential, was_exchanged = await self._exchange_credential(credential)
254+
async with self._get_credential_lock():
255+
# Step 1: Validate credential configuration
256+
await self._validate_credential()
257+
258+
# Step 2: Check if credential is already ready (no processing needed)
259+
raw_auth_credential = self._auth_config.raw_auth_credential
260+
if self._is_credential_ready() and raw_auth_credential is not None:
261+
# Return a copy to avoid leaking mutations across invocations/users when
262+
# tools share a long-lived AuthConfig instance.
263+
return raw_auth_credential.model_copy(deep=True)
264+
265+
# Step 3: Try to load existing processed credential
266+
# This is intentionally inside the lock so an invocation that waited can
267+
# observe a credential saved by the previous holder.
268+
credential = await self._load_existing_credential(context)
269+
270+
# Step 4: If no existing credential, load from auth response
271+
# TODO instead of load from auth response, we can store auth response in
272+
# credential service.
273+
was_from_auth_response = False
274+
if not credential:
275+
credential = await self._load_from_auth_response(context)
276+
was_from_auth_response = True
277+
278+
# Step 5: If still no credential available, check if client credentials
279+
if not credential:
280+
# For client credentials flow, use raw credentials directly
281+
if self._is_client_credentials_flow():
282+
# Exchange/refresh steps may mutate the credential object in-place,
283+
# so do not operate on the shared tool config.
284+
raw_credential = self._auth_config.raw_auth_credential
285+
assert raw_credential is not None
286+
credential = raw_credential.model_copy(deep=True)
287+
else:
288+
# For authorization code flow, return None to trigger user
289+
# authorization.
290+
return None
291+
292+
# Step 6: Exchange credential if needed (e.g., service account to access
293+
# token)
294+
credential, was_exchanged = await self._exchange_credential(credential)
295+
296+
# Step 7: Refresh credential if expired
297+
was_refreshed = False
298+
if not was_exchanged:
299+
credential, was_refreshed = await self._refresh_credential(credential)
300+
301+
# Step 8: Save credential if it was modified
302+
if was_from_auth_response or was_exchanged or was_refreshed:
303+
await self._save_credential(context, credential)
264304

265-
# Step 7: Refresh credential if expired
266-
was_refreshed = False
267-
if not was_exchanged:
268-
credential, was_refreshed = await self._refresh_credential(credential)
269-
270-
# Step 8: Save credential if it was modified
271-
if was_from_auth_response or was_exchanged or was_refreshed:
272-
await self._save_credential(context, credential)
273-
274-
return credential
305+
return credential
275306

276307
async def _load_existing_credential(
277308
self, context: CallbackContext

tests/unittests/auth/test_credential_manager.py

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import asyncio
1516
from unittest.mock import ANY
1617
from unittest.mock import AsyncMock
1718
from unittest.mock import Mock
@@ -329,6 +330,102 @@ async def test_load_auth_credentials_no_credential(self):
329330

330331
assert result is None
331332

333+
@pytest.mark.asyncio
334+
async def test_get_auth_credential_serializes_same_credential_key(self):
335+
"""Concurrent managers should process a credential only once."""
336+
raw_credential = Mock(spec=AuthCredential)
337+
processed_credential = Mock(spec=AuthCredential)
338+
auth_config = Mock(
339+
spec=AuthConfig,
340+
auth_scheme=Mock(spec=AuthScheme),
341+
raw_auth_credential=raw_credential,
342+
credential_key="shared-key",
343+
)
344+
managers = [CredentialManager(auth_config), CredentialManager(auth_config)]
345+
context = Mock(spec=CallbackContext)
346+
stored_credential = None
347+
exchange_count = 0
348+
349+
async def load_existing_credential(_):
350+
return stored_credential
351+
352+
async def exchange_credential(credential):
353+
nonlocal exchange_count
354+
if credential is processed_credential:
355+
return credential, False
356+
exchange_count += 1
357+
await asyncio.sleep(0)
358+
return processed_credential, True
359+
360+
async def save_credential(_, credential):
361+
nonlocal stored_credential
362+
stored_credential = credential
363+
364+
for manager in managers:
365+
manager._validate_credential = AsyncMock()
366+
manager._is_credential_ready = Mock(return_value=False)
367+
manager._load_existing_credential = AsyncMock(
368+
side_effect=load_existing_credential
369+
)
370+
manager._load_from_auth_response = AsyncMock(return_value=raw_credential)
371+
manager._exchange_credential = AsyncMock(side_effect=exchange_credential)
372+
manager._refresh_credential = AsyncMock(
373+
side_effect=lambda credential: (credential, False)
374+
)
375+
manager._save_credential = AsyncMock(side_effect=save_credential)
376+
377+
results = await asyncio.gather(
378+
*(manager.get_auth_credential(context) for manager in managers)
379+
)
380+
381+
assert results == [processed_credential, processed_credential]
382+
assert exchange_count == 1
383+
384+
@pytest.mark.asyncio
385+
async def test_get_auth_credential_keeps_different_keys_concurrent(self):
386+
"""Credential processing for different keys should not block."""
387+
both_started = asyncio.Event()
388+
active_count = 0
389+
max_active_count = 0
390+
391+
async def exchange_credential(credential):
392+
nonlocal active_count, max_active_count
393+
active_count += 1
394+
max_active_count = max(max_active_count, active_count)
395+
if active_count == 2:
396+
both_started.set()
397+
await asyncio.wait_for(both_started.wait(), timeout=1)
398+
active_count -= 1
399+
return credential, False
400+
401+
managers = []
402+
for credential_key in ("key-a", "key-b"):
403+
raw_credential = Mock(spec=AuthCredential)
404+
auth_config = Mock(
405+
spec=AuthConfig,
406+
auth_scheme=Mock(spec=AuthScheme),
407+
raw_auth_credential=raw_credential,
408+
credential_key=credential_key,
409+
)
410+
manager = CredentialManager(auth_config)
411+
manager._validate_credential = AsyncMock()
412+
manager._is_credential_ready = Mock(return_value=False)
413+
manager._load_existing_credential = AsyncMock(return_value=None)
414+
manager._load_from_auth_response = AsyncMock(return_value=raw_credential)
415+
manager._exchange_credential = AsyncMock(side_effect=exchange_credential)
416+
manager._refresh_credential = AsyncMock(
417+
side_effect=lambda credential: (credential, False)
418+
)
419+
manager._save_credential = AsyncMock()
420+
managers.append(manager)
421+
422+
await asyncio.gather(*(
423+
manager.get_auth_credential(Mock(spec=CallbackContext))
424+
for manager in managers
425+
))
426+
427+
assert max_active_count == 2
428+
332429
@pytest.mark.asyncio
333430
async def test_load_existing_credential_already_exchanged(self):
334431
"""Test _load_existing_credential ignores shared config cache."""

0 commit comments

Comments
 (0)