Skip to content

Commit 527864f

Browse files
committed
Treat an access token as expired at its expiry time
1 parent a525a3d commit 527864f

4 files changed

Lines changed: 23 additions & 2 deletions

File tree

‎src/mcp/client/auth/extensions/identity_assertion.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Req
167167
self._expiry = calculate_token_expiry(self._tokens.expires_in) if self._tokens else None
168168
self._initialized = True
169169

170-
if self._tokens and (self._expiry is None or time.time() <= self._expiry):
170+
if self._tokens and (self._expiry is None or time.time() < self._expiry):
171171
request.headers["Authorization"] = f"Bearer {self._tokens.access_token}"
172172
response = yield request
173173

‎src/mcp/client/auth/oauth2.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ def is_token_valid(self) -> bool:
188188
return bool(
189189
self.current_tokens
190190
and self.current_tokens.access_token
191-
and (not self.token_expiry_time or time.time() <= self.token_expiry_time)
191+
and (not self.token_expiry_time or time.time() < self.token_expiry_time)
192192
)
193193

194194
def can_refresh_token(self) -> bool:

‎tests/client/auth/extensions/test_identity_assertion.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import base64
99
import json
1010
import urllib.parse
11+
from unittest import mock
1112

1213
import httpx2
1314
import pytest
@@ -324,6 +325,21 @@ async def test_stored_token_is_reused_without_reauthorizing() -> None:
324325
assert requests[0].headers["Authorization"] == "Bearer cached"
325326

326327

328+
@pytest.mark.anyio
329+
async def test_stored_token_is_not_sent_at_its_expiry_instant() -> None:
330+
"""A stored token whose expiry equals the current time is already expired, so it is not attached."""
331+
requests: list[httpx2.Request] = []
332+
storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=0))
333+
auth = make_provider(storage)
334+
335+
transport = mock_transport(requests, rs_first_status=200)
336+
with mock.patch("time.time", return_value=1_800_000_000.0):
337+
async with httpx2.AsyncClient(transport=transport, auth=auth) as http:
338+
await http.post(f"{RS}/mcp")
339+
340+
assert "Authorization" not in requests[0].headers
341+
342+
327343
@pytest.mark.anyio
328344
async def test_second_401_re_exchanges_without_refetching_asm() -> None:
329345
"""ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint."""

‎tests/client/test_auth.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,11 @@ async def test_token_validity_checking(self, oauth_provider: OAuthClientProvider
244244
assert not context.is_token_valid()
245245
assert context.can_refresh_token() # Can still refresh
246246

247+
# At the expiry instant itself the token is already expired (RFC 7519 §4.1.4)
248+
with mock.patch("time.time", return_value=1_800_000_000.0):
249+
context.token_expiry_time = 1_800_000_000.0
250+
assert not context.is_token_valid()
251+
247252
# Remove refresh token
248253
context.current_tokens.refresh_token = None
249254
assert not context.can_refresh_token()

0 commit comments

Comments
 (0)