A terminal client for Tangled.
0

Configure Feed

Select the types of activity you want to include in your feed.

atproto: switch oauth session storage from files to os keyring

Aly Raffauf (Jul 17, 2026, 8:35 PM EDT) 1404faff ba0c5e69

+850 -295
+15 -1
README.md
··· 80 80 `tg repo edit`, `tg repo set-default-branch`, `tg repo delete --yes`, `tg repo fork`, 81 81 `tg ssh-key delete`, `tg browse`, `tg completion`, `tg auth token`, and `tg api` are available. 82 82 83 + ### Authentication & token storage 84 + 85 + `tg` stores a single OAuth session in the system keyring: macOS Keychain or 86 + the Secret Service on Linux (GNOME Keyring / KWallet). The keyring unlocks 87 + with your login session, so no separate passphrase is needed. 88 + 89 + Logging in again replaces the current session. The keyring is accessed on 90 + first use (not at startup), so authentication only fails once you run a 91 + command that needs a session. On Linux this requires a Secret Service 92 + provider to be running; on a headless system without a D-Bus session bus 93 + (e.g. a server or container), install and start `gnome-keyring-daemon` or 94 + `kwalletd`, or set `DBUS_SESSION_BUS_ADDRESS`. 95 + 83 96 ## Architecture 84 97 85 98 - `cmd/tg/` — CLI entry point 86 99 - `internal/cli/` — Cobra command tree (`repo`, `issue`, `pr`) 87 100 - `internal/gitutil/` — Git operations (clone, fetch, patch apply) 88 101 - `tangled/` — Typed client for the Bobbin API (`api.tangled.org`) 89 - - `atproto/` — Identity resolution (handle ↔ DID, PDS discovery) 102 + - `atproto/` — Identity resolution (handle ↔ DID, PDS discovery); OAuth session storage (system keyring) 90 103 91 104 ## Dependencies 92 105 ··· 94 107 - `git` and `ssh` (for clone and PR checkout) 95 108 - [`github.com/bluesky-social/indigo`](https://github.com/bluesky-social/indigo) — atproto SDK 96 109 - [`github.com/spf13/cobra`](https://github.com/spf13/cobra) — CLI framework 110 + - [`github.com/zalando/go-keyring`](https://github.com/zalando/go-keyring) — platform keyring access 97 111 98 112 ## License 99 113
+42 -102
atproto/auth.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/json" 6 5 "errors" 7 - "fmt" 8 6 "net/url" 9 - "os" 10 - "path/filepath" 11 7 12 - "github.com/bluesky-social/indigo/atproto/atclient" 13 8 "github.com/bluesky-social/indigo/atproto/auth/oauth" 14 9 "github.com/bluesky-social/indigo/atproto/syntax" 10 + "github.com/zalando/go-keyring" 15 11 ) 16 12 17 13 var ErrNotAuthenticated = errors.New("not authenticated") 18 14 19 - // DefaultScopes are requested for a CLI session. The rpc scopes are 20 - // needed for the PDS to mint service-auth JWTs for knot procedures. The 21 - // blob scope is required for uploading PR patch blobs to the PDS. 15 + // DefaultScopes are requested for a CLI session. The rpc scopes are needed for the 16 + // PDS to mint service-auth JWTs for knot procedures. The blob scope is 17 + // required for uploading PR patch blobs to the PDS. 22 18 var DefaultScopes = []string{ 23 19 "atproto", 24 20 "repo:sh.tangled.actor.profile", ··· 41 37 } 42 38 43 39 type AuthManager struct { 44 - App *oauth.ClientApp 45 - Store *FileStore 46 - state authState 47 - statePath string 48 - } 49 - 50 - type authState struct { 51 - CurrentDID string `json:"current_did,omitempty"` 52 - CurrentSession string `json:"current_session,omitempty"` 53 - } 54 - 55 - // ConfigDir returns the configuration directory for tg. 56 - // 57 - // It respects the XDG Base Directory Specification: if XDG_CONFIG_HOME is set, 58 - // it uses that directory; otherwise it falls back to ~/.config/tg. 59 - func ConfigDir() (string, error) { 60 - if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" { 61 - return filepath.Join(dir, "tg"), nil 62 - } 63 - 64 - home, err := os.UserHomeDir() 65 - if err != nil { 66 - return "", err 67 - } 68 - return filepath.Join(home, ".config", "tg"), nil 40 + app *oauth.ClientApp 41 + store *KeyringStore 69 42 } 70 43 71 - // NewAuthManager creates an AuthManager. callbackURL must be reachable by the 72 - // user's browser during login. 73 - func NewAuthManager(callbackURL string, dir string) (*AuthManager, error) { 44 + func NewAuthManager(callbackURL string) *AuthManager { 74 45 config := oauth.NewLocalhostConfig(callbackURL, DefaultScopes) 75 46 config.UserAgent = "tg" 76 - 77 - store := NewFileStore(filepath.Join(dir, "oauth")) 78 - manager := &AuthManager{ 79 - App: oauth.NewClientApp(&config, store), 80 - Store: store, 81 - statePath: filepath.Join(dir, "auth.json"), 82 - } 83 - if err := manager.loadState(); err != nil { 84 - return nil, fmt.Errorf("load auth state: %w", err) 47 + store := NewKeyringStore() 48 + return &AuthManager{ 49 + app: oauth.NewClientApp(&config, store), 50 + store: store, 85 51 } 86 - return manager, nil 87 52 } 88 53 89 54 func (m *AuthManager) StartLogin(ctx context.Context, identifier string) (string, error) { 90 - return m.App.StartAuthFlow(ctx, identifier) 55 + return m.app.StartAuthFlow(ctx, identifier) 91 56 } 92 57 93 58 func (m *AuthManager) FinishLogin(ctx context.Context, query url.Values) error { 94 - session, err := m.App.ProcessCallback(ctx, query) 95 - if err != nil { 96 - return err 97 - } 59 + _, err := m.app.ProcessCallback(ctx, query) 60 + return err 61 + } 98 62 99 - m.state.CurrentDID = session.AccountDID.String() 100 - m.state.CurrentSession = session.SessionID 101 - return m.saveState() 63 + // CancelLogin cleans up any pending auth request written by StartLogin when the 64 + // login flow is abandoned (e.g. the user closes the browser before the 65 + // callback). It is safe to call after a completed login. 66 + func (m *AuthManager) CancelLogin() { 67 + _ = m.store.DeletePendingAuthRequest() 102 68 } 103 69 104 - func (m *AuthManager) CurrentDID() syntax.DID { 105 - if m.state.CurrentDID == "" { 106 - return syntax.DID("") 107 - } 108 - did, err := syntax.ParseDID(m.state.CurrentDID) 70 + func (m *AuthManager) CurrentDID(ctx context.Context) (syntax.DID, error) { 71 + session, err := m.CurrentSession(ctx) 109 72 if err != nil { 110 - return syntax.DID("") 73 + return "", err 111 74 } 112 - return did 113 - } 114 - 115 - func (m *AuthManager) IsAuthenticated() bool { 116 - return m.state.CurrentDID != "" && m.state.CurrentSession != "" 75 + return session.Data.AccountDID, nil 117 76 } 118 77 119 78 func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, error) { 120 - if !m.IsAuthenticated() { 121 - return nil, ErrNotAuthenticated 122 - } 123 - return m.App.ResumeSession(ctx, m.CurrentDID(), m.state.CurrentSession) 124 - } 125 - 126 - func (m *AuthManager) APIClient(ctx context.Context) (*atclient.APIClient, error) { 127 - session, err := m.CurrentSession(ctx) 79 + session, err := m.app.ResumeSession(ctx, "", "") 128 80 if err != nil { 81 + if errors.Is(err, keyring.ErrNotFound) { 82 + return nil, ErrNotAuthenticated 83 + } 129 84 return nil, err 130 85 } 131 - return session.APIClient(), nil 86 + return session, nil 132 87 } 133 88 134 89 func (m *AuthManager) Logout(ctx context.Context) error { 135 - if !m.IsAuthenticated() { 90 + err := m.app.Logout(ctx, "", "") 91 + if err == nil { 136 92 return nil 137 93 } 138 - if err := m.App.Logout(ctx, m.CurrentDID(), m.state.CurrentSession); err != nil { 139 - return err 140 - } 141 - 142 - m.state = authState{} 143 - return m.saveState() 144 - } 145 - 146 - func (m *AuthManager) loadState() error { 147 - data, err := os.ReadFile(m.statePath) 148 - if err != nil { 149 - if os.IsNotExist(err) { 150 - return nil 151 - } 152 - return err 153 - } 154 - return json.Unmarshal(data, &m.state) 155 - } 156 - 157 - func (m *AuthManager) saveState() error { 158 - if err := os.MkdirAll(filepath.Dir(m.statePath), 0o700); err != nil { 159 - return err 94 + if errors.Is(err, keyring.ErrNotFound) { 95 + return ErrNotAuthenticated 160 96 } 161 - 162 - data, err := json.MarshalIndent(m.state, "", " ") 163 - if err != nil { 164 - return err 97 + // Logout failed partway — most commonly because the session entry is 98 + // corrupt or the keyring is transiently unavailable. The upstream Logout 99 + // aborts before DeleteSession in that case, so the bad entry would 100 + // otherwise be unrecoverable short of manual keyring surgery. Best-effort 101 + // clear it so the user can re-login; if that also fails, surface the 102 + // original error. 103 + if deleteErr := m.store.DeleteSession(ctx, "", ""); deleteErr == nil { 104 + return nil 165 105 } 166 - return os.WriteFile(m.statePath, data, 0o600) 106 + return err 167 107 }
+152
atproto/keyring_store.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "errors" 7 + "fmt" 8 + "sync" 9 + 10 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + "github.com/zalando/go-keyring" 13 + ) 14 + 15 + // Reverse-DNS of the repo so it won't collide with other clients. 16 + const keyringService = "io.github.alyraffauf.tg" 17 + 18 + // Interface over go-keyring so tests can inject a fake. 19 + type secretBackend interface { 20 + Get(service, user string) (string, error) 21 + Set(service, user, password string) error 22 + Delete(service, user string) error 23 + } 24 + 25 + type goKeyringBackend struct{} 26 + 27 + func (goKeyringBackend) Get(service, user string) (string, error) { 28 + return keyring.Get(service, user) 29 + } 30 + 31 + func (goKeyringBackend) Set(service, user, password string) error { 32 + return keyring.Set(service, user, password) 33 + } 34 + 35 + func (goKeyringBackend) Delete(service, user string) error { 36 + return keyring.Delete(service, user) 37 + } 38 + 39 + // KeyringStore implements oauth.ClientAuthStore on top of the OS keyring. 40 + // 41 + // A mutex serializes access within one process. It does NOT coordinate across 42 + // processes: concurrent invocations that refresh tokens can race, and the loser 43 + // may need to re-login. 44 + type KeyringStore struct { 45 + backend secretBackend 46 + service string 47 + 48 + mu sync.Mutex 49 + // Most recent auth-request state, tracked so an abandoned login can clean up. 50 + pendingState string 51 + } 52 + 53 + func NewKeyringStore() *KeyringStore { 54 + return &KeyringStore{backend: goKeyringBackend{}, service: keyringService} 55 + } 56 + 57 + const currentSessionKey = "session:current" 58 + 59 + func requestKey(state string) string { 60 + return "request:" + state 61 + } 62 + 63 + func (s *KeyringStore) getSecret(key string, target any) error { 64 + data, err := s.backend.Get(s.service, key) 65 + if err != nil { 66 + return err 67 + } 68 + if err := json.Unmarshal([]byte(data), target); err != nil { 69 + return fmt.Errorf("decode secret %q: %w", key, err) 70 + } 71 + return nil 72 + } 73 + 74 + func (s *KeyringStore) saveSecret(key string, value any) error { 75 + data, err := json.Marshal(value) 76 + if err != nil { 77 + return fmt.Errorf("marshal value: %w", err) 78 + } 79 + return s.backend.Set(s.service, key, string(data)) 80 + } 81 + 82 + // Ignore not-found errors; the entry is already gone. 83 + func (s *KeyringStore) deleteSecret(key string) error { 84 + err := s.backend.Delete(s.service, key) 85 + if err != nil && !errors.Is(err, keyring.ErrNotFound) { 86 + return err 87 + } 88 + return nil 89 + } 90 + 91 + func (s *KeyringStore) GetSession(_ context.Context, _ syntax.DID, _ string) (*oauth.ClientSessionData, error) { 92 + s.mu.Lock() 93 + defer s.mu.Unlock() 94 + var session oauth.ClientSessionData 95 + if err := s.getSecret(currentSessionKey, &session); err != nil { 96 + return nil, err 97 + } 98 + return &session, nil 99 + } 100 + 101 + func (s *KeyringStore) SaveSession(_ context.Context, session oauth.ClientSessionData) error { 102 + s.mu.Lock() 103 + defer s.mu.Unlock() 104 + return s.saveSecret(currentSessionKey, session) 105 + } 106 + 107 + func (s *KeyringStore) DeleteSession(_ context.Context, _ syntax.DID, _ string) error { 108 + s.mu.Lock() 109 + defer s.mu.Unlock() 110 + return s.deleteSecret(currentSessionKey) 111 + } 112 + 113 + func (s *KeyringStore) GetAuthRequestInfo(_ context.Context, state string) (*oauth.AuthRequestData, error) { 114 + s.mu.Lock() 115 + defer s.mu.Unlock() 116 + var info oauth.AuthRequestData 117 + if err := s.getSecret(requestKey(state), &info); err != nil { 118 + return nil, err 119 + } 120 + return &info, nil 121 + } 122 + 123 + func (s *KeyringStore) SaveAuthRequestInfo(_ context.Context, info oauth.AuthRequestData) error { 124 + s.mu.Lock() 125 + defer s.mu.Unlock() 126 + s.pendingState = info.State 127 + return s.saveSecret(requestKey(info.State), info) 128 + } 129 + 130 + func (s *KeyringStore) DeleteAuthRequestInfo(_ context.Context, state string) error { 131 + s.mu.Lock() 132 + defer s.mu.Unlock() 133 + if s.pendingState == state { 134 + s.pendingState = "" 135 + } 136 + return s.deleteSecret(requestKey(state)) 137 + } 138 + 139 + // DeletePendingAuthRequest removes the auth-request entry written by the most 140 + // recent SaveAuthRequestInfo, if it hasn't already been consumed. Used to clean 141 + // up when a login is abandoned before the callback completes. Missing entries 142 + // are ignored. 143 + func (s *KeyringStore) DeletePendingAuthRequest() error { 144 + s.mu.Lock() 145 + state := s.pendingState 146 + s.pendingState = "" 147 + s.mu.Unlock() 148 + if state == "" { 149 + return nil 150 + } 151 + return s.deleteSecret(requestKey(state)) 152 + }
+538
atproto/keyring_store_test.go
··· 1 + package atproto 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "reflect" 7 + "strings" 8 + "sync" 9 + "testing" 10 + 11 + "github.com/bluesky-social/indigo/atproto/atcrypto" 12 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 14 + "github.com/zalando/go-keyring" 15 + ) 16 + 17 + type fakeKeyring struct { 18 + secrets map[string]string 19 + deleteErr error 20 + getErr error 21 + } 22 + 23 + func newFakeKeyring() *fakeKeyring { 24 + return &fakeKeyring{secrets: make(map[string]string)} 25 + } 26 + 27 + func backendKey(service, user string) string { return service + "\x00" + user } 28 + 29 + func (f *fakeKeyring) Get(service, user string) (string, error) { 30 + if f.getErr != nil { 31 + return "", f.getErr 32 + } 33 + if v, ok := f.secrets[backendKey(service, user)]; ok { 34 + return v, nil 35 + } 36 + return "", keyring.ErrNotFound 37 + } 38 + 39 + func (f *fakeKeyring) Set(service, user, password string) error { 40 + f.secrets[backendKey(service, user)] = password 41 + return nil 42 + } 43 + 44 + func (f *fakeKeyring) Delete(service, user string) error { 45 + if f.deleteErr != nil { 46 + return f.deleteErr 47 + } 48 + delete(f.secrets, backendKey(service, user)) 49 + return nil 50 + } 51 + 52 + func newAuthManagerForTest(callbackURL string, store *KeyringStore) *AuthManager { 53 + config := oauth.NewLocalhostConfig(callbackURL, DefaultScopes) 54 + config.UserAgent = "tg" 55 + return &AuthManager{ 56 + app: oauth.NewClientApp(&config, store), 57 + store: store, 58 + } 59 + } 60 + 61 + func testKeyringStore(backend secretBackend) *KeyringStore { 62 + return &KeyringStore{backend: backend, service: keyringService} 63 + } 64 + 65 + func mustDID(t *testing.T, raw string) syntax.DID { 66 + t.Helper() 67 + did, err := syntax.ParseDID(raw) 68 + if err != nil { 69 + t.Fatalf("parse DID: %v", err) 70 + } 71 + return did 72 + } 73 + 74 + func sampleSession(did syntax.DID) oauth.ClientSessionData { 75 + return oauth.ClientSessionData{ 76 + AccountDID: did, 77 + SessionID: "session-1", 78 + HostURL: "https://example.com", 79 + AccessToken: "access-token", 80 + RefreshToken: "refresh-token", 81 + } 82 + } 83 + 84 + func mustGenerateDpopKey(t *testing.T) string { 85 + t.Helper() 86 + key, err := atcrypto.GeneratePrivateKeyP256() 87 + if err != nil { 88 + t.Fatalf("generate DPoP key: %v", err) 89 + } 90 + return key.Multibase() 91 + } 92 + 93 + func TestKeyringStore_SaveAndGetSession(t *testing.T) { 94 + store := testKeyringStore(newFakeKeyring()) 95 + ctx := context.Background() 96 + did := mustDID(t, "did:plc:abcdefghijklmnopqrstuvwxyz") 97 + 98 + if err := store.SaveSession(ctx, sampleSession(did)); err != nil { 99 + t.Fatalf("SaveSession: %v", err) 100 + } 101 + 102 + got, err := store.GetSession(ctx, did, "") 103 + if err != nil { 104 + t.Fatalf("GetSession: %v", err) 105 + } 106 + if got.AccessToken != "access-token" { 107 + t.Errorf("AccessToken = %q, want %q", got.AccessToken, "access-token") 108 + } 109 + } 110 + 111 + func TestKeyringStore_GetSessionNotFound(t *testing.T) { 112 + store := testKeyringStore(newFakeKeyring()) 113 + did := mustDID(t, "did:plc:abcdefghijklmnopqrstuvwxyz") 114 + 115 + _, err := store.GetSession(context.Background(), did, "") 116 + if err == nil { 117 + t.Fatal("expected error for missing session, got nil") 118 + } 119 + if !errors.Is(err, keyring.ErrNotFound) { 120 + t.Errorf("error does not wrap keyring.ErrNotFound: %v", err) 121 + } 122 + } 123 + 124 + func TestKeyringStore_DeleteSession(t *testing.T) { 125 + backend := newFakeKeyring() 126 + store := testKeyringStore(backend) 127 + ctx := context.Background() 128 + did := mustDID(t, "did:plc:abcdefghijklmnopqrstuvwxyz") 129 + 130 + if err := store.SaveSession(ctx, sampleSession(did)); err != nil { 131 + t.Fatalf("SaveSession: %v", err) 132 + } 133 + if err := store.DeleteSession(ctx, did, ""); err != nil { 134 + t.Fatalf("DeleteSession: %v", err) 135 + } 136 + if _, err := store.GetSession(ctx, did, ""); !errors.Is(err, keyring.ErrNotFound) { 137 + t.Fatalf("session still present after delete: %v", err) 138 + } 139 + 140 + if err := store.DeleteSession(ctx, did, ""); err != nil { 141 + t.Fatalf("deleting missing session errored: %v", err) 142 + } 143 + } 144 + 145 + func TestKeyringStore_SaveOverwritesPrevious(t *testing.T) { 146 + store := testKeyringStore(newFakeKeyring()) 147 + ctx := context.Background() 148 + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") 149 + second := mustDID(t, "did:plc:secondsecondsecondsecond") 150 + 151 + if err := store.SaveSession(ctx, sampleSession(first)); err != nil { 152 + t.Fatalf("SaveSession first: %v", err) 153 + } 154 + if err := store.SaveSession(ctx, sampleSession(second)); err != nil { 155 + t.Fatalf("SaveSession second: %v", err) 156 + } 157 + 158 + got, err := store.GetSession(ctx, first, "") 159 + if err != nil { 160 + t.Fatalf("GetSession: %v", err) 161 + } 162 + if got.AccountDID != second { 163 + t.Errorf("AccountDID = %q, want %q (second DID)", got.AccountDID, second) 164 + } 165 + } 166 + 167 + func TestKeyringStore_AuthRequestRoundTrip(t *testing.T) { 168 + store := testKeyringStore(newFakeKeyring()) 169 + ctx := context.Background() 170 + info := oauth.AuthRequestData{State: "state-1", PKCEVerifier: "verifier"} 171 + 172 + if err := store.SaveAuthRequestInfo(ctx, info); err != nil { 173 + t.Fatalf("SaveAuthRequestInfo: %v", err) 174 + } 175 + got, err := store.GetAuthRequestInfo(ctx, "state-1") 176 + if err != nil { 177 + t.Fatalf("GetAuthRequestInfo: %v", err) 178 + } 179 + if got.PKCEVerifier != "verifier" { 180 + t.Errorf("PKCEVerifier = %q, want %q", got.PKCEVerifier, "verifier") 181 + } 182 + if err := store.DeleteAuthRequestInfo(ctx, "state-1"); err != nil { 183 + t.Fatalf("DeleteAuthRequestInfo: %v", err) 184 + } 185 + } 186 + 187 + func TestKeyringStore_DeleteSessionPropagatesError(t *testing.T) { 188 + backend := newFakeKeyring() 189 + backend.deleteErr = errors.New("keyring daemon unavailable") 190 + store := testKeyringStore(backend) 191 + ctx := context.Background() 192 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 193 + 194 + if err := store.SaveSession(ctx, sampleSession(did)); err != nil { 195 + t.Fatalf("SaveSession: %v", err) 196 + } 197 + 198 + err := store.DeleteSession(ctx, did, "") 199 + if err == nil { 200 + t.Fatal("expected error, got nil") 201 + } 202 + if !strings.Contains(err.Error(), "keyring daemon unavailable") { 203 + t.Errorf("error = %q, want substring %q", err, "keyring daemon unavailable") 204 + } 205 + } 206 + 207 + func TestAuthManager_CurrentSessionErrNotFound(t *testing.T) { 208 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", testKeyringStore(newFakeKeyring())) 209 + _, err := manager.CurrentSession(context.Background()) 210 + if !errors.Is(err, ErrNotAuthenticated) { 211 + t.Errorf("expected ErrNotAuthenticated, got %v", err) 212 + } 213 + } 214 + 215 + func TestAuthManager_CurrentSessionPropagatesGetError(t *testing.T) { 216 + backend := newFakeKeyring() 217 + backend.getErr = errors.New("dbus connection refused") 218 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", testKeyringStore(backend)) 219 + _, err := manager.CurrentSession(context.Background()) 220 + if err == nil { 221 + t.Fatal("expected error, got nil") 222 + } 223 + if errors.Is(err, ErrNotAuthenticated) { 224 + t.Error("keyring access error should not be classified as unauthenticated") 225 + } 226 + if !strings.Contains(err.Error(), "dbus connection refused") { 227 + t.Errorf("error = %q, want substring %q", err, "dbus connection refused") 228 + } 229 + } 230 + 231 + func TestAuthManager_LogoutWithoutSession(t *testing.T) { 232 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", testKeyringStore(newFakeKeyring())) 233 + err := manager.Logout(context.Background()) 234 + if !errors.Is(err, ErrNotAuthenticated) { 235 + t.Errorf("expected ErrNotAuthenticated for logout without session, got %v", err) 236 + } 237 + } 238 + 239 + func TestAuthManager_LogoutDeletionErrorPropagates(t *testing.T) { 240 + backend := newFakeKeyring() 241 + store := testKeyringStore(backend) 242 + ctx := context.Background() 243 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 244 + 245 + session := sampleSession(did) 246 + session.DPoPPrivateKeyMultibase = mustGenerateDpopKey(t) 247 + if err := store.SaveSession(ctx, session); err != nil { 248 + t.Fatalf("SaveSession: %v", err) 249 + } 250 + 251 + backend.deleteErr = errors.New("keyring daemon unavailable") 252 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) 253 + err := manager.Logout(context.Background()) 254 + if err == nil { 255 + t.Fatal("expected error, got nil") 256 + } 257 + if !strings.Contains(err.Error(), "keyring daemon unavailable") { 258 + t.Errorf("error = %q, want substring %q", err, "keyring daemon unavailable") 259 + } 260 + } 261 + 262 + // fullyPopulatedSession returns a ClientSessionData with every field set to a 263 + // distinct, non-zero value, so a round-trip test catches any field that gets 264 + // dropped or mis-serialized. 265 + func fullyPopulatedSession(t *testing.T, did syntax.DID) oauth.ClientSessionData { 266 + t.Helper() 267 + return oauth.ClientSessionData{ 268 + AccountDID: did, 269 + SessionID: "session-1", 270 + HostURL: "https://pds.example.com", 271 + AuthServerURL: "https://auth.example.com", 272 + AuthServerTokenEndpoint: "https://auth.example.com/token", 273 + AuthServerRevocationEndpoint: "https://auth.example.com/revoke", 274 + Scopes: []string{"atproto", "repo:sh.tangled.repo"}, 275 + AccessToken: "access-token-123", 276 + RefreshToken: "refresh-token-456", 277 + DPoPAuthServerNonce: "authserver-nonce", 278 + DPoPHostNonce: "host-nonce", 279 + DPoPPrivateKeyMultibase: mustGenerateDpopKey(t), 280 + } 281 + } 282 + 283 + // TestKeyringStore_SessionRoundTrip_FullyPopulated verifies that every field 284 + // of ClientSessionData survives a save→load cycle, including the DPoP private 285 + // key, scopes, and the omitempty revocation endpoint. 286 + func TestKeyringStore_SessionRoundTrip_FullyPopulated(t *testing.T) { 287 + store := testKeyringStore(newFakeKeyring()) 288 + ctx := context.Background() 289 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 290 + want := fullyPopulatedSession(t, did) 291 + 292 + if err := store.SaveSession(ctx, want); err != nil { 293 + t.Fatalf("SaveSession: %v", err) 294 + } 295 + got, err := store.GetSession(ctx, did, "") 296 + if err != nil { 297 + t.Fatalf("GetSession: %v", err) 298 + } 299 + if !reflect.DeepEqual(*got, want) { 300 + t.Errorf("round-trip mismatch:\n got %+v\n want %+v", *got, want) 301 + } 302 + } 303 + 304 + // TestKeyringStore_SessionRoundTrip_EmptyScopesAndRevocation verifies the 305 + // omitempty/empty-slice edge cases (empty scopes slice, empty revocation 306 + // endpoint) round-trip without losing the distinction that matters. 307 + func TestKeyringStore_SessionRoundTrip_EmptyScopesAndRevocation(t *testing.T) { 308 + store := testKeyringStore(newFakeKeyring()) 309 + ctx := context.Background() 310 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 311 + want := fullyPopulatedSession(t, did) 312 + want.AuthServerRevocationEndpoint = "" 313 + want.Scopes = []string{} 314 + 315 + if err := store.SaveSession(ctx, want); err != nil { 316 + t.Fatalf("SaveSession: %v", err) 317 + } 318 + got, err := store.GetSession(ctx, did, "") 319 + if err != nil { 320 + t.Fatalf("GetSession: %v", err) 321 + } 322 + if got.AuthServerRevocationEndpoint != "" { 323 + t.Errorf("revocation endpoint = %q, want empty", got.AuthServerRevocationEndpoint) 324 + } 325 + if len(got.Scopes) != 0 { 326 + t.Errorf("scopes = %v, want empty", got.Scopes) 327 + } 328 + } 329 + 330 + // TestKeyringStore_AuthRequestRoundTrip_FullyPopulated verifies every field of 331 + // AuthRequestData round-trips, including the *syntax.DID pointer in both the 332 + // non-nil and nil cases. 333 + func TestKeyringStore_AuthRequestRoundTrip_FullyPopulated(t *testing.T) { 334 + store := testKeyringStore(newFakeKeyring()) 335 + ctx := context.Background() 336 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 337 + 338 + want := oauth.AuthRequestData{ 339 + State: "state-1", 340 + AuthServerURL: "https://auth.example.com", 341 + AccountDID: &did, 342 + Scopes: []string{"atproto", "repo:sh.tangled.repo"}, 343 + RequestURI: "urn:ietf:params:oauth:request_uri:abc", 344 + AuthServerTokenEndpoint: "https://auth.example.com/token", 345 + AuthServerRevocationEndpoint: "https://auth.example.com/revoke", 346 + PKCEVerifier: "verifier-123", 347 + DPoPAuthServerNonce: "nonce-123", 348 + DPoPPrivateKeyMultibase: mustGenerateDpopKey(t), 349 + } 350 + if err := store.SaveAuthRequestInfo(ctx, want); err != nil { 351 + t.Fatalf("SaveAuthRequestInfo: %v", err) 352 + } 353 + got, err := store.GetAuthRequestInfo(ctx, "state-1") 354 + if err != nil { 355 + t.Fatalf("GetAuthRequestInfo: %v", err) 356 + } 357 + if !reflect.DeepEqual(*got, want) { 358 + t.Errorf("round-trip mismatch:\n got %+v\n want %+v", *got, want) 359 + } 360 + 361 + // nil AccountDID must round-trip as nil (omitempty drops it). 362 + wantNil := want 363 + wantNil.State = "state-2" 364 + wantNil.AccountDID = nil 365 + if err := store.SaveAuthRequestInfo(ctx, wantNil); err != nil { 366 + t.Fatalf("SaveAuthRequestInfo (nil DID): %v", err) 367 + } 368 + gotNil, err := store.GetAuthRequestInfo(ctx, "state-2") 369 + if err != nil { 370 + t.Fatalf("GetAuthRequestInfo (nil DID): %v", err) 371 + } 372 + if gotNil.AccountDID != nil { 373 + t.Errorf("AccountDID = %v, want nil", gotNil.AccountDID) 374 + } 375 + } 376 + 377 + // TestKeyringStore_GetSessionIgnoresDID verifies the singleton contract the 378 + // codebase relies on: the did and sessionID arguments are ignored, and the 379 + // stored session is returned regardless of what is requested. This nails down 380 + // the design so a future multi-session refactor is caught. 381 + func TestKeyringStore_GetSessionIgnoresDID(t *testing.T) { 382 + store := testKeyringStore(newFakeKeyring()) 383 + ctx := context.Background() 384 + savedDID := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 385 + if err := store.SaveSession(ctx, sampleSession(savedDID)); err != nil { 386 + t.Fatalf("SaveSession: %v", err) 387 + } 388 + 389 + otherDID := mustDID(t, "did:plc:zzzzzzzzzzzzzzzzzzzzzzzz") 390 + got, err := store.GetSession(ctx, otherDID, "nonexistent-session") 391 + if err != nil { 392 + t.Fatalf("GetSession with different DID: %v", err) 393 + } 394 + if got.AccountDID != savedDID { 395 + t.Errorf("AccountDID = %q, want %q (singleton ignores requested DID)", got.AccountDID, savedDID) 396 + } 397 + } 398 + 399 + // TestKeyringStore_GetSessionMalformedJSON verifies that a corrupt keyring 400 + // entry (e.g. written by an older build or another program) produces a 401 + // clear, non-ErrNotFound error rather than being mistaken for "no session". 402 + func TestKeyringStore_GetSessionMalformedJSON(t *testing.T) { 403 + backend := newFakeKeyring() 404 + store := testKeyringStore(backend) 405 + // Seed a corrupt entry directly under the session key. 406 + backend.secrets[backendKey(keyringService, currentSessionKey)] = "not-json{" 407 + 408 + _, err := store.GetSession(context.Background(), syntax.DID(""), "") 409 + if err == nil { 410 + t.Fatal("expected error for corrupt session, got nil") 411 + } 412 + if errors.Is(err, keyring.ErrNotFound) { 413 + t.Error("corrupt entry should not be classified as not-found") 414 + } 415 + if !strings.Contains(err.Error(), "decode secret") { 416 + t.Errorf("error = %q, want substring %q", err, "decode secret") 417 + } 418 + } 419 + 420 + // TestKeyringStore_ConcurrentAccess exercises concurrent reads/writes against 421 + // a single store under the race detector. It verifies the process-local mutex 422 + // keeps operations from tearing (the fake keyring's map would otherwise race). 423 + func TestKeyringStore_ConcurrentAccess(t *testing.T) { 424 + store := testKeyringStore(newFakeKeyring()) 425 + ctx := context.Background() 426 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 427 + session := fullyPopulatedSession(t, did) 428 + 429 + var wg sync.WaitGroup 430 + for range 16 { 431 + wg.Add(1) 432 + go func() { 433 + defer wg.Done() 434 + for range 50 { 435 + if err := store.SaveSession(ctx, session); err != nil { 436 + t.Errorf("SaveSession: %v", err) 437 + return 438 + } 439 + if _, err := store.GetSession(ctx, did, ""); err != nil { 440 + t.Errorf("GetSession: %v", err) 441 + return 442 + } 443 + } 444 + }() 445 + } 446 + wg.Wait() 447 + } 448 + 449 + // TestAuthManager_PersistSessionCallbackPersistsRefresh verifies that the 450 + // PersistSessionCallback indigo wires during ResumeSession actually writes 451 + // rotated tokens back to the keyring. This is the property that lets a token 452 + // refresh survive a process restart. 453 + func TestAuthManager_PersistSessionCallbackPersistsRefresh(t *testing.T) { 454 + store := testKeyringStore(newFakeKeyring()) 455 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) 456 + ctx := context.Background() 457 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 458 + 459 + session := fullyPopulatedSession(t, did) 460 + if err := store.SaveSession(ctx, session); err != nil { 461 + t.Fatalf("SaveSession: %v", err) 462 + } 463 + 464 + sess, err := manager.CurrentSession(ctx) 465 + if err != nil { 466 + t.Fatalf("CurrentSession: %v", err) 467 + } 468 + if sess.PersistSessionCallback == nil { 469 + t.Fatal("PersistSessionCallback not wired by ResumeSession") 470 + } 471 + 472 + // Simulate a token refresh: rotate both tokens and persist. 473 + sess.Data.AccessToken = "rotated-access-token" 474 + sess.Data.RefreshToken = "rotated-refresh-token" 475 + sess.PersistSessionCallback(ctx, sess.Data) 476 + 477 + reloaded, err := store.GetSession(ctx, did, "") 478 + if err != nil { 479 + t.Fatalf("GetSession after refresh: %v", err) 480 + } 481 + if reloaded.AccessToken != "rotated-access-token" { 482 + t.Errorf("AccessToken = %q, want %q", reloaded.AccessToken, "rotated-access-token") 483 + } 484 + if reloaded.RefreshToken != "rotated-refresh-token" { 485 + t.Errorf("RefreshToken = %q, want %q", reloaded.RefreshToken, "rotated-refresh-token") 486 + } 487 + } 488 + 489 + // TestAuthManager_LogoutClearsCorruptSession verifies that Logout recovers 490 + // from a corrupt session entry (invalid DPoP key) by force-clearing it, so the 491 + // user can re-login instead of being permanently locked out. 492 + func TestAuthManager_LogoutClearsCorruptSession(t *testing.T) { 493 + store := testKeyringStore(newFakeKeyring()) 494 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) 495 + ctx := context.Background() 496 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 497 + 498 + // Save a session with an invalid (empty) DPoP private key. ResumeSession 499 + // will fail to parse it, so indigo's Logout never reaches DeleteSession. 500 + corrupt := sampleSession(did) 501 + corrupt.DPoPPrivateKeyMultibase = "" 502 + if err := store.SaveSession(ctx, corrupt); err != nil { 503 + t.Fatalf("SaveSession: %v", err) 504 + } 505 + 506 + if err := manager.Logout(ctx); err != nil { 507 + t.Fatalf("Logout should force-clear a corrupt session, got: %v", err) 508 + } 509 + if _, err := store.GetSession(ctx, did, ""); !errors.Is(err, keyring.ErrNotFound) { 510 + t.Fatalf("corrupt session should have been cleared, got: %v", err) 511 + } 512 + } 513 + 514 + // TestAuthManager_CancelLoginDeletesPendingRequest verifies that an abandoned 515 + // login (StartLogin without FinishLogin) leaves no auth-request entry behind 516 + // once CancelLogin is called. 517 + func TestAuthManager_CancelLoginDeletesPendingRequest(t *testing.T) { 518 + store := testKeyringStore(newFakeKeyring()) 519 + ctx := context.Background() 520 + info := oauth.AuthRequestData{ 521 + State: "pending-state", 522 + PKCEVerifier: "verifier", 523 + DPoPPrivateKeyMultibase: mustGenerateDpopKey(t), 524 + } 525 + if err := store.SaveAuthRequestInfo(ctx, info); err != nil { 526 + t.Fatalf("SaveAuthRequestInfo: %v", err) 527 + } 528 + 529 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) 530 + manager.CancelLogin() 531 + 532 + if _, err := store.GetAuthRequestInfo(ctx, "pending-state"); !errors.Is(err, keyring.ErrNotFound) { 533 + t.Fatalf("pending auth request should have been cleared, got: %v", err) 534 + } 535 + 536 + // CancelLogin after the request is already gone is a no-op. 537 + manager.CancelLogin() 538 + }
-95
atproto/store.go
··· 1 - package atproto 2 - 3 - import ( 4 - "context" 5 - "encoding/json" 6 - "fmt" 7 - "os" 8 - "path/filepath" 9 - 10 - "github.com/bluesky-social/indigo/atproto/auth/oauth" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - ) 13 - 14 - // FileStore implements oauth.ClientAuthStore on the local filesystem. 15 - type FileStore struct { 16 - Directory string 17 - } 18 - 19 - func NewFileStore(dir string) *FileStore { 20 - return &FileStore{Directory: dir} 21 - } 22 - 23 - func (s *FileStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) { 24 - data, err := os.ReadFile(s.sessionPath(did, sessionID)) 25 - if err != nil { 26 - if os.IsNotExist(err) { 27 - return nil, fmt.Errorf("session not found: %w", err) 28 - } 29 - return nil, err 30 - } 31 - 32 - var session oauth.ClientSessionData 33 - if err := json.Unmarshal(data, &session); err != nil { 34 - return nil, fmt.Errorf("decode session: %w", err) 35 - } 36 - return &session, nil 37 - } 38 - 39 - func (s *FileStore) SaveSession(ctx context.Context, session oauth.ClientSessionData) error { 40 - path := s.sessionPath(session.AccountDID, session.SessionID) 41 - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { 42 - return err 43 - } 44 - 45 - data, err := json.MarshalIndent(session, "", " ") 46 - if err != nil { 47 - return err 48 - } 49 - return os.WriteFile(path, data, 0o600) 50 - } 51 - 52 - func (s *FileStore) DeleteSession(ctx context.Context, did syntax.DID, sessionID string) error { 53 - return os.Remove(s.sessionPath(did, sessionID)) 54 - } 55 - 56 - func (s *FileStore) GetAuthRequestInfo(ctx context.Context, state string) (*oauth.AuthRequestData, error) { 57 - data, err := os.ReadFile(s.requestPath(state)) 58 - if err != nil { 59 - if os.IsNotExist(err) { 60 - return nil, fmt.Errorf("auth request not found: %w", err) 61 - } 62 - return nil, err 63 - } 64 - 65 - var info oauth.AuthRequestData 66 - if err := json.Unmarshal(data, &info); err != nil { 67 - return nil, fmt.Errorf("decode auth request: %w", err) 68 - } 69 - return &info, nil 70 - } 71 - 72 - func (s *FileStore) SaveAuthRequestInfo(ctx context.Context, info oauth.AuthRequestData) error { 73 - path := s.requestPath(info.State) 74 - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { 75 - return err 76 - } 77 - 78 - data, err := json.MarshalIndent(info, "", " ") 79 - if err != nil { 80 - return err 81 - } 82 - return os.WriteFile(path, data, 0o600) 83 - } 84 - 85 - func (s *FileStore) DeleteAuthRequestInfo(ctx context.Context, state string) error { 86 - return os.Remove(s.requestPath(state)) 87 - } 88 - 89 - func (s *FileStore) sessionPath(did syntax.DID, sessionID string) string { 90 - return filepath.Join(s.Directory, "sessions", did.String(), sessionID+".json") 91 - } 92 - 93 - func (s *FileStore) requestPath(state string) string { 94 - return filepath.Join(s.Directory, "requests", state+".json") 95 - }
+1
flake.nix
··· 22 22 "aarch64-linux" 23 23 "x86_64-linux" 24 24 "aarch64-darwin" 25 + "x86_64-darwin" 25 26 ]; 26 27 27 28 imports = [
+4 -1
go.mod
··· 5 5 require ( 6 6 github.com/bluesky-social/indigo v0.0.0-20260629160527-dfe5578fd537 7 7 github.com/spf13/cobra v1.10.2 8 + github.com/zalando/go-keyring v0.2.8 8 9 ) 9 10 10 11 require ( 11 12 github.com/beorn7/perks v1.0.1 // indirect 12 13 github.com/cespare/xxhash/v2 v2.2.0 // indirect 14 + github.com/danieljoos/wincred v1.2.3 // indirect 13 15 github.com/earthboundkid/versioninfo/v2 v2.24.1 // indirect 16 + github.com/godbus/dbus/v5 v5.2.2 // indirect 14 17 github.com/golang-jwt/jwt/v5 v5.2.2 // indirect 15 18 github.com/google/go-cmp v0.6.0 // indirect 16 19 github.com/google/go-querystring v1.1.0 // indirect ··· 41 44 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 42 45 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 43 46 golang.org/x/crypto v0.21.0 // indirect 44 - golang.org/x/sys v0.22.0 // indirect 47 + golang.org/x/sys v0.27.0 // indirect 45 48 golang.org/x/time v0.3.0 // indirect 46 49 golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 // indirect 47 50 google.golang.org/protobuf v1.33.0 // indirect
+12 -4
go.sum
··· 7 7 github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 8 8 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 9 9 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 10 + github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= 11 + github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= 10 12 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 11 13 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 14 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 13 15 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 14 16 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 17 + github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= 18 + github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= 15 19 github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 16 20 github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 17 21 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= ··· 80 84 github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= 81 85 github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= 82 86 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 83 - github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 84 - github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 87 + github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= 88 + github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 89 + github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= 90 + github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 85 91 github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 86 92 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= 87 93 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw= 88 94 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 89 95 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 96 + github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= 97 + github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0= 90 98 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 91 99 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 92 100 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= ··· 98 106 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 99 107 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 100 108 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 - golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 102 - golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 109 + golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= 110 + golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 103 111 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 104 112 golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= 105 113 golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+3 -7
internal/cli/api.go
··· 23 23 Short: "Call an authenticated XRPC endpoint", 24 24 Args: cobra.ExactArgs(1), 25 25 RunE: func(cmd *cobra.Command, args []string) error { 26 - if auth == nil || !auth.IsAuthenticated() { 27 - return fmt.Errorf("not logged in; run \"tg auth login\" first") 28 - } 29 - 30 26 endpoint, err := syntax.ParseNSID(args[0]) 31 27 if err != nil { 32 28 return fmt.Errorf("parse NSID: %w", err) ··· 43 39 return fmt.Errorf("method must be GET or POST, got %q", apiMethod) 44 40 } 45 41 46 - client, err := auth.APIClient(cmd.Context()) 42 + session, err := requireAuthSession(cmd.Context()) 47 43 if err != nil { 48 - return fmt.Errorf("get auth client: %w", err) 44 + return err 49 45 } 50 - response, err := doAPIRequest(cmd, client, endpoint, method, fields) 46 + response, err := doAPIRequest(cmd, session.APIClient(), endpoint, method, fields) 51 47 if err != nil { 52 48 return err 53 49 }
+15 -6
internal/cli/atproto_auth.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 6 7 7 8 "github.com/alyraffauf/tg/atproto" 9 + "github.com/bluesky-social/indigo/atproto/auth/oauth" 8 10 ) 9 11 10 - func authenticatedATProto(ctx context.Context) (*atproto.ATProto, string, error) { 11 - if auth == nil || !auth.IsAuthenticated() { 12 - return nil, "", fmt.Errorf("not logged in; run \"tg auth login\" first") 12 + func requireAuthSession(ctx context.Context) (*oauth.ClientSession, error) { 13 + session, err := auth.CurrentSession(ctx) 14 + if err != nil { 15 + if errors.Is(err, atproto.ErrNotAuthenticated) { 16 + return nil, fmt.Errorf("not logged in; run \"tg auth login\" first") 17 + } 18 + return nil, fmt.Errorf("resume OAuth session: %w", err) 13 19 } 20 + return session, nil 21 + } 14 22 15 - pds, err := auth.APIClient(ctx) 23 + func authenticatedATProto(ctx context.Context) (*atproto.ATProto, string, error) { 24 + session, err := requireAuthSession(ctx) 16 25 if err != nil { 17 - return nil, "", fmt.Errorf("get auth client: %w", err) 26 + return nil, "", err 18 27 } 19 - return &atproto.ATProto{Client: pds}, auth.CurrentDID().String(), nil 28 + return &atproto.ATProto{Client: session.APIClient()}, session.Data.AccountDID.String(), nil 20 29 }
+9 -8
internal/cli/auth_login.go
··· 4 4 "context" 5 5 "fmt" 6 6 "net/http" 7 + "os" 7 8 "os/exec" 8 9 "runtime" 9 10 ··· 16 17 Long: `Log in to atproto via OAuth using a local browser callback.`, 17 18 Args: cobra.MaximumNArgs(1), 18 19 RunE: func(cmd *cobra.Command, args []string) error { 19 - if auth == nil { 20 - return fmt.Errorf("auth is not available") 21 - } 22 - 23 20 identifier := "" 24 21 if len(args) == 1 { 25 22 identifier = args[0] ··· 37 34 ctx := cmd.Context() 38 35 loginURL, err := auth.StartLogin(ctx, identifier) 39 36 if err != nil { 37 + auth.CancelLogin() 40 38 return err 41 39 } 40 + defer auth.CancelLogin() 42 41 43 42 fmt.Println("Opening browser to complete login...") 44 43 if err := openBrowser(loginURL); err != nil { ··· 50 49 if err != nil { 51 50 return err 52 51 } 53 - fmt.Printf("Logged in as %s\n", auth.CurrentDID()) 52 + did, err := auth.CurrentDID(ctx) 53 + if err != nil { 54 + fmt.Fprintln(os.Stderr, "Login completed but session could not be confirmed.") 55 + return err 56 + } 57 + fmt.Printf("Logged in as %s\n", did) 54 58 return nil 55 59 case <-ctx.Done(): 56 60 return ctx.Err() ··· 58 62 }, 59 63 } 60 64 61 - // runCallbackServer starts the local HTTP server that receives the OAuth 62 - // redirect after the user approves the login in their browser. 63 65 func runCallbackServer() (*http.Server, <-chan error, error) { 64 66 resultChannel := make(chan error, 1) 65 67 ··· 88 90 return server, resultChannel, nil 89 91 } 90 92 91 - // openBrowser launches the user's default browser to url. 92 93 func openBrowser(url string) error { 93 94 var cmd string 94 95 var args []string
+17 -7
internal/cli/auth_logout.go
··· 1 1 package cli 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 7 + "github.com/alyraffauf/tg/atproto" 6 8 "github.com/spf13/cobra" 7 9 ) 8 10 ··· 10 12 Use: "logout", 11 13 Short: "Log out of your AT Protocol account", 12 14 RunE: func(cmd *cobra.Command, args []string) error { 13 - if auth == nil { 14 - return fmt.Errorf("auth is not available") 15 - } 16 - if err := auth.Logout(cmd.Context()); err != nil { 17 - return err 15 + err := auth.Logout(cmd.Context()) 16 + wasLoggedIn := true 17 + if err != nil { 18 + if errors.Is(err, atproto.ErrNotAuthenticated) { 19 + wasLoggedIn = false 20 + } else { 21 + return err 22 + } 18 23 } 19 - fmt.Println("Logged out.") 20 - return nil 24 + return output(authLogoutResult{WasLoggedIn: wasLoggedIn}, func(r authLogoutResult) { 25 + if r.WasLoggedIn { 26 + fmt.Println("Logged out.") 27 + } else { 28 + fmt.Println("Not logged in.") 29 + } 30 + }) 21 31 }, 22 32 }
+8 -2
internal/cli/auth_status.go
··· 1 1 package cli 2 2 3 3 import ( 4 + "errors" 4 5 "fmt" 5 6 7 + "github.com/alyraffauf/tg/atproto" 6 8 "github.com/spf13/cobra" 7 9 ) 8 10 ··· 12 14 RunE: func(cmd *cobra.Command, args []string) error { 13 15 ctx := cmd.Context() 14 16 15 - if auth == nil || !auth.IsAuthenticated() { 17 + did, err := auth.CurrentDID(ctx) 18 + if err != nil { 19 + if !errors.Is(err, atproto.ErrNotAuthenticated) { 20 + return fmt.Errorf("resume OAuth session: %w", err) 21 + } 16 22 return output(authStatusResult{}, func(_ authStatusResult) { 17 23 fmt.Println("Not logged in.") 18 24 }) 19 25 } 20 26 21 - author := resolveAuthor(ctx, auth.CurrentDID().String()) 27 + author := resolveAuthor(ctx, did.String()) 22 28 result := authStatusResult{ 23 29 Authenticated: true, 24 30 DID: author.DID,
+2 -6
internal/cli/auth_token.go
··· 11 11 Short: "Print the current OAuth access token", 12 12 Args: cobra.NoArgs, 13 13 RunE: func(cmd *cobra.Command, _ []string) error { 14 - if auth == nil || !auth.IsAuthenticated() { 15 - return fmt.Errorf("not logged in; run \"tg auth login\" first") 16 - } 17 - 18 - session, err := auth.CurrentSession(cmd.Context()) 14 + session, err := requireAuthSession(cmd.Context()) 19 15 if err != nil { 20 - return fmt.Errorf("resume OAuth session: %w", err) 16 + return err 21 17 } 22 18 token, _ := session.GetHostAccessData() 23 19 if token == "" {
+6
internal/cli/output.go
··· 91 91 DID string `json:"did,omitempty"` 92 92 Handle string `json:"handle,omitempty"` 93 93 } 94 + 95 + type authLogoutResult struct { 96 + // WasLoggedIn reports whether a session existed and was cleared. It is false 97 + // when there was nothing to log out (not a failure; the command still exits 0). 98 + WasLoggedIn bool `json:"wasLoggedIn"` 99 + }
+4 -8
internal/cli/pr_create.go
··· 33 33 Args: cobra.NoArgs, 34 34 RunE: func(cmd *cobra.Command, args []string) error { 35 35 ctx := cmd.Context() 36 - if auth == nil || !auth.IsAuthenticated() { 37 - return fmt.Errorf("not logged in; run \"tg auth login\" first") 36 + atClient, did, err := authenticatedATProto(ctx) 37 + if err != nil { 38 + return err 38 39 } 39 40 40 41 repoDir, err := os.Getwd() ··· 74 75 if err != nil { 75 76 return fmt.Errorf("generate pull request patch: %w", err) 76 77 } 77 - pds, err := auth.APIClient(ctx) 78 - if err != nil { 79 - return fmt.Errorf("get auth client: %w", err) 80 - } 81 - atClient := &atproto.ATProto{Client: pds} 82 78 blob, err := atClient.UploadBlob(ctx, patch, patchMimeType) 83 79 if err != nil { 84 80 return err 85 81 } 86 82 87 - uri, err := createPullRecord(ctx, atClient, auth.CurrentDID().String(), prCreateRecord{ 83 + uri, err := createPullRecord(ctx, atClient, did, prCreateRecord{ 88 84 Title: prCreateTitle, 89 85 Body: body, 90 86 RepoDid: target.Value.RepoDid,
+2 -8
internal/cli/repo_create.go
··· 39 39 RunE: func(cmd *cobra.Command, args []string) error { 40 40 ctx := cmd.Context() 41 41 42 - if auth == nil || !auth.IsAuthenticated() { 43 - return fmt.Errorf("not logged in; run \"tg auth login\" first") 44 - } 45 - 46 - pds, err := auth.APIClient(ctx) 42 + atClient, did, err := authenticatedATProto(ctx) 47 43 if err != nil { 48 - return fmt.Errorf("get auth client: %w", err) 44 + return err 49 45 } 50 - atClient := &atproto.ATProto{Client: pds} 51 - did := auth.CurrentDID().String() 52 46 53 47 knotHost := repoCreateKnot 54 48 if knotHost == "" {
+4 -10
internal/cli/repo_fork.go
··· 16 16 Short: "Fork a Tangled repository", 17 17 Args: cobra.RangeArgs(1, 2), 18 18 RunE: func(cmd *cobra.Command, args []string) error { 19 - if auth == nil || !auth.IsAuthenticated() { 20 - return fmt.Errorf("not logged in; run \"tg auth login\" first") 19 + ctx := cmd.Context() 20 + atClient, ownerDID, err := authenticatedATProto(ctx) 21 + if err != nil { 22 + return err 21 23 } 22 24 23 25 handle, sourceName, err := parseHandleRepo(args[0]) ··· 29 31 name = args[1] 30 32 } 31 33 32 - ctx := cmd.Context() 33 34 source, err := getForkSource(ctx, handle, sourceName) 34 35 if err != nil { 35 36 return err 36 37 } 37 - pds, err := auth.APIClient(ctx) 38 - if err != nil { 39 - return fmt.Errorf("get auth client: %w", err) 40 - } 41 - atClient := &atproto.ATProto{Client: pds} 42 - ownerDID := auth.CurrentDID().String() 43 - 44 38 token, err := atClient.GetServiceAuth(ctx, "did:web:"+source.Knot, "sh.tangled.repo.create") 45 39 if err != nil { 46 40 return fmt.Errorf("get knot service auth: %w", err)
+9 -3
internal/cli/repo_list.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "errors" 5 6 "fmt" 6 7 "strings" 7 8 9 + "github.com/alyraffauf/tg/atproto" 8 10 "github.com/alyraffauf/tg/internal/gitutil" 9 11 "github.com/alyraffauf/tg/tangled" 10 12 "github.com/spf13/cobra" ··· 61 63 if len(args) == 1 { 62 64 return args[0], nil 63 65 } 64 - if auth == nil || !auth.IsAuthenticated() { 65 - return "", fmt.Errorf("not logged in; provide a handle or run \"tg auth login\"") 66 + did, err := auth.CurrentDID(ctx) 67 + if err != nil { 68 + if errors.Is(err, atproto.ErrNotAuthenticated) { 69 + return "", fmt.Errorf("not logged in; provide a handle or run \"tg auth login\"") 70 + } 71 + return "", fmt.Errorf("resume OAuth session: %w", err) 66 72 } 67 - ident, err := resolver.ResolveDID(ctx, auth.CurrentDID().String()) 73 + ident, err := resolver.ResolveDID(ctx, did.String()) 68 74 if err != nil { 69 75 return "", fmt.Errorf("resolve your DID: %w", err) 70 76 }
+3 -17
internal/cli/root.go
··· 1 1 package cli 2 2 3 3 import ( 4 - "fmt" 5 4 "log/slog" 6 5 "os" 7 6 ··· 23 22 Client: &atclient.APIClient{Host: appviewHost()}, 24 23 Logger: slog.Default(), 25 24 } 26 - auth *atproto.AuthManager 25 + auth = atproto.NewAuthManager(oauthCallbackURL) 27 26 28 27 jsonOutput bool 29 28 appview string ··· 39 38 var rootCmd = &cobra.Command{ 40 39 Use: "tg", 41 40 Short: "A CLI for Tangled", 41 + // Errors such as "not logged in" are expected and shouldn't dump usage. 42 + SilenceUsage: true, 42 43 PersistentPreRun: func(cmd *cobra.Command, args []string) { 43 44 client.Client.Host = appview 44 45 }, ··· 52 53 rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") 53 54 rootCmd.PersistentFlags().StringVar(&appview, "appview", appviewHost(), "Appview host URL (overrides the TG_APPVIEW environment variable)") 54 55 55 - initAuth() 56 - 57 56 rootCmd.AddCommand(authCmd) 58 57 authCmd.AddCommand(authLoginCmd) 59 58 authCmd.AddCommand(authLogoutCmd) ··· 100 99 rootCmd.AddCommand(completionCmd) 101 100 rootCmd.AddCommand(apiCmd) 102 101 } 103 - 104 - func initAuth() { 105 - dir, err := atproto.ConfigDir() 106 - if err != nil { 107 - fmt.Fprintf(os.Stderr, "warning: could not determine config dir: %v\n", err) 108 - return 109 - } 110 - 111 - auth, err = atproto.NewAuthManager(oauthCallbackURL, dir) 112 - if err != nil { 113 - fmt.Fprintf(os.Stderr, "warning: auth initialization failed: %v\n", err) 114 - } 115 - }
+3 -9
internal/cli/ssh_key_add.go
··· 25 25 RunE: func(cmd *cobra.Command, args []string) error { 26 26 ctx := cmd.Context() 27 27 28 - if auth == nil || !auth.IsAuthenticated() { 29 - return fmt.Errorf("not logged in; run \"tg auth login\" first") 28 + atClient, did, err := authenticatedATProto(ctx) 29 + if err != nil { 30 + return err 30 31 } 31 32 32 33 keyPath := "~/.ssh/id_ed25519.pub" ··· 54 55 if title == "" { 55 56 title = filepath.Base(keyPath) 56 57 } 57 - 58 - pds, err := auth.APIClient(ctx) 59 - if err != nil { 60 - return fmt.Errorf("get auth client: %w", err) 61 - } 62 - atClient := &atproto.ATProto{Client: pds} 63 - did := auth.CurrentDID().String() 64 58 65 59 uri, _, err := atClient.PutRecord(ctx, atproto.PutRecordInput{ 66 60 Repo: did,
+1 -1
nix/tg.nix
··· 8 8 pname = "tg"; 9 9 version = "dev"; 10 10 src = ../.; 11 - vendorHash = "sha256-4e3RU0z5rh8cDSW7fQSfQM8sqeD53PA0BYGTTjtF23E="; 11 + vendorHash = "sha256-WDM1O622yKsP6qifLSh796qph5HzrJR42F8OpJwNzJQ="; 12 12 subPackages = ["cmd/tg"]; 13 13 env.CGO_ENABLED = "0"; 14 14