Repository navigation
auth() silently swallows non-OAuthError exceptions from refreshAuthorization / saveTokens, preventing token persistence #2034
Description
Describe the bug
In auth() (src/client/auth.ts), the catch block after refreshAuthorization() + saveTokens() silently discards all non-OAuthError exceptions — including I/O errors thrown by saveTokens(). No log, no rethrow. The function falls through to startAuthorization() → redirectToAuthorization(), which in headless/CLI clients is typically a no-op.
Result: the Authorization Server may have rotated the refresh token, but the new tokens are never persisted. The client is stuck with stale (or now-invalid) tokens on disk.
// src/client/auth.ts — the catch block around line 277
catch (error) {
if (!(error instanceof OAuthError) || error instanceof ServerError) {
// silently discarded — no log, no rethrow
}
else {
throw error;
}
}The condition !(error instanceof OAuthError) || error instanceof ServerError catches:
- Any non-OAuthError (TypeError, filesystem I/O errors from
saveTokens, network errors) ServerError(subclass of OAuthError)
All are silently discarded.
To Reproduce
- Implement an
OAuthClientProviderwith asaveTokens()that can throw (e.g., filesystem-backed with file lock contention, or any I/O error) - Configure an OAuth 2.1 AS with rotating refresh tokens enabled (default in Keycloak)
- Let the access token expire
auth()is called →refreshAuthorization()succeeds at the AS →saveTokens()throws- The catch block swallows the error
auth()falls through tostartAuthorization()→redirectToAuthorization()(no-op in CLI clients)- Old expired tokens remain on disk, new tokens are lost
- With rotating refresh tokens, the old refresh token is now invalidated server-side — the client must fully re-authenticate
Expected behavior
At minimum: log the error so operators can diagnose failures.
Ideally: separaterefreshAuthorization()failures (where fallthrough to re-auth may be appropriate) fromsaveTokens()failures (which should always propagate since the AS-side exchange already succeeded):
let newTokens;
try {
newTokens = await refreshAuthorization(authorizationServerUrl, { ... });
} catch (error) {
if (!(error instanceof OAuthError) || error instanceof ServerError) {
// AS-side refresh failure — fall through to re-auth
} else {
throw error;
}
}
if (newTokens) {
await provider.saveTokens(newTokens); // let this throw naturally
return 'AUTHORIZED';
}Logs
No logs — that's the problem. The catch block produces zero output. Diagnosis required reading SDK source code and correlating Keycloak audit logs (which showed successful token exchanges) with the unchanged mcp-auth.json on the client.
Additional context
- SDK version: 1.27.1
- AS: Keycloak 26.6.1 with rotating refresh tokens (default)
- Client: OpenCode 1.14.41 (Bun standalone, CLI —
redirectToAuthorizationis a no-oplog.info) - With rotating refresh tokens this bug is destructive: the AS invalidates the old RT upon issuing a new one, but
saveTokens()failure is swallowed so the new RT is lost. The client has no valid tokens at all. - Related: adaptOAuthProvider returns expired tokens without checking expiry, breaking long-running StreamableHTTP connections #1954 (expired tokens returned without checking expiry), Client should silently refresh access tokens on 401 when a refresh token is available #2031 (silent refresh on 401) — neither covers this specific silent-swallow in the catch block
- Workaround: external cron script that reads the auth file, refreshes tokens via
curl, and writes them back atomically with file locking
Activity
fix(client/auth): propagate saveTokens errors after refresh
Coooder-Crypto commentedon Jul 19, 2026
Hi! I’d like to work on this. I plan to move token persistence outside the refresh error-handling block, add regression coverage ensuring saveTokens() failures propagate without starting a new authorization flow, and keep the change scoped to the behavior described here. Should the initial PR target main, with a separate v1.x backport?
fix(client/auth): log refresh fallthrough, document behavior change, …
test(client/auth): cover the invalid_client warn and share the modelc…
Merge branch 'main' into claude/oauth-tokens-null-fields-v2