feat(secrets): FileStore.Delete to purge a user's OAuth tokens

Account disconnect/delete needs to remove the per-user YouTube refresh
token from the SecretStore. Add Delete(ref) on the file-backed store,
mirroring Put: atomic temp-file+rename, 0600, no-op on an absent ref.

Kept off the read-only ports.SecretStore (Get) — write/delete follow the
existing auth.TokenWriter convention of narrow capability interfaces, so
the youtube adapter's read-only dependency is unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 16:50:20 +02:00
co-authored by Claude Opus 4.8
parent c7624d97fe
commit 17d5e8c393
2 changed files with 71 additions and 0 deletions
+37
View File
@@ -89,6 +89,43 @@ func (s *FileStore) Put(ref, value string) error {
return nil
}
// Delete removes the secret stored under ref, persisting the file atomically
// (temp file + rename) with 0600 permissions. Deleting an absent ref — or one in
// a file that does not exist yet — is a no-op, not an error. Used by account
// management (disconnect / delete-account) to purge a user's OAuth tokens.
func (s *FileStore) Delete(ref string) error {
s.mu.Lock()
defer s.mu.Unlock()
m, err := s.load()
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil // nothing to delete
}
return err
}
if _, ok := m[ref]; !ok {
return nil // already absent
}
delete(m, ref)
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return fmt.Errorf("secrets: create dir: %w", err)
}
b, err := json.Marshal(m)
if err != nil {
return fmt.Errorf("secrets: marshal: %w", err)
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("secrets: write temp: %w", err)
}
if err := os.Rename(tmp, s.path); err != nil {
return fmt.Errorf("secrets: rename: %w", err)
}
return nil
}
// load reads the backing file. A missing file yields an empty map (not an
// error) for Get's caller, except Put distinguishes os.ErrNotExist.
func (s *FileStore) load() (map[string]string, error) {
+34
View File
@@ -49,6 +49,40 @@ func TestPutIsOwnerOnly(t *testing.T) {
}
}
func TestDeleteRemovesRefAndLeavesOthers(t *testing.T) {
path := filepath.Join(t.TempDir(), "secrets.json")
s := secrets.NewFileStore(path)
if err := s.Put("youtube/u1/refresh_token", "rt-1"); err != nil {
t.Fatalf("Put: %v", err)
}
if err := s.Put("youtube/u2/refresh_token", "rt-2"); err != nil {
t.Fatalf("Put: %v", err)
}
if err := s.Delete("youtube/u1/refresh_token"); err != nil {
t.Fatalf("Delete: %v", err)
}
// The deleted ref is gone (persisted: re-open from disk)...
s2 := secrets.NewFileStore(path)
if _, err := s2.Get(context.Background(), "youtube/u1/refresh_token"); !errors.Is(err, secrets.ErrNotFound) {
t.Errorf("Get deleted ref: err = %v, want ErrNotFound", err)
}
// ...and the other user's secret survives.
if got, err := s2.Get(context.Background(), "youtube/u2/refresh_token"); err != nil || got != "rt-2" {
t.Errorf("Get surviving ref = (%q, %v), want (%q, nil)", got, err, "rt-2")
}
}
func TestDeleteAbsentRefIsNoop(t *testing.T) {
// Deleting an unknown ref — or from a file that does not exist yet — is a
// no-op, not an error (mirrors store.DeleteConnection semantics).
s := secrets.NewFileStore(filepath.Join(t.TempDir(), "secrets.json"))
if err := s.Delete("missing"); err != nil {
t.Errorf("Delete absent ref: %v, want nil", err)
}
}
func TestPutMergesEntries(t *testing.T) {
path := filepath.Join(t.TempDir(), "secrets.json")
s := secrets.NewFileStore(path)