close
An unofficial terminal client for Tangled, optimized for humans and agents. tgcli.wisp.place
cli atproto go tangled
164

Configure Feed

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

auth, cli, docs: add multi-account keychain sessions #7

Merged opened by okami.mom targeting master from [deleted fork]: codex/multi-account-auth
  • adds tg auth list and tg auth switch <handle-or-did>
  • stores OAuth and app-password sessions independently by account DID
  • maintains an accounts:index Keychain entry for account discovery and active-account selection
  • makes logout account-scoped and add tg auth logout --all
  • lazily migrates existing singleton Keychain sessions

OAuth and app-password credentials use separate Keychain entries:

  • oauth:<did>
  • password:<did>
  • accounts:index

tested on macos, untested on linux. tested live by creating issues on okami.mom/tg with alternating accounts

code written by gpt 5.6 sol and has been reviewed/tested by me

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:3rwz3xfw2crswgifqgc3g7zh/sh.tangled.repo.pull/3mr2dn2ymcd22
+841 -75
Diff #0
+13 -11
README.md
··· 38 38 printf '%s\n' "$ATPROTO_APP_PASSWORD" | tg auth login alice.example.com --password-stdin 39 39 ``` 40 40 41 - Authentication is persisted locally. The current account is recorded in 42 - `~/.config/tg/auth.json` (or `$XDG_CONFIG_HOME/tg/auth.json`); OAuth session 43 - credentials are stored under `~/.config/tg/oauth/`, and app-password sessions 44 - are stored in `~/.config/tg/password-session.json`. These files are created 45 - with user-only permissions. Use `tg auth logout` to remove the active login. 41 + Authentication is persisted in the system keyring. Multiple accounts can be 42 + stored at once; use `tg auth list` and `tg auth switch <handle-or-did>` to select 43 + the default account, or `--account <handle-or-did>` for a one-command override. 44 + Use `tg auth logout` to remove the selected account or `tg auth logout --all` 45 + to remove every account. 46 46 47 47 `tg` auto-detects the repository from the `origin` remote when run inside a cloned Tangled repo. For now, only ssh origins are supported. You can also pass a fully-qualified `handle/repo` argument. 48 48 ··· 129 129 130 130 ### Authentication & token storage 131 131 132 - `tg` stores a single OAuth session in the system keyring: macOS Keychain or 132 + `tg` stores OAuth and app-password sessions in the system keyring: macOS Keychain or 133 133 the Secret Service on Linux (GNOME Keyring / KWallet). The keyring unlocks 134 134 with your login session, so no separate passphrase is needed. 135 135 136 - Logging in again replaces the current session. The keyring is accessed on 136 + Logging in adds or replaces that account and selects it. The keyring is accessed on 137 137 first use (not at startup), so authentication only fails once you run a 138 138 command that needs a session. On Linux this requires a Secret Service 139 139 provider to be running; on a headless system without a D-Bus session bus ··· 163 163 164 164 ### Environment variables 165 165 166 - | Variable | Config key | Purpose | 167 - |---------------|------------|-------------------| 168 - | `TG_APPVIEW` | `appview` | Appview host URL | 166 + | Variable | Config key | Purpose | 167 + |--------------|------------|-----------------------| 168 + | `TG_APPVIEW` | `appview` | Appview host URL | 169 + | `TG_ACCOUNT` | `account` | Account handle or DID | 169 170 170 171 Keys containing `.` or `-` map to `TG_`-prefixed underscore-separated names 171 172 (e.g. `foo.bar` โ†’ `TG_FOO_BAR`). ··· 175 176 | Flag | Purpose | 176 177 |-------------|------------------------------------------------------------------| 177 178 | `--config` | Path to config file | 178 - | `--appview` | Appview host URL (overrides config file and `TG_APPVIEW`) | 179 + | `--appview` | Appview host URL (overrides config file and `TG_APPVIEW`) | 180 + | `--account` | Account handle or DID for this command | 179 181 180 182 ## Architecture 181 183
+133 -32
atproto/auth.go
··· 73 73 } 74 74 75 75 type AuthManager struct { 76 - app *oauth.ClientApp 77 - store *KeyringStore 76 + app *oauth.ClientApp 77 + store *KeyringStore 78 + selector string 79 + pendingIdentifier string 80 + } 81 + 82 + func (m *AuthManager) SetAccount(selector string) { 83 + m.selector = selector 84 + } 85 + 86 + func (m *AuthManager) Accounts() ([]Account, string, error) { 87 + return m.store.Accounts() 88 + } 89 + 90 + func (m *AuthManager) SelectAccount(selector string) (Account, error) { 91 + return m.store.SelectAccount(selector) 92 + } 93 + 94 + func (m *AuthManager) activeAccount() (Account, error) { 95 + account, err := m.store.Account(m.selector) 96 + if errors.Is(err, keyring.ErrNotFound) { 97 + return Account{}, ErrNotAuthenticated 98 + } 99 + return account, err 78 100 } 79 101 80 102 func NewAuthManager(callbackURL string) *AuthManager { ··· 89 111 90 112 // LoginWithPassword authenticates with an atproto app password and stores the 91 113 // resulting session in the keyring. Any existing OAuth session is cleared so 92 - // only one auth method is active at a time. 114 + // only one auth method is active for this account. 93 115 func (m *AuthManager) LoginWithPassword(ctx context.Context, identifier, password string) error { 94 116 parsedIdentifier, err := syntax.ParseAtIdentifier(identifier) 95 117 if err != nil { ··· 106 128 if !ok { 107 129 return errors.New("password login returned an unexpected auth type") 108 130 } 109 - _ = m.store.DeleteSession(ctx, "", "") 110 - return m.store.SavePasswordSession(ctx, passwordAuth.Session) 131 + if err := m.store.SavePasswordSession(ctx, passwordAuth.Session); err != nil { 132 + return err 133 + } 134 + did := passwordAuth.Session.AccountDID.String() 135 + if err := m.store.SetAccountHandle(did, identifier); err != nil { 136 + return err 137 + } 138 + _, err = m.store.SelectAccount(did) 139 + return err 111 140 } 112 141 113 142 func (m *AuthManager) StartLogin(ctx context.Context, identifier string) (string, error) { 114 - return m.app.StartAuthFlow(ctx, identifier) 143 + loginURL, err := m.app.StartAuthFlow(ctx, identifier) 144 + if err == nil { 145 + m.pendingIdentifier = identifier 146 + } 147 + return loginURL, err 115 148 } 116 149 117 150 func (m *AuthManager) FinishLogin(ctx context.Context, query url.Values) error { 118 - _, err := m.app.ProcessCallback(ctx, query) 151 + session, err := m.app.ProcessCallback(ctx, query) 119 152 if err != nil { 120 153 return err 121 154 } 122 - // Clear any existing password session so only one auth method is active. 123 - _ = m.store.DeletePasswordSession(ctx) 124 - return nil 155 + handle := m.pendingIdentifier 156 + if handle == "" { 157 + handle = session.AccountDID.String() 158 + } 159 + m.pendingIdentifier = "" 160 + did := session.AccountDID.String() 161 + if err := m.store.SetAccountHandle(did, handle); err != nil { 162 + return err 163 + } 164 + _, err = m.store.SelectAccount(did) 165 + return err 125 166 } 126 167 127 168 // CancelLogin cleans up any pending auth request written by StartLogin when the 128 169 // login flow is abandoned (e.g. the user closes the browser before the 129 170 // callback). It is safe to call after a completed login. 130 171 func (m *AuthManager) CancelLogin() { 172 + m.pendingIdentifier = "" 131 173 _ = m.store.DeletePendingAuthRequest() 132 174 } 133 175 134 176 func (m *AuthManager) CurrentDID(ctx context.Context) (syntax.DID, error) { 135 - session, err := m.app.ResumeSession(ctx, "", "") 136 - if err == nil { 137 - return session.Data.AccountDID, nil 177 + account, err := m.activeAccount() 178 + if err != nil { 179 + return "", err 138 180 } 139 - if !errors.Is(err, keyring.ErrNotFound) { 181 + did, err := syntax.ParseDID(account.DID) 182 + if err != nil { 140 183 return "", err 141 184 } 142 - passwordSession, err := m.store.GetPasswordSession(ctx) 185 + if account.Method == AuthMethodOAuth { 186 + session, err := m.app.ResumeSession(ctx, did, "") 187 + if err != nil { 188 + if errors.Is(err, keyring.ErrNotFound) { 189 + return "", ErrNotAuthenticated 190 + } 191 + return "", err 192 + } 193 + return session.Data.AccountDID, nil 194 + } 195 + passwordSession, err := m.store.GetPasswordSession(ctx, did) 143 196 if err != nil { 144 197 if errors.Is(err, keyring.ErrNotFound) { 145 198 return "", ErrNotAuthenticated ··· 150 203 } 151 204 152 205 func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, error) { 153 - session, err := m.app.ResumeSession(ctx, "", "") 206 + account, err := m.activeAccount() 207 + if err != nil { 208 + return nil, err 209 + } 210 + if account.Method != AuthMethodOAuth { 211 + return nil, ErrNotAuthenticated 212 + } 213 + did, err := syntax.ParseDID(account.DID) 214 + if err != nil { 215 + return nil, err 216 + } 217 + session, err := m.app.ResumeSession(ctx, did, "") 154 218 if err != nil { 155 219 if errors.Is(err, keyring.ErrNotFound) { 156 220 return nil, ErrNotAuthenticated ··· 164 228 // whether OAuth or app-password. Token refreshes are persisted back to the 165 229 // keyring. 166 230 func (m *AuthManager) APIClient(ctx context.Context) (*atclient.APIClient, syntax.DID, error) { 167 - session, err := m.app.ResumeSession(ctx, "", "") 168 - if err == nil { 169 - return session.APIClient(), session.Data.AccountDID, nil 231 + account, err := m.activeAccount() 232 + if err != nil { 233 + return nil, "", err 170 234 } 171 - if !errors.Is(err, keyring.ErrNotFound) { 235 + did, err := syntax.ParseDID(account.DID) 236 + if err != nil { 172 237 return nil, "", err 173 238 } 174 - passwordSession, err := m.store.GetPasswordSession(ctx) 239 + if account.Method == AuthMethodOAuth { 240 + session, err := m.app.ResumeSession(ctx, did, "") 241 + if err != nil { 242 + if errors.Is(err, keyring.ErrNotFound) { 243 + return nil, "", ErrNotAuthenticated 244 + } 245 + return nil, "", err 246 + } 247 + return session.APIClient(), session.Data.AccountDID, nil 248 + } 249 + passwordSession, err := m.store.GetPasswordSession(ctx, did) 175 250 if err != nil { 176 251 if errors.Is(err, keyring.ErrNotFound) { 177 252 return nil, "", ErrNotAuthenticated ··· 186 261 } 187 262 188 263 func (m *AuthManager) Logout(ctx context.Context) error { 189 - err := m.app.Logout(ctx, "", "") 190 - switch { 191 - case err == nil: 192 - return nil 193 - case errors.Is(err, keyring.ErrNotFound): 194 - // No OAuth session; continue to password logout below. 195 - default: 264 + account, err := m.activeAccount() 265 + if err != nil { 266 + return err 267 + } 268 + did, err := syntax.ParseDID(account.DID) 269 + if err != nil { 270 + return err 271 + } 272 + if account.Method == AuthMethodOAuth { 273 + err := m.app.Logout(ctx, did, "") 274 + if err == nil { 275 + return nil 276 + } 196 277 // Corrupt or transient OAuth failure โ€” force clear so the user can 197 278 // re-login instead of being locked out. 198 - if deleteErr := m.store.DeleteSession(ctx, "", ""); deleteErr == nil { 279 + if deleteErr := m.store.DeleteSession(ctx, did, ""); deleteErr == nil { 199 280 return nil 200 281 } 201 282 return err 202 283 } 203 284 204 - passwordSession, err := m.store.GetPasswordSession(ctx) 285 + passwordSession, err := m.store.GetPasswordSession(ctx, did) 205 286 if err != nil { 206 287 if errors.Is(err, keyring.ErrNotFound) { 207 288 return ErrNotAuthenticated ··· 212 293 passwordAuth, ok := client.Auth.(*atclient.PasswordAuth) 213 294 if !ok { 214 295 // Corrupt password session โ€” force clear. 215 - _ = m.store.DeletePasswordSession(ctx) 296 + _ = m.store.DeletePasswordSession(ctx, did) 216 297 return nil 217 298 } 218 299 if err := passwordAuth.Logout(ctx, client.Client); err != nil { 219 300 return fmt.Errorf("revoke password session: %w", err) 220 301 } 221 - return m.store.DeletePasswordSession(ctx) 302 + return m.store.DeletePasswordSession(ctx, did) 303 + } 304 + 305 + func (m *AuthManager) LogoutAll(ctx context.Context) error { 306 + accounts, _, err := m.store.Accounts() 307 + if err != nil { 308 + return err 309 + } 310 + if len(accounts) == 0 { 311 + return ErrNotAuthenticated 312 + } 313 + originalSelector := m.selector 314 + defer func() { m.selector = originalSelector }() 315 + var errs []error 316 + for _, account := range accounts { 317 + m.selector = account.DID 318 + if err := m.Logout(ctx); err != nil { 319 + errs = append(errs, fmt.Errorf("logout %s: %w", account.DID, err)) 320 + } 321 + } 322 + return errors.Join(errs...) 222 323 }
+126 -3
atproto/auth_test.go
··· 31 31 t.Fatalf("SavePasswordSession: %v", err) 32 32 } 33 33 34 - got, err := store.GetPasswordSession(ctx) 34 + got, err := store.GetPasswordSession(ctx, did) 35 35 if err != nil { 36 36 t.Fatalf("GetPasswordSession: %v", err) 37 37 } ··· 51 51 52 52 func TestPasswordSessionNotFound(t *testing.T) { 53 53 store := testKeyringStore(newFakeKeyring()) 54 - _, err := store.GetPasswordSession(context.Background()) 54 + _, err := store.GetPasswordSession(context.Background(), "") 55 55 if !errors.Is(err, keyring.ErrNotFound) { 56 56 t.Errorf("GetPasswordSession = %v, want keyring.ErrNotFound", err) 57 57 } ··· 84 84 } 85 85 } 86 86 87 + func TestCurrentDIDRejectsStalePasswordIndex(t *testing.T) { 88 + backend := newFakeKeyring() 89 + store := testKeyringStore(backend) 90 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) 91 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 92 + if err := store.SavePasswordSession(context.Background(), samplePasswordSession(did, "https://pds.example")); err != nil { 93 + t.Fatal(err) 94 + } 95 + delete(backend.secrets, backendKey(keyringService, passwordKey(did.String()))) 96 + if _, err := manager.CurrentDID(context.Background()); !errors.Is(err, ErrNotAuthenticated) { 97 + t.Fatalf("CurrentDID = %v, want ErrNotAuthenticated", err) 98 + } 99 + } 100 + 101 + func TestCurrentDIDRejectsStaleOAuthIndex(t *testing.T) { 102 + backend := newFakeKeyring() 103 + store := testKeyringStore(backend) 104 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) 105 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 106 + if err := store.SaveSession(context.Background(), sampleSession(did)); err != nil { 107 + t.Fatal(err) 108 + } 109 + delete(backend.secrets, backendKey(keyringService, sessionKey(did.String()))) 110 + if _, err := manager.CurrentDID(context.Background()); !errors.Is(err, ErrNotAuthenticated) { 111 + t.Fatalf("CurrentDID = %v, want ErrNotAuthenticated", err) 112 + } 113 + } 114 + 87 115 func TestAPIClientReturnsPasswordClient(t *testing.T) { 88 116 store := testKeyringStore(newFakeKeyring()) 89 117 manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) ··· 147 175 if authorization != "Bearer refresh" { 148 176 t.Errorf("Authorization = %q, want %q", authorization, "Bearer refresh") 149 177 } 150 - if _, err := store.GetPasswordSession(ctx); !errors.Is(err, keyring.ErrNotFound) { 178 + if _, err := store.GetPasswordSession(ctx, did); !errors.Is(err, keyring.ErrNotFound) { 151 179 t.Errorf("password session should have been removed, got: %v", err) 152 180 } 153 181 } 182 + 183 + func TestPasswordLogoutPreservesOtherAccount(t *testing.T) { 184 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 185 + w.WriteHeader(http.StatusOK) 186 + })) 187 + defer server.Close() 188 + 189 + store := testKeyringStore(newFakeKeyring()) 190 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) 191 + ctx := context.Background() 192 + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") 193 + second := mustDID(t, "did:plc:secondsecondsecondsecond") 194 + if err := store.SavePasswordSession(ctx, samplePasswordSession(first, server.URL)); err != nil { 195 + t.Fatal(err) 196 + } 197 + if err := store.SetAccountHandle(first.String(), "first.example"); err != nil { 198 + t.Fatal(err) 199 + } 200 + if err := store.SavePasswordSession(ctx, samplePasswordSession(second, server.URL)); err != nil { 201 + t.Fatal(err) 202 + } 203 + if err := store.SetAccountHandle(second.String(), "second.example"); err != nil { 204 + t.Fatal(err) 205 + } 206 + manager.SetAccount("second.example") 207 + if err := manager.Logout(ctx); err != nil { 208 + t.Fatalf("Logout: %v", err) 209 + } 210 + if _, err := store.GetPasswordSession(ctx, second); !errors.Is(err, keyring.ErrNotFound) { 211 + t.Fatalf("logged-out account remains: %v", err) 212 + } 213 + if _, err := store.GetPasswordSession(ctx, first); err != nil { 214 + t.Fatalf("other account was removed: %v", err) 215 + } 216 + } 217 + 218 + func TestAccountOverrideSelectsWithoutChangingDefault(t *testing.T) { 219 + store := testKeyringStore(newFakeKeyring()) 220 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) 221 + ctx := context.Background() 222 + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") 223 + second := mustDID(t, "did:plc:secondsecondsecondsecond") 224 + if err := store.SavePasswordSession(ctx, samplePasswordSession(first, "https://one.example")); err != nil { 225 + t.Fatal(err) 226 + } 227 + if err := store.SavePasswordSession(ctx, samplePasswordSession(second, "https://two.example")); err != nil { 228 + t.Fatal(err) 229 + } 230 + if _, err := store.SelectAccount(first.String()); err != nil { 231 + t.Fatal(err) 232 + } 233 + manager.SetAccount(second.String()) 234 + _, did, err := manager.APIClient(ctx) 235 + if err != nil || did != second { 236 + t.Fatalf("override DID = %q, err = %v", did, err) 237 + } 238 + _, active, err := store.Accounts() 239 + if err != nil || active != first.String() { 240 + t.Fatalf("persistent active = %q, want %q (err %v)", active, first, err) 241 + } 242 + } 243 + 244 + func TestLogoutAllRevokesEveryAccount(t *testing.T) { 245 + var requests int 246 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 247 + requests++ 248 + w.WriteHeader(http.StatusOK) 249 + })) 250 + defer server.Close() 251 + 252 + store := testKeyringStore(newFakeKeyring()) 253 + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) 254 + ctx := context.Background() 255 + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") 256 + second := mustDID(t, "did:plc:secondsecondsecondsecond") 257 + if err := store.SavePasswordSession(ctx, samplePasswordSession(first, server.URL)); err != nil { 258 + t.Fatal(err) 259 + } 260 + if err := store.SavePasswordSession(ctx, samplePasswordSession(second, server.URL)); err != nil { 261 + t.Fatal(err) 262 + } 263 + if err := manager.LogoutAll(ctx); err != nil { 264 + t.Fatalf("LogoutAll: %v", err) 265 + } 266 + if requests != 2 { 267 + t.Fatalf("deleteSession requests = %d, want 2", requests) 268 + } 269 + accounts, active, err := store.Accounts() 270 + if err != nil { 271 + t.Fatal(err) 272 + } 273 + if len(accounts) != 0 || active != "" { 274 + t.Fatalf("accounts after logout = %#v active %q", accounts, active) 275 + } 276 + }
+300 -11
atproto/keyring_store.go
··· 1 1 package atproto 2 2 3 3 import ( 4 + "bytes" 5 + "compress/gzip" 4 6 "context" 7 + "encoding/base64" 5 8 "encoding/json" 6 9 "errors" 7 10 "fmt" 11 + "io" 12 + "slices" 13 + "strings" 8 14 "sync" 9 15 10 16 "github.com/bluesky-social/indigo/atproto/atclient" ··· 13 19 "github.com/zalando/go-keyring" 14 20 ) 15 21 22 + const compressedSecretPrefix = "gzip:" 23 + 16 24 // Reverse-DNS of the repo so it won't collide with other clients. 17 25 const keyringService = "io.github.alyraffauf.tg" 18 26 ··· 59 67 60 68 const currentPasswordKey = "password:current" 61 69 70 + const accountIndexKey = "accounts:index" 71 + 72 + const ( 73 + AuthMethodOAuth = "oauth" 74 + AuthMethodPassword = "password" 75 + ) 76 + 77 + type Account struct { 78 + DID string `json:"did"` 79 + Handle string `json:"handle,omitempty"` 80 + Method string `json:"method"` 81 + } 82 + 83 + type accountIndex struct { 84 + ActiveDID string `json:"activeDid,omitempty"` 85 + Accounts []Account `json:"accounts"` 86 + } 87 + 88 + func sessionKey(did string) string { return "oauth:" + did } 89 + 90 + func passwordKey(did string) string { return "password:" + did } 91 + 62 92 func requestKey(state string) string { 63 93 return "request:" + state 64 94 } ··· 68 98 if err != nil { 69 99 return err 70 100 } 71 - if err := json.Unmarshal([]byte(data), target); err != nil { 101 + decoded := []byte(data) 102 + if strings.HasPrefix(data, compressedSecretPrefix) { 103 + compressed, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(data, compressedSecretPrefix)) 104 + if err != nil { 105 + return fmt.Errorf("decode compressed secret %q: %w", key, err) 106 + } 107 + reader, err := gzip.NewReader(bytes.NewReader(compressed)) 108 + if err != nil { 109 + return fmt.Errorf("open compressed secret %q: %w", key, err) 110 + } 111 + decoded, err = io.ReadAll(reader) 112 + closeErr := reader.Close() 113 + if err != nil { 114 + return fmt.Errorf("read compressed secret %q: %w", key, err) 115 + } 116 + if closeErr != nil { 117 + return fmt.Errorf("close compressed secret %q: %w", key, closeErr) 118 + } 119 + } 120 + if err := json.Unmarshal(decoded, target); err != nil { 72 121 return fmt.Errorf("decode secret %q: %w", key, err) 73 122 } 74 123 return nil ··· 79 128 if err != nil { 80 129 return fmt.Errorf("marshal value: %w", err) 81 130 } 131 + if len(data) > 1024 { 132 + var compressed bytes.Buffer 133 + writer := gzip.NewWriter(&compressed) 134 + if _, err := writer.Write(data); err != nil { 135 + return fmt.Errorf("compress value: %w", err) 136 + } 137 + if err := writer.Close(); err != nil { 138 + return fmt.Errorf("finish compressed value: %w", err) 139 + } 140 + data = []byte(compressedSecretPrefix + base64.StdEncoding.EncodeToString(compressed.Bytes())) 141 + } 82 142 return s.backend.Set(s.service, key, string(data)) 83 143 } 84 144 ··· 91 151 return nil 92 152 } 93 153 94 - func (s *KeyringStore) GetSession(_ context.Context, _ syntax.DID, _ string) (*oauth.ClientSessionData, error) { 154 + func (s *KeyringStore) loadIndexLocked() (accountIndex, error) { 155 + var index accountIndex 156 + err := s.getSecret(accountIndexKey, &index) 157 + if err == nil { 158 + return index, nil 159 + } 160 + if !errors.Is(err, keyring.ErrNotFound) { 161 + return accountIndex{}, err 162 + } 163 + return s.migrateLegacyLocked() 164 + } 165 + 166 + func (s *KeyringStore) migrateLegacyLocked() (accountIndex, error) { 167 + index := accountIndex{Accounts: []Account{}} 168 + var oauthSession oauth.ClientSessionData 169 + if err := s.getSecret(currentSessionKey, &oauthSession); err == nil { 170 + did := oauthSession.AccountDID.String() 171 + if err := s.saveSecret(sessionKey(did), oauthSession); err != nil { 172 + return accountIndex{}, err 173 + } 174 + index.Accounts = append(index.Accounts, Account{DID: did, Method: AuthMethodOAuth}) 175 + index.ActiveDID = did 176 + } else if !errors.Is(err, keyring.ErrNotFound) { 177 + return accountIndex{}, err 178 + } 179 + 180 + var passwordSession atclient.PasswordSessionData 181 + if err := s.getSecret(currentPasswordKey, &passwordSession); err == nil { 182 + did := passwordSession.AccountDID.String() 183 + if err := s.saveSecret(passwordKey(did), passwordSession); err != nil { 184 + return accountIndex{}, err 185 + } 186 + if !slices.ContainsFunc(index.Accounts, func(a Account) bool { return a.DID == did }) { 187 + index.Accounts = append(index.Accounts, Account{DID: did, Method: AuthMethodPassword}) 188 + if index.ActiveDID == "" { 189 + index.ActiveDID = did 190 + } 191 + } 192 + } else if !errors.Is(err, keyring.ErrNotFound) { 193 + return accountIndex{}, err 194 + } 195 + 196 + if len(index.Accounts) == 0 { 197 + return index, nil 198 + } 199 + if err := s.saveSecret(accountIndexKey, index); err != nil { 200 + return accountIndex{}, err 201 + } 202 + if err := s.deleteSecret(currentSessionKey); err != nil { 203 + return accountIndex{}, err 204 + } 205 + if err := s.deleteSecret(currentPasswordKey); err != nil { 206 + return accountIndex{}, err 207 + } 208 + return index, nil 209 + } 210 + 211 + func (s *KeyringStore) saveIndexLocked(index accountIndex) error { 212 + return s.saveSecret(accountIndexKey, index) 213 + } 214 + 215 + func (s *KeyringStore) upsertAccountLocked(index *accountIndex, account Account) { 216 + for i := range index.Accounts { 217 + if index.Accounts[i].DID == account.DID { 218 + if account.Handle == "" { 219 + account.Handle = index.Accounts[i].Handle 220 + } 221 + index.Accounts[i] = account 222 + return 223 + } 224 + } 225 + index.Accounts = append(index.Accounts, account) 226 + if index.ActiveDID == "" { 227 + index.ActiveDID = account.DID 228 + } 229 + } 230 + 231 + func (s *KeyringStore) removeAccountLocked(index *accountIndex, did, method string) { 232 + index.Accounts = slices.DeleteFunc(index.Accounts, func(a Account) bool { 233 + return a.DID == did && a.Method == method 234 + }) 235 + if index.ActiveDID == did && len(index.Accounts) > 0 { 236 + index.ActiveDID = index.Accounts[0].DID 237 + } else if len(index.Accounts) == 0 { 238 + index.ActiveDID = "" 239 + } 240 + } 241 + 242 + func findAccount(index accountIndex, selector string) (Account, error) { 243 + if selector == "" { 244 + selector = index.ActiveDID 245 + } 246 + for _, account := range index.Accounts { 247 + if account.DID == selector || strings.EqualFold(account.Handle, selector) { 248 + return account, nil 249 + } 250 + } 251 + return Account{}, keyring.ErrNotFound 252 + } 253 + 254 + func (s *KeyringStore) Accounts() ([]Account, string, error) { 255 + s.mu.Lock() 256 + defer s.mu.Unlock() 257 + index, err := s.loadIndexLocked() 258 + if err != nil { 259 + return nil, "", err 260 + } 261 + return slices.Clone(index.Accounts), index.ActiveDID, nil 262 + } 263 + 264 + func (s *KeyringStore) Account(selector string) (Account, error) { 265 + s.mu.Lock() 266 + defer s.mu.Unlock() 267 + index, err := s.loadIndexLocked() 268 + if err != nil { 269 + return Account{}, err 270 + } 271 + return findAccount(index, selector) 272 + } 273 + 274 + func (s *KeyringStore) SelectAccount(selector string) (Account, error) { 275 + s.mu.Lock() 276 + defer s.mu.Unlock() 277 + index, err := s.loadIndexLocked() 278 + if err != nil { 279 + return Account{}, err 280 + } 281 + account, err := findAccount(index, selector) 282 + if err != nil { 283 + return Account{}, err 284 + } 285 + switch account.Method { 286 + case AuthMethodOAuth: 287 + var session oauth.ClientSessionData 288 + if err := s.getSecret(sessionKey(account.DID), &session); err != nil { 289 + return Account{}, err 290 + } 291 + case AuthMethodPassword: 292 + var session atclient.PasswordSessionData 293 + if err := s.getSecret(passwordKey(account.DID), &session); err != nil { 294 + return Account{}, err 295 + } 296 + default: 297 + return Account{}, fmt.Errorf("unsupported auth method %q", account.Method) 298 + } 299 + index.ActiveDID = account.DID 300 + return account, s.saveIndexLocked(index) 301 + } 302 + 303 + func (s *KeyringStore) SetAccountHandle(did, handle string) error { 304 + s.mu.Lock() 305 + defer s.mu.Unlock() 306 + index, err := s.loadIndexLocked() 307 + if err != nil { 308 + return err 309 + } 310 + for i := range index.Accounts { 311 + if index.Accounts[i].DID == did { 312 + index.Accounts[i].Handle = handle 313 + return s.saveIndexLocked(index) 314 + } 315 + } 316 + return keyring.ErrNotFound 317 + } 318 + 319 + func (s *KeyringStore) GetSession(_ context.Context, did syntax.DID, _ string) (*oauth.ClientSessionData, error) { 95 320 s.mu.Lock() 96 321 defer s.mu.Unlock() 322 + index, err := s.loadIndexLocked() 323 + if err != nil { 324 + return nil, err 325 + } 326 + account, err := findAccount(index, did.String()) 327 + if err != nil || account.Method != AuthMethodOAuth { 328 + return nil, keyring.ErrNotFound 329 + } 97 330 var session oauth.ClientSessionData 98 - if err := s.getSecret(currentSessionKey, &session); err != nil { 331 + if err := s.getSecret(sessionKey(account.DID), &session); err != nil { 99 332 return nil, err 100 333 } 101 334 return &session, nil ··· 104 337 func (s *KeyringStore) SaveSession(_ context.Context, session oauth.ClientSessionData) error { 105 338 s.mu.Lock() 106 339 defer s.mu.Unlock() 107 - return s.saveSecret(currentSessionKey, session) 340 + index, err := s.loadIndexLocked() 341 + if err != nil { 342 + return err 343 + } 344 + did := session.AccountDID.String() 345 + if err := s.saveSecret(sessionKey(did), session); err != nil { 346 + return err 347 + } 348 + s.upsertAccountLocked(&index, Account{DID: did, Method: AuthMethodOAuth}) 349 + if err := s.saveIndexLocked(index); err != nil { 350 + return err 351 + } 352 + return s.deleteSecret(passwordKey(did)) 108 353 } 109 354 110 - func (s *KeyringStore) DeleteSession(_ context.Context, _ syntax.DID, _ string) error { 355 + func (s *KeyringStore) DeleteSession(_ context.Context, did syntax.DID, _ string) error { 111 356 s.mu.Lock() 112 357 defer s.mu.Unlock() 113 - return s.deleteSecret(currentSessionKey) 358 + index, err := s.loadIndexLocked() 359 + if err != nil { 360 + return err 361 + } 362 + account, err := findAccount(index, did.String()) 363 + if err != nil || account.Method != AuthMethodOAuth { 364 + return nil 365 + } 366 + if err := s.deleteSecret(sessionKey(account.DID)); err != nil { 367 + return err 368 + } 369 + s.removeAccountLocked(&index, account.DID, AuthMethodOAuth) 370 + return s.saveIndexLocked(index) 114 371 } 115 372 116 - func (s *KeyringStore) GetPasswordSession(_ context.Context) (*atclient.PasswordSessionData, error) { 373 + func (s *KeyringStore) GetPasswordSession(_ context.Context, did syntax.DID) (*atclient.PasswordSessionData, error) { 117 374 s.mu.Lock() 118 375 defer s.mu.Unlock() 376 + index, err := s.loadIndexLocked() 377 + if err != nil { 378 + return nil, err 379 + } 380 + account, err := findAccount(index, did.String()) 381 + if err != nil || account.Method != AuthMethodPassword { 382 + return nil, keyring.ErrNotFound 383 + } 119 384 var session atclient.PasswordSessionData 120 - if err := s.getSecret(currentPasswordKey, &session); err != nil { 385 + if err := s.getSecret(passwordKey(account.DID), &session); err != nil { 121 386 return nil, err 122 387 } 123 388 return &session, nil ··· 126 391 func (s *KeyringStore) SavePasswordSession(_ context.Context, session atclient.PasswordSessionData) error { 127 392 s.mu.Lock() 128 393 defer s.mu.Unlock() 129 - return s.saveSecret(currentPasswordKey, session) 394 + index, err := s.loadIndexLocked() 395 + if err != nil { 396 + return err 397 + } 398 + did := session.AccountDID.String() 399 + if err := s.saveSecret(passwordKey(did), session); err != nil { 400 + return err 401 + } 402 + s.upsertAccountLocked(&index, Account{DID: did, Method: AuthMethodPassword}) 403 + if err := s.saveIndexLocked(index); err != nil { 404 + return err 405 + } 406 + return s.deleteSecret(sessionKey(did)) 130 407 } 131 408 132 - func (s *KeyringStore) DeletePasswordSession(_ context.Context) error { 409 + func (s *KeyringStore) DeletePasswordSession(_ context.Context, did syntax.DID) error { 133 410 s.mu.Lock() 134 411 defer s.mu.Unlock() 135 - return s.deleteSecret(currentPasswordKey) 412 + index, err := s.loadIndexLocked() 413 + if err != nil { 414 + return err 415 + } 416 + account, err := findAccount(index, did.String()) 417 + if err != nil || account.Method != AuthMethodPassword { 418 + return nil 419 + } 420 + if err := s.deleteSecret(passwordKey(account.DID)); err != nil { 421 + return err 422 + } 423 + s.removeAccountLocked(&index, account.DID, AuthMethodPassword) 424 + return s.saveIndexLocked(index) 136 425 } 137 426 138 427 func (s *KeyringStore) GetAuthRequestInfo(_ context.Context, state string) (*oauth.AuthRequestData, error) {
+175 -17
atproto/keyring_store_test.go
··· 2 2 3 3 import ( 4 4 "context" 5 + "encoding/json" 5 6 "errors" 7 + "fmt" 6 8 "reflect" 7 9 "strings" 8 10 "sync" ··· 142 144 } 143 145 } 144 146 145 - func TestKeyringStore_SaveOverwritesPrevious(t *testing.T) { 147 + func TestKeyringStore_SavesMultipleAccounts(t *testing.T) { 146 148 store := testKeyringStore(newFakeKeyring()) 147 149 ctx := context.Background() 148 150 first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") ··· 159 161 if err != nil { 160 162 t.Fatalf("GetSession: %v", err) 161 163 } 164 + if got.AccountDID != first { 165 + t.Errorf("AccountDID = %q, want %q", got.AccountDID, first) 166 + } 167 + got, err = store.GetSession(ctx, second, "") 168 + if err != nil { 169 + t.Fatalf("GetSession second: %v", err) 170 + } 162 171 if got.AccountDID != second { 163 - t.Errorf("AccountDID = %q, want %q (second DID)", got.AccountDID, second) 172 + t.Errorf("AccountDID = %q, want %q", got.AccountDID, second) 173 + } 174 + } 175 + 176 + func TestKeyringStore_SelectAccountByHandleOrDID(t *testing.T) { 177 + store := testKeyringStore(newFakeKeyring()) 178 + ctx := context.Background() 179 + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") 180 + second := mustDID(t, "did:plc:secondsecondsecondsecond") 181 + if err := store.SaveSession(ctx, sampleSession(first)); err != nil { 182 + t.Fatal(err) 183 + } 184 + if err := store.SetAccountHandle(first.String(), "first.example"); err != nil { 185 + t.Fatal(err) 186 + } 187 + if err := store.SaveSession(ctx, sampleSession(second)); err != nil { 188 + t.Fatal(err) 189 + } 190 + if err := store.SetAccountHandle(second.String(), "second.example"); err != nil { 191 + t.Fatal(err) 192 + } 193 + 194 + if _, err := store.SelectAccount("SECOND.EXAMPLE"); err != nil { 195 + t.Fatalf("SelectAccount by handle: %v", err) 196 + } 197 + _, active, err := store.Accounts() 198 + if err != nil || active != second.String() { 199 + t.Fatalf("active = %q, err = %v", active, err) 200 + } 201 + if _, err := store.SelectAccount(first.String()); err != nil { 202 + t.Fatalf("SelectAccount by DID: %v", err) 203 + } 204 + } 205 + 206 + func TestKeyringStore_SelectAccountRejectsStaleIndex(t *testing.T) { 207 + backend := newFakeKeyring() 208 + store := testKeyringStore(backend) 209 + ctx := context.Background() 210 + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") 211 + second := mustDID(t, "did:plc:secondsecondsecondsecond") 212 + if err := store.SaveSession(ctx, sampleSession(first)); err != nil { 213 + t.Fatal(err) 214 + } 215 + if err := store.SaveSession(ctx, sampleSession(second)); err != nil { 216 + t.Fatal(err) 217 + } 218 + delete(backend.secrets, backendKey(keyringService, sessionKey(second.String()))) 219 + if _, err := store.SelectAccount(second.String()); !errors.Is(err, keyring.ErrNotFound) { 220 + t.Fatalf("SelectAccount = %v, want keyring.ErrNotFound", err) 221 + } 222 + _, active, err := store.Accounts() 223 + if err != nil || active != first.String() { 224 + t.Fatalf("active changed after failed switch: %q, err %v", active, err) 225 + } 226 + } 227 + 228 + func TestKeyringStore_RefreshDoesNotChangeActiveAccount(t *testing.T) { 229 + store := testKeyringStore(newFakeKeyring()) 230 + ctx := context.Background() 231 + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") 232 + second := mustDID(t, "did:plc:secondsecondsecondsecond") 233 + if err := store.SaveSession(ctx, sampleSession(first)); err != nil { 234 + t.Fatal(err) 235 + } 236 + if err := store.SaveSession(ctx, sampleSession(second)); err != nil { 237 + t.Fatal(err) 238 + } 239 + if _, err := store.SelectAccount(first.String()); err != nil { 240 + t.Fatal(err) 241 + } 242 + refreshed := sampleSession(second) 243 + refreshed.AccessToken = "rotated" 244 + if err := store.SaveSession(ctx, refreshed); err != nil { 245 + t.Fatal(err) 246 + } 247 + _, active, err := store.Accounts() 248 + if err != nil || active != first.String() { 249 + t.Fatalf("active after refresh = %q, want %q (err %v)", active, first, err) 250 + } 251 + } 252 + 253 + func TestKeyringStore_MethodReplacementIsPerAccount(t *testing.T) { 254 + backend := newFakeKeyring() 255 + store := testKeyringStore(backend) 256 + ctx := context.Background() 257 + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") 258 + second := mustDID(t, "did:plc:secondsecondsecondsecond") 259 + if err := store.SaveSession(ctx, sampleSession(first)); err != nil { 260 + t.Fatal(err) 261 + } 262 + if err := store.SaveSession(ctx, sampleSession(second)); err != nil { 263 + t.Fatal(err) 264 + } 265 + if err := store.SavePasswordSession(ctx, samplePasswordSession(first, "https://pds.example")); err != nil { 266 + t.Fatal(err) 267 + } 268 + if _, err := store.GetSession(ctx, first, ""); !errors.Is(err, keyring.ErrNotFound) { 269 + t.Fatalf("replaced OAuth session still available: %v", err) 270 + } 271 + if _, err := store.GetPasswordSession(ctx, first); err != nil { 272 + t.Fatalf("password session unavailable: %v", err) 273 + } 274 + if _, err := store.GetSession(ctx, second, ""); err != nil { 275 + t.Fatalf("other account was affected: %v", err) 276 + } 277 + } 278 + 279 + func TestKeyringStore_MigratesLegacySingleton(t *testing.T) { 280 + backend := newFakeKeyring() 281 + store := testKeyringStore(backend) 282 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 283 + legacy, err := json.Marshal(sampleSession(did)) 284 + if err != nil { 285 + t.Fatal(err) 286 + } 287 + backend.secrets[backendKey(keyringService, currentSessionKey)] = string(legacy) 288 + 289 + accounts, active, err := store.Accounts() 290 + if err != nil { 291 + t.Fatalf("Accounts: %v", err) 292 + } 293 + if len(accounts) != 1 || accounts[0].DID != did.String() || active != did.String() { 294 + t.Fatalf("migration result = %#v active %q", accounts, active) 295 + } 296 + if _, ok := backend.secrets[backendKey(keyringService, currentSessionKey)]; ok { 297 + t.Fatal("legacy entry was not deleted") 298 + } 299 + if _, err := store.GetSession(context.Background(), did, ""); err != nil { 300 + t.Fatalf("migrated session unavailable: %v", err) 164 301 } 165 302 } 166 303 ··· 186 323 187 324 func TestKeyringStore_DeleteSessionPropagatesError(t *testing.T) { 188 325 backend := newFakeKeyring() 189 - backend.deleteErr = errors.New("keyring daemon unavailable") 190 326 store := testKeyringStore(backend) 191 327 ctx := context.Background() 192 328 did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") ··· 194 330 if err := store.SaveSession(ctx, sampleSession(did)); err != nil { 195 331 t.Fatalf("SaveSession: %v", err) 196 332 } 333 + backend.deleteErr = errors.New("keyring daemon unavailable") 197 334 198 335 err := store.DeleteSession(ctx, did, "") 199 336 if err == nil { ··· 301 438 } 302 439 } 303 440 441 + func TestKeyringStore_CompressesOversizedSecrets(t *testing.T) { 442 + backend := newFakeKeyring() 443 + store := testKeyringStore(backend) 444 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 445 + session := fullyPopulatedSession(t, did) 446 + session.Scopes = make([]string, 300) 447 + for i := range session.Scopes { 448 + session.Scopes[i] = fmt.Sprintf("repo:sh.tangled.collection.%03d", i) 449 + } 450 + if err := store.SaveSession(context.Background(), session); err != nil { 451 + t.Fatalf("SaveSession: %v", err) 452 + } 453 + raw := backend.secrets[backendKey(keyringService, sessionKey(did.String()))] 454 + if !strings.HasPrefix(raw, compressedSecretPrefix) { 455 + t.Fatalf("oversized secret was not compressed") 456 + } 457 + got, err := store.GetSession(context.Background(), did, "") 458 + if err != nil { 459 + t.Fatalf("GetSession: %v", err) 460 + } 461 + if !reflect.DeepEqual(*got, session) { 462 + t.Fatal("compressed session did not round-trip") 463 + } 464 + } 465 + 304 466 // TestKeyringStore_SessionRoundTrip_EmptyScopesAndRevocation verifies the 305 467 // omitempty/empty-slice edge cases (empty scopes slice, empty revocation 306 468 // endpoint) round-trip without losing the distinction that matters. ··· 374 536 } 375 537 } 376 538 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) { 539 + func TestKeyringStore_GetSessionUsesDID(t *testing.T) { 382 540 store := testKeyringStore(newFakeKeyring()) 383 541 ctx := context.Background() 384 542 savedDID := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") ··· 387 545 } 388 546 389 547 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) 548 + _, err := store.GetSession(ctx, otherDID, "nonexistent-session") 549 + if !errors.Is(err, keyring.ErrNotFound) { 550 + t.Fatalf("GetSession with different DID = %v, want keyring.ErrNotFound", err) 396 551 } 397 552 } 398 553 ··· 402 557 func TestKeyringStore_GetSessionMalformedJSON(t *testing.T) { 403 558 backend := newFakeKeyring() 404 559 store := testKeyringStore(backend) 405 - // Seed a corrupt entry directly under the session key. 406 - backend.secrets[backendKey(keyringService, currentSessionKey)] = "not-json{" 560 + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") 561 + index := accountIndex{ActiveDID: did.String(), Accounts: []Account{{DID: did.String(), Method: AuthMethodOAuth}}} 562 + data, _ := json.Marshal(index) 563 + backend.secrets[backendKey(keyringService, accountIndexKey)] = string(data) 564 + backend.secrets[backendKey(keyringService, sessionKey(did.String()))] = "not-json{" 407 565 408 - _, err := store.GetSession(context.Background(), syntax.DID(""), "") 566 + _, err := store.GetSession(context.Background(), did, "") 409 567 if err == nil { 410 568 t.Fatal("expected error for corrupt session, got nil") 411 569 }
+44
internal/cli/auth_list.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/spf13/cobra" 7 + ) 8 + 9 + var authListCmd = &cobra.Command{ 10 + Use: "list", 11 + Short: "List authenticated accounts", 12 + Args: cobra.NoArgs, 13 + RunE: func(cmd *cobra.Command, _ []string) error { 14 + accounts, activeDID, err := auth.Accounts() 15 + if err != nil { 16 + return fmt.Errorf("list accounts: %w", err) 17 + } 18 + results := make([]authAccountResult, 0, len(accounts)) 19 + for _, account := range accounts { 20 + handle := account.Handle 21 + resolved := resolveAuthor(cmd.Context(), account.DID) 22 + if resolved.Handle != account.DID { 23 + handle = resolved.Handle 24 + } 25 + results = append(results, authAccountResult{ 26 + Active: account.DID == activeDID, 27 + DID: account.DID, Handle: handle, Method: account.Method, 28 + }) 29 + } 30 + return output(results, func(items []authAccountResult) { 31 + if len(items) == 0 { 32 + fmt.Println("No accounts.") 33 + return 34 + } 35 + for _, item := range items { 36 + marker := " " 37 + if item.Active { 38 + marker = "*" 39 + } 40 + fmt.Printf("%s %s %s %s\n", marker, item.Handle, item.DID, item.Method) 41 + } 42 + }) 43 + }, 44 + }
+12 -1
internal/cli/auth_logout.go
··· 8 8 "github.com/spf13/cobra" 9 9 ) 10 10 11 + var authLogoutAll bool 12 + 11 13 var authLogoutCmd = &cobra.Command{ 12 14 Use: "logout", 13 15 Short: "Log out of your AT Protocol account", 14 16 RunE: func(cmd *cobra.Command, args []string) error { 15 - err := auth.Logout(cmd.Context()) 17 + var err error 18 + if authLogoutAll { 19 + err = auth.LogoutAll(cmd.Context()) 20 + } else { 21 + err = auth.Logout(cmd.Context()) 22 + } 16 23 wasLoggedIn := true 17 24 if err != nil { 18 25 if errors.Is(err, atproto.ErrNotAuthenticated) { ··· 30 37 }) 31 38 }, 32 39 } 40 + 41 + func init() { 42 + authLogoutCmd.Flags().BoolVar(&authLogoutAll, "all", false, "Log out all accounts") 43 + }
+25
internal/cli/auth_switch.go
··· 1 + package cli 2 + 3 + import ( 4 + "fmt" 5 + 6 + "github.com/spf13/cobra" 7 + ) 8 + 9 + var authSwitchCmd = &cobra.Command{ 10 + Use: "switch <handle-or-did>", 11 + Short: "Select the active account", 12 + Args: cobra.ExactArgs(1), 13 + RunE: func(cmd *cobra.Command, args []string) error { 14 + account, err := auth.SelectAccount(args[0]) 15 + if err != nil { 16 + return fmt.Errorf("select account %q: %w", args[0], err) 17 + } 18 + resolved := resolveAuthor(cmd.Context(), account.DID) 19 + return output(authAccountResult{ 20 + Active: true, DID: account.DID, Handle: resolved.Handle, Method: account.Method, 21 + }, func(item authAccountResult) { 22 + fmt.Printf("Switched to %s\n", item.Handle) 23 + }) 24 + }, 25 + }
+1
internal/cli/config.go
··· 38 38 config.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) 39 39 config.AutomaticEnv() 40 40 config.SetDefault("appview", defaultAppview) 41 + config.SetDefault("account", "") 41 42 42 43 if err := config.ReadInConfig(); err != nil { 43 44 if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); ok {
+7
internal/cli/output.go
··· 115 115 // when there was nothing to log out (not a failure; the command still exits 0). 116 116 WasLoggedIn bool `json:"wasLoggedIn"` 117 117 } 118 + 119 + type authAccountResult struct { 120 + Active bool `json:"active"` 121 + DID string `json:"did"` 122 + Handle string `json:"handle"` 123 + Method string `json:"method"` 124 + }
+5
internal/cli/root.go
··· 33 33 SilenceUsage: true, 34 34 PersistentPreRun: func(cmd *cobra.Command, args []string) { 35 35 client.Client.Host = config.GetString("appview") 36 + auth.SetAccount(config.GetString("account")) 36 37 }, 37 38 } 38 39 ··· 46 47 rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file (default: $XDG_CONFIG_HOME/tg/config.toml)") 47 48 rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") 48 49 rootCmd.PersistentFlags().String("appview", defaultAppview, "Appview host URL (overrides config file and TG_APPVIEW)") 50 + rootCmd.PersistentFlags().String("account", "", "Account handle or DID to use (overrides the active account and TG_ACCOUNT)") 49 51 50 52 config.BindPFlag("appview", rootCmd.PersistentFlags().Lookup("appview")) 53 + config.BindPFlag("account", rootCmd.PersistentFlags().Lookup("account")) 51 54 52 55 rootCmd.AddCommand(authCmd) 53 56 authCmd.AddCommand(authLoginCmd) 54 57 authCmd.AddCommand(authLogoutCmd) 55 58 authCmd.AddCommand(authStatusCmd) 56 59 authCmd.AddCommand(authTokenCmd) 60 + authCmd.AddCommand(authListCmd) 61 + authCmd.AddCommand(authSwitchCmd) 57 62 58 63 rootCmd.AddCommand(issueCmd) 59 64 issueCmd.AddCommand(issueListCmd)

History

1 round 1 comment
Sign up or Login to add to the discussion
okami.mom submitted #0
1 commit
Expand
57a03444
auth, cli, docs: add multi-account keychain sessions
Expand 1 comment

Looks good! merging.

also looks like there's an issue where, if the refresh token is expired, we're not properly handling the 401 with app passwords, and auth status/authlist is just lying (it doesn't validate sessions before telling you ok).

Not related to this PR, but I discovered it here and thought it was bugged. we need better auth checking/handling/failures throughout anyway, so I'll push a fix as a follow up! ty!

Pull request successfully merged