Skip to content

Commit e53e526

Browse files
committed
fix(client/auth): drop the orphaned refresh token on expiry discard; centralize the expiry predicate
A refresh token is bound to the client it was issued to (RFC 6749 §6), so once the expiry discard replaces the registration, the kept refresh token could only be presented as the wrong client and fail invalid_grant — which happened whenever a flow failed between re-registration and token exchange. Both discard sites (401 pre-Step-4 and 403 step-up) now drop the refresh token with the record and persist the trimmed tokens, so an interrupted flow or a restart cannot resurrect the orphan; the live access token is a bearer credential and is kept. The null-guarded expiry check, previously inlined verbatim at three flow sites, moves into OAuthContext.registration_secret_expired() beside its sibling predicates, and the discard mechanics into OAuthContext.discard_expired_registration(), giving the rule a single home.
1 parent 5a246e6 commit e53e526

3 files changed

Lines changed: 158 additions & 16 deletions

File tree

docs/client/oauth-clients.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ The in-memory version above works. It also forgets everything when the process e
5555
One exception: a stored registration whose dynamically issued secret has expired (a non-zero
5656
`client_secret_expires_at` in the past) is treated as absent — the expired secret could never
5757
authenticate again, so the provider discards the record and re-registers on the next flow,
58-
overwriting it in your storage with the fresh registration.
58+
overwriting it in your storage with the fresh registration. Any refresh token goes with the
59+
discarded record (it was issued to that `client_id` and no other client can redeem it), so the
60+
stored tokens are rewritten without it; a still-live access token is kept and keeps working.
5961

6062
### The two handlers
6163

src/mcp/client/auth/oauth2.py

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,25 @@ def can_refresh_token(self) -> bool:
208208
"""Check if token can be refreshed."""
209209
return bool(self.current_tokens and self.current_tokens.refresh_token and self.client_info)
210210

211+
def registration_secret_expired(self) -> bool:
212+
"""Whether the loaded registration's minted secret has lapsed (RFC 7591)."""
213+
return self.client_info is not None and stored_registration_expired(self.client_info)
214+
215+
async def discard_expired_registration(self) -> None:
216+
"""Discard a registration whose minted secret lapsed so the flow re-registers.
217+
218+
The refresh token goes with it: RFC 6749 §6 binds a refresh token to the client
219+
it was issued to, so once the flow re-registers under a fresh `client_id` the
220+
orphaned token could only fail `invalid_grant`. The trimmed tokens are persisted
221+
so an interrupted flow (or a restart) cannot resurrect the orphan. The live
222+
access token is a bearer credential that keeps working without client
223+
authentication, so it is kept.
224+
"""
225+
self.client_info = None
226+
if self.current_tokens is not None and self.current_tokens.refresh_token is not None:
227+
self.current_tokens.refresh_token = None
228+
await self.storage.set_tokens(self.current_tokens)
229+
211230
def clear_tokens(self) -> None:
212231
"""Clear current tokens."""
213232
self.current_tokens = None
@@ -673,9 +692,7 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
673692
# `invalid_client`. Skip the doomed refresh and fall through to the 401 flow,
674693
# which re-registers; the record itself is kept for now so the flow's SEP-2352
675694
# issuer checks can still read its issuer stamp before the expiry discard runs.
676-
registration_expired = self.context.client_info is not None and stored_registration_expired(
677-
self.context.client_info
678-
)
695+
registration_expired = self.context.registration_secret_expired()
679696

680697
if not self.context.is_token_valid() and self.context.can_refresh_token() and not registration_expired:
681698
# Try to refresh token
@@ -794,13 +811,14 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
794811
# token endpoint. Discard it only now, after the SEP-2352 issuer
795812
# checks above, so an expired record bound to a different issuer
796813
# still got its cross-issuer cleanup; Step 4 then re-registers,
797-
# overwriting the dead record in storage. Any stored tokens are kept:
798-
# a live access token keeps working without client authentication.
799-
if self.context.client_info is not None and stored_registration_expired(self.context.client_info):
814+
# overwriting the dead record in storage. The refresh token is
815+
# dropped with the record it was issued to; the live access token
816+
# is kept — it works without client authentication.
817+
if self.context.registration_secret_expired():
800818
logger.debug(
801819
"Stored client registration secret has expired; discarding so this flow re-registers"
802820
)
803-
self.context.client_info = None
821+
await self.context.discard_expired_registration()
804822

805823
# Step 4: Register client or use URL-based client ID (CIMD)
806824
if not self.context.client_info:
@@ -847,13 +865,11 @@ async def async_auth_flow(self, request: httpx2.Request) -> AsyncGenerator[httpx
847865
# 401 flow's discard from ever running. Discard it and mint fresh
848866
# credentials first (mirroring the 401 flow's Step 4, reusing any
849867
# AS metadata already discovered).
850-
if self.context.client_info is not None and stored_registration_expired(
851-
self.context.client_info
852-
):
868+
if self.context.registration_secret_expired():
853869
logger.debug(
854870
"Stored client registration secret has expired; re-registering before the step-up"
855871
)
856-
self.context.client_info = None
872+
await self.context.discard_expired_registration()
857873
if not self.context.client_info:
858874
registration_request = await self._prepare_client_registration()
859875
if registration_request is not None:

tests/client/test_auth.py

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3302,6 +3302,24 @@ def test_stored_registration_expired_only_for_lapsed_secret_backed_registrations
33023302
)
33033303

