Skip to content

Commit 7018afc

Browse files
dhruvkej9Ubuntu
authored andcommitted
fix: restore OAuth token expiry across process restarts
1 parent a4f4ccd commit 7018afc

4 files changed

Lines changed: 45 additions & 0 deletions

File tree

src/mcp/client/auth/extensions/client_credentials.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ async def _initialize(self) -> None:
7878
"""Load stored tokens and set pre-configured client_info."""
7979
self.context.current_tokens = await self.context.storage.get_tokens()
8080
self.context.client_info = self._fixed_client_info
81+
self.context.restore_token_expiry()
8182
self._initialized = True
8283

8384
async def _perform_authorization(self) -> httpx2.Request:
@@ -292,6 +293,7 @@ async def _initialize(self) -> None:
292293
"""Load stored tokens and set pre-configured client_info."""
293294
self.context.current_tokens = await self.context.storage.get_tokens()
294295
self.context.client_info = self._fixed_client_info
296+
self.context.restore_token_expiry()
295297
self._initialized = True
296298

297299
async def _perform_authorization(self) -> httpx2.Request:

src/mcp/client/auth/oauth2.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,19 @@ def update_token_expiry(self, token: OAuthToken) -> None:
180180
"""Update token expiry time using shared util function."""
181181
self.token_expiry_time = calculate_token_expiry(token.expires_in)
182182

183+
def restore_token_expiry(self) -> None:
184+
"""Restore ``token_expiry_time`` from the persisted absolute expiry.
185+
186+
``_initialize`` reloads ``current_tokens`` from storage, but the stored
187+
``OAuthToken`` only carries the relative ``expires_in``, so the absolute
188+
expiry must be persisted separately (``expires_at``) and restored here.
189+
Without it, ``is_token_valid()`` treats an already-expired access token
190+
as valid on a fresh process and sends a stale Bearer, wasting a 401
191+
round-trip before re-authentication.
192+
"""
193+
if self.current_tokens and self.current_tokens.expires_at is not None:
194+
self.token_expiry_time = self.current_tokens.expires_at
195+
183196
def is_token_valid(self) -> bool:
184197
"""Check if current token is valid."""
185198
return bool(
@@ -484,6 +497,8 @@ async def _handle_token_response(self, response: httpx2.Response) -> None:
484497
# Store tokens in context
485498
self.context.current_tokens = token_response
486499
self.context.update_token_expiry(token_response)
500+
# Persist the absolute expiry so it survives a process restart
501+
token_response.expires_at = self.context.token_expiry_time
487502
await self.context.storage.set_tokens(token_response)
488503

489504
async def _refresh_token(self) -> httpx2.Request:
@@ -539,6 +554,7 @@ async def _handle_refresh_response(self, response: httpx2.Response) -> bool:
539554

540555
self.context.current_tokens = token_response
541556
self.context.update_token_expiry(token_response)
557+
token_response.expires_at = self.context.token_expiry_time
542558
await self.context.storage.set_tokens(token_response)
543559

544560
return True
@@ -551,6 +567,7 @@ async def _initialize(self) -> None:
551567
"""Load stored tokens and client info."""
552568
self.context.current_tokens = await self.context.storage.get_tokens()
553569
self.context.client_info = await self.context.storage.get_client_info()
570+
self.context.restore_token_expiry()
554571
self._initialized = True
555572

556573
def _add_auth_header(self, request: httpx2.Request) -> None:

src/mcp/shared/auth.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class OAuthToken(BaseModel):
2929
access_token: str
3030
token_type: Literal["Bearer"] = "Bearer"
3131
expires_in: int | None = None
32+
expires_at: float | None = None
3233
scope: str | None = None
3334
refresh_token: str | None = None
3435

tests/client/auth/extensions/test_client_credentials.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import time
12
import urllib.parse
23

34
import jwt
@@ -94,6 +95,30 @@ async def test_init_with_client_secret_post(self, mock_storage: MockTokenStorage
9495
assert provider.context.client_info is not None
9596
assert provider.context.client_info.token_endpoint_auth_method == "client_secret_post"
9697

98+
@pytest.mark.anyio
99+
async def test_init_restores_expired_token_expiry(self, mock_storage: MockTokenStorage):
100+
"""_initialize must restore token_expiry_time from the persisted expires_at.
101+
102+
Regression for the stale-Bearer bug: without restoring the absolute
103+
expiry, an already-expired access token looks valid after a restart and
104+
a 401 round-trip is wasted before re-auth.
105+
"""
106+
mock_storage._tokens = OAuthToken(
107+
access_token="expired-token",
108+
expires_at=time.time() - 10, # already expired
109+
)
110+
provider = ClientCredentialsOAuthProvider(
111+
server_url="https://api.example.com",
112+
storage=mock_storage,
113+
client_id="test-client-id",
114+
client_secret="test-client-secret",
115+
)
116+
117+
await provider._initialize()
118+
119+
assert provider.context.token_expiry_time is not None
120+
assert not provider.context.is_token_valid()
121+
97122
@pytest.mark.anyio
98123
async def test_exchange_token_client_credentials(self, mock_storage: MockTokenStorage):
99124
"""Test token exchange request building."""

0 commit comments

Comments
 (0)