You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
V2 provider OAuth refresh races across locations and processes #50759
Provider OAuth credentials are global, while Integration instances are location-scoped. When a credential expires, concurrent locations or processes can read the same refresh token, exchange it independently, and overwrite each other. Providers that rotate refresh tokens and revoke the token family on reuse can then require a new login.
The fixed five-minute refresh margin also causes access tokens with a 300-second lifetime to refresh immediately. On OpenCode 2.0.14, a fresh token was refreshed 327 ms after login and the next exchange returned invalid_grant. The logs support the concurrency hypothesis but do not establish the exact overlap.
Expected behavior:
Coordinate credential reread, refresh eligibility, exchange, and persistence across processes sharing credential storage.
Use a bounded refresh margin that permits short-lived tokens to remain usable.
Prevent stale refresh results from overwriting login, removal, or replacement.
Do not retry an uncertain exchange with a potentially consumed refresh token.
Related to #34520, which tracks the separate MCP OAuth path.
Plugins
Internal provider integration using OAuth
OpenCode version
2.0.14
Steps to reproduce
Configure an OAuth provider that issues 300-second access tokens and rotates refresh tokens.
Log in and resolve the credential immediately.
Resolve an expired credential concurrently from two locations or processes sharing the same credential database.
Observe the immediate refresh and duplicate use of the rotating refresh token.
👋 Hi @neriousy , I see you got assigned to this! This is my first real issue with OpenCode - how should I proceed towards getting a review? I know y'all must have an overwhelming amount of issues and PRs to wade through, so absolutely no rush, I just don't want this to end up closed by the bot 😅
Thanks for the detailed report. I reproduced the refresh-margin part on the latest dev (fa1260d6).
I used a fake OAuth provider that gives out 300-second access tokens and a new refresh token on every exchange. I saved a freshly issued credential and resolved it three times, and every resolve refreshed it: refresh-0, refresh-1, refresh-2, when none should have. The cause is the fixed 5-minute margin in Integration.connection.resolve (packages/core/src/integration.ts): a token that lives 5 minutes or less always counts as expiring, so it is refreshed right after login, as in your logs. The test script below reproduces it.
The race between locations and processes has been addressed on dev by #54023, which adds a shared per-credential lock, a cross-process file lock and a reread before refreshing. I didn't test that part again here, and I didn't test the case of a refresh result overwriting a credential that was removed or replaced in the meantime.
Related: #50760 (your earlier PR), #34520 (the MCP OAuth path).
// Reproduction for anomalyco/opencode#50759 (refresh margin part).// Copy into packages/core/test/ and run: cd packages/core && bun test test/integration-refresh-margin.test.tsimport{expect}from"bun:test"import{Clock,Context,Effect,Layer}from"effect"import{Node}from"@opencode/util/effect/app-node"import{LayerNode}from"@opencode/util/effect/layer-node"import{Credential}from"@opencode/core/credential"import{Integration}from"@opencode/core/integration"import{testEffect}from"./lib/effect"constit=testEffect(Layer.empty)it.effect("a fresh 300-second OAuth token is used without refreshing",()=>Effect.gen(function*(){constlayer=LayerNode.compile(LayerNode.group([Integration.node,Credential.node]),{shared: Node.tags.values.global,})constctx=yield*Layer.build(layer)constintegration=Context.get(ctx,Integration.Service)constcredentials=Context.get(ctx,Credential.Service)constintegrationID=Integration.ID.make("openai")constmethodID=Integration.MethodID.make("oauth")constexchanged: string[]=[]letn=0yield*integration.transform((editor)=>editor.method.update({
integrationID,method: {id: methodID,type: "oauth",label: "OAuth"},authorize: ()=>Effect.die("unused sign-in"),// Provider issues 300-second access tokens and rotates the refresh token on each exchange.refresh: (value: Credential.OAuth)=>Effect.gen(function*(){exchanged.push(value.refresh)n++constnow=yield*Clock.currentTimeMillisreturnCredential.OAuth.make({ ...value,access: `access-${n}`,refresh: `refresh-${n}`,expires: now+300_000})}),}),)constnow=yield*Clock.currentTimeMillis// Just logged in: token valid for the full 300 seconds.conststored=yield*credentials.create({
integrationID,value: Credential.OAuth.make({type: "oauth", methodID,access: "access-0",refresh: "refresh-0",expires: now+300_000}),})constconnection={type: "credential"asconst,id: stored.id,label: stored.label,method: "oauth"asconst}for(leti=0;i<3;i++)yield*integration.connection.resolve(connection)console.log("refresh tokens exchanged:",exchanged)expect(exchanged).toEqual([])}),)
Activity
hcrosse commentedon Sep 23, 2026
👋 Hi @neriousy , I see you got assigned to this! This is my first real issue with OpenCode - how should I proceed towards getting a review? I know y'all must have an overwhelming amount of issues and PRs to wade through, so absolutely no rush, I just don't want this to end up closed by the bot 😅
opencodejr commentedon Oct 28, 2026
Thanks for the detailed report. I reproduced the refresh-margin part on the latest
dev(fa1260d6).I used a fake OAuth provider that gives out 300-second access tokens and a new refresh token on every exchange. I saved a freshly issued credential and resolved it three times, and every resolve refreshed it:
refresh-0,refresh-1,refresh-2, when none should have. The cause is the fixed 5-minute margin inIntegration.connection.resolve(packages/core/src/integration.ts): a token that lives 5 minutes or less always counts as expiring, so it is refreshed right after login, as in your logs. The test script below reproduces it.The race between locations and processes has been addressed on
devby #54023, which adds a shared per-credential lock, a cross-process file lock and a reread before refreshing. I didn't test that part again here, and I didn't test the case of a refresh result overwriting a credential that was removed or replaced in the meantime.Related: #50760 (your earlier PR), #34520 (the MCP OAuth path).
Reproduction script (integration-refresh-margin.test.ts)
Run it:
bun integration-refresh-margin.test.ts