33043304

3305+
def test_registration_secret_expired_is_false_until_a_lapsed_registration_is_loaded(
3306+
oauth_provider: OAuthClientProvider,
3307+
):
3308+
"""`OAuthContext.registration_secret_expired` owns the null check for the flow's call
3309+
sites (refresh gate, 401 discard, 403 step-up): no registration loaded means "not
3310+
expired", and a loaded record defers to `stored_registration_expired`.
3311+
"""
3312+
assert not oauth_provider.context.registration_secret_expired()
3313+
oauth_provider.context.client_info = OAuthClientInformationFull(
3314+
client_id="dead-client",
3315+
client_secret="expired-secret",
3316+
client_secret_expires_at=int(time.time()) - 3600,
3317+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
3318+
token_endpoint_auth_method="client_secret_post",
3319+
)
3320+
assert oauth_provider.context.registration_secret_expired()
3321+
3322+
33053323
@pytest.mark.anyio
33063324
async def test_expired_stored_registration_is_discarded_and_the_flow_re_registers(
33073325
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken
@@ -3311,8 +3329,9 @@ async def test_expired_stored_registration_is_discarded_and_the_flow_re_register
33113329
Reusing it makes every token-endpoint interaction fail with ``invalid_client`` — even a
33123330
fresh interactive authorization ends in the same failure, so the client is permanently
33133331
stuck (\"I re-authenticated and nothing changed\"). The 401 flow must discard the lapsed
3314-
record and re-register instead of presenting the dead secret; stored tokens are kept
3315-
(a live access token still works without client authentication).
3332+
record and re-register instead of presenting the dead secret; the access token is kept
3333+
(it still works without client authentication) while the refresh token — issued to the
3334+
discarded `client_id` — goes with the record.
33163335
"""
33173336
await mock_storage.set_client_info(
33183337
OAuthClientInformationFull(
@@ -3355,6 +3374,13 @@ async def test_expired_stored_registration_is_discarded_and_the_flow_re_register
33553374
assert oauth_provider.context.client_info is None # discarded in-flow, just before Step 4
33563375
assert register_req.method == "POST"
33573376
assert str(register_req.url) == "https://api.example.com/register"
3377+
# The discard also dropped the dead client's refresh token, in memory and in storage.
3378+
assert oauth_provider.context.current_tokens is not None
3379+
assert oauth_provider.context.current_tokens.refresh_token is None
3380+
stored_tokens = await mock_storage.get_tokens()
3381+
assert stored_tokens is not None
3382+
assert stored_tokens.refresh_token is None
3383+
assert stored_tokens.access_token == valid_tokens.access_token
33583384
await auth_flow.aclose()
33593385

33603386

@@ -3515,6 +3541,93 @@ async def test_refresh_is_skipped_when_registration_secret_expired_mid_session(
35153541
await auth_flow.aclose()
35163542

35173543

3544+
@pytest.mark.anyio
3545+
async def test_interrupted_flow_cannot_pair_the_old_refresh_token_with_the_fresh_registration(
3546+
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage
3547+
):
3548+
"""RFC 6749 §6 binds a refresh token to the client it was issued to, so the expiry
3549+
discard drops the token alongside the dead record — otherwise a flow failing between
3550+
re-registration and token exchange leaves fresh `client_info` paired with the old
3551+
client's refresh token, and the next request presents that token as the new client
3552+
(a guaranteed `invalid_grant` round trip).
3553+
3554+
Steps:
3555+
1. A registration whose secret lapsed mid-session sits next to an expired access
3556+
token and a refresh token issued to it.
3557+
2. The 401 flow discards the record — dropping the refresh token in memory and in
3558+
storage — and re-registers; the interactive authorization then fails (the user
3559+
abandons the consent) after the fresh registration was already persisted.
3560+
3. The next request attempts no refresh: with the orphan gone, `can_refresh_token()`
3561+
is false and the request goes out unauthenticated.
3562+
"""
3563+
oauth_provider._initialized = True
3564+
oauth_provider.context.client_info = OAuthClientInformationFull(
3565+
client_id="dead-client",
3566+
client_secret="expired-secret",
3567+
client_secret_expires_at=int(time.time()) - 3600, # lapsed after load
3568+
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
3569+
token_endpoint_auth_method="client_secret_post",
3570+
)
3571+
oauth_provider.context.current_tokens = OAuthToken(access_token="stale", refresh_token="issued-to-dead-client")
3572+
oauth_provider.context.token_expiry_time = time.time() - 60 # access token expired
3573+
3574+
async def abandoned_consent(url: str) -> None:
3575+
raise RuntimeError("user closed the browser")
3576+
3577+
oauth_provider.context.redirect_handler = abandoned_consent
3578+
3579+
auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3580+
request = await auth_flow.__anext__()
3581+
3582+
# 401 → discovery; the lapsed record is discarded and the flow re-registers.
3583+
prm_req = await auth_flow.asend(httpx2.Response(401, request=request))
3584+
prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
3585+
asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req))
3586+
asm_response = httpx2.Response(
3587+
200,
3588+
content=(
3589+
b'{"issuer": "https://api.example.com", '
3590+
b'"authorization_endpoint": "https://api.example.com/authorize", '
3591+
b'"token_endpoint": "https://api.example.com/token", '
3592+
b'"registration_endpoint": "https://api.example.com/register"}'
3593+
),
3594+
request=asm_req,
3595+
)
3596+
register_req = await auth_flow.asend(asm_response)
3597+
register_response = httpx2.Response(
3598+
201,
3599+
json={
3600+
"client_id": "fresh-client",
3601+
"client_secret": "fresh-secret",
3602+
"redirect_uris": ["http://localhost:3030/callback"],
3603+
"token_endpoint_auth_method": "client_secret_post",
3604+
},
3605+
request=register_req,
3606+
)
3607+
3608+
# The fresh registration is persisted, then the interactive authorization fails.
3609+
with pytest.raises(RuntimeError, match="user closed the browser"):
3610+
await auth_flow.asend(register_response)
3611+
stored = await mock_storage.get_client_info()
3612+
assert stored is not None
3613+
assert stored.client_id == "fresh-client"
3614+
3615+
# The orphaned refresh token is gone from memory and storage; the access token survives.
3616+
assert oauth_provider.context.current_tokens is not None
3617+
assert oauth_provider.context.current_tokens.refresh_token is None
3618+
stored_tokens = await mock_storage.get_tokens()
3619+
assert stored_tokens is not None
3620+
assert stored_tokens.refresh_token is None
3621+
assert stored_tokens.access_token == "stale"
3622+
3623+
# The next request attempts no refresh: it goes out unauthenticated straight away.
3624+
retry_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp"))
3625+
retry_request = await retry_flow.__anext__()
3626+
assert str(retry_request.url) == "https://api.example.com/v1/mcp"
3627+
assert "Authorization" not in retry_request.headers
3628+
await retry_flow.aclose()
3629+
3630+
35183631
@pytest.mark.anyio
35193632
async def test_403_step_up_re_registers_when_registration_secret_expired(
35203633
oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage
@@ -3525,7 +3638,8 @@ async def test_403_step_up_re_registers_when_registration_secret_expired(
35253638
without its own re-check every step-up would complete a full interactive authorization
35263639
only to fail ``invalid_client`` at the token exchange, until the access token itself
35273640
expires. The step-up mints a fresh registration first — against the AS metadata already
3528-
discovered — and completes the exchange with the new credentials.
3641+
discovered — and completes the exchange with the new credentials; the dead client's
3642+
refresh token is dropped with its record.
35293643
"""
35303644
oauth_provider._initialized = True
35313645
oauth_provider.context.client_info = OAuthClientInformationFull(
@@ -3535,7 +3649,9 @@ async def test_403_step_up_re_registers_when_registration_secret_expired(
35353649
redirect_uris=[AnyUrl("http://localhost:3030/callback")],
35363650
token_endpoint_auth_method="client_secret_post",
35373651
)
3538-
oauth_provider.context.current_tokens = OAuthToken(access_token="live-token", scope="read")
3652+
oauth_provider.context.current_tokens = OAuthToken(
3653+
access_token="live-token", refresh_token="issued-to-dead-client", scope="read"
3654+
)
35393655
oauth_provider.context.token_expiry_time = time.time() + 1800 # access token still live
35403656
oauth_provider.context.oauth_metadata = OAuthMetadata(
35413657
issuer=AnyHttpUrl("https://auth.example.com"),
@@ -3571,6 +3687,14 @@ async def mock_callback() -> AuthorizationCodeResult:
35713687
register_req = await auth_flow.asend(response_403)
35723688
assert register_req.method == "POST"
35733689
assert str(register_req.url) == "https://auth.example.com/register"
3690+
# The discard dropped the dead client's refresh token (in memory and in storage) while
3691+
# keeping the live access token.
3692+
assert oauth_provider.context.current_tokens is not None
3693+
assert oauth_provider.context.current_tokens.refresh_token is None
3694+
stored_tokens = await mock_storage.get_tokens()
3695+
assert stored_tokens is not None
3696+
assert stored_tokens.refresh_token is None
3697+
assert stored_tokens.access_token == "live-token"
35743698
register_response = httpx2.Response(
35753699
201,
35763700
json={

0 commit comments

Comments
 (0)