Skip to content

bug(sdk/go): oidc_token.json format is incompatible with the CLI (expires_at/issuer/client_id) #4508

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. This breaks sharing a gateway login between the CLI and a Go program.

Problem Statement

The Rust CLI persists oidc_token.json as {access_token, refresh_token, expires_at (unix seconds), issuer, client_id} (openshell-bootstrap/src/oidc_token.rs). The Go SDK reads and writes a different shape: {access_token, refresh_token, expiry (RFC 3339), expires_in} (gateway/token.go, oidc/token.go), although oidc/doc.go says the file is compatible with the CLI and gateway/token.go says it re-reads the file to pick up CLI-refreshed tokens.

  • Reading a CLI-written file: expires_at is ignored, so an expired token is returned as valid with a zero Expiry.
  • Writing (oidc.Login): the file lacks issuer/client_id/expires_at, which the CLI requires to deserialize, so the CLI treats the user as logged out.

Impact / Why This Matters

Go clients created with gateway.NewClient keep sending an expired bearer token after the CLI login expires; the SDK's own "expired, re-authenticate with the CLI" error never fires and users see opaque Unauthenticated errors. Logging in through the Go SDK silently logs the CLI out of the same gateway.

Acceptance Criteria

  • The Go SDK reads expires_at from CLI-written token files and reports an expired token as expired.
  • Token files written by the Go SDK can be loaded by the CLI (openshell commands against that gateway keep working).
  • A round-trip test covers both directions with the CLI's on-disk format.

Reproduction Steps

Package gateway test:

past := time.Now().Add(-time.Hour).Unix()
cli := fmt.Sprintf(`{"access_token":"stale","refresh_token":"r","expires_at":%d,"issuer":"https://idp.example","client_id":"cli"}`, past)
os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(cli), 0o600)
tok, err := newDiskTokenSource(dir).Token() // err == nil, tok.Expiry.IsZero() == true

Package oidc test: writeToken(dir, tok) produces {"access_token":"a","refresh_token":"r","expiry":"...","expires_in":3599} with no expires_at, issuer or client_id.

Suggested UX (if applicable)

No response

Environment

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

Logs

BUG: expired CLI token accepted (access_token="stale", Expiry zero=true)
BUG: written oidc_token.json missing CLI field "expires_at"
BUG: written oidc_token.json missing CLI field "issuer"
BUG: written oidc_token.json missing CLI field "client_id"

