authful-mcp-proxy 0.1.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.

Potentially problematic release.


This version of authful-mcp-proxy might be problematic. Click here for more details.

@@ -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,435 @@
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 asyncio import Future
26
+ from collections.abc import AsyncGenerator
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from typing import Any
30
+ from urllib.parse import urlencode, urlparse
31
+
32
+ import anyio
33
+ import httpx
34
+ from fastmcp.client.auth.oauth import FileTokenStorage
35
+ from fastmcp.client.oauth_callback import create_oauth_callback_server
36
+ from fastmcp.server.auth.oidc_proxy import OIDCConfiguration
37
+ from fastmcp.utilities.logging import get_logger
38
+ from mcp.client.auth import PKCEParameters
39
+ from mcp.shared.auth import OAuthToken
40
+ from pydantic import AnyHttpUrl
41
+ from uvicorn.server import Server
42
+
43
+ __all__ = ["ExternalOIDCAuth"]
44
+
45
+ logger = get_logger(__name__)
46
+
47
+ HTTPX_REQUEST_TIMEOUT_SECONDS = 5
48
+ BROWSER_LOGIN_TIMEOUT_SECONDS = 300
49
+
50
+
51
+ @dataclass
52
+ class OIDCContext:
53
+ """OIDC OAuth flow context - similar to OAuthContext but for external OIDC providers."""
54
+
55
+ issuer_url: str
56
+ client_id: str
57
+ client_secret: str | None
58
+ scopes: list[str]
59
+ redirect_uri: str
60
+ storage: FileTokenStorage
61
+
62
+ # Discovered metadata
63
+ oidc_config: OIDCConfiguration
64
+
65
+ # Token management
66
+ current_tokens: OAuthToken | None = None
67
+ token_expiry_time: float | None = None
68
+
69
+ # State
70
+ lock: anyio.Lock = field(default_factory=anyio.Lock)
71
+
72
+ def get_redirect_port(self) -> int:
73
+ """Extract the port number from the redirect URI."""
74
+ parsed = urlparse(self.redirect_uri)
75
+ return parsed.port or 80
76
+
77
+ def get_authorization_url(self, state: str, pkce: PKCEParameters) -> str:
78
+ """Build the authorization URL with PKCE parameters."""
79
+ auth_params = {
80
+ "response_type": "code",
81
+ "client_id": self.client_id,
82
+ "redirect_uri": self.redirect_uri,
83
+ "scope": " ".join(self.scopes),
84
+ "state": state,
85
+ "code_challenge": pkce.code_challenge,
86
+ "code_challenge_method": "S256",
87
+ }
88
+ return f"{self.oidc_config.authorization_endpoint}?{urlencode(auth_params)}"
89
+
90
+ def get_token_exchange_data(
91
+ self, auth_code: str, pkce: PKCEParameters
92
+ ) -> dict[str, str]:
93
+ """Build token exchange request data."""
94
+ token_data = {
95
+ "grant_type": "authorization_code",
96
+ "code": auth_code,
97
+ "redirect_uri": self.redirect_uri,
98
+ "client_id": self.client_id,
99
+ "code_verifier": pkce.code_verifier,
100
+ }
101
+ if self.client_secret:
102
+ token_data["client_secret"] = self.client_secret
103
+ return token_data
104
+
105
+ def get_token_refresh_data(self) -> dict[str, str]:
106
+ """Build token refresh request data."""
107
+ token_data = {
108
+ "grant_type": "refresh_token",
109
+ "refresh_token": self.current_tokens.refresh_token,
110
+ "client_id": self.client_id,
111
+ }
112
+ if self.client_secret:
113
+ token_data["client_secret"] = self.client_secret
114
+ return token_data
115
+
116
+ def update_token_expiry(self, token: OAuthToken) -> None:
117
+ """Update token expiry time."""
118
+ if token.expires_in:
119
+ self.token_expiry_time = time.time() + token.expires_in
120
+ else:
121
+ self.token_expiry_time = None
122
+
123
+ def is_token_valid(self) -> bool:
124
+ """Check if current token is valid and not expired."""
125
+ return bool(
126
+ self.current_tokens
127
+ and self.current_tokens.access_token
128
+ and (
129
+ not self.token_expiry_time or time.time() < self.token_expiry_time - 60
130
+ )
131
+ )
132
+
133
+ def can_refresh_token(self) -> bool:
134
+ """Check if token can be refreshed."""
135
+ return bool(self.current_tokens and self.current_tokens.refresh_token)
136
+
137
+ def clear_tokens(self) -> None:
138
+ """Clear current tokens."""
139
+ self.current_tokens = None
140
+ self.token_expiry_time = None
141
+
142
+
143
+ class ExternalOIDCAuth(httpx.Auth):
144
+ """
145
+ OAuth client provider that authenticates against external OIDC providers.
146
+
147
+ This client fetches OAuth configuration from an external OIDC provider's
148
+ /.well-known/openid-configuration endpoint and uses static client credentials
149
+ (client_id and client_secret) instead of dynamic client registration.
150
+
151
+ Key differences from standard OAuth client:
152
+ - Fetches config from issuer's /.well-known/openid-configuration (not MCP server)
153
+ - Uses static client_id/client_secret (no dynamic registration)
154
+ - Works with any OIDC-compliant provider (Keycloak, Auth0, Okta, etc.)
155
+
156
+ Example:
157
+ ```python
158
+ from fastmcp.client import Client
159
+ from fastmcp.client.auth import OIDCAuth
160
+
161
+ auth = OIDCAuth(
162
+ issuer_url="https://your-keycloak.example.com/realms/myrealm",
163
+ client_id="your-client-id",
164
+ client_secret="your-client-secret",
165
+ scopes=["openid", "profile", "email"],
166
+ redirect_url="http://localhost:8080/auth/callback"
167
+ )
168
+
169
+ async with Client("http://localhost:8000/mcp", auth=auth) as client:
170
+ # Use authenticated client
171
+ result = await client.call_tool("my_tool", {"arg": "value"})
172
+ ```
173
+ """
174
+
175
+ def __init__(
176
+ self,
177
+ issuer_url: str,
178
+ client_id: str,
179
+ client_secret: str | None = None,
180
+ scopes: str | list[str] | None = None,
181
+ token_storage_cache_dir: Path | None = None,
182
+ redirect_url: str | None = None,
183
+ ):
184
+ """
185
+ Initialize OIDC Auth client provider.
186
+
187
+ Args:
188
+ issuer_url: OIDC issuer URL (e.g., "https://keycloak.example.com/realms/myrealm")
189
+ client_id: Static OAuth client ID
190
+ client_secret: Static OAuth client secret (optional for public OIDC clients that don't require any such)
191
+ scopes: OAuth scopes to request (default: ["openid"]). Can be a
192
+ space-separated string or a list of strings.
193
+ token_storage_cache_dir: Directory for token storage (cache)
194
+ redirect_url: Localhost URL for OAuth redirect (default: http://localhost:8080/auth/callback)
195
+ """
196
+ # Validate required parameters
197
+ if not issuer_url:
198
+ raise ValueError("Missing required issuer URL")
199
+ if not client_id:
200
+ raise ValueError("Missing required client id")
201
+
202
+ # Parse and validate scopes
203
+ if isinstance(scopes, list):
204
+ scopes_list = scopes
205
+ elif scopes is not None:
206
+ scopes_list = scopes.split()
207
+ else:
208
+ scopes_list = ["openid"]
209
+
210
+ # Ensure openid scope is always included
211
+ if "openid" not in scopes_list:
212
+ scopes_list.insert(0, "openid")
213
+
214
+ # Setup redirect port and redirect URI
215
+ redirect_uri = redirect_url or "http://localhost:8080/auth/callback"
216
+
217
+ # Initialize token storage - reuse FileTokenStorage
218
+ storage = FileTokenStorage(
219
+ server_url=issuer_url, cache_dir=token_storage_cache_dir
220
+ )
221
+
222
+ # Fetch OIDC configuration
223
+ config_url = f"{issuer_url.rstrip('/')}/.well-known/openid-configuration"
224
+ oidc_config = OIDCConfiguration.get_oidc_configuration(
225
+ AnyHttpUrl(config_url),
226
+ strict=True,
227
+ timeout_seconds=HTTPX_REQUEST_TIMEOUT_SECONDS,
228
+ )
229
+
230
+ # Validate required endpoints
231
+ if not oidc_config.authorization_endpoint:
232
+ raise ValueError("OIDC configuration missing authorization_endpoint")
233
+ if not oidc_config.token_endpoint:
234
+ raise ValueError("OIDC configuration missing token_endpoint")
235
+
236
+ # Create context with all configuration and state
237
+ self.context = OIDCContext(
238
+ issuer_url=issuer_url,
239
+ client_id=client_id,
240
+ client_secret=client_secret,
241
+ scopes=scopes_list,
242
+ redirect_uri=redirect_uri,
243
+ oidc_config=oidc_config,
244
+ storage=storage,
245
+ )
246
+
247
+ self._initialized = False
248
+
249
+ async def _initialize(self) -> None:
250
+ """Load stored tokens if available."""
251
+ if self._initialized:
252
+ return
253
+
254
+ self.context.current_tokens = await self.context.storage.get_tokens()
255
+ if self.context.current_tokens:
256
+ self.context.update_token_expiry(self.context.current_tokens)
257
+ self._initialized = True
258
+ logger.debug("OIDC Auth client initialized")
259
+
260
+ async def _run_callback_server(self) -> tuple[str, str]:
261
+ """Handle OAuth callback and return (auth_code, state)."""
262
+ # Create a future to capture the OAuth response
263
+ response_future: Future[Any] = asyncio.get_running_loop().create_future()
264
+
265
+ # Create server with the future
266
+ server: Server = create_oauth_callback_server(
267
+ port=self.context.get_redirect_port(),
268
+ server_url=self.context.issuer_url,
269
+ response_future=response_future,
270
+ )
271
+
272
+ # Run server until response is received with timeout logic
273
+ async with anyio.create_task_group() as tg:
274
+ tg.start_soon(server.serve)
275
+ logger.info(
276
+ f"🎧 OIDC Auth callback server started on {self.context.redirect_uri}"
277
+ )
278
+
279
+ try:
280
+ with anyio.fail_after(BROWSER_LOGIN_TIMEOUT_SECONDS):
281
+ auth_code, state = await response_future
282
+ return auth_code, state
283
+ except TimeoutError:
284
+ raise TimeoutError(
285
+ f"OIDC Auth callback timed out after {BROWSER_LOGIN_TIMEOUT_SECONDS} seconds"
286
+ )
287
+ finally:
288
+ server.should_exit = True
289
+ await asyncio.sleep(0.1) # Allow server to shut down gracefully
290
+ tg.cancel_scope.cancel()
291
+
292
+ raise RuntimeError("OIDC Auth callback handler could not be started")
293
+
294
+ async def _perform_auth_flow(self) -> OAuthToken:
295
+ """Perform the OAuth authorization code flow with PKCE."""
296
+ async with self.context.lock:
297
+ # Generate PKCE parameters and state
298
+ pkce = PKCEParameters.generate()
299
+ state = secrets.token_urlsafe(32)
300
+
301
+ # Build authorization URL using context method
302
+ authorization_url = self.context.get_authorization_url(state, pkce)
303
+
304
+ # Open browser for authorization
305
+ logger.info(f"Opening browser for OIDC authorization: {authorization_url}")
306
+ webbrowser.open(authorization_url)
307
+
308
+ # Wait for callback
309
+ auth_code, returned_state = await self._run_callback_server()
310
+
311
+ # Validate state
312
+ if returned_state is None or not secrets.compare_digest(
313
+ returned_state, state
314
+ ):
315
+ raise RuntimeError(
316
+ f"OAuth state mismatch: {returned_state} != {state} - possible CSRF attack"
317
+ )
318
+
319
+ # Validate auth code
320
+ if not auth_code:
321
+ raise RuntimeError("No authorization code received")
322
+
323
+ # Build token data using context method
324
+ token_data = self.context.get_token_exchange_data(auth_code, pkce)
325
+
326
+ # Exchange authorization code for tokens
327
+ async with httpx.AsyncClient() as client:
328
+ response = await client.post(
329
+ str(self.context.oidc_config.token_endpoint),
330
+ data=token_data,
331
+ timeout=float(HTTPX_REQUEST_TIMEOUT_SECONDS),
332
+ )
333
+ response.raise_for_status()
334
+ token_response = response.json()
335
+
336
+ # Parse and store tokens
337
+ tokens = OAuthToken.model_validate(token_response)
338
+ await self.context.storage.set_tokens(tokens)
339
+ self.context.current_tokens = tokens
340
+ self.context.update_token_expiry(tokens)
341
+
342
+ logger.info("OIDC Auth flow completed successfully")
343
+ return tokens
344
+
345
+ async def _refresh_tokens(self) -> OAuthToken:
346
+ """Refresh access token using refresh token."""
347
+ async with self.context.lock:
348
+ if not self.context.can_refresh_token():
349
+ raise RuntimeError("No refresh token available")
350
+
351
+ token_data = self.context.get_token_refresh_data()
352
+
353
+ async with httpx.AsyncClient() as client:
354
+ response = await client.post(
355
+ str(self.context.oidc_config.token_endpoint),
356
+ data=token_data,
357
+ timeout=float(HTTPX_REQUEST_TIMEOUT_SECONDS),
358
+ )
359
+ response.raise_for_status()
360
+ token_response = response.json()
361
+
362
+ # Parse and store new tokens
363
+ tokens = OAuthToken.model_validate(token_response)
364
+ await self.context.storage.set_tokens(tokens)
365
+ self.context.current_tokens = tokens
366
+ self.context.update_token_expiry(tokens)
367
+
368
+ logger.debug("OIDC Auth tokens refreshed")
369
+ return tokens
370
+
371
+ async def _get_token(self) -> str:
372
+ """
373
+ Get a valid access token, renewing it if necessary.
374
+
375
+ Returns:
376
+ A valid access token, either from cache or after renewal.
377
+ """
378
+ await self._initialize()
379
+
380
+ # If token is valid, return it
381
+ if self.context.is_token_valid():
382
+ return self.context.current_tokens.access_token
383
+
384
+ # Token expired or missing - refresh or re-auth
385
+ return await self._renew_token()
386
+
387
+ async def _renew_token(self) -> str:
388
+ """Handle authentication errors by refreshing or re-authenticating."""
389
+ if self.context.can_refresh_token():
390
+ try:
391
+ await self._refresh_tokens()
392
+ logger.debug("Token refreshed successfully")
393
+ except Exception as e:
394
+ logger.warning(f"Token refresh failed: {e}, performing full auth flow")
395
+ await self._perform_auth_flow()
396
+ else:
397
+ logger.debug("No refresh token available, performing full auth flow")
398
+ await self._perform_auth_flow()
399
+
400
+ return self.context.current_tokens.access_token
401
+
402
+ async def async_auth_flow(
403
+ self, request: httpx.Request
404
+ ) -> AsyncGenerator[httpx.Request, httpx.Response]:
405
+ """
406
+ HTTPX auth flow implementation.
407
+
408
+ This method is compatible with httpx.Auth interface and automatically
409
+ adds the Bearer token to requests.
410
+ """
411
+ # Get current access token or a new one if it has expired
412
+ access_token = await self._get_token()
413
+
414
+ # Add authorization header
415
+ request.headers["Authorization"] = f"Bearer {access_token}"
416
+
417
+ # Yield request and handle response
418
+ response = yield request
419
+
420
+ # If we get 401, handle auth error and retry
421
+ if response.status_code == 401:
422
+ logger.debug("Received 401, attempting token refresh")
423
+ try:
424
+ # Token invalid or missing - refresh or re-auth
425
+ access_token = await self._renew_token()
426
+
427
+ # Update request with new token
428
+ request.headers["Authorization"] = f"Bearer {access_token}"
429
+
430
+ # Retry request
431
+ response = yield request
432
+ except Exception as e:
433
+ logger.error(f"Token refresh and retry failed: {e}")
434
+ # Return original 401 response
435
+ pass