Skip to content

fix(oauth): store keyring tokens as one entry per provider#3

Closed
euxaristia wants to merge 1 commit into
mainfrom
fix/keyring-oauth-per-provider-entries
Closed

fix(oauth): store keyring tokens as one entry per provider#3
euxaristia wants to merge 1 commit into
mainfrom
fix/keyring-oauth-per-provider-entries

Conversation

@euxaristia

Copy link
Copy Markdown
Owner

Summary

PR Gitlawb#574 moved the macOS keyring write path to security -i, which is
necessary to keep the secret out of the process list and out of a
getpass(3) /dev/tty prompt. But security -i's command parser caps a
single write at 4095 bytes, and the oauth store combines every
provider and MCP token into one JSON blob under a single keyring
entry. That blob has no size bound of its own: three or more
logged-in providers routinely exceeds the cap, so Set() starts failing
for every provider, not just the one that pushed it over the line.

This splits keyring storage into one entry per token key (account =
key), plus a small index entry listing which keys currently exist,
since KeyringClient has no list operation. Each write is now bounded
to a single token's size, comfortably under the 4095-byte cap
regardless of how many providers are logged in.

Installs still on the old combined-entry format keep reading
correctly through a legacy fallback, and get migrated to per-key
entries (with the legacy entry removed) the next time anything is
saved.

Test plan

  • go build ./...
  • make lint (fmt-check + vet)
  • go test ./internal/oauth/... ./internal/keyring/... ./internal/credstore/... -race
  • New test simulates 5 logged-in providers with realistic JWT-sized
    tokens and asserts no single keyring entry exceeds a 3000-byte
    margin under the line cap
  • New test covers migration from the legacy combined-entry format
  • Verified against the real macOS security CLI in a throwaway
    test keychain that the underlying security -i write/read
    mechanism this builds on works correctly (quoting, round-trip,
    length guard)

