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

Aperta
#7,053 2 commenti 0 reazioni 1 assegnatario Rivendicata da @x1n95c Vedi su GitHub
bug
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.

Ricevi le nuove issue nella tua casella

Un breve riepilogo di issue GitHub adatte ai principianti.