Skip to content

bug(sdk/go): stored refresh token is never used; expired logins always fall back to interactive login #4514

Open

Description

@CloneOfAlex

User Story

I use OpenShell and build automation against its SDKs. While reviewing sdk/ I hit this behavior and reproduced it with the minimal test below against main.

Problem Statement

The Go SDK persists refresh_token but contains no refresh_token grant. oidc.readToken returns nil for an expired access token even when a valid refresh token is stored, so Login starts a full browser flow, and gateway.NewClient OIDC auth cannot renew at all. The CLI refreshes in this case (ensure_valid_oidc_token_bundle).

Impact / Why This Matters

Go programs and services must re-run an interactive browser login every time the access token expires (often hourly), which is unusable for unattended automation.

Acceptance Criteria

  • With an expired access token and a valid refresh token on disk, Login returns a refreshed token without user interaction and persists it.
  • OIDC auth created by gateway.NewClient renews via the refresh token (or the stored file) before expiry.

Reproduction Steps

Write {"access_token":"a","refresh_token":"still-good","expiry":"2020-01-01T00:00:00Z"} to oidc_token.json; readToken(dir) returns nil, nil, and Login proceeds to the interactive flow.

Suggested UX (if applicable)

No response

Environment

  • OpenShell: main @ 82977e2 (2026-10-13)
  • SDK: Go (sdk/go/openshell/v1/oidc, gateway)
  • Toolchain: Go 1.25.4 linux/amd64, Node 22

Logs

BUG: refresh token "still-good" dropped; caller is forced into interactive login

Activity

  1. added theissue type on Oct 13, 2026
  2. rhuss commented on Oct 14, 2026

    @rhuss
    Contributor

    📋 triage-agent

    Thanks for the report.

    Confirmed on main. The Go SDK implements the authorization_code, device_code and client_credentials grants but no refresh_token grant. oidc.readToken treats an expired access token as missing regardless of the stored refresh token, and the OIDC auth that gateway.NewClient builds (RefreshableToken over the disk token source) only re-reads oidc_token.json. It renews only if the CLI happens to refresh the file in the meantime.

    Adding the grant is straightforward. Deciding where the refreshed token goes is the harder part, because the refresh token in oidc_token.json is one credential shared with the CLI (see the discussion on #4508). If the SDK refreshes and keeps the result elsewhere, providers that rotate refresh tokens on use leave the CLI holding a revoked token. That leaves two workable designs:

    1. Shared file protocol. The SDK and the CLI both refresh and write back to oidc_token.json in the CLI's format, under a file lock, re-reading the file before refreshing (another process may already have done it) and replacing it atomically via a temp file and rename. This also fixes an existing race in the CLI itself: store_oidc_token writes the file in place without locking, so two CLI processes refreshing at once can clobber each other.
    2. CLI as the only writer. The CLI gets a command that prints a valid access token and refreshes when needed, and the SDK calls it, similar to kubectl exec credential plugins. Simpler ownership, but Go programs then need the CLI binary installed.

    For a standalone oidc.Login without a gateway (WithInMemory), an in-memory refresh is fine either way. This depends on #4508 for the issuer and client_id fields that the refresh request needs, and on #4513 for the scopes. Next step: maintainer acceptance decision, including which of the two designs to follow.

  3. rhuss commented on Oct 14, 2026

    @rhuss
    Contributor

    One more data point that narrows the design question: the Python SDK already implements the shared-file approach (python/openshell/sandbox.py, the OIDC refresher). Before refreshing, it re-reads oidc_token.json and adopts the disk bundle if its expires_at is later, so it never refreshes with a refresh token another process has already rotated. It writes the result back atomically. If the refresh still fails with invalid_grant, it re-reads once and retries with a peer's newer refresh token. The Go SDK should mirror that behavior rather than introduce a third variant, and the CLI's store_oidc_token should switch to an atomic write so concurrent readers never see a partial file.

    Related: refreshableAuth.isTokenValid() treats a zero Expiry as valid forever. Combined with #4508 (CLI-written expires_at is ignored, so every loaded token has a zero expiry), the Go client caches the first access token for the life of the process and never re-reads the file. The fix for #4508 should include a test that an expired cached token actually triggers a re-read.

  4. added
    state:acceptedA maintainer decided OpenShell should pursue this issue
    and removed
    state:triage-neededOpened without agent diagnostics and needs triage
    on Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:acceptedA maintainer decided OpenShell should pursue this issue

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions