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
bug(sdk/go): stored refresh token is never used; expired logins always fall back to interactive login #4514
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.
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:
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.
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.
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.
Activity
rhuss commentedon Oct 14, 2026
Thanks for the report.
Confirmed on
main. The Go SDK implements theauthorization_code,device_codeandclient_credentialsgrants but norefresh_tokengrant.oidc.readTokentreats an expired access token as missing regardless of the stored refresh token, and the OIDC auth thatgateway.NewClientbuilds (RefreshableTokenover the disk token source) only re-readsoidc_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.jsonis 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:oidc_token.jsonin 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_tokenwrites the file in place without locking, so two CLI processes refreshing at once can clobber each other.For a standalone
oidc.Loginwithout a gateway (WithInMemory), an in-memory refresh is fine either way. This depends on #4508 for theissuerandclient_idfields that the refresh request needs, and on #4513 for the scopes. Next step: maintainer acceptance decision, including which of the two designs to follow.rhuss commentedon Oct 14, 2026
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-readsoidc_token.jsonand adopts the disk bundle if itsexpires_atis 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 withinvalid_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'sstore_oidc_tokenshould switch to an atomic write so concurrent readers never see a partial file.Related:
refreshableAuth.isTokenValid()treats a zeroExpiryas valid forever. Combined with #4508 (CLI-writtenexpires_atis 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.