Skip to content

Commit f8530da

Browse files
committed
fix(oauth): serialize keyring operations under user-level lock
Derive the keyring backend lock file path from the user's home directory rather than file store configuration, ensuring processes with distinct store paths share the same lock domain for the OS keychain. Refs #938
1 parent fa6ceda commit f8530da

3 files changed

Lines changed: 162 additions & 9 deletions

File tree

‎internal/oauth/store.go‎

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,10 @@ type StoreOptions struct {
100100
// Keyring is the client used when Storage=="keyring"; nil => keyring.New().
101101
// Injected by tests to avoid touching a real keychain.
102102
Keyring KeyringClient
103+
// KeyringLockPath overrides the cross-process lock file used by the keyring
104+
// backend. When empty, it is derived from the keyring storage identity
105+
// via ResolveKeyringLockPath(Env).
106+
KeyringLockPath string
103107
}
104108

105109
// KeyringClient is the minimal OS-keyring surface the store needs. *keyring.Keyring
@@ -191,6 +195,35 @@ func ResolveStorePath(env map[string]string) (string, error) {
191195
return filepath.Join(configHome, "zero", "oauth-tokens.json"), nil
192196
}
193197

198+
// ResolveKeyringLockPath determines the on-disk cross-process lock file for the
199+
// keyring backend. Unlike file-backed storage, keyring entries are anchored to
200+
// the user's OS keychain (zero/oauth-tokens) and are shared across all processes
201+
// for that OS user, regardless of file-store overrides such as
202+
// ZERO_OAUTH_TOKENS_PATH, FilePath, or XDG_CONFIG_HOME.
203+
func ResolveKeyringLockPath(env map[string]string) (string, error) {
204+
if override := strings.TrimSpace(envValue(env, "ZERO_OAUTH_KEYRING_LOCK_PATH")); override != "" {
205+
if filepath.IsAbs(override) {
206+
return filepath.Clean(override), nil
207+
}
208+
return filepath.Abs(override)
209+
}
210+
home := strings.TrimSpace(firstNonEmpty(envValue(env, "HOME"), envValue(env, "USERPROFILE")))
211+
if home == "" {
212+
var err error
213+
home, err = os.UserHomeDir()
214+
if err != nil {
215+
return "", fmt.Errorf("oauth: resolve user home for keyring lock: %w", err)
216+
}
217+
} else if !filepath.IsAbs(home) {
218+
resolved, err := filepath.Abs(home)
219+
if err != nil {
220+
return "", err
221+
}
222+
home = resolved
223+
}
224+
return filepath.Join(home, ".zero", "oauth-keyring.lockfile"), nil
225+
}
226+
194227
// NewStore builds a token store with the configured backend (file by default,
195228
// or the OS keyring when Storage/ZERO_OAUTH_STORAGE selects it).
196229
func NewStore(options StoreOptions) (*Store, error) {
@@ -230,11 +263,15 @@ func NewStore(options StoreOptions) (*Store, error) {
230263
kr = osKeyring
231264
}
232265
// Serialize the keyring's read-modify-write across processes with a lock
233-
// file beside where the file backend would live. Best-effort: if no config
234-
// location resolves, fall back to in-process serialization only.
235-
lockPath := ""
236-
if storePath, perr := ResolveStorePath(options.Env); perr == nil {
237-
lockPath = filepath.Join(filepath.Dir(storePath), "oauth-keyring.lockfile")
266+
// file derived from the keyring storage identity ("zero"/"oauth-tokens").
267+
// Unlike the file backend, the keyring does not vary with file configuration
268+
// (ZERO_OAUTH_TOKENS_PATH, FilePath, or XDG_CONFIG_HOME). Best-effort: if no
269+
// home location resolves, fall back to in-process serialization only.
270+
lockPath := strings.TrimSpace(options.KeyringLockPath)
271+
if lockPath == "" {
272+
if lp, perr := ResolveKeyringLockPath(options.Env); perr == nil {
273+
lockPath = lp
274+
}
238275
}
239276
return &Store{blob: keyringBlob{kr: kr, service: keyringService, account: keyringAccount, lockPath: lockPath}, now: now}, nil
240277
default:

‎internal/oauth/store_keyring_chunked_test.go‎

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package oauth
22

33
import (
44
"errors"
5+
"path/filepath"
56
"strconv"
67
"strings"
78
"testing"
@@ -30,7 +31,10 @@ func bigToken(seed string) Token {
3031

3132
func newCappedKeyringStore(t *testing.T, kr KeyringClient) *Store {
3233
t.Helper()
33-
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
34+
dir := t.TempDir()
35+
t.Setenv("HOME", dir)
36+
t.Setenv("USERPROFILE", dir)
37+
t.Setenv("XDG_CONFIG_HOME", dir)
3438
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
3539
if err != nil {
3640
t.Fatalf("NewStore(keyring): %v", err)
@@ -486,6 +490,8 @@ func TestStoreKeyringSweepsStrayChunksOnFirstGrowth(t *testing.T) {
486490
// has deleted old-generation chunks).
487491
func TestStoreKeyringReadSerializedWithLockDuringChunkedWrite(t *testing.T) {
488492
dir := t.TempDir()
493+
t.Setenv("HOME", dir)
494+
t.Setenv("USERPROFILE", dir)
489495
t.Setenv("XDG_CONFIG_HOME", dir)
490496

491497
kr := newCappedFakeKR(macOSLikeBudget)
@@ -565,6 +571,112 @@ func TestStoreKeyringReadSerializedWithLockDuringChunkedWrite(t *testing.T) {
565571
}
566572
}
567573

574+
// TestStoreKeyringSharedLockAcrossDifferentEnvironmentRoots is the regression for
575+
// split lock domains when processes run with different ZERO_OAUTH_TOKENS_PATH,
576+
// FilePath, or XDG_CONFIG_HOME. Because the OS keychain is single-domain for the
577+
// user ("zero"/"oauth-tokens"), all stores must share the same lock file so
578+
// concurrent reads, writes, and manifest rotations remain strictly serialized.
579+
func TestStoreKeyringSharedLockAcrossDifferentEnvironmentRoots(t *testing.T) {
580+
homeDir := t.TempDir()
581+
t.Setenv("HOME", homeDir)
582+
t.Setenv("USERPROFILE", homeDir)
583+
584+
dirA := t.TempDir()
585+
dirB := t.TempDir()
586+
587+
kr := newCappedFakeKR(macOSLikeBudget)
588+
storeA, err := NewStore(StoreOptions{
589+
Storage: "keyring",
590+
Keyring: kr,
591+
FilePath: filepath.Join(dirA, "custom-tokens.json"),
592+
Env: map[string]string{
593+
"ZERO_OAUTH_TOKENS_PATH": filepath.Join(dirA, "custom-tokens.json"),
594+
"XDG_CONFIG_HOME": dirA,
595+
},
596+
})
597+
if err != nil {
598+
t.Fatalf("NewStore(storeA): %v", err)
599+
}
600+
601+
storeB, err := NewStore(StoreOptions{
602+
Storage: "keyring",
603+
Keyring: kr,
604+
FilePath: filepath.Join(dirB, "custom-tokens.json"),
605+
Env: map[string]string{
606+
"ZERO_OAUTH_TOKENS_PATH": filepath.Join(dirB, "custom-tokens.json"),
607+
"XDG_CONFIG_HOME": dirB,
608+
},
609+
})
610+
if err != nil {
611+
t.Fatalf("NewStore(storeB): %v", err)
612+
}
613+
614+
lockA := storeA.blob.(keyringBlob).lockPath
615+
lockB := storeB.blob.(keyringBlob).lockPath
616+
if lockA == "" || lockB == "" {
617+
t.Fatalf("lockPath should not be empty: lockA=%q lockB=%q", lockA, lockB)
618+
}
619+
if lockA != lockB {
620+
t.Fatalf("stores with distinct env roots must share the keyring lock: lockA=%q != lockB=%q", lockA, lockB)
621+
}
622+
623+
mustSave(t, storeA, "keyA", bigToken("a"))
624+
625+
// 1. Force overlapping save/read: hold the lock manually to simulate an
626+
// in-flight multi-step write by storeA, and verify storeB's Load / Status blocks.
627+
unlock, err := acquireFileLock(lockA, time.Now)
628+
if err != nil {
629+
t.Fatalf("acquireFileLock: %v", err)
630+
}
631+
632+
loaded := make(chan Token, 1)
633+
loadErr := make(chan error, 1)
634+
go func() {
635+
tok, _, err := storeB.Load(ProviderKey("keyA"))
636+
loadErr <- err
637+
loaded <- tok
638+
}()
639+
640+
select {
641+
case <-loaded:
642+
t.Fatal("storeB.Load completed while storeA's lock was held")
643+
case <-time.After(50 * time.Millisecond):
644+
}
645+
646+
unlock()
647+
648+
select {
649+
case err := <-loadErr:
650+
if err != nil {
651+
t.Fatalf("storeB.Load failed after unlock: %v", err)
652+
}
653+
tok := <-loaded
654+
if tok.AccessToken != bigToken("a").AccessToken {
655+
t.Errorf("storeB.Load got unexpected token: %v", tok)
656+
}
657+
case <-time.After(2 * time.Second):
658+
t.Fatal("storeB.Load timed out waiting for lock release")
659+
}
660+
661+
// 2. Force overlapping save/save: write from storeB and storeA consecutively,
662+
// verifying both updates are preserved and readable by either store.
663+
if err := storeB.Save(ProviderKey("keyB"), bigToken("b")); err != nil {
664+
t.Fatalf("storeB.Save: %v", err)
665+
}
666+
if err := storeA.Save(ProviderKey("keyC"), bigToken("c")); err != nil {
667+
t.Fatalf("storeA.Save: %v", err)
668+
}
669+
670+
gotA := mustLoad(t, storeB, "keyA")
671+
gotB := mustLoad(t, storeA, "keyB")
672+
gotC := mustLoad(t, storeB, "keyC")
673+
if gotA.AccessToken != bigToken("a").AccessToken ||
674+
gotB.AccessToken != bigToken("b").AccessToken ||
675+
gotC.AccessToken != bigToken("c").AccessToken {
676+
t.Errorf("concurrent state was not preserved across stores")
677+
}
678+
}
679+
568680
// TestStoreKeyringShrinkResidueIsReclaimedOnRegrowth is the regression for a
569681
// retired generation orphaned permanently. Cleanup is hygiene rather than
570682
// correctness only while a manifest exists to state the counts; writeWhole

‎internal/oauth/store_keyring_test.go‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,10 @@ func (f *fakeKR) chunkAccounts(family string) []string {
9191
}
9292

9393
func TestStoreKeyringBackendRoundTrip(t *testing.T) {
94-
// Keep the cross-process keyring lock file inside a temp config dir.
95-
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
94+
// Keep the cross-process keyring lock file inside a temp dir.
95+
dir := t.TempDir()
96+
t.Setenv("HOME", dir)
97+
t.Setenv("USERPROFILE", dir)
9698
kr := newFakeKR()
9799
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
98100
if err != nil {
@@ -158,7 +160,9 @@ func TestNewStoreStorageSelection(t *testing.T) {
158160
}
159161

160162
func TestStoreKeyringStatus(t *testing.T) {
161-
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
163+
dir := t.TempDir()
164+
t.Setenv("HOME", dir)
165+
t.Setenv("USERPROFILE", dir)
162166
kr := newFakeKR()
163167
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
164168
if err != nil {

0 commit comments

Comments
 (0)