|
14 | 14 |
|
15 | 15 | from __future__ import annotations |
16 | 16 |
|
| 17 | +import asyncio |
17 | 18 | from collections.abc import Sequence |
18 | 19 | import logging |
19 | 20 | import threading |
20 | 21 | from typing import Optional |
| 22 | +from weakref import WeakKeyDictionary |
| 23 | +from weakref import WeakValueDictionary |
21 | 24 |
|
22 | 25 | from fastapi.openapi.models import OAuth2 |
23 | 26 |
|
@@ -102,6 +105,27 @@ class CredentialManager: |
102 | 105 |
|
103 | 106 | _auth_provider_registry = AuthProviderRegistry() |
104 | 107 | _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 |
105 | 129 |
|
106 | 130 | @classmethod |
107 | 131 | def register_auth_provider(cls, provider: BaseAuthProvider) -> None: |
@@ -227,51 +251,58 @@ async def get_auth_credential( |
227 | 251 | return None |
228 | 252 | return provided_credential |
229 | 253 |
|
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) |
264 | 304 |
|
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 |
275 | 306 |
|
276 | 307 | async def _load_existing_credential( |
277 | 308 | self, context: CallbackContext |
|
0 commit comments