mcp-proxy-oauth-dcr 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.
@@ -0,0 +1,388 @@
1
+ """Authenticated HTTP client that integrates OAuth authentication with HTTP requests.
2
+
3
+ This module provides an HTTP client wrapper that:
4
+ - Automatically attaches Bearer tokens to all HTTP MCP requests
5
+ - Handles 401 Unauthorized responses with automatic token refresh
6
+ - Implements fallback to DCR when token refresh fails
7
+ - Provides transparent authentication for all HTTP operations
8
+ - Comprehensive error logging and diagnostics
9
+ """
10
+
11
+ import logging
12
+ from typing import Optional, AsyncIterator
13
+
14
+ from ..auth.manager import AuthenticationManagerImpl
15
+ from ..models import HttpMcpRequest, HttpMcpResponse
16
+ from ..exceptions import (
17
+ AuthenticationError,
18
+ TokenError,
19
+ HttpError,
20
+ )
21
+ from .client import HttpClientImpl, SSEEvent
22
+ from ..logging_config import get_logger
23
+
24
+ logger = get_logger(__name__)
25
+
26
+
27
+ class AuthenticatedHttpClient:
28
+ """HTTP client with integrated OAuth authentication.
29
+
30
+ This client wraps HttpClientImpl and AuthenticationManagerImpl to provide
31
+ transparent authentication for all HTTP requests. It automatically:
32
+ - Attaches valid Bearer tokens to requests
33
+ - Refreshes tokens when they expire
34
+ - Handles 401 responses by refreshing tokens and retrying
35
+ - Falls back to DCR if token refresh fails
36
+
37
+ Attributes:
38
+ http_client: Underlying HTTP client for making requests
39
+ auth_manager: Authentication manager for token management
40
+ """
41
+
42
+ def __init__(
43
+ self,
44
+ http_client: HttpClientImpl,
45
+ auth_manager: AuthenticationManagerImpl,
46
+ ):
47
+ """Initialize the authenticated HTTP client.
48
+
49
+ Args:
50
+ http_client: HTTP client instance for making requests
51
+ auth_manager: Authentication manager for token management
52
+ """
53
+ self._http_client = http_client
54
+ self._auth_manager = auth_manager
55
+ self._max_auth_retries = 2 # Maximum retries for 401 errors
56
+
57
+ logger.debug("AuthenticatedHttpClient initialized")
58
+
59
+ @property
60
+ def http_client(self) -> HttpClientImpl:
61
+ """Get the underlying HTTP client."""
62
+ return self._http_client
63
+
64
+ @property
65
+ def auth_manager(self) -> AuthenticationManagerImpl:
66
+ """Get the authentication manager."""
67
+ return self._auth_manager
68
+
69
+ @property
70
+ def session_id(self) -> Optional[str]:
71
+ """Get the current session ID from HTTP client."""
72
+ return self._http_client.session_id
73
+
74
+ @session_id.setter
75
+ def session_id(self, value: Optional[str]) -> None:
76
+ """Set the session ID on HTTP client."""
77
+ self._http_client.session_id = value
78
+
79
+ async def initialize(self) -> None:
80
+ """Initialize the authenticated client.
81
+
82
+ This method:
83
+ 1. Initializes the authentication manager (performs DCR if needed)
84
+ 2. Obtains an initial access token
85
+ 3. Sets the token on the HTTP client
86
+
87
+ Raises:
88
+ AuthenticationError: If initialization fails
89
+ """
90
+ logger.info("Initializing authenticated HTTP client")
91
+
92
+ # Initialize authentication manager (performs DCR and gets token)
93
+ await self._auth_manager.initialize()
94
+
95
+ # Set initial token on HTTP client
96
+ await self._update_http_client_token()
97
+
98
+ logger.info("Authenticated HTTP client initialized successfully")
99
+
100
+ async def _update_http_client_token(self) -> None:
101
+ """Update the HTTP client with the current access token.
102
+
103
+ Raises:
104
+ TokenError: If unable to obtain a valid token
105
+ """
106
+ token = await self._auth_manager.get_access_token()
107
+ self._http_client.set_auth_token(token)
108
+ logger.debug("Updated HTTP client with fresh access token")
109
+
110
+ async def send_request(self, request: HttpMcpRequest) -> HttpMcpResponse:
111
+ """Send an authenticated HTTP request with automatic token refresh.
112
+
113
+ This method:
114
+ 1. Ensures a valid token is attached to the request
115
+ 2. Sends the request via the HTTP client
116
+ 3. Handles 401 responses by refreshing the token and retrying
117
+ 4. Falls back to DCR if token refresh fails
118
+
119
+ Args:
120
+ request: HTTP MCP request to send
121
+
122
+ Returns:
123
+ HTTP MCP response
124
+
125
+ Raises:
126
+ AuthenticationError: If authentication fails after all retries
127
+ HttpError: If HTTP error occurs (non-401)
128
+ ConnectionError: If connection fails
129
+ """
130
+ # Ensure we have a valid token before sending
131
+ await self._update_http_client_token()
132
+
133
+ # Try sending the request with automatic 401 handling
134
+ for attempt in range(self._max_auth_retries + 1):
135
+ try:
136
+ response = await self._http_client.send_request(request)
137
+
138
+ # Check for 401 Unauthorized
139
+ if response.is_auth_error():
140
+ logger.warning(
141
+ "Received 401 Unauthorized response",
142
+ attempt=attempt + 1,
143
+ max_attempts=self._max_auth_retries + 1,
144
+ url=request.url,
145
+ method=request.method,
146
+ session_id=self._http_client.session_id,
147
+ response_body=response.body[:500] if response.body else None, # Log response for debugging
148
+ response_headers=response.headers,
149
+ )
150
+
151
+ # If this is not the last attempt, try to refresh and retry
152
+ if attempt < self._max_auth_retries:
153
+ await self._handle_auth_error(attempt)
154
+ continue
155
+ else:
156
+ # Last attempt failed, raise error with diagnostics
157
+ auth_diagnostics = self._auth_manager.get_diagnostic_info()
158
+ logger.error(
159
+ "Authentication failed after all retry attempts",
160
+ attempts=attempt + 1,
161
+ url=request.url,
162
+ method=request.method,
163
+ auth_diagnostics=auth_diagnostics,
164
+ )
165
+ raise AuthenticationError(
166
+ "Authentication failed after token refresh attempts",
167
+ details={
168
+ "status": 401,
169
+ "body": response.body,
170
+ "diagnostics": auth_diagnostics,
171
+ }
172
+ )
173
+
174
+ # Success - return response
175
+ logger.debug(
176
+ "Request completed successfully",
177
+ url=request.url,
178
+ method=request.method,
179
+ status=response.status,
180
+ )
181
+ return response
182
+
183
+ except HttpError as e:
184
+ # If it's a 401 error from the HTTP client, handle it
185
+ if e.status_code == 401 and attempt < self._max_auth_retries:
186
+ logger.warning(
187
+ "HTTP client raised 401 error",
188
+ attempt=attempt + 1,
189
+ max_attempts=self._max_auth_retries + 1,
190
+ url=request.url,
191
+ method=request.method,
192
+ )
193
+ await self._handle_auth_error(attempt)
194
+ continue
195
+ else:
196
+ # Not a 401 or last attempt, re-raise
197
+ logger.error(
198
+ "HTTP error during request",
199
+ status_code=e.status_code,
200
+ error=str(e),
201
+ url=request.url,
202
+ method=request.method,
203
+ )
204
+ raise
205
+
206
+ # Should not reach here, but just in case
207
+ raise AuthenticationError("Authentication failed with unknown error")
208
+
209
+ async def _handle_auth_error(self, attempt: int) -> None:
210
+ """Handle 401 authentication error by refreshing token.
211
+
212
+ This method implements the authentication recovery strategy:
213
+ 1. First attempt: Try to refresh the token
214
+ 2. Second attempt: Fall back to DCR if refresh fails
215
+
216
+ Args:
217
+ attempt: Current attempt number (0-indexed)
218
+
219
+ Raises:
220
+ AuthenticationError: If unable to recover from auth error
221
+ """
222
+ try:
223
+ if attempt == 0:
224
+ # First 401: Try token refresh
225
+ logger.info(
226
+ "Attempting token refresh after 401 response",
227
+ attempt=attempt + 1,
228
+ )
229
+ new_token = await self._auth_manager.refresh_token()
230
+ self._http_client.set_auth_token(new_token)
231
+ logger.info("Token refreshed successfully, retrying request")
232
+ else:
233
+ # Second 401: Fall back to DCR
234
+ logger.warning(
235
+ "Token refresh failed to resolve 401, falling back to DCR",
236
+ attempt=attempt + 1,
237
+ )
238
+ await self._auth_manager.perform_dcr()
239
+ new_token = await self._auth_manager.refresh_token()
240
+ self._http_client.set_auth_token(new_token)
241
+ logger.info("DCR and token refresh completed, retrying request")
242
+
243
+ except TokenError as e:
244
+ logger.error(
245
+ "Failed to refresh token",
246
+ error=str(e),
247
+ error_type=type(e).__name__,
248
+ attempt=attempt + 1,
249
+ auth_diagnostics=self._auth_manager.get_diagnostic_info(),
250
+ )
251
+ # If first attempt, we'll try DCR on next iteration
252
+ # If second attempt, we'll fail on next iteration
253
+ if attempt >= 1:
254
+ raise AuthenticationError(
255
+ "Failed to recover from authentication error",
256
+ details={
257
+ "original_error": str(e),
258
+ "diagnostics": self._auth_manager.get_diagnostic_info(),
259
+ }
260
+ )
261
+ except Exception as e:
262
+ logger.error(
263
+ "Unexpected error during auth recovery",
264
+ error=str(e),
265
+ error_type=type(e).__name__,
266
+ attempt=attempt + 1,
267
+ exc_info=True,
268
+ )
269
+ raise AuthenticationError(
270
+ "Unexpected error during authentication recovery",
271
+ details={
272
+ "error": str(e),
273
+ "error_type": type(e).__name__,
274
+ }
275
+ )
276
+
277
+ async def post(
278
+ self,
279
+ path: str,
280
+ body: str,
281
+ headers: Optional[dict] = None,
282
+ ) -> HttpMcpResponse:
283
+ """Send an authenticated POST request.
284
+
285
+ Convenience method that wraps send_request for POST requests.
286
+
287
+ Args:
288
+ path: URL path (will be appended to base URL)
289
+ body: Request body (JSON string)
290
+ headers: Additional headers
291
+
292
+ Returns:
293
+ HTTP MCP response
294
+ """
295
+ request = HttpMcpRequest(
296
+ method="POST",
297
+ url=path,
298
+ headers=headers or {},
299
+ body=body,
300
+ session_id=self._http_client.session_id,
301
+ )
302
+ return await self.send_request(request)
303
+
304
+ async def get(
305
+ self,
306
+ path: str,
307
+ headers: Optional[dict] = None,
308
+ ) -> HttpMcpResponse:
309
+ """Send an authenticated GET request.
310
+
311
+ Convenience method that wraps send_request for GET requests.
312
+
313
+ Args:
314
+ path: URL path (will be appended to base URL)
315
+ headers: Additional headers
316
+
317
+ Returns:
318
+ HTTP MCP response
319
+ """
320
+ request = HttpMcpRequest(
321
+ method="GET",
322
+ url=path,
323
+ headers=headers or {},
324
+ session_id=self._http_client.session_id,
325
+ )
326
+ return await self.send_request(request)
327
+
328
+ async def open_sse_stream(
329
+ self,
330
+ path: str = "",
331
+ headers: Optional[dict] = None,
332
+ ) -> AsyncIterator[SSEEvent]:
333
+ """Open an authenticated Server-Sent Events stream.
334
+
335
+ This method ensures a valid token is attached before opening the stream.
336
+ Note: SSE streams are long-lived, so token expiration during the stream
337
+ is not automatically handled. The caller should handle reconnection if needed.
338
+
339
+ Args:
340
+ path: URL path for SSE endpoint (default: base URL)
341
+ headers: Additional headers
342
+
343
+ Yields:
344
+ SSEEvent objects as they arrive from the server
345
+
346
+ Raises:
347
+ AuthenticationError: If authentication fails
348
+ ConnectionError: If connection fails
349
+ StreamError: If stream encounters an error
350
+ """
351
+ # Ensure we have a valid token before opening stream
352
+ await self._update_http_client_token()
353
+
354
+ # Open the SSE stream (delegates to HTTP client)
355
+ async for event in self._http_client.open_sse_stream(path, headers):
356
+ yield event
357
+
358
+ async def close(self) -> None:
359
+ """Close the authenticated client and release resources.
360
+
361
+ This closes both the HTTP client and authentication manager.
362
+ """
363
+ logger.info("Closing authenticated HTTP client")
364
+
365
+ # Close authentication manager first (stops background tasks)
366
+ await self._auth_manager.close()
367
+
368
+ # Close HTTP client (releases connections)
369
+ await self._http_client.close()
370
+
371
+ logger.info("Authenticated HTTP client closed")
372
+
373
+ async def __aenter__(self) -> "AuthenticatedHttpClient":
374
+ """Async context manager entry."""
375
+ await self.initialize()
376
+ return self
377
+
378
+ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None:
379
+ """Async context manager exit."""
380
+ await self.close()
381
+
382
+ def __repr__(self) -> str:
383
+ """String representation of the authenticated client."""
384
+ return (
385
+ f"AuthenticatedHttpClient("
386
+ f"authenticated={self._auth_manager.is_token_valid()}, "
387
+ f"session_id={self.session_id})"
388
+ )