authful-mcp-proxy 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """Authful MCP proxy."""
2
+
3
+ from importlib.metadata import version, PackageNotFoundError
4
+
5
+ try:
6
+ __version__ = version("authful_mcp_proxy")
7
+ except PackageNotFoundError:
8
+ # If the package is not installed, use a development version
9
+ __version__ = "0.0.0-dev"
@@ -0,0 +1,180 @@
1
+ """
2
+ Authful MCP Proxy - Command-line interface.
3
+
4
+ This module provides the CLI entry point for running the MCP proxy server. It:
5
+
6
+ - Parses command-line arguments and environment variables
7
+ - Configures OIDC authentication parameters
8
+ - Launches the proxy server with appropriate settings
9
+ - Handles graceful shutdown and error reporting
10
+
11
+ The CLI supports configuration via both command-line options (--oidc-*) and
12
+ environment variables (OIDC_*), with CLI arguments taking precedence.
13
+ """
14
+
15
+ import argparse
16
+ import asyncio
17
+ import logging
18
+ import os
19
+ import sys
20
+
21
+ from . import __version__, mcp_proxy
22
+ from .config import OIDCConfig
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ def cli():
28
+ """
29
+ Parse command line arguments and merge with environment variables.
30
+
31
+ Parses CLI arguments for OIDC configuration, backend URL, and logging options.
32
+ Falls back to environment variables when CLI arguments are not provided, with
33
+ CLI arguments taking precedence.
34
+
35
+ Returns:
36
+ Namespace: Parsed arguments with all configuration options.
37
+ """
38
+ parser = argparse.ArgumentParser(
39
+ description=f"Authful Remote-HTTP-to-Local-stdio MCP Proxy (version {__version__})"
40
+ )
41
+
42
+ # Proxy server arguments
43
+ parser.add_argument(
44
+ "mcp_backend_url",
45
+ metavar="MCP_BACKEND_URL",
46
+ nargs="?",
47
+ help="URL of remote backend MCP server to be proxied (can also be set via MCP_BACKEND_URL env var)",
48
+ )
49
+
50
+ parser.add_argument(
51
+ "--no-banner",
52
+ action="store_true",
53
+ help="Don't show the proxy server banner",
54
+ )
55
+
56
+ # OIDC options
57
+ parser.add_argument(
58
+ "--oidc-issuer-url",
59
+ help="OIDC issuer URL (can also be set via OIDC_ISSUER_URL env var)",
60
+ )
61
+ parser.add_argument(
62
+ "--oidc-client-id",
63
+ help="OAuth client ID (can also be set via OIDC_CLIENT_ID env var)",
64
+ )
65
+ parser.add_argument(
66
+ "--oidc-client-secret",
67
+ help="OAuth client secret (can also be set via OIDC_CLIENT_SECRET env var, optional for public OIDC clients that don't require any such)",
68
+ )
69
+ parser.add_argument(
70
+ "--oidc-scopes",
71
+ help="Space-separated OAuth scopes (can also be set via OIDC_SCOPES env var, default: 'openid profile email')",
72
+ )
73
+ parser.add_argument(
74
+ "--oidc-redirect-url",
75
+ help="Localhost URL for OAuth redirect (can also be set via OIDC_REDIRECT_URL env var, default: http://localhost:8080/auth/callback)",
76
+ )
77
+
78
+ # Logging options
79
+ group = parser.add_mutually_exclusive_group()
80
+ group.add_argument("--silent", action="store_true", help="Show only error messages")
81
+ group.add_argument(
82
+ "--debug",
83
+ action="store_true",
84
+ help="Enable debug logging (can also be set through 'MCP_PROXY_DEBUG' environment variable)",
85
+ )
86
+
87
+ args = parser.parse_args()
88
+
89
+ # Fallback to environment variables (CLI args take precedence)
90
+ if not args.mcp_backend_url:
91
+ args.mcp_backend_url = os.getenv("MCP_BACKEND_URL")
92
+ if not args.oidc_issuer_url:
93
+ args.oidc_issuer_url = os.getenv("OIDC_ISSUER_URL")
94
+ if not args.oidc_client_id:
95
+ args.oidc_client_id = os.getenv("OIDC_CLIENT_ID")
96
+ if not args.oidc_client_secret:
97
+ args.oidc_client_secret = os.getenv("OIDC_CLIENT_SECRET")
98
+ if not args.oidc_scopes:
99
+ args.oidc_scopes = os.getenv("OIDC_SCOPES")
100
+ if not args.oidc_redirect_url:
101
+ args.oidc_redirect_url = os.getenv("OIDC_REDIRECT_URL")
102
+ if not args.debug:
103
+ args.debug = os.getenv("MCP_PROXY_DEBUG", "").lower() in (
104
+ "1",
105
+ "true",
106
+ "yes",
107
+ "on",
108
+ )
109
+
110
+ return args
111
+
112
+
113
+ def get_log_level_name(args) -> str:
114
+ """
115
+ Determine the appropriate log level based on command line arguments.
116
+
117
+ Args:
118
+ args: Parsed command line arguments containing silent/debug flags.
119
+
120
+ Returns:
121
+ str: Log level name ('ERROR', 'DEBUG', or 'INFO').
122
+ """
123
+ if args.silent:
124
+ return logging.getLevelName(logging.ERROR)
125
+ elif args.debug:
126
+ return logging.getLevelName(logging.DEBUG)
127
+ else:
128
+ return logging.getLevelName(logging.INFO)
129
+
130
+
131
+ def main():
132
+ """
133
+ Main entry point for the Authful MCP Proxy application.
134
+
135
+ Parses configuration, creates the OIDC config object, and launches the proxy server.
136
+ Handles graceful shutdown and provides appropriate error messages for different
137
+ exception types.
138
+
139
+ Exits with status code 1 on errors, 0 on successful completion.
140
+ """
141
+ args = cli()
142
+
143
+ try:
144
+ # Create OIDC config
145
+ oidc_config = OIDCConfig(
146
+ issuer_url=args.oidc_issuer_url,
147
+ client_id=args.oidc_client_id,
148
+ client_secret=args.oidc_client_secret,
149
+ scopes=args.oidc_scopes,
150
+ redirect_url=args.oidc_redirect_url,
151
+ )
152
+
153
+ # Start the MCP proxy
154
+ asyncio.run(
155
+ mcp_proxy.run_async(
156
+ backend_url=args.mcp_backend_url,
157
+ oidc_config=oidc_config,
158
+ show_banner=not args.no_banner,
159
+ log_level=get_log_level_name(args),
160
+ )
161
+ )
162
+ except KeyboardInterrupt:
163
+ # Graceful shutdown, suppress noisy logs resulting from asyncio.run task cancellation propagation
164
+ pass
165
+ except ValueError as e:
166
+ # Configuration error, log w/o stack trace
167
+ logger.error(f"Configuration error: {e}")
168
+ sys.exit(1)
169
+ except RuntimeError as e:
170
+ # Runtime error, log w/o stack trace
171
+ logger.error(f"Runtime error: {e}")
172
+ sys.exit(1)
173
+ except Exception as e:
174
+ # Unexpected internal error, include full stack trace
175
+ logger.error(f"Internal error: {e}", exc_info=True)
176
+ sys.exit(1)
177
+
178
+
179
+ if __name__ == "__main__":
180
+ main()
@@ -0,0 +1,27 @@
1
+ """Configuration models for the MCP proxy."""
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass
7
+ class OIDCConfig:
8
+ """
9
+ OIDC authentication configuration.
10
+
11
+ This dataclass encapsulates OIDC/OAuth 2.0 parameters used for authenticating
12
+ with external authorization servers. All fields are optional to support
13
+ configuration via environment variables as fallback.
14
+
15
+ Attributes:
16
+ issuer_url: OIDC issuer URL (e.g., https://keycloak.example.com/realms/myrealm)
17
+ client_id: OAuth client identifier
18
+ client_secret: OAuth client secret (optional for public OIDC clients that don't require any such)
19
+ scopes: Space-separated OAuth scopes (e.g., "openid profile email")
20
+ redirect_url: Localhost callback URL for OAuth redirect (e.g., http://localhost:8080/auth/callback)
21
+ """
22
+
23
+ issuer_url: str
24
+ client_id: str
25
+ client_secret: str | None = None
26
+ scopes: str | None = None
27
+ redirect_url: str | None = None
@@ -0,0 +1,468 @@
1
+ """
2
+ OIDC Auth client provider for external OpenID Connect (OIDC) providers.
3
+
4
+ This module provides an OAuth client for external OIDC providers (Keycloak, Auth0,
5
+ Okta, etc.) that handles the complete OAuth 2.0 authorization code flow with PKCE
6
+ using static client credentials. Key features include:
7
+
8
+ - Automatic provider configuration discovery via /.well-known/openid-configuration
9
+ - Browser-based user authentication with automatic OAuth callback handling
10
+ - Secure token exchange using PKCE (Proof Key for Code Exchange)
11
+ - Local token caching to eliminate repeated browser authentication
12
+ - Automatic access token refresh using refresh tokens
13
+ - Support for custom scopes and redirect URLs
14
+
15
+ Unlike dynamic client registration, this uses pre-configured client credentials
16
+ (client_id/client_secret) that must be set up in the OIDC provider beforehand.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import asyncio
22
+ import secrets
23
+ import time
24
+ import webbrowser
25
+ from collections.abc import AsyncGenerator
26
+ from dataclasses import dataclass, field
27
+ from pathlib import Path
28
+ from urllib.parse import urlencode, urlparse
29
+
30
+ import anyio
31
+ import httpx
32
+ from fastmcp.client.auth.oauth import TokenStorageAdapter
33
+ from fastmcp.client.oauth_callback import (
34
+ OAuthCallbackResult,
35
+ create_oauth_callback_server,
36
+ )
37
+ from fastmcp.server.auth.oidc_proxy import OIDCConfiguration
38
+ from fastmcp.utilities.logging import get_logger
39
+ from key_value.aio.stores.disk import DiskStore
40
+ from mcp.client.auth import PKCEParameters, TokenStorage
41
+ from mcp.shared.auth import OAuthToken
42
+ from pydantic import AnyHttpUrl
43
+ from uvicorn.server import Server
44
+
45
+ __all__ = ["ExternalOIDCAuth"]
46
+
47
+ logger = get_logger(__name__)
48
+
49
+ HTTPX_REQUEST_TIMEOUT_SECONDS = 5
50
+ BROWSER_LOGIN_TIMEOUT_SECONDS = 300
51
+
52
+
53
+ @dataclass
54
+ class OIDCContext:
55
+ """OIDC OAuth flow context - similar to OAuthContext but for external OIDC providers."""
56
+
57
+ issuer_url: str
58
+ client_id: str
59
+ client_secret: str | None
60
+ scopes: list[str]
61
+ redirect_uri: str
62
+ storage: TokenStorage
63
+
64
+ # Discovered metadata
65
+ oidc_config: OIDCConfiguration
66
+
67
+ # Token management
68
+ current_tokens: OAuthToken | None = None
69
+ token_expiry_time: float | None = None
70
+
71
+ # State
72
+ lock: anyio.Lock = field(default_factory=anyio.Lock)
73
+
74
+ def get_redirect_port(self) -> int:
75
+ """Extract the port number from the redirect URI."""
76
+ parsed = urlparse(self.redirect_uri)
77
+ return parsed.port or 80
78
+
79
+ def get_authorization_url(self, state: str, pkce: PKCEParameters) -> str:
80
+ """Build the authorization URL with PKCE parameters."""
81
+ auth_params = {
82
+ "response_type": "code",
83
+ "client_id": self.client_id,
84
+ "redirect_uri": self.redirect_uri,
85
+ "scope": " ".join(self.scopes),
86
+ "state": state,
87
+ "code_challenge": pkce.code_challenge,
88
+ "code_challenge_method": "S256",
89
+ }
90
+ return f"{self.oidc_config.authorization_endpoint}?{urlencode(auth_params)}"
91
+
92
+ def get_token_exchange_data(
93
+ self, auth_code: str, pkce: PKCEParameters
94
+ ) -> dict[str, str]:
95
+ """Build token exchange request data."""
96
+ token_data = {
97
+ "grant_type": "authorization_code",
98
+ "code": auth_code,
99
+ "redirect_uri": self.redirect_uri,
100
+ "client_id": self.client_id,
101
+ "code_verifier": pkce.code_verifier,
102
+ }
103
+ if self.client_secret:
104
+ token_data["client_secret"] = self.client_secret
105
+ return token_data
106
+
107
+ def get_token_refresh_data(self) -> dict[str, str]:
108
+ """Build token refresh request data.
109
+
110
+ Note: Callers should check can_refresh_token() before calling this method.
111
+ """
112
+ if not self.current_tokens or not self.current_tokens.refresh_token:
113
+ raise RuntimeError("No refresh token available")
114
+ token_data: dict[str, str] = {
115
+ "grant_type": "refresh_token",
116
+ "refresh_token": self.current_tokens.refresh_token,
117
+ "client_id": self.client_id,
118
+ }
119
+ if self.client_secret:
120
+ token_data["client_secret"] = self.client_secret
121
+ return token_data
122
+
123
+ def set_tokens(self, tokens: OAuthToken | None) -> None:
124
+ """Set current tokens and update expiry time."""
125
+ self.current_tokens = tokens
126
+ if tokens and tokens.expires_in:
127
+ self.token_expiry_time = time.time() + tokens.expires_in
128
+ else:
129
+ self.token_expiry_time = None
130
+
131
+ def is_token_valid(self) -> bool:
132
+ """Check if current token is valid and not expired."""
133
+ return bool(
134
+ self.current_tokens
135
+ and self.current_tokens.access_token
136
+ and (
137
+ not self.token_expiry_time or time.time() < self.token_expiry_time - 60
138
+ )
139
+ )
140
+
141
+ def can_refresh_token(self) -> bool:
142
+ """Check if token can be refreshed."""
143
+ return bool(self.current_tokens and self.current_tokens.refresh_token)
144
+
145
+ def get_access_token(self) -> str:
146
+ """Get the current access token.
147
+
148
+ Raises:
149
+ RuntimeError: If no valid access token is available.
150
+ """
151
+ if not self.current_tokens or not self.current_tokens.access_token:
152
+ raise RuntimeError("No access token available")
153
+ return self.current_tokens.access_token
154
+
155
+ def clear_tokens(self) -> None:
156
+ """Clear current tokens."""
157
+ self.current_tokens = None
158
+ self.token_expiry_time = None
159
+
160
+
161
+ class ExternalOIDCAuth(httpx.Auth):
162
+ """
163
+ OAuth client provider that authenticates against external OIDC providers.
164
+
165
+ This client fetches OAuth configuration from an external OIDC provider's
166
+ /.well-known/openid-configuration endpoint and uses static client credentials
167
+ (client_id and client_secret) instead of dynamic client registration.
168
+
169
+ Key differences from standard OAuth client:
170
+ - Fetches config from issuer's /.well-known/openid-configuration (not MCP server)
171
+ - Uses static client_id/client_secret (no dynamic registration)
172
+ - Works with any OIDC-compliant provider (Keycloak, Auth0, Okta, etc.)
173
+
174
+ Example:
175
+ ```python
176
+ from fastmcp.client import Client
177
+ from fastmcp.client.auth import OIDCAuth
178
+
179
+ auth = OIDCAuth(
180
+ issuer_url="https://your-keycloak.example.com/realms/myrealm",
181
+ client_id="your-client-id",
182
+ client_secret="your-client-secret",
183
+ scopes=["openid", "profile", "email"],
184
+ redirect_url="http://localhost:8080/auth/callback"
185
+ )
186
+
187
+ async with Client("http://localhost:8000/mcp", auth=auth) as client:
188
+ # Use authenticated client
189
+ result = await client.call_tool("my_tool", {"arg": "value"})
190
+ ```
191
+ """
192
+
193
+ def __init__(
194
+ self,
195
+ issuer_url: str,
196
+ client_id: str,
197
+ client_secret: str | None = None,
198
+ scopes: str | list[str] | None = None,
199
+ token_storage_cache_dir: Path | None = None,
200
+ redirect_url: str | None = None,
201
+ ):
202
+ """
203
+ Initialize OIDC Auth client provider.
204
+
205
+ Args:
206
+ issuer_url: OIDC issuer URL (e.g., "https://keycloak.example.com/realms/myrealm")
207
+ client_id: Static OAuth client ID
208
+ client_secret: Static OAuth client secret (optional for public OIDC clients that don't require any such)
209
+ scopes: OAuth scopes to request (default: ["openid"]). Can be a
210
+ space-separated string or a list of strings.
211
+ token_storage_cache_dir: Directory for token storage cache (default: ~/.mcp/authful_mcp_proxy/tokens/)
212
+ redirect_url: Localhost URL for OAuth redirect (default: http://localhost:8080/auth/callback)
213
+ """
214
+ # Validate required parameters
215
+ if not issuer_url:
216
+ raise ValueError("Missing required issuer URL")
217
+ if not client_id:
218
+ raise ValueError("Missing required client id")
219
+
220
+ # Parse and validate scopes
221
+ if isinstance(scopes, list):
222
+ scopes_list = scopes
223
+ elif scopes is not None:
224
+ scopes_list = scopes.split()
225
+ else:
226
+ scopes_list = ["openid"]
227
+
228
+ # Ensure openid scope is always included
229
+ if "openid" not in scopes_list:
230
+ scopes_list.insert(0, "openid")
231
+
232
+ # Setup redirect port and redirect URI
233
+ redirect_uri = redirect_url or "http://localhost:8080/auth/callback"
234
+
235
+ # Initialize token storage using DiskStore and TokenStorageAdapter
236
+ # DiskStore provides persistent disk-based storage for OAuth tokens
237
+ # Use a default cache directory if none is provided
238
+ cache_dir = (
239
+ token_storage_cache_dir
240
+ or Path.home() / ".mcp" / "authful_mcp_proxy" / "tokens"
241
+ )
242
+ disk_store = DiskStore(directory=cache_dir)
243
+ storage = TokenStorageAdapter(async_key_value=disk_store, server_url=issuer_url)
244
+
245
+ # Fetch OIDC configuration
246
+ config_url = f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
247
+ oidc_config = OIDCConfiguration.get_oidc_configuration(
248
+ AnyHttpUrl(config_url),
249
+ strict=True,
250
+ timeout_seconds=HTTPX_REQUEST_TIMEOUT_SECONDS,
251
+ )
252
+
253
+ # Validate required endpoints
254
+ if not oidc_config.authorization_endpoint:
255
+ raise ValueError("OIDC configuration missing authorization_endpoint")
256
+ if not oidc_config.token_endpoint:
257
+ raise ValueError("OIDC configuration missing token_endpoint")
258
+
259
+ # Create context with all configuration and state
260
+ self.context = OIDCContext(
261
+ issuer_url=issuer_url,
262
+ client_id=client_id,
263
+ client_secret=client_secret,
264
+ scopes=scopes_list,
265
+ redirect_uri=redirect_uri,
266
+ oidc_config=oidc_config,
267
+ storage=storage,
268
+ )
269
+
270
+ self._initialized = False
271
+
272
+ async def _initialize(self) -> None:
273
+ """Load stored tokens if available."""
274
+ if self._initialized:
275
+ return
276
+
277
+ self.context.set_tokens(await self.context.storage.get_tokens())
278
+ self._initialized = True
279
+ logger.debug("OIDC Auth client initialized")
280
+
281
+ async def _run_callback_server(self) -> tuple[str, str]:
282
+ """Handle OAuth callback and return (auth_code, state)."""
283
+ # Create result container and event for async coordination
284
+ result_container = OAuthCallbackResult()
285
+ result_ready = anyio.Event()
286
+
287
+ # Create server with result container and event
288
+ server: Server = create_oauth_callback_server(
289
+ port=self.context.get_redirect_port(),
290
+ server_url=self.context.issuer_url,
291
+ result_container=result_container,
292
+ result_ready=result_ready,
293
+ )
294
+
295
+ # Run server until response is received with timeout logic
296
+ async with anyio.create_task_group() as tg:
297
+ tg.start_soon(server.serve)
298
+ logger.info(
299
+ f"🎧 OIDC Auth callback server started on {self.context.redirect_uri}"
300
+ )
301
+
302
+ try:
303
+ with anyio.fail_after(BROWSER_LOGIN_TIMEOUT_SECONDS):
304
+ await result_ready.wait()
305
+
306
+ # Check for errors
307
+ if result_container.error:
308
+ raise result_container.error
309
+
310
+ # Validate that we received code and state
311
+ if not result_container.code or not result_container.state:
312
+ raise RuntimeError(
313
+ "OAuth callback did not return code or state"
314
+ )
315
+
316
+ # Return code and state
317
+ return result_container.code, result_container.state
318
+ except TimeoutError:
319
+ raise TimeoutError(
320
+ f"OIDC Auth callback timed out after {BROWSER_LOGIN_TIMEOUT_SECONDS} seconds"
321
+ )
322
+ finally:
323
+ server.should_exit = True
324
+ await asyncio.sleep(0.1) # Allow server to shut down gracefully
325
+ tg.cancel_scope.cancel()
326
+
327
+ raise RuntimeError("OIDC Auth callback handler could not be started")
328
+
329
+ async def _perform_auth_flow(self) -> OAuthToken:
330
+ """Perform the OAuth authorization code flow with PKCE."""
331
+ async with self.context.lock:
332
+ # Generate PKCE parameters and state
333
+ pkce = PKCEParameters.generate()
334
+ state = secrets.token_urlsafe(32)
335
+
336
+ # Build authorization URL using context method
337
+ authorization_url = self.context.get_authorization_url(state, pkce)
338
+
339
+ # Open browser for authorization
340
+ logger.info(f"Opening browser for OIDC authorization: {authorization_url}")
341
+ webbrowser.open(authorization_url)
342
+
343
+ # Wait for callback
344
+ auth_code, returned_state = await self._run_callback_server()
345
+
346
+ # Validate state
347
+ if returned_state is None or not secrets.compare_digest(
348
+ returned_state, state
349
+ ):
350
+ raise RuntimeError(
351
+ f"OAuth state mismatch: {returned_state} != {state} - possible CSRF attack"
352
+ )
353
+
354
+ # Validate auth code
355
+ if not auth_code:
356
+ raise RuntimeError("No authorization code received")
357
+
358
+ # Build token data using context method
359
+ token_data = self.context.get_token_exchange_data(auth_code, pkce)
360
+
361
+ # Exchange authorization code for tokens
362
+ async with httpx.AsyncClient() as client:
363
+ response = await client.post(
364
+ str(self.context.oidc_config.token_endpoint),
365
+ data=token_data,
366
+ timeout=float(HTTPX_REQUEST_TIMEOUT_SECONDS),
367
+ )
368
+ response.raise_for_status()
369
+ token_response = response.json()
370
+
371
+ # Parse and store tokens
372
+ tokens = OAuthToken.model_validate(token_response)
373
+ await self.context.storage.set_tokens(tokens)
374
+ self.context.set_tokens(tokens)
375
+
376
+ logger.info("OIDC Auth flow completed successfully")
377
+ return tokens
378
+
379
+ async def _refresh_tokens(self) -> OAuthToken:
380
+ """Refresh access token using refresh token."""
381
+ async with self.context.lock:
382
+ if not self.context.can_refresh_token():
383
+ raise RuntimeError("No refresh token available")
384
+
385
+ token_data = self.context.get_token_refresh_data()
386
+
387
+ async with httpx.AsyncClient() as client:
388
+ response = await client.post(
389
+ str(self.context.oidc_config.token_endpoint),
390
+ data=token_data,
391
+ timeout=float(HTTPX_REQUEST_TIMEOUT_SECONDS),
392
+ )
393
+ response.raise_for_status()
394
+ token_response = response.json()
395
+
396
+ # Parse and store new tokens
397
+ tokens = OAuthToken.model_validate(token_response)
398
+ await self.context.storage.set_tokens(tokens)
399
+ self.context.set_tokens(tokens)
400
+
401
+ logger.debug("OIDC Auth tokens refreshed")
402
+ return tokens
403
+
404
+ async def _get_token(self) -> str:
405
+ """
406
+ Get a valid access token, renewing it if necessary.
407
+
408
+ Returns:
409
+ A valid access token, either from cache or after renewal.
410
+ """
411
+ await self._initialize()
412
+
413
+ # If token is valid, return it
414
+ if self.context.is_token_valid():
415
+ return self.context.get_access_token()
416
+
417
+ # Token expired or missing - refresh or re-auth
418
+ return await self._renew_token()
419
+
420
+ async def _renew_token(self) -> str:
421
+ """Handle authentication errors by refreshing or re-authenticating."""
422
+ if self.context.can_refresh_token():
423
+ try:
424
+ await self._refresh_tokens()
425
+ logger.debug("Token refreshed successfully")
426
+ except Exception as e:
427
+ logger.warning(f"Token refresh failed: {e}, performing full auth flow")
428
+ await self._perform_auth_flow()
429
+ else:
430
+ logger.debug("No refresh token available, performing full auth flow")
431
+ await self._perform_auth_flow()
432
+
433
+ return self.context.get_access_token()
434
+
435
+ async def async_auth_flow(
436
+ self, request: httpx.Request
437
+ ) -> AsyncGenerator[httpx.Request, httpx.Response]:
438
+ """
439
+ HTTPX auth flow implementation.
440
+
441
+ This method is compatible with httpx.Auth interface and automatically
442
+ adds the Bearer token to requests.
443
+ """
444
+ # Get current access token or a new one if it has expired
445
+ access_token = await self._get_token()
446
+
447
+ # Add authorization header
448
+ request.headers["Authorization"] = f"Bearer {access_token}"
449
+
450
+ # Yield request and handle response
451
+ response = yield request
452
+
453
+ # If we get 401, handle auth error and retry
454
+ if response.status_code == 401:
455
+ logger.debug("Received 401, attempting token refresh")
456
+ try:
457
+ # Token invalid or missing - refresh or re-auth
458
+ access_token = await self._renew_token()
459
+
460
+ # Update request with new token
461
+ request.headers["Authorization"] = f"Bearer {access_token}"
462
+
463
+ # Retry request
464
+ response = yield request
465
+ except Exception as e:
466
+ logger.error(f"Token refresh and retry failed: {e}")
467
+ # Return original 401 response
468
+ pass