Skip to content

V2 provider OAuth refresh races across locations and processes #50759

Open

Description

@hcrosse

Description

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

  1. Configure an OAuth provider that issues 300-second access tokens and rotates refresh tokens.
  2. Log in and resolve the credential immediately.
  3. Resolve an expired credential concurrently from two locations or processes sharing the same credential database.
  4. Observe the immediate refresh and duplicate use of the rotating refresh token.

Screenshot and/or share link

No response

Operating System

macOS 26.6.2

Terminal

Ghostty

Activity

  1. hcrosse commented on Sep 23, 2026

    @hcrosse
    Author

    👋 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 😅

  2. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    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 script (integration-refresh-margin.test.ts)
    // 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.ts
    import { 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"
    
    const it = testEffect(Layer.empty)
    
    it.effect("a fresh 300-second OAuth token is used without refreshing", () =>
      Effect.gen(function* () {
        const layer = LayerNode.compile(LayerNode.group([Integration.node, Credential.node]), {
          shared: Node.tags.values.global,
        })
        const ctx = yield* Layer.build(layer)
        const integration = Context.get(ctx, Integration.Service)
        const credentials = Context.get(ctx, Credential.Service)
        const integrationID = Integration.ID.make("openai")
        const methodID = Integration.MethodID.make("oauth")
        const exchanged: string[] = []
        let n = 0
        yield* 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++
                const now = yield* Clock.currentTimeMillis
                return Credential.OAuth.make({ ...value, access: `access-${n}`, refresh: `refresh-${n}`, expires: now + 300_000 })
              }),
          }),
        )
        const now = yield* Clock.currentTimeMillis
        // Just logged in: token valid for the full 300 seconds.
        const stored = yield* credentials.create({
          integrationID,
          value: Credential.OAuth.make({ type: "oauth", methodID, access: "access-0", refresh: "refresh-0", expires: now + 300_000 }),
        })
        const connection = { type: "credential" as const, id: stored.id, label: stored.label, method: "oauth" as const }
        for (let i = 0; i < 3; i++) yield* integration.connection.resolve(connection)
        console.log("refresh tokens exchanged:", exchanged)
        expect(exchanged).toEqual([])
      }),
    )

    Run it: bun integration-refresh-margin.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions