|
13 | 13 |
|
14 | 14 | from mcp.client.auth import OAuthClientProvider, PKCEParameters |
15 | 15 | from mcp.client.auth.exceptions import OAuthFlowError, OAuthRegistrationError, OAuthTokenError |
| 16 | +from mcp.client.auth.oauth2 import stored_registration_expired |
16 | 17 | from mcp.client.auth.utils import ( |
17 | 18 | build_oauth_authorization_server_metadata_discovery_urls, |
18 | 19 | build_protected_resource_metadata_discovery_urls, |
@@ -3253,3 +3254,103 @@ async def echo_callback() -> AuthorizationCodeResult: |
3253 | 3254 | await auth_flow.asend(httpx2.Response(200, request=final_req)) |
3254 | 3255 | except StopAsyncIteration: |
3255 | 3256 | pass |
| 3257 | + |
| 3258 | + |
| 3259 | +def test_stored_registration_expired_only_for_lapsed_secret_backed_registrations(): |
| 3260 | + """RFC 7591: only a non-zero, past `client_secret_expires_at` on a secret-authenticating |
| 3261 | + registration marks the stored record as expired; `0` means the secret never expires, and |
| 3262 | + methods that send no secret (`none`) are unaffected by the lapse. |
| 3263 | + """ |
| 3264 | + base: dict[str, object] = { |
| 3265 | + "client_id": "c", |
| 3266 | + "client_secret": "s", |
| 3267 | + "redirect_uris": [AnyUrl("http://localhost:3030/callback")], |
| 3268 | + } |
| 3269 | + lapsed = int(time.time()) - 3600 |
| 3270 | + live = int(time.time()) + 3600 |
| 3271 | + |
| 3272 | + expired = OAuthClientInformationFull.model_validate( |
| 3273 | + {**base, "token_endpoint_auth_method": "client_secret_post", "client_secret_expires_at": lapsed} |
| 3274 | + ) |
| 3275 | + assert stored_registration_expired(expired) |
| 3276 | + assert stored_registration_expired( |
| 3277 | + OAuthClientInformationFull.model_validate( |
| 3278 | + {**base, "token_endpoint_auth_method": "client_secret_basic", "client_secret_expires_at": lapsed} |
| 3279 | + ) |
| 3280 | + ) |
| 3281 | + |
| 3282 | + # 0 means "never expires" (RFC 7591); absent means no expiry was declared. |
| 3283 | + assert not stored_registration_expired( |
| 3284 | + OAuthClientInformationFull.model_validate( |
| 3285 | + {**base, "token_endpoint_auth_method": "client_secret_post", "client_secret_expires_at": 0} |
| 3286 | + ) |
| 3287 | + ) |
| 3288 | + assert not stored_registration_expired( |
| 3289 | + OAuthClientInformationFull.model_validate({**base, "token_endpoint_auth_method": "client_secret_post"}) |
| 3290 | + ) |
| 3291 | + |
| 3292 | + # Still-live secret, and methods that never present the secret. |
| 3293 | + assert not stored_registration_expired( |
| 3294 | + OAuthClientInformationFull.model_validate( |
| 3295 | + {**base, "token_endpoint_auth_method": "client_secret_post", "client_secret_expires_at": live} |
| 3296 | + ) |
| 3297 | + ) |
| 3298 | + assert not stored_registration_expired( |
| 3299 | + OAuthClientInformationFull.model_validate( |
| 3300 | + {**base, "token_endpoint_auth_method": "none", "client_secret_expires_at": lapsed} |
| 3301 | + ) |
| 3302 | + ) |
| 3303 | + |
| 3304 | + |
| 3305 | +@pytest.mark.anyio |
| 3306 | +async def test_expired_stored_registration_is_discarded_and_the_flow_re_registers( |
| 3307 | + oauth_provider: OAuthClientProvider, mock_storage: MockTokenStorage, valid_tokens: OAuthToken |
| 3308 | +): |
| 3309 | + """Regression for #3256: a stored DCR registration whose secret has lapsed is not reused. |
| 3310 | +
|
| 3311 | + Reusing it makes every token-endpoint interaction fail with ``invalid_client`` — even a |
| 3312 | + fresh interactive authorization ends in the same failure, so the client is permanently |
| 3313 | + stuck (\"I re-authenticated and nothing changed\"). The lapsed record must be treated as |
| 3314 | + absent on load, so the next 401 flow re-registers instead of presenting the dead secret; |
| 3315 | + stored tokens are kept (a live access token still works without client authentication). |
| 3316 | + """ |
| 3317 | + await mock_storage.set_client_info( |
| 3318 | + OAuthClientInformationFull( |
| 3319 | + client_id="dead-client", |
| 3320 | + client_secret="expired-secret", |
| 3321 | + client_secret_expires_at=int(time.time()) - 3600, |
| 3322 | + redirect_uris=[AnyUrl("http://localhost:3030/callback")], |
| 3323 | + token_endpoint_auth_method="client_secret_post", |
| 3324 | + ) |
| 3325 | + ) |
| 3326 | + await mock_storage.set_tokens(valid_tokens) |
| 3327 | + |
| 3328 | + auth_flow = oauth_provider.async_auth_flow(httpx2.Request("GET", "https://api.example.com/v1/mcp")) |
| 3329 | + |
| 3330 | + # The lapsed registration is treated as absent; the stored access token is kept and used. |
| 3331 | + request = await auth_flow.__anext__() |
| 3332 | + assert oauth_provider.context.client_info is None |
| 3333 | + assert oauth_provider.context.current_tokens is not None |
| 3334 | + assert request.headers["Authorization"] == f"Bearer {valid_tokens.access_token}" |
| 3335 | + |
| 3336 | + # Server rejects the stale token: the 401 flow re-registers instead of reusing the record. |
| 3337 | + response_401 = httpx2.Response(401, request=request) |
| 3338 | + prm_req = await auth_flow.asend(response_401) |
| 3339 | + prm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) |
| 3340 | + asm_req = await auth_flow.asend(httpx2.Response(404, request=prm_req)) |
| 3341 | + assert str(asm_req.url) == "https://api.example.com/.well-known/oauth-authorization-server" |
| 3342 | + asm_response = httpx2.Response( |
| 3343 | + 200, |
| 3344 | + content=( |
| 3345 | + b'{"issuer": "https://api.example.com", ' |
| 3346 | + b'"authorization_endpoint": "https://api.example.com/authorize", ' |
| 3347 | + b'"token_endpoint": "https://api.example.com/token", ' |
| 3348 | + b'"registration_endpoint": "https://api.example.com/register"}' |
| 3349 | + ), |
| 3350 | + request=asm_req, |
| 3351 | + ) |
| 3352 | + |
| 3353 | + register_req = await auth_flow.asend(asm_response) |
| 3354 | + assert register_req.method == "POST" |
| 3355 | + assert str(register_req.url) == "https://api.example.com/register" |
| 3356 | + await auth_flow.aclose() |
0 commit comments