···8080`tg repo edit`, `tg repo set-default-branch`, `tg repo delete --yes`, `tg repo fork`,
8181`tg ssh-key delete`, `tg browse`, `tg completion`, `tg auth token`, and `tg api` are available.
82828383+### Authentication & token storage
8484+8585+`tg` stores a single OAuth session in the system keyring: macOS Keychain or
8686+the Secret Service on Linux (GNOME Keyring / KWallet). The keyring unlocks
8787+with your login session, so no separate passphrase is needed.
8888+8989+Logging in again replaces the current session. The keyring is accessed on
9090+first use (not at startup), so authentication only fails once you run a
9191+command that needs a session. On Linux this requires a Secret Service
9292+provider to be running; on a headless system without a D-Bus session bus
9393+(e.g. a server or container), install and start `gnome-keyring-daemon` or
9494+`kwalletd`, or set `DBUS_SESSION_BUS_ADDRESS`.
9595+8396## Architecture
84978598- `cmd/tg/` — CLI entry point
8699- `internal/cli/` — Cobra command tree (`repo`, `issue`, `pr`)
87100- `internal/gitutil/` — Git operations (clone, fetch, patch apply)
88101- `tangled/` — Typed client for the Bobbin API (`api.tangled.org`)
8989-- `atproto/` — Identity resolution (handle ↔ DID, PDS discovery)
102102+- `atproto/` — Identity resolution (handle ↔ DID, PDS discovery); OAuth session storage (system keyring)
9010391104## Dependencies
92105···94107- `git` and `ssh` (for clone and PR checkout)
95108- [`github.com/bluesky-social/indigo`](https://github.com/bluesky-social/indigo) — atproto SDK
96109- [`github.com/spf13/cobra`](https://github.com/spf13/cobra) — CLI framework
110110+- [`github.com/zalando/go-keyring`](https://github.com/zalando/go-keyring) — platform keyring access
9711198112## License
99113
+42-102
atproto/auth.go
···2233import (
44 "context"
55- "encoding/json"
65 "errors"
77- "fmt"
86 "net/url"
99- "os"
1010- "path/filepath"
1171212- "github.com/bluesky-social/indigo/atproto/atclient"
138 "github.com/bluesky-social/indigo/atproto/auth/oauth"
149 "github.com/bluesky-social/indigo/atproto/syntax"
1010+ "github.com/zalando/go-keyring"
1511)
16121713var ErrNotAuthenticated = errors.New("not authenticated")
18141919-// DefaultScopes are requested for a CLI session. The rpc scopes are
2020-// needed for the PDS to mint service-auth JWTs for knot procedures. The
2121-// blob scope is required for uploading PR patch blobs to the PDS.
1515+// DefaultScopes are requested for a CLI session. The rpc scopes are needed for the
1616+// PDS to mint service-auth JWTs for knot procedures. The blob scope is
1717+// required for uploading PR patch blobs to the PDS.
2218var DefaultScopes = []string{
2319 "atproto",
2420 "repo:sh.tangled.actor.profile",
···4137}
42384339type AuthManager struct {
4444- App *oauth.ClientApp
4545- Store *FileStore
4646- state authState
4747- statePath string
4848-}
4949-5050-type authState struct {
5151- CurrentDID string `json:"current_did,omitempty"`
5252- CurrentSession string `json:"current_session,omitempty"`
5353-}
5454-5555-// ConfigDir returns the configuration directory for tg.
5656-//
5757-// It respects the XDG Base Directory Specification: if XDG_CONFIG_HOME is set,
5858-// it uses that directory; otherwise it falls back to ~/.config/tg.
5959-func ConfigDir() (string, error) {
6060- if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
6161- return filepath.Join(dir, "tg"), nil
6262- }
6363-6464- home, err := os.UserHomeDir()
6565- if err != nil {
6666- return "", err
6767- }
6868- return filepath.Join(home, ".config", "tg"), nil
4040+ app *oauth.ClientApp
4141+ store *KeyringStore
6942}
70437171-// NewAuthManager creates an AuthManager. callbackURL must be reachable by the
7272-// user's browser during login.
7373-func NewAuthManager(callbackURL string, dir string) (*AuthManager, error) {
4444+func NewAuthManager(callbackURL string) *AuthManager {
7445 config := oauth.NewLocalhostConfig(callbackURL, DefaultScopes)
7546 config.UserAgent = "tg"
7676-7777- store := NewFileStore(filepath.Join(dir, "oauth"))
7878- manager := &AuthManager{
7979- App: oauth.NewClientApp(&config, store),
8080- Store: store,
8181- statePath: filepath.Join(dir, "auth.json"),
8282- }
8383- if err := manager.loadState(); err != nil {
8484- return nil, fmt.Errorf("load auth state: %w", err)
4747+ store := NewKeyringStore()
4848+ return &AuthManager{
4949+ app: oauth.NewClientApp(&config, store),
5050+ store: store,
8551 }
8686- return manager, nil
8752}
88538954func (m *AuthManager) StartLogin(ctx context.Context, identifier string) (string, error) {
9090- return m.App.StartAuthFlow(ctx, identifier)
5555+ return m.app.StartAuthFlow(ctx, identifier)
9156}
92579358func (m *AuthManager) FinishLogin(ctx context.Context, query url.Values) error {
9494- session, err := m.App.ProcessCallback(ctx, query)
9595- if err != nil {
9696- return err
9797- }
5959+ _, err := m.app.ProcessCallback(ctx, query)
6060+ return err
6161+}
98629999- m.state.CurrentDID = session.AccountDID.String()
100100- m.state.CurrentSession = session.SessionID
101101- return m.saveState()
6363+// CancelLogin cleans up any pending auth request written by StartLogin when the
6464+// login flow is abandoned (e.g. the user closes the browser before the
6565+// callback). It is safe to call after a completed login.
6666+func (m *AuthManager) CancelLogin() {
6767+ _ = m.store.DeletePendingAuthRequest()
10268}
10369104104-func (m *AuthManager) CurrentDID() syntax.DID {
105105- if m.state.CurrentDID == "" {
106106- return syntax.DID("")
107107- }
108108- did, err := syntax.ParseDID(m.state.CurrentDID)
7070+func (m *AuthManager) CurrentDID(ctx context.Context) (syntax.DID, error) {
7171+ session, err := m.CurrentSession(ctx)
10972 if err != nil {
110110- return syntax.DID("")
7373+ return "", err
11174 }
112112- return did
113113-}
114114-115115-func (m *AuthManager) IsAuthenticated() bool {
116116- return m.state.CurrentDID != "" && m.state.CurrentSession != ""
7575+ return session.Data.AccountDID, nil
11776}
1187711978func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, error) {
120120- if !m.IsAuthenticated() {
121121- return nil, ErrNotAuthenticated
122122- }
123123- return m.App.ResumeSession(ctx, m.CurrentDID(), m.state.CurrentSession)
124124-}
125125-126126-func (m *AuthManager) APIClient(ctx context.Context) (*atclient.APIClient, error) {
127127- session, err := m.CurrentSession(ctx)
7979+ session, err := m.app.ResumeSession(ctx, "", "")
12880 if err != nil {
8181+ if errors.Is(err, keyring.ErrNotFound) {
8282+ return nil, ErrNotAuthenticated
8383+ }
12984 return nil, err
13085 }
131131- return session.APIClient(), nil
8686+ return session, nil
13287}
1338813489func (m *AuthManager) Logout(ctx context.Context) error {
135135- if !m.IsAuthenticated() {
9090+ err := m.app.Logout(ctx, "", "")
9191+ if err == nil {
13692 return nil
13793 }
138138- if err := m.App.Logout(ctx, m.CurrentDID(), m.state.CurrentSession); err != nil {
139139- return err
140140- }
141141-142142- m.state = authState{}
143143- return m.saveState()
144144-}
145145-146146-func (m *AuthManager) loadState() error {
147147- data, err := os.ReadFile(m.statePath)
148148- if err != nil {
149149- if os.IsNotExist(err) {
150150- return nil
151151- }
152152- return err
153153- }
154154- return json.Unmarshal(data, &m.state)
155155-}
156156-157157-func (m *AuthManager) saveState() error {
158158- if err := os.MkdirAll(filepath.Dir(m.statePath), 0o700); err != nil {
159159- return err
9494+ if errors.Is(err, keyring.ErrNotFound) {
9595+ return ErrNotAuthenticated
16096 }
161161-162162- data, err := json.MarshalIndent(m.state, "", " ")
163163- if err != nil {
164164- return err
9797+ // Logout failed partway — most commonly because the session entry is
9898+ // corrupt or the keyring is transiently unavailable. The upstream Logout
9999+ // aborts before DeleteSession in that case, so the bad entry would
100100+ // otherwise be unrecoverable short of manual keyring surgery. Best-effort
101101+ // clear it so the user can re-login; if that also fails, surface the
102102+ // original error.
103103+ if deleteErr := m.store.DeleteSession(ctx, "", ""); deleteErr == nil {
104104+ return nil
165105 }
166166- return os.WriteFile(m.statePath, data, 0o600)
106106+ return err
167107}
+152
atproto/keyring_store.go
···11+package atproto
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "errors"
77+ "fmt"
88+ "sync"
99+1010+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1111+ "github.com/bluesky-social/indigo/atproto/syntax"
1212+ "github.com/zalando/go-keyring"
1313+)
1414+1515+// Reverse-DNS of the repo so it won't collide with other clients.
1616+const keyringService = "io.github.alyraffauf.tg"
1717+1818+// Interface over go-keyring so tests can inject a fake.
1919+type secretBackend interface {
2020+ Get(service, user string) (string, error)
2121+ Set(service, user, password string) error
2222+ Delete(service, user string) error
2323+}
2424+2525+type goKeyringBackend struct{}
2626+2727+func (goKeyringBackend) Get(service, user string) (string, error) {
2828+ return keyring.Get(service, user)
2929+}
3030+3131+func (goKeyringBackend) Set(service, user, password string) error {
3232+ return keyring.Set(service, user, password)
3333+}
3434+3535+func (goKeyringBackend) Delete(service, user string) error {
3636+ return keyring.Delete(service, user)
3737+}
3838+3939+// KeyringStore implements oauth.ClientAuthStore on top of the OS keyring.
4040+//
4141+// A mutex serializes access within one process. It does NOT coordinate across
4242+// processes: concurrent invocations that refresh tokens can race, and the loser
4343+// may need to re-login.
4444+type KeyringStore struct {
4545+ backend secretBackend
4646+ service string
4747+4848+ mu sync.Mutex
4949+ // Most recent auth-request state, tracked so an abandoned login can clean up.
5050+ pendingState string
5151+}
5252+5353+func NewKeyringStore() *KeyringStore {
5454+ return &KeyringStore{backend: goKeyringBackend{}, service: keyringService}
5555+}
5656+5757+const currentSessionKey = "session:current"
5858+5959+func requestKey(state string) string {
6060+ return "request:" + state
6161+}
6262+6363+func (s *KeyringStore) getSecret(key string, target any) error {
6464+ data, err := s.backend.Get(s.service, key)
6565+ if err != nil {
6666+ return err
6767+ }
6868+ if err := json.Unmarshal([]byte(data), target); err != nil {
6969+ return fmt.Errorf("decode secret %q: %w", key, err)
7070+ }
7171+ return nil
7272+}
7373+7474+func (s *KeyringStore) saveSecret(key string, value any) error {
7575+ data, err := json.Marshal(value)
7676+ if err != nil {
7777+ return fmt.Errorf("marshal value: %w", err)
7878+ }
7979+ return s.backend.Set(s.service, key, string(data))
8080+}
8181+8282+// Ignore not-found errors; the entry is already gone.
8383+func (s *KeyringStore) deleteSecret(key string) error {
8484+ err := s.backend.Delete(s.service, key)
8585+ if err != nil && !errors.Is(err, keyring.ErrNotFound) {
8686+ return err
8787+ }
8888+ return nil
8989+}
9090+9191+func (s *KeyringStore) GetSession(_ context.Context, _ syntax.DID, _ string) (*oauth.ClientSessionData, error) {
9292+ s.mu.Lock()
9393+ defer s.mu.Unlock()
9494+ var session oauth.ClientSessionData
9595+ if err := s.getSecret(currentSessionKey, &session); err != nil {
9696+ return nil, err
9797+ }
9898+ return &session, nil
9999+}
100100+101101+func (s *KeyringStore) SaveSession(_ context.Context, session oauth.ClientSessionData) error {
102102+ s.mu.Lock()
103103+ defer s.mu.Unlock()
104104+ return s.saveSecret(currentSessionKey, session)
105105+}
106106+107107+func (s *KeyringStore) DeleteSession(_ context.Context, _ syntax.DID, _ string) error {
108108+ s.mu.Lock()
109109+ defer s.mu.Unlock()
110110+ return s.deleteSecret(currentSessionKey)
111111+}
112112+113113+func (s *KeyringStore) GetAuthRequestInfo(_ context.Context, state string) (*oauth.AuthRequestData, error) {
114114+ s.mu.Lock()
115115+ defer s.mu.Unlock()
116116+ var info oauth.AuthRequestData
117117+ if err := s.getSecret(requestKey(state), &info); err != nil {
118118+ return nil, err
119119+ }
120120+ return &info, nil
121121+}
122122+123123+func (s *KeyringStore) SaveAuthRequestInfo(_ context.Context, info oauth.AuthRequestData) error {
124124+ s.mu.Lock()
125125+ defer s.mu.Unlock()
126126+ s.pendingState = info.State
127127+ return s.saveSecret(requestKey(info.State), info)
128128+}
129129+130130+func (s *KeyringStore) DeleteAuthRequestInfo(_ context.Context, state string) error {
131131+ s.mu.Lock()
132132+ defer s.mu.Unlock()
133133+ if s.pendingState == state {
134134+ s.pendingState = ""
135135+ }
136136+ return s.deleteSecret(requestKey(state))
137137+}
138138+139139+// DeletePendingAuthRequest removes the auth-request entry written by the most
140140+// recent SaveAuthRequestInfo, if it hasn't already been consumed. Used to clean
141141+// up when a login is abandoned before the callback completes. Missing entries
142142+// are ignored.
143143+func (s *KeyringStore) DeletePendingAuthRequest() error {
144144+ s.mu.Lock()
145145+ state := s.pendingState
146146+ s.pendingState = ""
147147+ s.mu.Unlock()
148148+ if state == "" {
149149+ return nil
150150+ }
151151+ return s.deleteSecret(requestKey(state))
152152+}
+538
atproto/keyring_store_test.go
···11+package atproto
22+33+import (
44+ "context"
55+ "errors"
66+ "reflect"
77+ "strings"
88+ "sync"
99+ "testing"
1010+1111+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1212+ "github.com/bluesky-social/indigo/atproto/auth/oauth"
1313+ "github.com/bluesky-social/indigo/atproto/syntax"
1414+ "github.com/zalando/go-keyring"
1515+)
1616+1717+type fakeKeyring struct {
1818+ secrets map[string]string
1919+ deleteErr error
2020+ getErr error
2121+}
2222+2323+func newFakeKeyring() *fakeKeyring {
2424+ return &fakeKeyring{secrets: make(map[string]string)}
2525+}
2626+2727+func backendKey(service, user string) string { return service + "\x00" + user }
2828+2929+func (f *fakeKeyring) Get(service, user string) (string, error) {
3030+ if f.getErr != nil {
3131+ return "", f.getErr
3232+ }
3333+ if v, ok := f.secrets[backendKey(service, user)]; ok {
3434+ return v, nil
3535+ }
3636+ return "", keyring.ErrNotFound
3737+}
3838+3939+func (f *fakeKeyring) Set(service, user, password string) error {
4040+ f.secrets[backendKey(service, user)] = password
4141+ return nil
4242+}
4343+4444+func (f *fakeKeyring) Delete(service, user string) error {
4545+ if f.deleteErr != nil {
4646+ return f.deleteErr
4747+ }
4848+ delete(f.secrets, backendKey(service, user))
4949+ return nil
5050+}
5151+5252+func newAuthManagerForTest(callbackURL string, store *KeyringStore) *AuthManager {
5353+ config := oauth.NewLocalhostConfig(callbackURL, DefaultScopes)
5454+ config.UserAgent = "tg"
5555+ return &AuthManager{
5656+ app: oauth.NewClientApp(&config, store),
5757+ store: store,
5858+ }
5959+}
6060+6161+func testKeyringStore(backend secretBackend) *KeyringStore {
6262+ return &KeyringStore{backend: backend, service: keyringService}
6363+}
6464+6565+func mustDID(t *testing.T, raw string) syntax.DID {
6666+ t.Helper()
6767+ did, err := syntax.ParseDID(raw)
6868+ if err != nil {
6969+ t.Fatalf("parse DID: %v", err)
7070+ }
7171+ return did
7272+}
7373+7474+func sampleSession(did syntax.DID) oauth.ClientSessionData {
7575+ return oauth.ClientSessionData{
7676+ AccountDID: did,
7777+ SessionID: "session-1",
7878+ HostURL: "https://example.com",
7979+ AccessToken: "access-token",
8080+ RefreshToken: "refresh-token",
8181+ }
8282+}
8383+8484+func mustGenerateDpopKey(t *testing.T) string {
8585+ t.Helper()
8686+ key, err := atcrypto.GeneratePrivateKeyP256()
8787+ if err != nil {
8888+ t.Fatalf("generate DPoP key: %v", err)
8989+ }
9090+ return key.Multibase()
9191+}
9292+9393+func TestKeyringStore_SaveAndGetSession(t *testing.T) {
9494+ store := testKeyringStore(newFakeKeyring())
9595+ ctx := context.Background()
9696+ did := mustDID(t, "did:plc:abcdefghijklmnopqrstuvwxyz")
9797+9898+ if err := store.SaveSession(ctx, sampleSession(did)); err != nil {
9999+ t.Fatalf("SaveSession: %v", err)
100100+ }
101101+102102+ got, err := store.GetSession(ctx, did, "")
103103+ if err != nil {
104104+ t.Fatalf("GetSession: %v", err)
105105+ }
106106+ if got.AccessToken != "access-token" {
107107+ t.Errorf("AccessToken = %q, want %q", got.AccessToken, "access-token")
108108+ }
109109+}
110110+111111+func TestKeyringStore_GetSessionNotFound(t *testing.T) {
112112+ store := testKeyringStore(newFakeKeyring())
113113+ did := mustDID(t, "did:plc:abcdefghijklmnopqrstuvwxyz")
114114+115115+ _, err := store.GetSession(context.Background(), did, "")
116116+ if err == nil {
117117+ t.Fatal("expected error for missing session, got nil")
118118+ }
119119+ if !errors.Is(err, keyring.ErrNotFound) {
120120+ t.Errorf("error does not wrap keyring.ErrNotFound: %v", err)
121121+ }
122122+}
123123+124124+func TestKeyringStore_DeleteSession(t *testing.T) {
125125+ backend := newFakeKeyring()
126126+ store := testKeyringStore(backend)
127127+ ctx := context.Background()
128128+ did := mustDID(t, "did:plc:abcdefghijklmnopqrstuvwxyz")
129129+130130+ if err := store.SaveSession(ctx, sampleSession(did)); err != nil {
131131+ t.Fatalf("SaveSession: %v", err)
132132+ }
133133+ if err := store.DeleteSession(ctx, did, ""); err != nil {
134134+ t.Fatalf("DeleteSession: %v", err)
135135+ }
136136+ if _, err := store.GetSession(ctx, did, ""); !errors.Is(err, keyring.ErrNotFound) {
137137+ t.Fatalf("session still present after delete: %v", err)
138138+ }
139139+140140+ if err := store.DeleteSession(ctx, did, ""); err != nil {
141141+ t.Fatalf("deleting missing session errored: %v", err)
142142+ }
143143+}
144144+145145+func TestKeyringStore_SaveOverwritesPrevious(t *testing.T) {
146146+ store := testKeyringStore(newFakeKeyring())
147147+ ctx := context.Background()
148148+ first := mustDID(t, "did:plc:firstfirstfirstfirstfirst")
149149+ second := mustDID(t, "did:plc:secondsecondsecondsecond")
150150+151151+ if err := store.SaveSession(ctx, sampleSession(first)); err != nil {
152152+ t.Fatalf("SaveSession first: %v", err)
153153+ }
154154+ if err := store.SaveSession(ctx, sampleSession(second)); err != nil {
155155+ t.Fatalf("SaveSession second: %v", err)
156156+ }
157157+158158+ got, err := store.GetSession(ctx, first, "")
159159+ if err != nil {
160160+ t.Fatalf("GetSession: %v", err)
161161+ }
162162+ if got.AccountDID != second {
163163+ t.Errorf("AccountDID = %q, want %q (second DID)", got.AccountDID, second)
164164+ }
165165+}
166166+167167+func TestKeyringStore_AuthRequestRoundTrip(t *testing.T) {
168168+ store := testKeyringStore(newFakeKeyring())
169169+ ctx := context.Background()
170170+ info := oauth.AuthRequestData{State: "state-1", PKCEVerifier: "verifier"}
171171+172172+ if err := store.SaveAuthRequestInfo(ctx, info); err != nil {
173173+ t.Fatalf("SaveAuthRequestInfo: %v", err)
174174+ }
175175+ got, err := store.GetAuthRequestInfo(ctx, "state-1")
176176+ if err != nil {
177177+ t.Fatalf("GetAuthRequestInfo: %v", err)
178178+ }
179179+ if got.PKCEVerifier != "verifier" {
180180+ t.Errorf("PKCEVerifier = %q, want %q", got.PKCEVerifier, "verifier")
181181+ }
182182+ if err := store.DeleteAuthRequestInfo(ctx, "state-1"); err != nil {
183183+ t.Fatalf("DeleteAuthRequestInfo: %v", err)
184184+ }
185185+}
186186+187187+func TestKeyringStore_DeleteSessionPropagatesError(t *testing.T) {
188188+ backend := newFakeKeyring()
189189+ backend.deleteErr = errors.New("keyring daemon unavailable")
190190+ store := testKeyringStore(backend)
191191+ ctx := context.Background()
192192+ did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff")
193193+194194+ if err := store.SaveSession(ctx, sampleSession(did)); err != nil {
195195+ t.Fatalf("SaveSession: %v", err)
196196+ }
197197+198198+ err := store.DeleteSession(ctx, did, "")
199199+ if err == nil {
200200+ t.Fatal("expected error, got nil")
201201+ }
202202+ if !strings.Contains(err.Error(), "keyring daemon unavailable") {
203203+ t.Errorf("error = %q, want substring %q", err, "keyring daemon unavailable")
204204+ }
205205+}
206206+207207+func TestAuthManager_CurrentSessionErrNotFound(t *testing.T) {
208208+ manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", testKeyringStore(newFakeKeyring()))
209209+ _, err := manager.CurrentSession(context.Background())
210210+ if !errors.Is(err, ErrNotAuthenticated) {
211211+ t.Errorf("expected ErrNotAuthenticated, got %v", err)
212212+ }
213213+}
214214+215215+func TestAuthManager_CurrentSessionPropagatesGetError(t *testing.T) {
216216+ backend := newFakeKeyring()
217217+ backend.getErr = errors.New("dbus connection refused")
218218+ manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", testKeyringStore(backend))
219219+ _, err := manager.CurrentSession(context.Background())
220220+ if err == nil {
221221+ t.Fatal("expected error, got nil")
222222+ }
223223+ if errors.Is(err, ErrNotAuthenticated) {
224224+ t.Error("keyring access error should not be classified as unauthenticated")
225225+ }
226226+ if !strings.Contains(err.Error(), "dbus connection refused") {
227227+ t.Errorf("error = %q, want substring %q", err, "dbus connection refused")
228228+ }
229229+}
230230+231231+func TestAuthManager_LogoutWithoutSession(t *testing.T) {
232232+ manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", testKeyringStore(newFakeKeyring()))
233233+ err := manager.Logout(context.Background())
234234+ if !errors.Is(err, ErrNotAuthenticated) {
235235+ t.Errorf("expected ErrNotAuthenticated for logout without session, got %v", err)
236236+ }
237237+}
238238+239239+func TestAuthManager_LogoutDeletionErrorPropagates(t *testing.T) {
240240+ backend := newFakeKeyring()
241241+ store := testKeyringStore(backend)
242242+ ctx := context.Background()
243243+ did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff")
244244+245245+ session := sampleSession(did)
246246+ session.DPoPPrivateKeyMultibase = mustGenerateDpopKey(t)
247247+ if err := store.SaveSession(ctx, session); err != nil {
248248+ t.Fatalf("SaveSession: %v", err)
249249+ }
250250+251251+ backend.deleteErr = errors.New("keyring daemon unavailable")
252252+ manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store)
253253+ err := manager.Logout(context.Background())
254254+ if err == nil {
255255+ t.Fatal("expected error, got nil")
256256+ }
257257+ if !strings.Contains(err.Error(), "keyring daemon unavailable") {
258258+ t.Errorf("error = %q, want substring %q", err, "keyring daemon unavailable")
259259+ }
260260+}
261261+262262+// fullyPopulatedSession returns a ClientSessionData with every field set to a
263263+// distinct, non-zero value, so a round-trip test catches any field that gets
264264+// dropped or mis-serialized.
265265+func fullyPopulatedSession(t *testing.T, did syntax.DID) oauth.ClientSessionData {
266266+ t.Helper()
267267+ return oauth.ClientSessionData{
268268+ AccountDID: did,
269269+ SessionID: "session-1",
270270+ HostURL: "https://pds.example.com",
271271+ AuthServerURL: "https://auth.example.com",
272272+ AuthServerTokenEndpoint: "https://auth.example.com/token",
273273+ AuthServerRevocationEndpoint: "https://auth.example.com/revoke",
274274+ Scopes: []string{"atproto", "repo:sh.tangled.repo"},
275275+ AccessToken: "access-token-123",
276276+ RefreshToken: "refresh-token-456",
277277+ DPoPAuthServerNonce: "authserver-nonce",
278278+ DPoPHostNonce: "host-nonce",
279279+ DPoPPrivateKeyMultibase: mustGenerateDpopKey(t),
280280+ }
281281+}
282282+283283+// TestKeyringStore_SessionRoundTrip_FullyPopulated verifies that every field
284284+// of ClientSessionData survives a save→load cycle, including the DPoP private
285285+// key, scopes, and the omitempty revocation endpoint.
286286+func TestKeyringStore_SessionRoundTrip_FullyPopulated(t *testing.T) {
287287+ store := testKeyringStore(newFakeKeyring())
288288+ ctx := context.Background()
289289+ did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff")
290290+ want := fullyPopulatedSession(t, did)
291291+292292+ if err := store.SaveSession(ctx, want); err != nil {
293293+ t.Fatalf("SaveSession: %v", err)
294294+ }
295295+ got, err := store.GetSession(ctx, did, "")
296296+ if err != nil {
297297+ t.Fatalf("GetSession: %v", err)
298298+ }
299299+ if !reflect.DeepEqual(*got, want) {
300300+ t.Errorf("round-trip mismatch:\n got %+v\n want %+v", *got, want)
301301+ }
302302+}
303303+304304+// TestKeyringStore_SessionRoundTrip_EmptyScopesAndRevocation verifies the
305305+// omitempty/empty-slice edge cases (empty scopes slice, empty revocation
306306+// endpoint) round-trip without losing the distinction that matters.
307307+func TestKeyringStore_SessionRoundTrip_EmptyScopesAndRevocation(t *testing.T) {
308308+ store := testKeyringStore(newFakeKeyring())
309309+ ctx := context.Background()
310310+ did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff")
311311+ want := fullyPopulatedSession(t, did)
312312+ want.AuthServerRevocationEndpoint = ""
313313+ want.Scopes = []string{}
314314+315315+ if err := store.SaveSession(ctx, want); err != nil {
316316+ t.Fatalf("SaveSession: %v", err)
317317+ }
318318+ got, err := store.GetSession(ctx, did, "")
319319+ if err != nil {
320320+ t.Fatalf("GetSession: %v", err)
321321+ }
322322+ if got.AuthServerRevocationEndpoint != "" {
323323+ t.Errorf("revocation endpoint = %q, want empty", got.AuthServerRevocationEndpoint)
324324+ }
325325+ if len(got.Scopes) != 0 {
326326+ t.Errorf("scopes = %v, want empty", got.Scopes)
327327+ }
328328+}
329329+330330+// TestKeyringStore_AuthRequestRoundTrip_FullyPopulated verifies every field of
331331+// AuthRequestData round-trips, including the *syntax.DID pointer in both the
332332+// non-nil and nil cases.
333333+func TestKeyringStore_AuthRequestRoundTrip_FullyPopulated(t *testing.T) {
334334+ store := testKeyringStore(newFakeKeyring())
335335+ ctx := context.Background()
336336+ did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff")
337337+338338+ want := oauth.AuthRequestData{
339339+ State: "state-1",
340340+ AuthServerURL: "https://auth.example.com",
341341+ AccountDID: &did,
342342+ Scopes: []string{"atproto", "repo:sh.tangled.repo"},
343343+ RequestURI: "urn:ietf:params:oauth:request_uri:abc",
344344+ AuthServerTokenEndpoint: "https://auth.example.com/token",
345345+ AuthServerRevocationEndpoint: "https://auth.example.com/revoke",
346346+ PKCEVerifier: "verifier-123",
347347+ DPoPAuthServerNonce: "nonce-123",
348348+ DPoPPrivateKeyMultibase: mustGenerateDpopKey(t),
349349+ }
350350+ if err := store.SaveAuthRequestInfo(ctx, want); err != nil {
351351+ t.Fatalf("SaveAuthRequestInfo: %v", err)
352352+ }
353353+ got, err := store.GetAuthRequestInfo(ctx, "state-1")
354354+ if err != nil {
355355+ t.Fatalf("GetAuthRequestInfo: %v", err)
356356+ }
357357+ if !reflect.DeepEqual(*got, want) {
358358+ t.Errorf("round-trip mismatch:\n got %+v\n want %+v", *got, want)
359359+ }
360360+361361+ // nil AccountDID must round-trip as nil (omitempty drops it).
362362+ wantNil := want
363363+ wantNil.State = "state-2"
364364+ wantNil.AccountDID = nil
365365+ if err := store.SaveAuthRequestInfo(ctx, wantNil); err != nil {
366366+ t.Fatalf("SaveAuthRequestInfo (nil DID): %v", err)
367367+ }
368368+ gotNil, err := store.GetAuthRequestInfo(ctx, "state-2")
369369+ if err != nil {
370370+ t.Fatalf("GetAuthRequestInfo (nil DID): %v", err)
371371+ }
372372+ if gotNil.AccountDID != nil {
373373+ t.Errorf("AccountDID = %v, want nil", gotNil.AccountDID)
374374+ }
375375+}
376376+377377+// TestKeyringStore_GetSessionIgnoresDID verifies the singleton contract the
378378+// codebase relies on: the did and sessionID arguments are ignored, and the
379379+// stored session is returned regardless of what is requested. This nails down
380380+// the design so a future multi-session refactor is caught.
381381+func TestKeyringStore_GetSessionIgnoresDID(t *testing.T) {
382382+ store := testKeyringStore(newFakeKeyring())
383383+ ctx := context.Background()
384384+ savedDID := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff")
385385+ if err := store.SaveSession(ctx, sampleSession(savedDID)); err != nil {
386386+ t.Fatalf("SaveSession: %v", err)
387387+ }
388388+389389+ otherDID := mustDID(t, "did:plc:zzzzzzzzzzzzzzzzzzzzzzzz")
390390+ got, err := store.GetSession(ctx, otherDID, "nonexistent-session")
391391+ if err != nil {
392392+ t.Fatalf("GetSession with different DID: %v", err)
393393+ }
394394+ if got.AccountDID != savedDID {
395395+ t.Errorf("AccountDID = %q, want %q (singleton ignores requested DID)", got.AccountDID, savedDID)
396396+ }
397397+}
398398+399399+// TestKeyringStore_GetSessionMalformedJSON verifies that a corrupt keyring
400400+// entry (e.g. written by an older build or another program) produces a
401401+// clear, non-ErrNotFound error rather than being mistaken for "no session".
402402+func TestKeyringStore_GetSessionMalformedJSON(t *testing.T) {
403403+ backend := newFakeKeyring()
404404+ store := testKeyringStore(backend)
405405+ // Seed a corrupt entry directly under the session key.
406406+ backend.secrets[backendKey(keyringService, currentSessionKey)] = "not-json{"
407407+408408+ _, err := store.GetSession(context.Background(), syntax.DID(""), "")
409409+ if err == nil {
410410+ t.Fatal("expected error for corrupt session, got nil")
411411+ }
412412+ if errors.Is(err, keyring.ErrNotFound) {
413413+ t.Error("corrupt entry should not be classified as not-found")
414414+ }
415415+ if !strings.Contains(err.Error(), "decode secret") {
416416+ t.Errorf("error = %q, want substring %q", err, "decode secret")
417417+ }
418418+}
419419+420420+// TestKeyringStore_ConcurrentAccess exercises concurrent reads/writes against
421421+// a single store under the race detector. It verifies the process-local mutex
422422+// keeps operations from tearing (the fake keyring's map would otherwise race).
423423+func TestKeyringStore_ConcurrentAccess(t *testing.T) {
424424+ store := testKeyringStore(newFakeKeyring())
425425+ ctx := context.Background()
426426+ did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff")
427427+ session := fullyPopulatedSession(t, did)
428428+429429+ var wg sync.WaitGroup
430430+ for range 16 {
431431+ wg.Add(1)
432432+ go func() {
433433+ defer wg.Done()
434434+ for range 50 {
435435+ if err := store.SaveSession(ctx, session); err != nil {
436436+ t.Errorf("SaveSession: %v", err)
437437+ return
438438+ }
439439+ if _, err := store.GetSession(ctx, did, ""); err != nil {
440440+ t.Errorf("GetSession: %v", err)
441441+ return
442442+ }
443443+ }
444444+ }()
445445+ }
446446+ wg.Wait()
447447+}
448448+449449+// TestAuthManager_PersistSessionCallbackPersistsRefresh verifies that the
450450+// PersistSessionCallback indigo wires during ResumeSession actually writes
451451+// rotated tokens back to the keyring. This is the property that lets a token
452452+// refresh survive a process restart.
453453+func TestAuthManager_PersistSessionCallbackPersistsRefresh(t *testing.T) {
454454+ store := testKeyringStore(newFakeKeyring())
455455+ manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store)
456456+ ctx := context.Background()
457457+ did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff")
458458+459459+ session := fullyPopulatedSession(t, did)
460460+ if err := store.SaveSession(ctx, session); err != nil {
461461+ t.Fatalf("SaveSession: %v", err)
462462+ }
463463+464464+ sess, err := manager.CurrentSession(ctx)
465465+ if err != nil {
466466+ t.Fatalf("CurrentSession: %v", err)
467467+ }
468468+ if sess.PersistSessionCallback == nil {
469469+ t.Fatal("PersistSessionCallback not wired by ResumeSession")
470470+ }
471471+472472+ // Simulate a token refresh: rotate both tokens and persist.
473473+ sess.Data.AccessToken = "rotated-access-token"
474474+ sess.Data.RefreshToken = "rotated-refresh-token"
475475+ sess.PersistSessionCallback(ctx, sess.Data)
476476+477477+ reloaded, err := store.GetSession(ctx, did, "")
478478+ if err != nil {
479479+ t.Fatalf("GetSession after refresh: %v", err)
480480+ }
481481+ if reloaded.AccessToken != "rotated-access-token" {
482482+ t.Errorf("AccessToken = %q, want %q", reloaded.AccessToken, "rotated-access-token")
483483+ }
484484+ if reloaded.RefreshToken != "rotated-refresh-token" {
485485+ t.Errorf("RefreshToken = %q, want %q", reloaded.RefreshToken, "rotated-refresh-token")
486486+ }
487487+}
488488+489489+// TestAuthManager_LogoutClearsCorruptSession verifies that Logout recovers
490490+// from a corrupt session entry (invalid DPoP key) by force-clearing it, so the
491491+// user can re-login instead of being permanently locked out.
492492+func TestAuthManager_LogoutClearsCorruptSession(t *testing.T) {
493493+ store := testKeyringStore(newFakeKeyring())
494494+ manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store)
495495+ ctx := context.Background()
496496+ did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff")
497497+498498+ // Save a session with an invalid (empty) DPoP private key. ResumeSession
499499+ // will fail to parse it, so indigo's Logout never reaches DeleteSession.
500500+ corrupt := sampleSession(did)
501501+ corrupt.DPoPPrivateKeyMultibase = ""
502502+ if err := store.SaveSession(ctx, corrupt); err != nil {
503503+ t.Fatalf("SaveSession: %v", err)
504504+ }
505505+506506+ if err := manager.Logout(ctx); err != nil {
507507+ t.Fatalf("Logout should force-clear a corrupt session, got: %v", err)
508508+ }
509509+ if _, err := store.GetSession(ctx, did, ""); !errors.Is(err, keyring.ErrNotFound) {
510510+ t.Fatalf("corrupt session should have been cleared, got: %v", err)
511511+ }
512512+}
513513+514514+// TestAuthManager_CancelLoginDeletesPendingRequest verifies that an abandoned
515515+// login (StartLogin without FinishLogin) leaves no auth-request entry behind
516516+// once CancelLogin is called.
517517+func TestAuthManager_CancelLoginDeletesPendingRequest(t *testing.T) {
518518+ store := testKeyringStore(newFakeKeyring())
519519+ ctx := context.Background()
520520+ info := oauth.AuthRequestData{
521521+ State: "pending-state",
522522+ PKCEVerifier: "verifier",
523523+ DPoPPrivateKeyMultibase: mustGenerateDpopKey(t),
524524+ }
525525+ if err := store.SaveAuthRequestInfo(ctx, info); err != nil {
526526+ t.Fatalf("SaveAuthRequestInfo: %v", err)
527527+ }
528528+529529+ manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store)
530530+ manager.CancelLogin()
531531+532532+ if _, err := store.GetAuthRequestInfo(ctx, "pending-state"); !errors.Is(err, keyring.ErrNotFound) {
533533+ t.Fatalf("pending auth request should have been cleared, got: %v", err)
534534+ }
535535+536536+ // CancelLogin after the request is already gone is a no-op.
537537+ manager.CancelLogin()
538538+}
···9191 DID string `json:"did,omitempty"`
9292 Handle string `json:"handle,omitempty"`
9393}
9494+9595+type authLogoutResult struct {
9696+ // WasLoggedIn reports whether a session existed and was cleared. It is false
9797+ // when there was nothing to log out (not a failure; the command still exits 0).
9898+ WasLoggedIn bool `json:"wasLoggedIn"`
9999+}