Skip to content

Commit db740e4

Browse files
committed
Address reviewer feedback on durable logout, legacy origin lifecycle, and generation fencing.
1 parent e6e04ed commit db740e4

2 files changed

Lines changed: 147 additions & 134 deletions

File tree

‎internal/oauth/store.go‎

Lines changed: 51 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -481,15 +481,15 @@ func (s *Store) Delete(key string) (bool, error) {
481481
if err != nil {
482482
return err
483483
}
484-
if _, ok := state.Tokens[key]; !ok {
485-
return nil
484+
if _, ok := state.Tokens[key]; ok {
485+
delete(state.Tokens, key)
486+
removed = true
487+
return s.writeState(state, map[string]bool{key: true}, check)
486488
}
487-
delete(state.Tokens, key)
488-
removed = true
489-
// Exclude the deleted key from legacy reconciliation so a credential
490-
// that was only present in the legacy blob (never indexed) is not
491-
// reclassified as a fresh old-binary login and written back.
492-
return s.writeState(state, map[string]bool{key: true}, check)
489+
if kb, ok := s.blob.(keyringBlob); ok && kb.hasResidentEntry(key) {
490+
return s.writeState(state, map[string]bool{key: true}, check)
491+
}
492+
return nil
493493
})
494494
return removed, err
495495
}
@@ -703,6 +703,23 @@ type keyringBlob struct {
703703
maxIndexKeys int
704704
}
705705

