Skip to content

Commit ebc8218

Browse files
fix(auth): repair duplicate token alias writes (#721)
Fixes #718. Co-authored-by: Andy Ye <[email protected]>
1 parent fba331c commit ebc8218

3 files changed

Lines changed: 171 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
### Fixed
1111

1212
- Docs: avoid duplicate empty paragraphs adjacent to Markdown headings while preserving body paragraph spacing. (#717, #720) — thanks @TurboTheTurtle.
13+
- Auth: repair duplicate macOS Keychain writes for legacy and subject token aliases without weakening primary token persistence. (#718, #721) — thanks @TurboTheTurtle.
1314

1415
## 0.23.0 - 2026-06-09
1516

internal/secrets/store.go

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -505,13 +505,13 @@ func (s *KeyringStore) setTokenNoLock(client string, email string, tok Token) er
505505
}
506506

507507
if normalizedClient == config.DefaultClientName {
508-
if err := verifiedSet(s.ring, legacyTokenKey(email), payload, "legacy token"); err != nil {
508+
if err := verifiedSetAlias(s.ring, legacyTokenKey(email), payload, "legacy token"); err != nil {
509509
return wrapKeychainError(fmt.Errorf("store legacy token: %w", err))
510510
}
511511
}
512512

513513
if tok.Subject != "" {
514-
if err := verifiedSet(s.ring, subjectTokenKey(normalizedClient, tok.Subject), payload, "subject token"); err != nil {
514+
if err := verifiedSetAlias(s.ring, subjectTokenKey(normalizedClient, tok.Subject), payload, "subject token"); err != nil {
515515
return wrapKeychainError(fmt.Errorf("store subject token: %w", err))
516516
}
517517
}
@@ -1017,3 +1017,33 @@ func verifiedSet(ring keyring.Keyring, key string, data []byte, label string) er
10171017

10181018
return nil
10191019
}
1020+
1021+
func verifiedSetAlias(ring keyring.Keyring, key string, data []byte, label string) error {
1022+
if err := verifiedSet(ring, key, data, label); err != nil {
1023+
if !isDuplicateKeyringItemError(err) {
1024+
return err
1025+
}
1026+
1027+
if removeErr := ring.Remove(key); removeErr != nil && !errors.Is(removeErr, keyring.ErrKeyNotFound) {
1028+
return fmt.Errorf("replace duplicate %s: remove stale item: %w", label, removeErr)
1029+
}
1030+
1031+
if retryErr := verifiedSet(ring, key, data, label); retryErr != nil {
1032+
return fmt.Errorf("replace duplicate %s: %w", label, retryErr)
1033+
}
1034+
}
1035+
1036+
return nil
1037+
}
1038+
1039+
func isDuplicateKeyringItemError(err error) bool {
1040+
if err == nil {
1041+
return false
1042+
}
1043+
1044+
msg := strings.ToLower(err.Error())
1045+
1046+
return strings.Contains(msg, "-25299") ||
1047+
strings.Contains(msg, "errsecduplicateitem") ||
1048+
strings.Contains(msg, "specified item already exists")
1049+
}

internal/secrets/store_more_test.go

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package secrets
22

33
import (
4+
"bytes"
45
"encoding/json"
56
"errors"
67
"fmt"
@@ -15,8 +16,9 @@ import (
1516
)
1617

1718
var (
18-
errTestKeychain = errors.New("test -25308 error")
19-
errTestReadBack = errors.New("test read-back failure")
19+
errTestDuplicateKeychain = errors.New("failed to update item in keychain: the specified item already exists in the keychain. (-25299)")
20+
errTestKeychain = errors.New("test -25308 error")
21+
errTestReadBack = errors.New("test read-back failure")
2022
)
2123

2224
func TestKeyringStore_ListDeleteDefault(t *testing.T) {
@@ -321,6 +323,111 @@ func TestKeyringStoreTokenAccessTokenRoundTrip(t *testing.T) {
321323
}
322324
}
323325

326+
func TestKeyringStoreSetTokenRepairsDuplicateAliasWrites(t *testing.T) {
327+
email := "[email protected]"
328+
subject := "google-sub-123"
329+
expires := time.Date(2026, 6, 9, 16, 0, 42, 0, time.UTC)
330+
331+
ring := &duplicateOnceKeyring{
332+
ArrayKeyring: keyring.NewArrayKeyring(nil),
333+
duplicateKeys: map[string]int{
334+
legacyTokenKey(email): 1,
335+
subjectTokenKey(config.DefaultClientName, subject): 1,
336+
},
337+
removedKeys: map[string]int{},
338+
}
339+
340+
for _, key := range []string{
341+
legacyTokenKey(email),
342+
subjectTokenKey(config.DefaultClientName, subject),
343+
} {
344+
if err := ring.ArrayKeyring.Set(keyringItem(key, []byte("stale"))); err != nil {
345+
t.Fatalf("seed stale alias %q: %v", key, err)
346+
}
347+
}
348+
349+
store := &KeyringStore{ring: ring}
350+
351+
err := store.SetToken(config.DefaultClientName, email, Token{
352+
Subject: subject,
353+
RefreshToken: "rt",
354+
AccessToken: "at",
355+
AccessTokenExpiresAt: expires,
356+
})
357+
if err != nil {
358+
t.Fatalf("SetToken: %v", err)
359+
}
360+
361+
primary, err := ring.Get(tokenKey(config.DefaultClientName, email))
362+
if err != nil {
363+
t.Fatalf("read primary token: %v", err)
364+
}
365+
366+
for _, key := range []string{
367+
legacyTokenKey(email),
368+
subjectTokenKey(config.DefaultClientName, subject),
369+
} {
370+
item, getErr := ring.Get(key)
371+
if getErr != nil {
372+
t.Fatalf("expected key %q persisted after duplicate repair: %v", key, getErr)
373+
}
374+
375+
if !bytes.Equal(item.Data, primary.Data) {
376+
t.Fatalf("alias %q was not replaced with primary payload", key)
377+
}
378+
379+
if ring.removedKeys[key] != 1 {
380+
t.Fatalf("alias %q remove count = %d, want 1", key, ring.removedKeys[key])
381+
}
382+
}
383+
384+
got, err := store.GetToken(config.DefaultClientName, email)
385+
if err != nil {
386+
t.Fatalf("GetToken: %v", err)
387+
}
388+
389+
if got.AccessToken != "at" || !got.AccessTokenExpiresAt.Equal(expires) {
390+
t.Fatalf("refreshed access metadata was not preserved: %#v", got)
391+
}
392+
}
393+
394+
func TestKeyringStoreSetTokenKeepsPrimaryDuplicateStrict(t *testing.T) {
395+
email := "[email protected]"
396+
primaryKey := tokenKey(config.DefaultClientName, email)
397+
398+
ring := &duplicateOnceKeyring{
399+
ArrayKeyring: keyring.NewArrayKeyring(nil),
400+
duplicateKeys: map[string]int{
401+
primaryKey: 1,
402+
},
403+
removedKeys: map[string]int{},
404+
}
405+
406+
if err := ring.ArrayKeyring.Set(keyringItem(primaryKey, []byte("stale-primary"))); err != nil {
407+
t.Fatalf("seed stale primary: %v", err)
408+
}
409+
410+
store := &KeyringStore{ring: ring}
411+
412+
err := store.SetToken(config.DefaultClientName, email, Token{RefreshToken: "rt"})
413+
if err == nil || !isDuplicateKeyringItemError(err) {
414+
t.Fatalf("expected primary duplicate error, got %v", err)
415+
}
416+
417+
if ring.removedKeys[primaryKey] != 0 {
418+
t.Fatalf("primary token was removed during strict write")
419+
}
420+
421+
item, getErr := ring.Get(primaryKey)
422+
if getErr != nil {
423+
t.Fatalf("read primary token: %v", getErr)
424+
}
425+
426+
if string(item.Data) != "stale-primary" {
427+
t.Fatalf("primary token changed after failed strict write: %q", item.Data)
428+
}
429+
}
430+
324431
func TestKeyringStoreDeleteTokenAliasPreservesSubjectKey(t *testing.T) {
325432
ring := keyring.NewArrayKeyring(nil)
326433
store := &KeyringStore{ring: ring}
@@ -494,6 +601,35 @@ func (r *readBackErrorKeyring) Get(_ string) (keyring.Item, error) {
494601
}
495602
func (r *readBackErrorKeyring) Keys() ([]string, error) { return nil, nil }
496603

604+
type duplicateOnceKeyring struct {
605+
*keyring.ArrayKeyring
606+
duplicateKeys map[string]int
607+
removedKeys map[string]int
608+
}
609+
610+
func (d *duplicateOnceKeyring) Set(item keyring.Item) error {
611+
if remaining := d.duplicateKeys[item.Key]; remaining > 0 {
612+
d.duplicateKeys[item.Key] = remaining - 1
613+
return errTestDuplicateKeychain
614+
}
615+
616+
if err := d.ArrayKeyring.Set(item); err != nil {
617+
return fmt.Errorf("set array keyring item: %w", err)
618+
}
619+
620+
return nil
621+
}
622+
623+
func (d *duplicateOnceKeyring) Remove(key string) error {
624+
d.removedKeys[key]++
625+
626+
if err := d.ArrayKeyring.Remove(key); err != nil {
627+
return fmt.Errorf("remove array keyring item: %w", err)
628+
}
629+
630+
return nil
631+
}
632+
497633
func TestSetTokenVerifyCatchesReadBackError(t *testing.T) {
498634
store := &KeyringStore{ring: &readBackErrorKeyring{}}
499635
client := config.DefaultClientName

0 commit comments

Comments
 (0)