Activity

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

    @rhuss
    Contributor

    📋 triage-agent

    Confirmed by code inspection on main (fc0e9db). The CLI's OidcTokenBundle (crates/openshell-bootstrap/src/oidc_token.rs) stores expires_at as unix seconds and requires issuer and client_id. The Go SDK bundles in sdk/go/openshell/v1/gateway/token.go and sdk/go/openshell/v1/oidc/token.go use expiry (RFC 3339) and expires_in and do not have the CLI fields. As a result, CLI-written expired tokens load with a zero expiry, and SDK-written files fail to deserialize in the CLI. The comment in oidc/token.go stating that the format is shared with the Rust CLI is incorrect.

    Since gateway.NewClient is meant to reuse CLI-managed gateway state, the CLI's on-disk format should be the reference. The implementation should include a cross-language compatibility test that keeps the two formats from drifting again, for example a shared fixture of a CLI-written oidc_token.json that the Go tests load and that the Rust tests deserialize from Go-written output. A Go-only round-trip test would not catch this class of bug, because both sides of the round trip would use the same wrong format.

    The report's evidence and acceptance criteria are complete. Related: #4514 depends on the SDK keeping issuer and client_id in this file. Next step: maintainer acceptance decision.

  3. rhuss commented on Oct 14, 2026

    @rhuss
    Contributor

    Thanks for submitting this! Not sure where the mismatch came from, but it's a good example that one must not only care about the proto files when doing the SDK, but also other schematas (like the gw config files)

  4. rhuss commented on Oct 14, 2026

    @rhuss
    Contributor

    Thinking about the write side more: the refresh token in oidc_token.json is a single credential shared by the CLI and the SDK. Once the SDK refreshes on its own, the only correct place for the result is that same file. Keeping it in memory or somewhere else either leaves the CLI with a revoked refresh token (on identity providers that rotate on use) or with diverging copies. So the write path can't really be fixed in isolation.

    I'd suggest keeping this issue to the format:

    • The Go SDK reads the CLI's format (expires_at, issuer, client_id) and reports expired tokens with a clear openshell gateway login hint.
    • When oidc.Login persists a fresh login for a gateway, it writes the CLI's format, equivalent to running openshell gateway login.
    • A shared fixture checked by both the Rust and Go tests keeps the format from drifting.

    The question of who may refresh, and how, belongs in #4514. The options I see are a shared file protocol (file lock, re-read before refresh, atomic write via temp file and rename, followed by both the CLI and the SDK), or making the CLI the only writer through a token helper command that the SDK calls. Note that even the CLI alone isn't safe here today: store_oidc_token writes the file directly without locking, so two CLI processes refreshing at the same time can race.

  5. 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
  6. lunarwhite commented on Oct 22, 2026

    @lunarwhite
    Contributor

    🏗️ build-plan

    Outcome

    The Go SDK reads and writes the CLI's oidc_token.json format. gateway.NewClient honors expires_at and picks up tokens that the CLI refreshes; once a token has actually expired, every RPC fails with Unauthenticated and a run `openshell gateway login <name>` hint instead of sending the expired token. oidc.Login(ctx, "<name>") writes the same file as openshell gateway login <name>, so CLI commands against that gateway keep working. One PR closes #4508.

    Design

    • The CLI's OidcTokenBundle (crates/openshell-bootstrap/src/oidc_token.rs:17-35) is the reference: access_token, optional refresh_token, optional expires_at (unix seconds), and required issuer and client_id. The Go SDK keeps two private structs with expiry and expires_in, so CLI files load in Go with no expiry and Go files fail in the CLI. Go replaces both with one internal type that mirrors the CLI bundle.
    • The gateway path re-reads the file on each refresh, rejects files without issuer or client_id (including files written by earlier Go SDK builds), and fails closed once expires_at has passed. Every load error carries the login hint.
    • oidc.Login writes the CLI format and reuses a stored token only when it is valid and its issuer (ignoring a trailing /) and client ID match the gateway's.
    • v1.RefreshableToken today returns its cached token after a failed refresh even when that token has expired. It now returns the cached token only while it is unexpired, and otherwise returns the refresh error, which gRPC surfaces as Unauthenticated. This is a public behavior change for every token source and matches the CLI.
    • A shared fixture, sdk/conformance/oidc-token-bundle.json, is checked by a Rust test against the CLI's serialization and by Go tests for reading and writing.
    • Limitation: Go Login still ignores the gateway's oidc_scopes and oidc_audience (bug(sdk/go): DeviceLogin/Login ignore gateway oidc_scopes and oidc_audience (and override WithIssuer/WithClientID) #4513), so on a gateway that requires them the gateway rejects a Go-written token. SDK-side refresh, file locking, and atomic CLI writes stay in bug(sdk/go): stored refresh token is never used; expired logins always fall back to interactive login #4514, as scoped in the maintainer comment.

    Tests

    • Go unit tests in gateway, oidc, and v1: the fixture round-trips, expires_at semantics, rejection of files missing issuer or client_id, the issue reproduction (an expired file fails with the login hint), re-read after the file changes, the Login reuse rule, and fail-closed behavior with an expired cache during and after backoff. Existing tests move to the new format, and tests that the new ones supersede or that cover only the old format are removed.
    • A Rust unit test in openshell-bootstrap asserts that the fixture is exactly what the CLI serializes.
    • A manual check against a local OIDC gateway covers the read path, a Go login followed by CLI commands, and a CLI refresh of a Go-written file. No E2E lane is needed: the change touches only the Go SDK and one Rust unit test.

    Documentation

    sdk/go/README.md, the RefreshableToken and oidc.Login doc comments, and sdk/go/docs/src/api/oidc.md and gateway.md describe the CLI format, the reuse rule, and the fail-closed behavior. No published docs/ page changes.

  7. lunarwhite commented on Oct 22, 2026

    @lunarwhite
    Contributor

    I'll open a PR based on the plan above.

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