706+
// hasResidentEntry reports whether key is still named in the key index or
707+
// has an individual entry in the OS keyring. Used during Store.Delete to
708+
// finish reconciling interrupted deletes whose tombstones already hide the key
709+
// from readState().
710+
func (b keyringBlob) hasResidentEntry(key string) bool {
711+
keys, ok, _, _, _, _ := b.readKeyIndex()
712+
if ok {
713+
for _, k := range keys {
714+
if k == key {
715+
return true
716+
}
717+
}
718+
}
719+
_, exists, _ := b.kr.Get(b.service, key)
720+
return exists
721+
}
722+
706723
func (b keyringBlob) read() ([]byte, bool, error) {
707724
keys, ok, _, _, _, err := b.readKeyIndex()
708725
if err != nil {
@@ -757,6 +774,9 @@ func (b keyringBlob) read() ([]byte, bool, error) {
757774
}
758775
tokens := make(map[string]Token, len(keys))
759776
for _, key := range keys {
777+
if tombstones[key] {
778+
continue
779+
}
760780
enc, ok, err := b.kr.Get(b.service, key)
761781
if err != nil {
762782
return nil, false, err
@@ -766,11 +786,6 @@ func (b keyringBlob) read() ([]byte, bool, error) {
766786
// from the legacy blob when present and not tombstoned; otherwise
767787
// skip rather than fail the whole read (the next Save/Delete prunes
768788
// the phantom index key so it cannot permanently consume capacity).
769-
// Tombstones do not hide a still-present indexed entry (in-flight
770-
// delete): they only block resurrection from the legacy account.
771-
if tombstones[key] {
772-
continue
773-
}
774789
loadLegacy()
775790
if token, has := legacyTokens[key]; has {
776791
tokens[key] = token
@@ -964,23 +979,22 @@ func (b keyringBlob) write(data []byte, mutations map[string]bool, checkLease le
964979
return err
965980
}
966981
legacyOriginChanged := false
967-
legacyTokensChanged := false
968982
if legacyTokens != nil {
969-
for key, deleted := range mutations {
970-
if deleted {
971-
if _, ok := legacyTokens[key]; ok {
972-
delete(legacyTokens, key)
973-
legacyTokensChanged = true
974-
}
983+
for key := range mutations {
984+
if legacyOrigin[key] {
985+
delete(legacyOrigin, key)
986+
legacyOriginChanged = true
975987
}
976988
}
977989
for key := range legacyTokens {
978990
if ValidateKey(key) != nil {
979991
continue
980992
}
981993
if !legacyOrigin[key] {
982-
legacyOrigin[key] = true
983-
legacyOriginChanged = true
994+
if _, mutated := mutations[key]; !mutated {
995+
legacyOrigin[key] = true
996+
legacyOriginChanged = true
997+
}
984998
}
985999
}
9861000
// Only once the legacy entry has actually been read (legacyTokens != nil)
@@ -1154,22 +1168,9 @@ func (b keyringBlob) write(data []byte, mutations map[string]bool, checkLease le
11541168
return err
11551169
}
11561170
}
1157-
if legacyTokensChanged {
1158-
if err := checkLease(); err != nil {
1159-
return err
1160-
}
1161-
if len(legacyTokens) == 0 {
1162-
if _, err := b.kr.Delete(b.service, b.legacyAccount); err != nil {
1163-
return fmt.Errorf("oauth: delete empty legacy keyring blob: %w", err)
1164-
}
1165-
} else {
1166-
legacyData, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: legacyTokens})
1167-
if err != nil {
1168-
return fmt.Errorf("oauth: encode updated legacy keyring blob: %w", err)
1169-
}
1170-
if err := b.kr.Set(b.service, b.legacyAccount, base64.StdEncoding.EncodeToString(legacyData)); err != nil {
1171-
return fmt.Errorf("oauth: update legacy keyring blob: %w", err)
1172-
}
1171+
if len(state.Tokens) == 0 && len(legacyTokens) > 0 {
1172+
if err := checkLease(); err == nil {
1173+
_, _ = b.kr.Delete(b.service, b.legacyAccount)
11731174
}
11741175
}
11751176
return nil
@@ -1648,6 +1649,12 @@ func (b keyringBlob) writeKeyIndexNewGeneration(chunkList [][]string, priorChunk
16481649
if err := checkLease(); err != nil {
16491650
return 0, 0, err
16501651
}
1652+
if priorGeneration > 0 {
1653+
_, ok, _, curGen, _, err := b.readKeyIndex()
1654+
if err == nil && ok && curGen != priorGeneration {
1655+
return 0, 0, fmt.Errorf("oauth: keyring key index generation conflict: expected %d, found %d", priorGeneration, curGen)
1656+
}
1657+
}
16511658
if err := b.kr.Set(b.service, b.indexAccount, base64.StdEncoding.EncodeToString(headerData)); err != nil {
16521659
return 0, 0, err
16531660
}
@@ -1735,6 +1742,12 @@ func (b keyringBlob) writeKeyIndexInPlace(chunkList [][]string, priorChunks, pri
17351742
if err := checkLease(); err != nil {
17361743
return 0, 0, err
17371744
}
1745+
if priorGeneration > 0 {
1746+
_, ok, _, curGen, _, err := b.readKeyIndex()
1747+
if err == nil && ok && curGen != priorGeneration {
1748+
return 0, 0, fmt.Errorf("oauth: keyring key index generation conflict: expected %d, found %d", priorGeneration, curGen)
1749+
}
1750+
}
17381751
if err := b.kr.Set(b.service, b.indexAccount, base64.StdEncoding.EncodeToString(headerData)); err != nil {
17391752
return 0, 0, err
17401753
}

‎internal/oauth/store_keyring_test.go‎

Lines changed: 96 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -3477,144 +3477,144 @@ func TestWritePreservesMissingMiddleChunkSlot(t *testing.T) {
34773477
}
34783478
}
34793479

3480-
func TestKeyringDeleteRemovesCredentialFromLegacyBlob(t *testing.T) {
3480+
// TestStoreKeyringDurableLogoutHidesSurvivingIndexedCredential is the regression for
3481+
// [P1] Make a durable logout hide a surviving indexed credential:
3482+
// when a tombstone exists for a key, read()/Load()/Status() must treat it as deleted
3483+
// even if an interrupted delete left the per-key entry and key index intact.
3484+
func TestStoreKeyringDurableLogoutHidesSurvivingIndexedCredential(t *testing.T) {
34813485
kr := newFakeKR()
3482-
legacyTokens := map[string]Token{
3483-
ProviderKey("p1"): {AccessToken: "at1", RefreshToken: "rt1"},
3484-
ProviderKey("p2"): {AccessToken: "at2", RefreshToken: "rt2"},
3485-
}
3486-
legacyRaw, _ := json.Marshal(storeFile{SchemaVersion: 1, Tokens: legacyTokens})
3487-
kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(legacyRaw)
3488-
34893486
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
34903487
if err != nil {
34913488
t.Fatalf("NewStore: %v", err)
34923489
}
34933490

3494-
// Loading p1 works via legacy migration.
3495-
tok1, ok, err := s.Load(ProviderKey("p1"))
3496-
if err != nil || !ok || tok1.AccessToken != "at1" {
3497-
t.Fatalf("Load(p1) = %+v, ok=%v, err=%v", tok1, ok, err)
3491+
if err := s.Save(ProviderKey("alpha"), Token{AccessToken: "alpha-token"}); err != nil {
3492+
t.Fatalf("Save(alpha): %v", err)
34983493
}
34993494

3500-
// Delete p1 (logout).
3501-
removed, err := s.Delete(ProviderKey("p1"))
3502-
if err != nil || !removed {
3503-
t.Fatalf("Delete(p1) = %v, err=%v", removed, err)
3495+
// Verify alpha is loaded.
3496+
tok, ok, err := s.Load(ProviderKey("alpha"))
3497+
if err != nil || !ok || tok.AccessToken != "alpha-token" {
3498+
t.Fatalf("Load(alpha) before interruption: tok=%v, ok=%v, err=%v", tok, ok, err)
35043499
}
35053500

3506-
// Inspect legacy entry directly: p1 must be deleted from legacy entry.
3507-
blob := keyringBlob{kr: kr, service: keyringService, indexAccount: keyringIndexAccount, legacyAccount: keyringLegacyAccount}
3508-
remainingLegacy, err := blob.readLegacyTokens()
3501+
// Simulate an interrupted Delete: publish tombstone for alpha, but leave alpha's
3502+
// per-key entry in keyring.
3503+
blob := keyringBlob{kr: kr, service: keyringService, indexAccount: keyringIndexAccount}
3504+
if err := blob.writeTombstones(map[string]bool{ProviderKey("alpha"): true}, noLeaseLoss); err != nil {
3505+
t.Fatalf("writeTombstones: %v", err)
3506+
}
3507+
3508+
// A fresh store reader must hide alpha immediately, despite the per-key entry surviving.
3509+
sFresh, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
35093510
if err != nil {
3510-
t.Fatalf("readLegacyTokens: %v", err)
3511+
t.Fatalf("NewStore fresh: %v", err)
35113512
}
3512-
if remainingLegacy != nil {
3513-
if _, exists := remainingLegacy[ProviderKey("p1")]; exists {
3514-
t.Fatal("p1 must not exist in legacy tokens blob after logout")
3515-
}
3516-
if _, exists := remainingLegacy[ProviderKey("p2")]; !exists {
3517-
t.Fatal("p2 must still be present in legacy tokens blob")
3518-
}
3513+
3514+
if tok, ok, err := sFresh.Load(ProviderKey("alpha")); err != nil || ok {
3515+
t.Fatalf("Load(alpha) after tombstone published: ok=%v (tok=%+v), err=%v; want ok=false", ok, tok, err)
35193516
}
35203517

3521-
// Delete p2 (all tokens removed): legacy account should be deleted entirely.
3522-
removed2, err := s.Delete(ProviderKey("p2"))
3523-
if err != nil || !removed2 {
3524-
t.Fatalf("Delete(p2) = %v, err=%v", removed2, err)
3518+
status, err := sFresh.Status("")
3519+
if err != nil {
3520+
t.Fatalf("Status: %v", err)
35253521
}
3526-
if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok {
3527-
t.Fatal("legacy account should be deleted when all legacy tokens are removed")
3522+
for _, entry := range status {
3523+
if entry.Key == ProviderKey("alpha") {
3524+
t.Fatalf("Status returned alpha after tombstone published: %+v", entry)
3525+
}
35283526
}
35293527
}
35303528

3531-
// TestStoreKeyringDowngradedLegacyReaderCannotAuthenticateAfterLogout is the
3532-
// regression for [P1] Logout doesn't revoke legacy representation: after migration
3533-
// to per-key entries, Delete removes the per-key entry and records a tombstone,
3534-
// but must also remove that provider from the legacy oauth-tokens blob so a
3535-
// downgraded / pre-PR binary reading the legacy account directly cannot
3536-
// authenticate using the logged-out credentials.
3537-
func TestStoreKeyringDowngradedLegacyReaderCannotAuthenticateAfterLogout(t *testing.T) {
3538-
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
3529+
// TestStoreKeyringReloginRetiresLegacyOriginPrecedence is the regression for
3530+
// [P1] Retire legacy-origin precedence when an explicit re-login succeeds:
3531+
// after legacy migration and old-binary logout of alpha, an explicit new-binary
3532+
// Save for alpha must retire the legacyOrigin marker so future reads and unrelated
3533+
// Saves do not re-delete alpha via the absence heuristic.
3534+
func TestStoreKeyringReloginRetiresLegacyOriginPrecedence(t *testing.T) {
35393535
kr := newFakeKR()
35403536

3541-
// 1. Older binary logged into github and anthropic via the legacy single-blob entry.
3537+
// 1. Initial state: legacy blob has alpha and beta.
35423538
legacyTokens := map[string]Token{
3543-
ProviderKey("github"): {AccessToken: "gh-secret-token", RefreshToken: "gh-refresh"},
3544-
ProviderKey("anthropic"): {AccessToken: "ant-secret-token", RefreshToken: "ant-refresh"},
3545-
}
3546-
legacyRaw, err := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: legacyTokens})
3547-
if err != nil {
3548-
t.Fatal(err)
3539+
ProviderKey("alpha"): {AccessToken: "old-alpha-token"},
3540+
ProviderKey("beta"): {AccessToken: "beta-token"},
35493541
}
3542+
legacyRaw, _ := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: legacyTokens})
35503543
kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(legacyRaw)
35513544

3552-
// A legacy reader helper simulating a pre-PR binary that only reads the legacy account.
3553-
legacyReaderAuthenticate := func(provider string) (Token, bool) {
3554-
enc, ok := kr.data[keyringService+"/"+keyringLegacyAccount]
3555-
if !ok {
3556-
return Token{}, false
3557-
}
3558-
raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc))
3559-
if err != nil {
3560-
return Token{}, false
3561-
}
3562-
var sf storeFile
3563-
if err := json.Unmarshal(raw, &sf); err != nil {
3564-
return Token{}, false
3565-
}
3566-
tok, has := sf.Tokens[ProviderKey(provider)]
3567-
return tok, has
3545+
s1, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
3546+
if err != nil {
3547+
t.Fatalf("NewStore s1: %v", err)
35683548
}
35693549

3570-
// Legacy reader sees both credentials initially.
3571-
if tok, ok := legacyReaderAuthenticate("github"); !ok || tok.AccessToken != "gh-secret-token" {
3572-
t.Fatalf("legacy reader before logout: github token = %v, ok = %v", tok, ok)
3550+
// Migration happens on save of any token or explicit read.
3551+
if _, ok, err := s1.Load(ProviderKey("alpha")); err != nil || !ok {
3552+
t.Fatalf("initial Load(alpha): ok=%v, err=%v", ok, err)
35733553
}
3574-
if tok, ok := legacyReaderAuthenticate("anthropic"); !ok || tok.AccessToken != "ant-secret-token" {
3575-
t.Fatalf("legacy reader before logout: anthropic token = %v, ok = %v", tok, ok)
3554+
3555+
// 2. Old binary logs out alpha by writing {beta} to the legacy blob.
3556+
legacyTokensAfterLogout := map[string]Token{
3557+
ProviderKey("beta"): {AccessToken: "beta-token"},
35763558
}
3559+
legacyRaw2, _ := json.Marshal(storeFile{SchemaVersion: storeSchemaVersion, Tokens: legacyTokensAfterLogout})
3560+
kr.data[keyringService+"/"+keyringLegacyAccount] = base64.StdEncoding.EncodeToString(legacyRaw2)
35773561

3578-
// 2. Upgraded binary initializes the store, migrating credentials to per-key entries.
3579-
s, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
3580-
if err != nil {
3581-
t.Fatal(err)
3562+
// 3. New binary explicitly logs into alpha with a new token.
3563+
if err := s1.Save(ProviderKey("alpha"), Token{AccessToken: "new-alpha-token"}); err != nil {
3564+
t.Fatalf("Save(alpha): %v", err)
35823565
}
35833566

3584-
// User logs out of github in the upgraded binary.
3585-
removed, err := s.Delete(ProviderKey("github"))
3586-
if err != nil || !removed {
3587-
t.Fatalf("Delete(github): removed=%v err=%v", removed, err)
3567+
// 4. Verify alpha is loaded immediately.
3568+
tok, ok, err := s1.Load(ProviderKey("alpha"))
3569+
if err != nil || !ok || tok.AccessToken != "new-alpha-token" {
3570+
t.Fatalf("Load(alpha) after re-login: tok=%v, ok=%v, err=%v", tok, ok, err)
35883571
}
35893572

3590-
// Upgraded binary confirms github is gone.
3591-
if _, ok, err := s.Load(ProviderKey("github")); err != nil || ok {
3592-
t.Fatalf("upgraded Load(github): ok=%v err=%v, want false", ok, err)
3573+
// 5. Subsequent read from a fresh store must preserve alpha.
3574+
sFresh, err := NewStore(StoreOptions{Storage: "keyring", Keyring: kr})
3575+
if err != nil {
3576+
t.Fatalf("NewStore sFresh: %v", err)
3577+
}
3578+
tok2, ok2, err2 := sFresh.Load(ProviderKey("alpha"))
3579+
if err2 != nil || !ok2 || tok2.AccessToken != "new-alpha-token" {
3580+
t.Fatalf("Load(alpha) from fresh store: tok=%v, ok=%v, err=%v", tok2, ok2, err2)
35933581
}
35943582

3595-
// 3. Mixed-version / downgraded reader check:
3596-
// A downgraded binary reading the legacy account must NOT find github tokens after logout.
3597-
if tok, ok := legacyReaderAuthenticate("github"); ok {
3598-
t.Fatalf("downgraded binary was able to authenticate with github token %v after logout!", tok)
3583+
// 6. An unrelated Save of gamma must NOT re-trigger absence deletion of alpha.
3584+
if err := sFresh.Save(ProviderKey("gamma"), Token{AccessToken: "gamma-token"}); err != nil {
3585+
t.Fatalf("Save(gamma): %v", err)
35993586
}
36003587

3601-
// The downgraded binary can still read the non-logged-out anthropic token.
3602-
if tok, ok := legacyReaderAuthenticate("anthropic"); !ok || tok.AccessToken != "ant-secret-token" {
3603-
t.Fatalf("downgraded binary anthropic token = %v, ok = %v, want anthropic preserved", tok, ok)
3588+
tok3, ok3, err3 := sFresh.Load(ProviderKey("alpha"))
3589+
if err3 != nil || !ok3 || tok3.AccessToken != "new-alpha-token" {
3590+
t.Fatalf("Load(alpha) after unrelated Save(gamma): tok=%v, ok=%v, err=%v", tok3, ok3, err3)
36043591
}
3592+
}
36053593

3606-
// Now logout of anthropic as well.
3607-
removed, err = s.Delete(ProviderKey("anthropic"))
3608-
if err != nil || !removed {
3609-
t.Fatalf("Delete(anthropic): removed=%v err=%v", removed, err)
3594+
// TestStoreKeyringKeyIndexGenerationConflictRejection is the regression for
3595+
// [P1] Do not treat a file-token read as a fencing guarantee:
3596+
// writeKeyIndex must detect when a concurrent process advanced the generation
3597+
// in the keyring and reject stale header publication.
3598+
func TestStoreKeyringKeyIndexGenerationConflictRejection(t *testing.T) {
3599+
kr := newFakeKR()
3600+
blob := keyringBlob{kr: kr, service: keyringService, indexAccount: keyringIndexAccount}
3601+
3602+
// 1. Initial generation 1 written.
3603+
_, gen1, err := blob.writeKeyIndex([]string{ProviderKey("p1")}, 0, 0, nil, noLeaseLoss)
3604+
if err != nil || gen1 != 1 {
3605+
t.Fatalf("initial writeKeyIndex: gen=%d, err=%v", gen1, err)
36103606
}
36113607

3612-
// Downgraded binary sees no tokens at all now.
3613-
if tok, ok := legacyReaderAuthenticate("anthropic"); ok {
3614-
t.Fatalf("downgraded binary was able to authenticate with anthropic token %v after all providers logged out!", tok)
3608+
// 2. Another process advances generation to 2.
3609+
_, gen2, err := blob.writeKeyIndex([]string{ProviderKey("p1"), ProviderKey("p2")}, 1, 1, nil, noLeaseLoss)
3610+
if err != nil || gen2 != 2 {
3611+
t.Fatalf("second writeKeyIndex: gen=%d, err=%v", gen2, err)
36153612
}
3616-
if _, ok := kr.data[keyringService+"/"+keyringLegacyAccount]; ok {
3617-
t.Fatal("legacy account still exists in keyring after all providers logged out")
3613+
3614+
// 3. Stale writer trying to write with priorGeneration=1 must fail due to generation conflict.
3615+
_, _, err = blob.writeKeyIndex([]string{ProviderKey("p1"), ProviderKey("p3")}, 1, 1, nil, noLeaseLoss)
3616+
if err == nil {
3617+
t.Fatal("stale writeKeyIndex with priorGeneration=1 should fail due to generation conflict")
36183618
}
36193619
}
36203620

0 commit comments

Comments
 (0)