From 527864f802397c29cdfc2ddcaa0770e6b513a530 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Oct 2026 11:34:40 +0000 Subject: [PATCH 1/2] Treat an access token as expired at its expiry time --- .../client/auth/extensions/identity_assertion.py | 2 +- src/mcp/client/auth/oauth2.py | 2 +- .../auth/extensions/test_identity_assertion.py | 16 ++++++++++++++++ tests/client/test_auth.py | 5 +++++ 4 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/auth/extensions/identity_assertion.py b/src/mcp/client/auth/extensions/identity_assertion.py index 5e715cb83a..4a659a35c9 100644 --- a/src/mcp/client/auth/extensions/identity_assertion.py +++ b/src/mcp/client/auth/extensions/identity_assertion.py @@ -167,7 +167,7 @@ async def _auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx2.Req self._expiry = calculate_token_expiry(self._tokens.expires_in) if self._tokens else None self._initialized = True - if self._tokens and (self._expiry is None or time.time() <= self._expiry): + if self._tokens and (self._expiry is None or time.time() < self._expiry): request.headers["Authorization"] = f"Bearer {self._tokens.access_token}" response = yield request diff --git a/src/mcp/client/auth/oauth2.py b/src/mcp/client/auth/oauth2.py index c513a99b46..f94a2f90c6 100644 --- a/src/mcp/client/auth/oauth2.py +++ b/src/mcp/client/auth/oauth2.py @@ -188,7 +188,7 @@ def is_token_valid(self) -> bool: return bool( self.current_tokens and self.current_tokens.access_token - and (not self.token_expiry_time or time.time() <= self.token_expiry_time) + and (not self.token_expiry_time or time.time() < self.token_expiry_time) ) def can_refresh_token(self) -> bool: diff --git a/tests/client/auth/extensions/test_identity_assertion.py b/tests/client/auth/extensions/test_identity_assertion.py index f4f9b6080c..8561f7ea5f 100644 --- a/tests/client/auth/extensions/test_identity_assertion.py +++ b/tests/client/auth/extensions/test_identity_assertion.py @@ -8,6 +8,7 @@ import base64 import json import urllib.parse +from unittest import mock import httpx2 import pytest @@ -324,6 +325,21 @@ async def test_stored_token_is_reused_without_reauthorizing() -> None: assert requests[0].headers["Authorization"] == "Bearer cached" +@pytest.mark.anyio +async def test_stored_token_is_not_sent_at_its_expiry_instant() -> None: + """A stored token whose expiry equals the current time is already expired, so it is not attached.""" + requests: list[httpx2.Request] = [] + storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=0)) + auth = make_provider(storage) + + transport = mock_transport(requests, rs_first_status=200) + with mock.patch("time.time", return_value=1_800_000_000.0): + async with httpx2.AsyncClient(transport=transport, auth=auth) as http: + await http.post(f"{RS}/mcp") + + assert "Authorization" not in requests[0].headers + + @pytest.mark.anyio async def test_second_401_re_exchanges_without_refetching_asm() -> None: """ASM is discovered once; a later 401 mints a fresh assertion against the cached token endpoint.""" diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index 18a1566705..af2a6482b9 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -244,6 +244,11 @@ async def test_token_validity_checking(self, oauth_provider: OAuthClientProvider assert not context.is_token_valid() assert context.can_refresh_token() # Can still refresh + # At the expiry instant itself the token is already expired (RFC 7519 §4.1.4) + with mock.patch("time.time", return_value=1_800_000_000.0): + context.token_expiry_time = 1_800_000_000.0 + assert not context.is_token_valid() + # Remove refresh token context.current_tokens.refresh_token = None assert not context.can_refresh_token() From 71b8a4e1c139e37787032bd45bbfcf977a44104d Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 25 Oct 2026 13:05:35 +0000 Subject: [PATCH 2/2] Move the expiry-instant check to a top-level test --- .../auth/extensions/test_identity_assertion.py | 2 +- tests/client/test_auth.py | 15 ++++++++++----- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/tests/client/auth/extensions/test_identity_assertion.py b/tests/client/auth/extensions/test_identity_assertion.py index 8561f7ea5f..34b8e04d6b 100644 --- a/tests/client/auth/extensions/test_identity_assertion.py +++ b/tests/client/auth/extensions/test_identity_assertion.py @@ -327,7 +327,7 @@ async def test_stored_token_is_reused_without_reauthorizing() -> None: @pytest.mark.anyio async def test_stored_token_is_not_sent_at_its_expiry_instant() -> None: - """A stored token whose expiry equals the current time is already expired, so it is not attached.""" + """SDK-defined: a token is expired at its `expires_in` deadline, like a JWT `exp` (RFC 7519 §4.1.4).""" requests: list[httpx2.Request] = [] storage = InMemoryStorage(tokens=OAuthToken(access_token="cached", token_type="Bearer", expires_in=0)) auth = make_provider(storage) diff --git a/tests/client/test_auth.py b/tests/client/test_auth.py index af2a6482b9..37ce7a0745 100644 --- a/tests/client/test_auth.py +++ b/tests/client/test_auth.py @@ -244,11 +244,6 @@ async def test_token_validity_checking(self, oauth_provider: OAuthClientProvider assert not context.is_token_valid() assert context.can_refresh_token() # Can still refresh - # At the expiry instant itself the token is already expired (RFC 7519 §4.1.4) - with mock.patch("time.time", return_value=1_800_000_000.0): - context.token_expiry_time = 1_800_000_000.0 - assert not context.is_token_valid() - # Remove refresh token context.current_tokens.refresh_token = None assert not context.can_refresh_token() @@ -3672,3 +3667,13 @@ async def echo_callback() -> AuthorizationCodeResult: await auth_flow.asend(httpx2.Response(200, request=final_req)) except StopAsyncIteration: pass + + +def test_a_token_is_invalid_at_its_expiry_instant(oauth_provider: OAuthClientProvider): + """SDK-defined: the deadline from `expires_in` is exclusive, like a JWT `exp` (RFC 7519 §4.1.4).""" + context = oauth_provider.context + with mock.patch("time.time", return_value=1_800_000_000.0): + token = OAuthToken(access_token="t", token_type="Bearer", expires_in=0) + context.current_tokens = token + context.update_token_expiry(token) + assert not context.is_token_valid()