11"""Channel creation utilities for the Athena client."""
22
3- import asyncio
43import json
4+ import threading
55import time
66from typing import override
77
1616)
1717
1818
19- class TokenMetadataPlugin (grpc .AuthMetadataPlugin ):
20- """Plugin that adds authorization token to gRPC metadata."""
21-
22- def __init__ (self , token : str ) -> None :
23- """Initialize the plugin with the auth token.
24-
25- Args:
26- ----
27- token: The authorization token to add to requests
28-
29- """
30- self ._token : str = token
31-
32- @override
33- def __call__ (
34- self ,
35- _ : grpc .AuthMetadataContext ,
36- callback : grpc .AuthMetadataPluginCallback ,
37- ) -> None :
38- """Pass authentication metadata to the provided callback.
39-
40- This method will be invoked asynchronously in a separate thread.
41-
42- Args:
43- ----
44- callback: An AuthMetadataPluginCallback to be invoked either
45- synchronously or asynchronously.
46-
47- """
48- metadata = (("authorization" , f"Token { self ._token } " ),)
49- callback (metadata , None )
50-
51-
5219class CredentialHelper :
5320 """OAuth credential helper for managing authentication tokens."""
5421
@@ -82,13 +49,14 @@ def __init__(
8249 self ._audience : str = audience
8350 self ._token : str | None = None
8451 self ._token_expires_at : float | None = None
85- self ._lock : asyncio .Lock = asyncio .Lock ()
52+ self ._lock : threading .Lock = threading .Lock ()
8653
87- async def get_token (self ) -> str :
54+ def get_token (self ) -> str :
8855 """Get a valid authentication token.
8956
90- This method will return a cached token if it's still valid,
91- or fetch a new token if needed.
57+ Returns a cached token if still valid, or acquires a new one.
58+ The entire check-and-refresh cycle runs under a single lock
59+ acquisition to prevent TOCTOU races with ``invalidate_token``.
9260
9361 Returns
9462 -------
@@ -97,21 +65,18 @@ async def get_token(self) -> str:
9765 Raises
9866 ------
9967 OAuthError: If token acquisition fails
100- TokenExpiredError : If token has expired and refresh fails
68+ RuntimeError : If token is unexpectedly None after refresh
10169
10270 """
103- async with self ._lock :
104- if self ._is_token_valid ():
105- if self ._token is None :
106- msg = "Token should be valid but is None"
107- raise RuntimeError (msg )
108- return self ._token
109-
110- await self ._refresh_token ()
111- if self ._token is None :
112- msg = "Token refresh failed"
71+ with self ._lock :
72+ if not self ._is_token_valid ():
73+ self ._refresh_token ()
74+
75+ token = self ._token
76+ if token is None :
77+ msg = "Token is unexpectedly None after validity check"
11378 raise RuntimeError (msg )
114- return self . _token
79+ return token
11580
11681 def _is_token_valid (self ) -> bool :
11782 """Check if the current token is valid and not expired.
@@ -127,9 +92,12 @@ def _is_token_valid(self) -> bool:
12792 # Add 30 second buffer before expiration
12893 return time .time () < (self ._token_expires_at - 30 )
12994
130- async def _refresh_token (self ) -> None :
95+ def _refresh_token (self ) -> None :
13196 """Refresh the authentication token by making an OAuth request.
13297
98+ This is a synchronous call (suitable for the gRPC metadata-plugin
99+ thread) and must be called while ``self._lock`` is held.
100+
133101 Raises
134102 ------
135103 OAuthError: If the OAuth request fails
@@ -145,21 +113,19 @@ async def _refresh_token(self) -> None:
145113 headers = {"content-type" : "application/json" }
146114
147115 try :
148- async with httpx .AsyncClient () as client :
149- response = await client .post (
116+ with httpx .Client () as client :
117+ response = client .post (
150118 self ._auth_url ,
151119 json = payload ,
152120 headers = headers ,
153121 timeout = 30.0 ,
154122 )
155123 _ = response .raise_for_status ()
156124
157- token_data = response .json ()
158- self ._token = token_data ["access_token" ]
159- expires_in = token_data .get (
160- "expires_in" , 3600
161- ) # Default 1 hour
162- self ._token_expires_at = time .time () + expires_in
125+ token_data = response .json ()
126+ self ._token = token_data ["access_token" ]
127+ expires_in = token_data .get ("expires_in" , 3600 ) # Default 1 hour
128+ self ._token_expires_at = time .time () + expires_in
163129
164130 except httpx .HTTPStatusError as e :
165131 error_detail = ""
@@ -190,13 +156,59 @@ async def _refresh_token(self) -> None:
190156 msg = f"Unexpected error during OAuth: { e } "
191157 raise OAuthError (msg ) from e
192158
193- async def invalidate_token (self ) -> None :
159+ def invalidate_token (self ) -> None :
194160 """Invalidate the current token to force a refresh on next use."""
195- async with self ._lock :
161+ with self ._lock :
196162 self ._token = None
197163 self ._token_expires_at = None
198164
199165
166+ class _AutoRefreshTokenAuthMetadataPlugin (grpc .AuthMetadataPlugin ):
167+ """gRPC auth plugin that fetches a fresh token for every RPC.
168+
169+ The plugin delegates to ``CredentialHelper.get_token()`` which
170+ handles caching, expiry checks, and thread-safe refresh internally.
171+ This callback is invoked by gRPC on a *separate* thread, so the
172+ underlying ``CredentialHelper`` must use ``threading.Lock`` (not
173+ ``asyncio.Lock``).
174+ """
175+
176+ def __init__ (self , credential_helper : CredentialHelper ) -> None :
177+ """Initialize with a credential helper.
178+
179+ Args:
180+ ----
181+ credential_helper: The helper that manages token lifecycle
182+
183+ """
184+ self ._credential_helper : CredentialHelper = credential_helper
185+
186+ @override
187+ def __call__ (
188+ self ,
189+ _ : grpc .AuthMetadataContext ,
190+ callback : grpc .AuthMetadataPluginCallback ,
191+ ) -> None :
192+ """Supply authorization metadata for an RPC.
193+
194+ Called by the gRPC runtime on a background thread before each
195+ RPC. On success the token is forwarded as a Bearer token; on
196+ failure the error is passed to the callback so gRPC can surface
197+ it as an RPC error.
198+
199+ Args:
200+ ----
201+ callback: gRPC callback to receive metadata or an error
202+
203+ """
204+ try :
205+ token = self ._credential_helper .get_token ()
206+ metadata = (("authorization" , f"Bearer { token } " ),)
207+ callback (metadata , None )
208+ except OAuthError as err :
209+ callback (None , err ) # pyright: ignore[reportArgumentType]
210+
211+
200212async def create_channel_with_credentials (
201213 host : str ,
202214 credential_helper : CredentialHelper ,
@@ -215,19 +227,23 @@ async def create_channel_with_credentials(
215227 Raises:
216228 ------
217229 InvalidHostError: If host is empty
218- OAuthError: If OAuth authentication fails
230+
231+ Note:
232+ ----
233+ OAuth errors are no longer raised at channel-creation time.
234+ Instead, they surface as RPC errors when the per-request auth
235+ metadata plugin attempts to acquire a token.
219236
220237 """
221238 if not host :
222239 raise InvalidHostError (InvalidHostError .default_message )
223240
224- # Get a valid token from the credential helper
225- token = await credential_helper .get_token ()
226-
227- # Create credentials with token authentication
241+ # Create credentials with per-RPC token refresh
228242 credentials = grpc .composite_channel_credentials (
229243 grpc .ssl_channel_credentials (),
230- grpc .access_token_call_credentials (token ),
244+ grpc .metadata_call_credentials (
245+ _AutoRefreshTokenAuthMetadataPlugin (credential_helper )
246+ ),
231247 )
232248
233249 # Configure gRPC options for persistent connections
0 commit comments