The keyring backend combined every provider and MCP token into a single
JSON blob under one keyring entry. On macOS, add-generic-password now
writes through security -i, whose command parser caps a single write at
4095 bytes (Gitlawb#574). The combined blob has no such bound: three or more
logged-in providers routinely exceeds it, so Set() starts failing for
every provider, not just the one that pushed it over.

Split storage into one keyring entry per token key, plus a small index
entry listing which keys exist (KeyringClient has no list operation).
Each write is now bounded to a single token's size, well under the
line cap regardless of how many providers are logged in. Installs on
the old combined-entry format keep reading correctly via a legacy
fallback, and get migrated to per-key entries on the next save.
@coderabbitai

coderabbitai Bot commented Jul 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The OAuth keyring backend now stores each token in a separate entry, maintains an encoded index, removes deleted entries, and migrates legacy combined storage. Tests cover round trips, size limits, deletion, and migration.

Changes

Keyring storage migration

Layer / File(s) Summary
Keyring accounts and indexed reads
internal/oauth/store.go
Separate legacy and index accounts are introduced, backend wiring is updated, and reads assemble tokens from indexed per-token entries with legacy fallback.
Per-token writes and cleanup
internal/oauth/store.go
Writes persist individual tokens, update the index, remove obsolete entries, delete the legacy blob, and report the index location.
Storage and migration validation
internal/oauth/store_keyring_test.go
Tests verify encoded per-token storage, deletion, entry-size limits, and migration from legacy combined storage.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers: gnanam1990

Poem

I’m a rabbit with tokens tucked neat,
Each provider gets its own little seat.
The old blob hops away,
While the index keeps sway,
And tests nibble bugs as a treat! 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: storing OAuth keyring tokens as separate per-provider entries.
Description check ✅ Passed The description accurately describes the keyring split, legacy migration, and test coverage in the changeset.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Zero automated PR review

Verdict: No blockers found

Blockers

  • None found.

Validation

  • [pass] Diff hygiene: git diff --check
  • [pass] Tests: go test ./...
  • [pass] Build: go run ./cmd/zero-release build
  • [pass] Smoke build: go run ./cmd/zero-release smoke

Scope

Head: 8eac9aa88d04
Changed files (2): internal/oauth/store.go, internal/oauth/store_keyring_test.go

This deterministic review checks validation status and basic diff hygiene. A human reviewer still owns product judgment and design quality.

@euxaristia

Copy link
Copy Markdown
Owner Author

Opened against upstream instead: Gitlawb#668.

@euxaristia euxaristia closed this Jul 14, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/oauth/store.go`:
- Around line 465-504: Update internal/oauth/store.go lines 465-504 in
keyringBlob.read to skip corrupted index or per-token base64/JSON decode
failures like missing entries, allowing valid tokens to load; update lines
521-560 in keyringBlob.write to treat indexedKeys decode failures as no known
prior keys and continue Save/Delete instead of returning an error.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 77b954a3-b167-42c9-8702-54387ad5b31f

📥 Commits

Reviewing files that changed from the base of the PR and between 80c39aa and 8eac9aa.

📒 Files selected for processing (2)
  • internal/oauth/store.go
  • internal/oauth/store_keyring_test.go

Comment thread internal/oauth/store.go
Comment on lines 465 to +504
func (b keyringBlob) read() ([]byte, bool, error) {
enc, ok, err := b.kr.Get(b.service, b.account)
indexEnc, ok, err := b.kr.Get(b.service, b.indexAccount)
if err != nil {
return nil, false, err
}
if !ok {
return b.readLegacy()
}
keys, err := decodeKeyIndex(indexEnc)
if err != nil {
return nil, false, fmt.Errorf("oauth: decode keyring token index: %w", err)
}
tokens := make(map[string]Token, len(keys))
for _, key := range keys {
enc, ok, err := b.kr.Get(b.service, key)
if err != nil {
return nil, false, err
}
if !ok {
// Index and entries fell out of sync (e.g. a killed process between
// writing an entry and updating the index); skip rather than fail the
// whole read, since the next Save/Delete will reconcile the index.
continue
}
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
if err != nil {
return nil, false, fmt.Errorf("oauth: decode keyring token entry %q: %w", key, err)
}
var token Token
if err := json.Unmarshal(raw, &token); err != nil {
return nil, false, fmt.Errorf("oauth: invalid keyring token entry %q: %w", key, err)
}
tokens[key] = token
}
data, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: tokens})
if err != nil {
return nil, false, err
}
return data, true, nil
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Decode/unmarshal errors for keyring index or per-token data abort the whole operation, instead of self-healing like "not found" already does. Both keyringBlob.read() and keyringBlob.write() (via indexedKeys()) treat a corrupted index or per-token entry as a fatal error for the entire call, even though the migration's whole purpose is to stop one bad entry from affecting every other provider/MCP token.

  • internal/oauth/store.go#L465-L504: in read(), skip (rather than error out on) a base64/JSON decode failure for the index (lines 473-476) or for an individual per-token entry (lines 489-496), the same way the !ok/"not found" branch already does, so one corrupted provider doesn't block Load() for the rest.
  • internal/oauth/store.go#L521-L560: in write(), treat a decode failure from indexedKeys() (line 526-529) as "no known prior keys" instead of aborting, so a corrupted index doesn't block every future Save/Delete for every provider/MCP token until it's cleared out-of-band.
📍 Affects 1 file
  • internal/oauth/store.go#L465-L504 (this comment)
  • internal/oauth/store.go#L521-L560
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/oauth/store.go` around lines 465 - 504, Update
internal/oauth/store.go lines 465-504 in keyringBlob.read to skip corrupted
index or per-token base64/JSON decode failures like missing entries, allowing
valid tokens to load; update lines 521-560 in keyringBlob.write to treat
indexedKeys decode failures as no known prior keys and continue Save/Delete
instead of returning an error.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant