agentscope-ai / agentscope-ai/QwenPaw
[Bug]: OAuth2 refresh never renews refresh_token (rotation) and has no proactive renewal — remote MCP permanently degrades to manual re-auth
- Lingua principale
- Python
- Stelle
- 34.9k
- Fork
- 3.1k
- Merge medio
- 1g 15h
- PR unite (30g)
- 225
Descrizione
## Summary
For remote MCP servers that use OAuth2 **Authorization Code** with **rotating refresh tokens** (e.g. XMind `https://app.xmind.com/api/mcp`), the `OAuth2AuthCodeProvider` renews `access_token` but **never persists the rotated `refresh_token`**. Combined with the fact that refresh is only **lazily** triggered inside `resolve()` within a tight 5-minute margin, drivers routinely end up with a dead `refresh_token` and permanently degrade to "Authorize via the UI" until the user manually re-authorizes.
## Environment
- qwenpaw `2.1.0`
- MCP transport: `streamable_http`
- Reproduced with remote MCP server requiring OAuth2 Authorization Code: `https://app.xmind.com/api/mcp`
- Note: local secondary check in workspace `drivers/mcp/*.yaml` (not relevant to the bug)
## Root cause
### 1. `StandardOAuth2Exchanger.exchange()` discards the rotated `refresh_token`
`qwenpaw/drivers/credentials/providers.py`, `StandardOAuth2Exchanger.exchange()`:
```python
payload = {
"grant_type": "refresh_token",
"refresh_token": secrets["refresh_token"],
"client_id": secrets.get("client_id", ""),
}
...
data = await _post_oauth_token_with_retry(client, token_endpoint, payload)
...
return access_token, int(data.get("expires_in", 3600)) # <- returns (token, expires_in) ONLY
```
The OAuth2 refresh response from a **rotating** provider includes a *new* `refresh_token`. The exchanger only returns `(access_token, expires_in)` and silently drops the new `refresh_token`.
### 2. `OAuth2AuthCodeProvider.resolve()` never persists a new `refresh_token`
`qwenpaw/drivers/credentials/providers.py`, `OAuth2AuthCodeProvider.resolve()` refresh branch:
```python
token, expires_in = await self._exchanger.exchange(values)
public = dict(record.public)
secrets = dict(record.secrets)
public["expires_at"] = time.time() + expires_in
secrets["access_token"] = token # <- only access_token updated
await self._store.put(
CredentialRecord(
ref=record.ref,
kind=record.kind,
public=public,
secrets=secrets, # <- refresh_token stays the OLD, now-invalid value
meta=dict(record.meta),
),
)
```
- `secrets["refresh_token"]` is never replaced.
- For **rotating** providers (XMind is one), the returned refresh token invalidates the previously stored one. The stale `refresh_token` in storage is now rejected on the next refresh → HTTP 400.
### 3. Refresh is lazy and confined to a 5-minute window (`_REFRESH_MARGIN_SECONDS = 300`)
`OAuth2AuthCodeProvider.resolve()` only attempts refresh when:
```python
if access_token and (
expires_at <= 0
or expires_at - time.time() > _REFRESH_MARGIN_SECONDS # 300s
):
return ResolvedCredential(...) # valid; use as-is, never refresh
```
Refresh only happens when a driver invocation happens while the token is **within 300s of expiry**. There is no background/proactive renewal. If no XMind tool is called during that 300s window, the token expires before any refresh, then refresh fails (400) because the stored refresh_token is already dead.
## Reproduced timeline
| Time (UTC) | Event |
|---|---|
| 02:16:41 | Authorized; `access_token` written with `expires_at = 02:16:41 + 3600s` (1h lifetime) |
| 02:09:30 | Connection attempt → HTTP 401 "server requires OAuth" (token had already expired) |
| 02:11:19 | Refresh attempt with stale `refresh_token` → **HTTP 400 Bad Request** at `https://app.xmind.com/api/oauth/token` |
| 02:16:41 | Manual re-authorization required to recover |
Observed error:
```
MCP capability invocation failed for Driver 'xmind' tool 'xmind_list_mindmaps':
Client error '400 Bad Request' for url 'https://app.xmind.com/api/oauth/token'
token, expires_in = await self._exchanger.exchange(values)
data = await _post_oauth_token_with_retry(...
httpx.HTTPStatusError: Client error '400 Bad Request' ...
```
`credentials.yaml` shows the stale state — `refresh_token` unchanged across refreshes, `expires_at` advancing only via manual re-auth:
```yaml
mcp/xmind/oauth:
kind: oauth2_auth_code
public:
expires_at: 1786763801.2137096
...
secrets:
access_token: ENC:...
refresh_token: ENC:... # never rotated by refresh
```
## Expected behavior
1. `StandardOAuth2Exchanger.exchange()` should also return any rotated `refresh_token` from the token endpoint response.
2. `OAuth2AuthCodeProvider.resolve()` should persist the new `refresh_token` (when present) alongside the new `access_token` / `expires_at`.
3. Optionally / additionally: a proactive renewal mechanism (background refresh before expiry, or a larger refresh margin) so `resolve()` does not depend on a coincidental invocation inside the final 5 minutes.
## Files
- `qwenpaw/drivers/credentials/providers.py` — `StandardOAuth2Exchanger` and `OAuth2AuthCodeProvider`
## Suggested fix (reference)
```python
# in exchange(): also return rotated refresh_token
return access_token, int(data.get("expires_in", 3600)), data.get("refresh_token")
# in resolve():
token, expires_in, new_refresh = await self._exchanger.exchange(values)
public["expires_at"] = time.time() + expires_in
secrets["access_token"] = token
if new_refresh:
secrets["refresh_token"] = new_refresh # persist rotation
await self._store.put(...)
```
Guida per i contributori
Apri la guida per i contributori
Valutazione
Questa issue non è ancora stata valutata.