@@ -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
33063324async 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
35193632async 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