feat(auth): add OAuth token keepalive via Messages API
Summary
Prevents OAuth token expiration during long periods of inactivity by proactively refreshing tokens before they expire and sending periodic ping requests to the Anthropic Messages API.
Fixes #9121 Closes #6559 Closes #4992
Problem
OAuth tokens expire after 1-2 hours of inactivity, causing "Token refresh failed: 400" errors. The previous approach in PR #9112 used /api/oauth/usage endpoint polling, which does not actually keep tokens active - it only retrieves usage statistics without extending the OAuth session lifetime.
The existing token refresh logic in opencode-anthropic-auth plugin only triggers during API calls. If the app is idle, no refresh happens and tokens expire.
Solution
Proactive token keepalive that runs every 30 minutes:
- Checks token expiry - If token expires within 10 minutes, refresh it
-
Refreshes via OAuth endpoint - Uses
POST /v1/oauth/tokenwithgrant_type: refresh_token -
Updates stored tokens - Saves refreshed tokens to
auth.json - Pings Messages API - Sends minimal request to maintain session activity
// packages/opencode/src/auth/keepalive.ts
// Token refresh
const response = await fetch("https://console.anthropic.com/v1/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: record.refresh,
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
}),
})
// Session ping
await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
Authorization: `Bearer ${record.access}`,
"anthropic-version": "2023-06-01",
"anthropic-beta": "oauth-2025-04-20",
},
body: JSON.stringify({
model: "claude-sonnet-4-20250514",
max_tokens: 5,
messages: [{ role: "user", content: "ping" }],
}),
})
Key Functions
| Function | Purpose |
|---|---|
refreshAnthropicToken() |
Calls Anthropic OAuth token endpoint |
updateStoredToken() |
Persists refreshed token to auth.json |
keepAliveAccount() |
Checks expiry, refreshes if needed, then pings |
pingAllAnthropicAccounts() |
Processes all OAuth accounts |
init() |
Starts 30-minute interval timer |
Changes
-
New:
packages/opencode/src/auth/keepalive.ts- Keepalive service- Runs every 30 minutes (first ping after 1 minute)
- Refreshes tokens 10 minutes before expiry
- Uses same
client_idasopencode-anthropic-authplugin - Logs under
auth.keepaliveservice
-
Modified:
packages/opencode/src/project/bootstrap.ts- Initializes
AuthKeepAliveon startup
- Initializes
Testing
- Start OpenCode with Anthropic OAuth authentication
- Leave idle for 2+ hours
- Check logs for
auth.keepaliveservice:INFO auth.keepalive: starting oauth keepalive {"intervalMs": 1800000} INFO auth.keepalive: token expired or expiring soon, refreshing {"recordId": "...", "expiresIn": 300} INFO auth.keepalive: token refresh successful {"recordId": "..."} INFO auth.keepalive: keepalive ping successful {"recordId": "..."} - Resume usage - no token expiration errors
Token Cost
- ~10 tokens per ping (input + output)
- ~480 tokens/day with 30-minute pings
- Negligible cost vs. user experience improvement
Note
App must be running for keepalive to work. If the app is closed, tokens will still expire normally.