Skip to content

[Bug]: corrupt stored token fails fatally on every command instead of prompting re-auth #872

Description

@KrasimirKralev

Bug type

Error handling / auth recovery — a corrupt (but present) stored token is treated as a fatal error on every command instead of degrading to a re-authentication prompt.

Summary

getTokenNoLockOptions returns a generic decode token: … error when a stored token entry is present but not decodable, and the API-client auth path (buildTokenSource) only recognizes keyring.ErrKeyNotFound as the "needs re-auth" signal. As a result, an absent token correctly maps to AuthRequiredError (re-auth), but a corrupt token falls through to a hard error — so the CLI fails on every command for that account with no actionable recovery, even though re-authenticating would fix it.

Where (code pointers, current main)

  • internal/secrets/token.go:188-191 — on json.Unmarshal failure the function returns a plain wrapped error:
    var st storedToken
    if err := json.Unmarshal(item.Data, &st); err != nil {
        return Token{}, fmt.Errorf("decode token: %w", err)
    }
  • internal/googleapi/client_auth.go:238-246 — the caller degrades to re-auth only for ErrKeyNotFound; any other error (including the corrupt-decode error above) becomes fatal:
    if t, err := store.GetToken(client, email); err != nil {
        if errors.Is(err, keyring.ErrKeyNotFound) {
            return nil, &AuthRequiredError{...}   // absent token -> re-auth
        }
        return nil, fmt.Errorf("get token for %s: %w", email, err) // corrupt token -> fatal
    }

Reproduction (verified)

The file backend is a first-class, documented configuration (GOG_KEYRING_BACKEND=file / gog auth keyring file), so a partially-written / corrupted token entry is a real on-disk failure mode (interrupted write, disk issue, or a stored-shape change across versions). The asymmetry is reproducible at the unit level with no binary build, using the same in-memory keyring the existing TestKeyringStore_TokenRoundTrip uses:

func TestGetToken_CorruptEntry_NotClassifiableAsReauth(t *testing.T) {
	ring := keyring.NewArrayKeyring(nil)
	store := &KeyringStore{ring: ring}
	client := config.DefaultClientName
	email := normalize("[email protected]")

	// Present-but-corrupt token blob (e.g. interrupted write).
	_ = ring.Set(keyring.Item{Key: tokenKey(client, email), Data: []byte("{not-json")})

	_, err := store.GetTokenNoMigrate(client, email)
	// err = "decode token: invalid character 'n' looking for beginning of object key string"
	if errors.Is(err, keyring.ErrKeyNotFound) { t.Fatal("would re-auth") }
	// errors.Is(err, ErrKeyNotFound) == false  => caller treats as FATAL, not re-auth (the bug)

	// Control: an ABSENT token *does* classify as ErrKeyNotFound -> re-auth.
	_, errAbsent := store.GetTokenNoMigrate(client, normalize("[email protected]"))
	// errors.Is(errAbsent, keyring.ErrKeyNotFound) == true  (correct)
}

Observed output (current main):

corrupt-token error: decode token: invalid character 'n' looking for beginning of object key string
errors.Is(err, ErrKeyNotFound) = false  => caller treats as FATAL, not re-auth (the bug)
control: absent token -> ErrKeyNotFound -> re-auth (correct)

Expected vs actual

  • Expected: a present-but-undecodable token is unusable and equivalent to "no usable credential" → surface AuthRequiredError so the user re-authenticates (same recovery as an absent token).
  • Actual: every command for that account fails with get token for <email>: decode token: … and no re-auth path; the user must manually find and delete the keyring entry.

Prior art in this org (same bug-class)

openclaw/mcporter had the identical class (corrupt credential cache crashed instead of degrading) — filed as #207, fixed in #208 by degrading a corrupt token/client cache to re-auth while keeping security-gating material (OAuth state) fail-closed. That distinction matters here too (see fix options).

Proposed fix (options)

  • Option A (preferred, matches mcporter feat: add sheets notes command to read cell notes #208): introduce a sentinel (e.g. ErrCorruptToken) returned specifically from the json.Unmarshal failure in getTokenNoLockOptions, and have buildTokenSource treat errors.Is(err, ErrCorruptToken) like ErrKeyNotFoundAuthRequiredError. This targets only the corrupt-decode case.
  • Option B: same as A but also delete/quarantine the corrupt entry on read so the next auth flow starts clean.
  • Option C (not recommended): broadly map all non-ErrKeyNotFound GetToken errors to re-auth. Avoid — this would mask genuinely transient/operational faults the code intentionally surfaces today (macOS Keychain locked in headless env, permission errors, wrong file-backend password — see the read-back guidance in internal/secrets/store.go). The fix must distinguish "credential is corrupt/unusable" from "the store is temporarily unavailable."

Happy to follow up with a PR (Option A) including the test above plus a caller-level assertion if a maintainer confirms the direction.


Filed with AI assistance (static analysis + a sandbox-run repro). The reproduction above was executed against current main; the production-frequency of corrupt entries is environment-dependent and not directly observed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Normal priority bug or improvement with limited blast radius.clawsweeper:fix-shape-clearClawSweeper found a clear likely implementation shape for this issue.clawsweeper:queueable-fixClawSweeper marked this issue as an existing queue_fix_pr work candidate.clawsweeper:source-reproClawSweeper found a high-confidence source-level issue reproduction.impact:auth-providerThis issue is about auth, provider routing, model choice, or SecretRef resolution.issue-rating: 🦞 diamond lobsterVery strong issue quality with high-confidence source-level or clear reproduction.no-staleExempts this issue from stale automation.

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions