diff --git a/README.md b/README.md --- a/README.md +++ b/README.md @@ -38,11 +38,11 @@ printf '%s\n' "$ATPROTO_APP_PASSWORD" | tg auth login alice.example.com --password-stdin ``` -Authentication is persisted locally. The current account is recorded in -`~/.config/tg/auth.json` (or `$XDG_CONFIG_HOME/tg/auth.json`); OAuth session -credentials are stored under `~/.config/tg/oauth/`, and app-password sessions -are stored in `~/.config/tg/password-session.json`. These files are created -with user-only permissions. Use `tg auth logout` to remove the active login. +Authentication is persisted in the system keyring. Multiple accounts can be +stored at once; use `tg auth list` and `tg auth switch ` to select +the default account, or `--account ` for a one-command override. +Use `tg auth logout` to remove the selected account or `tg auth logout --all` +to remove every account. `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. @@ -129,11 +129,11 @@ ### Authentication & token storage -`tg` stores a single OAuth session in the system keyring: macOS Keychain or +`tg` stores OAuth and app-password sessions in the system keyring: macOS Keychain or the Secret Service on Linux (GNOME Keyring / KWallet). The keyring unlocks with your login session, so no separate passphrase is needed. -Logging in again replaces the current session. The keyring is accessed on +Logging in adds or replaces that account and selects it. The keyring is accessed on first use (not at startup), so authentication only fails once you run a command that needs a session. On Linux this requires a Secret Service provider to be running; on a headless system without a D-Bus session bus @@ -163,9 +163,10 @@ ### Environment variables -| Variable | Config key | Purpose | -|---------------|------------|-------------------| -| `TG_APPVIEW` | `appview` | Appview host URL | +| Variable | Config key | Purpose | +|--------------|------------|-----------------------| +| `TG_APPVIEW` | `appview` | Appview host URL | +| `TG_ACCOUNT` | `account` | Account handle or DID | Keys containing `.` or `-` map to `TG_`-prefixed underscore-separated names (e.g. `foo.bar` → `TG_FOO_BAR`). @@ -175,7 +176,8 @@ | Flag | Purpose | |-------------|------------------------------------------------------------------| | `--config` | Path to config file | -| `--appview` | Appview host URL (overrides config file and `TG_APPVIEW`) | +| `--appview` | Appview host URL (overrides config file and `TG_APPVIEW`) | +| `--account` | Account handle or DID for this command | ## Architecture diff --git a/atproto/auth.go b/atproto/auth.go --- a/atproto/auth.go +++ b/atproto/auth.go @@ -73,8 +73,30 @@ } type AuthManager struct { - app *oauth.ClientApp - store *KeyringStore + app *oauth.ClientApp + store *KeyringStore + selector string + pendingIdentifier string +} + +func (m *AuthManager) SetAccount(selector string) { + m.selector = selector +} + +func (m *AuthManager) Accounts() ([]Account, string, error) { + return m.store.Accounts() +} + +func (m *AuthManager) SelectAccount(selector string) (Account, error) { + return m.store.SelectAccount(selector) +} + +func (m *AuthManager) activeAccount() (Account, error) { + account, err := m.store.Account(m.selector) + if errors.Is(err, keyring.ErrNotFound) { + return Account{}, ErrNotAuthenticated + } + return account, err } func NewAuthManager(callbackURL string) *AuthManager { @@ -89,7 +111,7 @@ // LoginWithPassword authenticates with an atproto app password and stores the // resulting session in the keyring. Any existing OAuth session is cleared so -// only one auth method is active at a time. +// only one auth method is active for this account. func (m *AuthManager) LoginWithPassword(ctx context.Context, identifier, password string) error { parsedIdentifier, err := syntax.ParseAtIdentifier(identifier) if err != nil { @@ -106,40 +128,71 @@ if !ok { return errors.New("password login returned an unexpected auth type") } - _ = m.store.DeleteSession(ctx, "", "") - return m.store.SavePasswordSession(ctx, passwordAuth.Session) + if err := m.store.SavePasswordSession(ctx, passwordAuth.Session); err != nil { + return err + } + did := passwordAuth.Session.AccountDID.String() + if err := m.store.SetAccountHandle(did, identifier); err != nil { + return err + } + _, err = m.store.SelectAccount(did) + return err } func (m *AuthManager) StartLogin(ctx context.Context, identifier string) (string, error) { - return m.app.StartAuthFlow(ctx, identifier) + loginURL, err := m.app.StartAuthFlow(ctx, identifier) + if err == nil { + m.pendingIdentifier = identifier + } + return loginURL, err } func (m *AuthManager) FinishLogin(ctx context.Context, query url.Values) error { - _, err := m.app.ProcessCallback(ctx, query) + session, err := m.app.ProcessCallback(ctx, query) if err != nil { return err } - // Clear any existing password session so only one auth method is active. - _ = m.store.DeletePasswordSession(ctx) - return nil + handle := m.pendingIdentifier + if handle == "" { + handle = session.AccountDID.String() + } + m.pendingIdentifier = "" + did := session.AccountDID.String() + if err := m.store.SetAccountHandle(did, handle); err != nil { + return err + } + _, err = m.store.SelectAccount(did) + return err } // CancelLogin cleans up any pending auth request written by StartLogin when the // login flow is abandoned (e.g. the user closes the browser before the // callback). It is safe to call after a completed login. func (m *AuthManager) CancelLogin() { + m.pendingIdentifier = "" _ = m.store.DeletePendingAuthRequest() } func (m *AuthManager) CurrentDID(ctx context.Context) (syntax.DID, error) { - session, err := m.app.ResumeSession(ctx, "", "") - if err == nil { - return session.Data.AccountDID, nil - } - if !errors.Is(err, keyring.ErrNotFound) { + account, err := m.activeAccount() + if err != nil { return "", err } - passwordSession, err := m.store.GetPasswordSession(ctx) + did, err := syntax.ParseDID(account.DID) + if err != nil { + return "", err + } + if account.Method == AuthMethodOAuth { + session, err := m.app.ResumeSession(ctx, did, "") + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return "", ErrNotAuthenticated + } + return "", err + } + return session.Data.AccountDID, nil + } + passwordSession, err := m.store.GetPasswordSession(ctx, did) if err != nil { if errors.Is(err, keyring.ErrNotFound) { return "", ErrNotAuthenticated @@ -150,7 +203,18 @@ } func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, error) { - session, err := m.app.ResumeSession(ctx, "", "") + account, err := m.activeAccount() + if err != nil { + return nil, err + } + if account.Method != AuthMethodOAuth { + return nil, ErrNotAuthenticated + } + did, err := syntax.ParseDID(account.DID) + if err != nil { + return nil, err + } + session, err := m.app.ResumeSession(ctx, did, "") if err != nil { if errors.Is(err, keyring.ErrNotFound) { return nil, ErrNotAuthenticated @@ -164,14 +228,25 @@ // whether OAuth or app-password. Token refreshes are persisted back to the // keyring. func (m *AuthManager) APIClient(ctx context.Context) (*atclient.APIClient, syntax.DID, error) { - session, err := m.app.ResumeSession(ctx, "", "") - if err == nil { - return session.APIClient(), session.Data.AccountDID, nil - } - if !errors.Is(err, keyring.ErrNotFound) { + account, err := m.activeAccount() + if err != nil { return nil, "", err } - passwordSession, err := m.store.GetPasswordSession(ctx) + did, err := syntax.ParseDID(account.DID) + if err != nil { + return nil, "", err + } + if account.Method == AuthMethodOAuth { + session, err := m.app.ResumeSession(ctx, did, "") + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return nil, "", ErrNotAuthenticated + } + return nil, "", err + } + return session.APIClient(), session.Data.AccountDID, nil + } + passwordSession, err := m.store.GetPasswordSession(ctx, did) if err != nil { if errors.Is(err, keyring.ErrNotFound) { return nil, "", ErrNotAuthenticated @@ -186,22 +261,28 @@ } func (m *AuthManager) Logout(ctx context.Context) error { - err := m.app.Logout(ctx, "", "") - switch { - case err == nil: - return nil - case errors.Is(err, keyring.ErrNotFound): - // No OAuth session; continue to password logout below. - default: + account, err := m.activeAccount() + if err != nil { + return err + } + did, err := syntax.ParseDID(account.DID) + if err != nil { + return err + } + if account.Method == AuthMethodOAuth { + err := m.app.Logout(ctx, did, "") + if err == nil { + return nil + } // Corrupt or transient OAuth failure — force clear so the user can // re-login instead of being locked out. - if deleteErr := m.store.DeleteSession(ctx, "", ""); deleteErr == nil { + if deleteErr := m.store.DeleteSession(ctx, did, ""); deleteErr == nil { return nil } return err } - passwordSession, err := m.store.GetPasswordSession(ctx) + passwordSession, err := m.store.GetPasswordSession(ctx, did) if err != nil { if errors.Is(err, keyring.ErrNotFound) { return ErrNotAuthenticated @@ -212,11 +293,31 @@ passwordAuth, ok := client.Auth.(*atclient.PasswordAuth) if !ok { // Corrupt password session — force clear. - _ = m.store.DeletePasswordSession(ctx) + _ = m.store.DeletePasswordSession(ctx, did) return nil } if err := passwordAuth.Logout(ctx, client.Client); err != nil { return fmt.Errorf("revoke password session: %w", err) } - return m.store.DeletePasswordSession(ctx) + return m.store.DeletePasswordSession(ctx, did) +} + +func (m *AuthManager) LogoutAll(ctx context.Context) error { + accounts, _, err := m.store.Accounts() + if err != nil { + return err + } + if len(accounts) == 0 { + return ErrNotAuthenticated + } + originalSelector := m.selector + defer func() { m.selector = originalSelector }() + var errs []error + for _, account := range accounts { + m.selector = account.DID + if err := m.Logout(ctx); err != nil { + errs = append(errs, fmt.Errorf("logout %s: %w", account.DID, err)) + } + } + return errors.Join(errs...) } diff --git a/atproto/auth_test.go b/atproto/auth_test.go --- a/atproto/auth_test.go +++ b/atproto/auth_test.go @@ -31,7 +31,7 @@ t.Fatalf("SavePasswordSession: %v", err) } - got, err := store.GetPasswordSession(ctx) + got, err := store.GetPasswordSession(ctx, did) if err != nil { t.Fatalf("GetPasswordSession: %v", err) } @@ -51,7 +51,7 @@ func TestPasswordSessionNotFound(t *testing.T) { store := testKeyringStore(newFakeKeyring()) - _, err := store.GetPasswordSession(context.Background()) + _, err := store.GetPasswordSession(context.Background(), "") if !errors.Is(err, keyring.ErrNotFound) { t.Errorf("GetPasswordSession = %v, want keyring.ErrNotFound", err) } @@ -81,6 +81,34 @@ _, err := manager.CurrentDID(context.Background()) if !errors.Is(err, ErrNotAuthenticated) { t.Errorf("CurrentDID = %v, want ErrNotAuthenticated", err) + } +} + +func TestCurrentDIDRejectsStalePasswordIndex(t *testing.T) { + backend := newFakeKeyring() + store := testKeyringStore(backend) + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + if err := store.SavePasswordSession(context.Background(), samplePasswordSession(did, "https://pds.example")); err != nil { + t.Fatal(err) + } + delete(backend.secrets, backendKey(keyringService, passwordKey(did.String()))) + if _, err := manager.CurrentDID(context.Background()); !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("CurrentDID = %v, want ErrNotAuthenticated", err) + } +} + +func TestCurrentDIDRejectsStaleOAuthIndex(t *testing.T) { + backend := newFakeKeyring() + store := testKeyringStore(backend) + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + if err := store.SaveSession(context.Background(), sampleSession(did)); err != nil { + t.Fatal(err) + } + delete(backend.secrets, backendKey(keyringService, sessionKey(did.String()))) + if _, err := manager.CurrentDID(context.Background()); !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("CurrentDID = %v, want ErrNotAuthenticated", err) } } @@ -147,7 +175,102 @@ if authorization != "Bearer refresh" { t.Errorf("Authorization = %q, want %q", authorization, "Bearer refresh") } - if _, err := store.GetPasswordSession(ctx); !errors.Is(err, keyring.ErrNotFound) { + if _, err := store.GetPasswordSession(ctx, did); !errors.Is(err, keyring.ErrNotFound) { t.Errorf("password session should have been removed, got: %v", err) + } +} + +func TestPasswordLogoutPreservesOtherAccount(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + store := testKeyringStore(newFakeKeyring()) + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) + ctx := context.Background() + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") + second := mustDID(t, "did:plc:secondsecondsecondsecond") + if err := store.SavePasswordSession(ctx, samplePasswordSession(first, server.URL)); err != nil { + t.Fatal(err) + } + if err := store.SetAccountHandle(first.String(), "first.example"); err != nil { + t.Fatal(err) + } + if err := store.SavePasswordSession(ctx, samplePasswordSession(second, server.URL)); err != nil { + t.Fatal(err) + } + if err := store.SetAccountHandle(second.String(), "second.example"); err != nil { + t.Fatal(err) + } + manager.SetAccount("second.example") + if err := manager.Logout(ctx); err != nil { + t.Fatalf("Logout: %v", err) + } + if _, err := store.GetPasswordSession(ctx, second); !errors.Is(err, keyring.ErrNotFound) { + t.Fatalf("logged-out account remains: %v", err) + } + if _, err := store.GetPasswordSession(ctx, first); err != nil { + t.Fatalf("other account was removed: %v", err) + } +} + +func TestAccountOverrideSelectsWithoutChangingDefault(t *testing.T) { + store := testKeyringStore(newFakeKeyring()) + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) + ctx := context.Background() + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") + second := mustDID(t, "did:plc:secondsecondsecondsecond") + if err := store.SavePasswordSession(ctx, samplePasswordSession(first, "https://one.example")); err != nil { + t.Fatal(err) + } + if err := store.SavePasswordSession(ctx, samplePasswordSession(second, "https://two.example")); err != nil { + t.Fatal(err) + } + if _, err := store.SelectAccount(first.String()); err != nil { + t.Fatal(err) + } + manager.SetAccount(second.String()) + _, did, err := manager.APIClient(ctx) + if err != nil || did != second { + t.Fatalf("override DID = %q, err = %v", did, err) + } + _, active, err := store.Accounts() + if err != nil || active != first.String() { + t.Fatalf("persistent active = %q, want %q (err %v)", active, first, err) + } +} + +func TestLogoutAllRevokesEveryAccount(t *testing.T) { + var requests int + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + store := testKeyringStore(newFakeKeyring()) + manager := newAuthManagerForTest("http://127.0.0.1:8095/callback", store) + ctx := context.Background() + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") + second := mustDID(t, "did:plc:secondsecondsecondsecond") + if err := store.SavePasswordSession(ctx, samplePasswordSession(first, server.URL)); err != nil { + t.Fatal(err) + } + if err := store.SavePasswordSession(ctx, samplePasswordSession(second, server.URL)); err != nil { + t.Fatal(err) + } + if err := manager.LogoutAll(ctx); err != nil { + t.Fatalf("LogoutAll: %v", err) + } + if requests != 2 { + t.Fatalf("deleteSession requests = %d, want 2", requests) + } + accounts, active, err := store.Accounts() + if err != nil { + t.Fatal(err) + } + if len(accounts) != 0 || active != "" { + t.Fatalf("accounts after logout = %#v active %q", accounts, active) } } diff --git a/atproto/keyring_store.go b/atproto/keyring_store.go --- a/atproto/keyring_store.go +++ b/atproto/keyring_store.go @@ -1,10 +1,16 @@ package atproto import ( + "bytes" + "compress/gzip" "context" + "encoding/base64" "encoding/json" "errors" "fmt" + "io" + "slices" + "strings" "sync" "github.com/bluesky-social/indigo/atproto/atclient" @@ -12,6 +18,8 @@ "github.com/bluesky-social/indigo/atproto/syntax" "github.com/zalando/go-keyring" ) + +const compressedSecretPrefix = "gzip:" // Reverse-DNS of the repo so it won't collide with other clients. const keyringService = "io.github.alyraffauf.tg" @@ -59,6 +67,28 @@ const currentPasswordKey = "password:current" +const accountIndexKey = "accounts:index" + +const ( + AuthMethodOAuth = "oauth" + AuthMethodPassword = "password" +) + +type Account struct { + DID string `json:"did"` + Handle string `json:"handle,omitempty"` + Method string `json:"method"` +} + +type accountIndex struct { + ActiveDID string `json:"activeDid,omitempty"` + Accounts []Account `json:"accounts"` +} + +func sessionKey(did string) string { return "oauth:" + did } + +func passwordKey(did string) string { return "password:" + did } + func requestKey(state string) string { return "request:" + state } @@ -68,7 +98,26 @@ if err != nil { return err } - if err := json.Unmarshal([]byte(data), target); err != nil { + decoded := []byte(data) + if strings.HasPrefix(data, compressedSecretPrefix) { + compressed, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(data, compressedSecretPrefix)) + if err != nil { + return fmt.Errorf("decode compressed secret %q: %w", key, err) + } + reader, err := gzip.NewReader(bytes.NewReader(compressed)) + if err != nil { + return fmt.Errorf("open compressed secret %q: %w", key, err) + } + decoded, err = io.ReadAll(reader) + closeErr := reader.Close() + if err != nil { + return fmt.Errorf("read compressed secret %q: %w", key, err) + } + if closeErr != nil { + return fmt.Errorf("close compressed secret %q: %w", key, closeErr) + } + } + if err := json.Unmarshal(decoded, target); err != nil { return fmt.Errorf("decode secret %q: %w", key, err) } return nil @@ -78,6 +127,17 @@ data, err := json.Marshal(value) if err != nil { return fmt.Errorf("marshal value: %w", err) + } + if len(data) > 1024 { + var compressed bytes.Buffer + writer := gzip.NewWriter(&compressed) + if _, err := writer.Write(data); err != nil { + return fmt.Errorf("compress value: %w", err) + } + if err := writer.Close(); err != nil { + return fmt.Errorf("finish compressed value: %w", err) + } + data = []byte(compressedSecretPrefix + base64.StdEncoding.EncodeToString(compressed.Bytes())) } return s.backend.Set(s.service, key, string(data)) } @@ -91,11 +151,184 @@ return nil } -func (s *KeyringStore) GetSession(_ context.Context, _ syntax.DID, _ string) (*oauth.ClientSessionData, error) { +func (s *KeyringStore) loadIndexLocked() (accountIndex, error) { + var index accountIndex + err := s.getSecret(accountIndexKey, &index) + if err == nil { + return index, nil + } + if !errors.Is(err, keyring.ErrNotFound) { + return accountIndex{}, err + } + return s.migrateLegacyLocked() +} + +func (s *KeyringStore) migrateLegacyLocked() (accountIndex, error) { + index := accountIndex{Accounts: []Account{}} + var oauthSession oauth.ClientSessionData + if err := s.getSecret(currentSessionKey, &oauthSession); err == nil { + did := oauthSession.AccountDID.String() + if err := s.saveSecret(sessionKey(did), oauthSession); err != nil { + return accountIndex{}, err + } + index.Accounts = append(index.Accounts, Account{DID: did, Method: AuthMethodOAuth}) + index.ActiveDID = did + } else if !errors.Is(err, keyring.ErrNotFound) { + return accountIndex{}, err + } + + var passwordSession atclient.PasswordSessionData + if err := s.getSecret(currentPasswordKey, &passwordSession); err == nil { + did := passwordSession.AccountDID.String() + if err := s.saveSecret(passwordKey(did), passwordSession); err != nil { + return accountIndex{}, err + } + if !slices.ContainsFunc(index.Accounts, func(a Account) bool { return a.DID == did }) { + index.Accounts = append(index.Accounts, Account{DID: did, Method: AuthMethodPassword}) + if index.ActiveDID == "" { + index.ActiveDID = did + } + } + } else if !errors.Is(err, keyring.ErrNotFound) { + return accountIndex{}, err + } + + if len(index.Accounts) == 0 { + return index, nil + } + if err := s.saveSecret(accountIndexKey, index); err != nil { + return accountIndex{}, err + } + if err := s.deleteSecret(currentSessionKey); err != nil { + return accountIndex{}, err + } + if err := s.deleteSecret(currentPasswordKey); err != nil { + return accountIndex{}, err + } + return index, nil +} + +func (s *KeyringStore) saveIndexLocked(index accountIndex) error { + return s.saveSecret(accountIndexKey, index) +} + +func (s *KeyringStore) upsertAccountLocked(index *accountIndex, account Account) { + for i := range index.Accounts { + if index.Accounts[i].DID == account.DID { + if account.Handle == "" { + account.Handle = index.Accounts[i].Handle + } + index.Accounts[i] = account + return + } + } + index.Accounts = append(index.Accounts, account) + if index.ActiveDID == "" { + index.ActiveDID = account.DID + } +} + +func (s *KeyringStore) removeAccountLocked(index *accountIndex, did, method string) { + index.Accounts = slices.DeleteFunc(index.Accounts, func(a Account) bool { + return a.DID == did && a.Method == method + }) + if index.ActiveDID == did && len(index.Accounts) > 0 { + index.ActiveDID = index.Accounts[0].DID + } else if len(index.Accounts) == 0 { + index.ActiveDID = "" + } +} + +func findAccount(index accountIndex, selector string) (Account, error) { + if selector == "" { + selector = index.ActiveDID + } + for _, account := range index.Accounts { + if account.DID == selector || strings.EqualFold(account.Handle, selector) { + return account, nil + } + } + return Account{}, keyring.ErrNotFound +} + +func (s *KeyringStore) Accounts() ([]Account, string, error) { s.mu.Lock() defer s.mu.Unlock() + index, err := s.loadIndexLocked() + if err != nil { + return nil, "", err + } + return slices.Clone(index.Accounts), index.ActiveDID, nil +} + +func (s *KeyringStore) Account(selector string) (Account, error) { + s.mu.Lock() + defer s.mu.Unlock() + index, err := s.loadIndexLocked() + if err != nil { + return Account{}, err + } + return findAccount(index, selector) +} + +func (s *KeyringStore) SelectAccount(selector string) (Account, error) { + s.mu.Lock() + defer s.mu.Unlock() + index, err := s.loadIndexLocked() + if err != nil { + return Account{}, err + } + account, err := findAccount(index, selector) + if err != nil { + return Account{}, err + } + switch account.Method { + case AuthMethodOAuth: + var session oauth.ClientSessionData + if err := s.getSecret(sessionKey(account.DID), &session); err != nil { + return Account{}, err + } + case AuthMethodPassword: + var session atclient.PasswordSessionData + if err := s.getSecret(passwordKey(account.DID), &session); err != nil { + return Account{}, err + } + default: + return Account{}, fmt.Errorf("unsupported auth method %q", account.Method) + } + index.ActiveDID = account.DID + return account, s.saveIndexLocked(index) +} + +func (s *KeyringStore) SetAccountHandle(did, handle string) error { + s.mu.Lock() + defer s.mu.Unlock() + index, err := s.loadIndexLocked() + if err != nil { + return err + } + for i := range index.Accounts { + if index.Accounts[i].DID == did { + index.Accounts[i].Handle = handle + return s.saveIndexLocked(index) + } + } + return keyring.ErrNotFound +} + +func (s *KeyringStore) GetSession(_ context.Context, did syntax.DID, _ string) (*oauth.ClientSessionData, error) { + s.mu.Lock() + defer s.mu.Unlock() + index, err := s.loadIndexLocked() + if err != nil { + return nil, err + } + account, err := findAccount(index, did.String()) + if err != nil || account.Method != AuthMethodOAuth { + return nil, keyring.ErrNotFound + } var session oauth.ClientSessionData - if err := s.getSecret(currentSessionKey, &session); err != nil { + if err := s.getSecret(sessionKey(account.DID), &session); err != nil { return nil, err } return &session, nil @@ -104,20 +337,52 @@ func (s *KeyringStore) SaveSession(_ context.Context, session oauth.ClientSessionData) error { s.mu.Lock() defer s.mu.Unlock() - return s.saveSecret(currentSessionKey, session) + index, err := s.loadIndexLocked() + if err != nil { + return err + } + did := session.AccountDID.String() + if err := s.saveSecret(sessionKey(did), session); err != nil { + return err + } + s.upsertAccountLocked(&index, Account{DID: did, Method: AuthMethodOAuth}) + if err := s.saveIndexLocked(index); err != nil { + return err + } + return s.deleteSecret(passwordKey(did)) } -func (s *KeyringStore) DeleteSession(_ context.Context, _ syntax.DID, _ string) error { +func (s *KeyringStore) DeleteSession(_ context.Context, did syntax.DID, _ string) error { s.mu.Lock() defer s.mu.Unlock() - return s.deleteSecret(currentSessionKey) + index, err := s.loadIndexLocked() + if err != nil { + return err + } + account, err := findAccount(index, did.String()) + if err != nil || account.Method != AuthMethodOAuth { + return nil + } + if err := s.deleteSecret(sessionKey(account.DID)); err != nil { + return err + } + s.removeAccountLocked(&index, account.DID, AuthMethodOAuth) + return s.saveIndexLocked(index) } -func (s *KeyringStore) GetPasswordSession(_ context.Context) (*atclient.PasswordSessionData, error) { +func (s *KeyringStore) GetPasswordSession(_ context.Context, did syntax.DID) (*atclient.PasswordSessionData, error) { s.mu.Lock() defer s.mu.Unlock() + index, err := s.loadIndexLocked() + if err != nil { + return nil, err + } + account, err := findAccount(index, did.String()) + if err != nil || account.Method != AuthMethodPassword { + return nil, keyring.ErrNotFound + } var session atclient.PasswordSessionData - if err := s.getSecret(currentPasswordKey, &session); err != nil { + if err := s.getSecret(passwordKey(account.DID), &session); err != nil { return nil, err } return &session, nil @@ -126,13 +391,37 @@ func (s *KeyringStore) SavePasswordSession(_ context.Context, session atclient.PasswordSessionData) error { s.mu.Lock() defer s.mu.Unlock() - return s.saveSecret(currentPasswordKey, session) + index, err := s.loadIndexLocked() + if err != nil { + return err + } + did := session.AccountDID.String() + if err := s.saveSecret(passwordKey(did), session); err != nil { + return err + } + s.upsertAccountLocked(&index, Account{DID: did, Method: AuthMethodPassword}) + if err := s.saveIndexLocked(index); err != nil { + return err + } + return s.deleteSecret(sessionKey(did)) } -func (s *KeyringStore) DeletePasswordSession(_ context.Context) error { +func (s *KeyringStore) DeletePasswordSession(_ context.Context, did syntax.DID) error { s.mu.Lock() defer s.mu.Unlock() - return s.deleteSecret(currentPasswordKey) + index, err := s.loadIndexLocked() + if err != nil { + return err + } + account, err := findAccount(index, did.String()) + if err != nil || account.Method != AuthMethodPassword { + return nil + } + if err := s.deleteSecret(passwordKey(account.DID)); err != nil { + return err + } + s.removeAccountLocked(&index, account.DID, AuthMethodPassword) + return s.saveIndexLocked(index) } func (s *KeyringStore) GetAuthRequestInfo(_ context.Context, state string) (*oauth.AuthRequestData, error) { diff --git a/atproto/keyring_store_test.go b/atproto/keyring_store_test.go --- a/atproto/keyring_store_test.go +++ b/atproto/keyring_store_test.go @@ -2,7 +2,9 @@ import ( "context" + "encoding/json" "errors" + "fmt" "reflect" "strings" "sync" @@ -142,7 +144,7 @@ } } -func TestKeyringStore_SaveOverwritesPrevious(t *testing.T) { +func TestKeyringStore_SavesMultipleAccounts(t *testing.T) { store := testKeyringStore(newFakeKeyring()) ctx := context.Background() first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") @@ -159,8 +161,143 @@ if err != nil { t.Fatalf("GetSession: %v", err) } + if got.AccountDID != first { + t.Errorf("AccountDID = %q, want %q", got.AccountDID, first) + } + got, err = store.GetSession(ctx, second, "") + if err != nil { + t.Fatalf("GetSession second: %v", err) + } if got.AccountDID != second { - t.Errorf("AccountDID = %q, want %q (second DID)", got.AccountDID, second) + t.Errorf("AccountDID = %q, want %q", got.AccountDID, second) + } +} + +func TestKeyringStore_SelectAccountByHandleOrDID(t *testing.T) { + store := testKeyringStore(newFakeKeyring()) + ctx := context.Background() + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") + second := mustDID(t, "did:plc:secondsecondsecondsecond") + if err := store.SaveSession(ctx, sampleSession(first)); err != nil { + t.Fatal(err) + } + if err := store.SetAccountHandle(first.String(), "first.example"); err != nil { + t.Fatal(err) + } + if err := store.SaveSession(ctx, sampleSession(second)); err != nil { + t.Fatal(err) + } + if err := store.SetAccountHandle(second.String(), "second.example"); err != nil { + t.Fatal(err) + } + + if _, err := store.SelectAccount("SECOND.EXAMPLE"); err != nil { + t.Fatalf("SelectAccount by handle: %v", err) + } + _, active, err := store.Accounts() + if err != nil || active != second.String() { + t.Fatalf("active = %q, err = %v", active, err) + } + if _, err := store.SelectAccount(first.String()); err != nil { + t.Fatalf("SelectAccount by DID: %v", err) + } +} + +func TestKeyringStore_SelectAccountRejectsStaleIndex(t *testing.T) { + backend := newFakeKeyring() + store := testKeyringStore(backend) + ctx := context.Background() + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") + second := mustDID(t, "did:plc:secondsecondsecondsecond") + if err := store.SaveSession(ctx, sampleSession(first)); err != nil { + t.Fatal(err) + } + if err := store.SaveSession(ctx, sampleSession(second)); err != nil { + t.Fatal(err) + } + delete(backend.secrets, backendKey(keyringService, sessionKey(second.String()))) + if _, err := store.SelectAccount(second.String()); !errors.Is(err, keyring.ErrNotFound) { + t.Fatalf("SelectAccount = %v, want keyring.ErrNotFound", err) + } + _, active, err := store.Accounts() + if err != nil || active != first.String() { + t.Fatalf("active changed after failed switch: %q, err %v", active, err) + } +} + +func TestKeyringStore_RefreshDoesNotChangeActiveAccount(t *testing.T) { + store := testKeyringStore(newFakeKeyring()) + ctx := context.Background() + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") + second := mustDID(t, "did:plc:secondsecondsecondsecond") + if err := store.SaveSession(ctx, sampleSession(first)); err != nil { + t.Fatal(err) + } + if err := store.SaveSession(ctx, sampleSession(second)); err != nil { + t.Fatal(err) + } + if _, err := store.SelectAccount(first.String()); err != nil { + t.Fatal(err) + } + refreshed := sampleSession(second) + refreshed.AccessToken = "rotated" + if err := store.SaveSession(ctx, refreshed); err != nil { + t.Fatal(err) + } + _, active, err := store.Accounts() + if err != nil || active != first.String() { + t.Fatalf("active after refresh = %q, want %q (err %v)", active, first, err) + } +} + +func TestKeyringStore_MethodReplacementIsPerAccount(t *testing.T) { + backend := newFakeKeyring() + store := testKeyringStore(backend) + ctx := context.Background() + first := mustDID(t, "did:plc:firstfirstfirstfirstfirst") + second := mustDID(t, "did:plc:secondsecondsecondsecond") + if err := store.SaveSession(ctx, sampleSession(first)); err != nil { + t.Fatal(err) + } + if err := store.SaveSession(ctx, sampleSession(second)); err != nil { + t.Fatal(err) + } + if err := store.SavePasswordSession(ctx, samplePasswordSession(first, "https://pds.example")); err != nil { + t.Fatal(err) + } + if _, err := store.GetSession(ctx, first, ""); !errors.Is(err, keyring.ErrNotFound) { + t.Fatalf("replaced OAuth session still available: %v", err) + } + if _, err := store.GetPasswordSession(ctx, first); err != nil { + t.Fatalf("password session unavailable: %v", err) + } + if _, err := store.GetSession(ctx, second, ""); err != nil { + t.Fatalf("other account was affected: %v", err) + } +} + +func TestKeyringStore_MigratesLegacySingleton(t *testing.T) { + backend := newFakeKeyring() + store := testKeyringStore(backend) + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + legacy, err := json.Marshal(sampleSession(did)) + if err != nil { + t.Fatal(err) + } + backend.secrets[backendKey(keyringService, currentSessionKey)] = string(legacy) + + accounts, active, err := store.Accounts() + if err != nil { + t.Fatalf("Accounts: %v", err) + } + if len(accounts) != 1 || accounts[0].DID != did.String() || active != did.String() { + t.Fatalf("migration result = %#v active %q", accounts, active) + } + if _, ok := backend.secrets[backendKey(keyringService, currentSessionKey)]; ok { + t.Fatal("legacy entry was not deleted") + } + if _, err := store.GetSession(context.Background(), did, ""); err != nil { + t.Fatalf("migrated session unavailable: %v", err) } } @@ -186,7 +323,6 @@ func TestKeyringStore_DeleteSessionPropagatesError(t *testing.T) { backend := newFakeKeyring() - backend.deleteErr = errors.New("keyring daemon unavailable") store := testKeyringStore(backend) ctx := context.Background() did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") @@ -194,6 +330,7 @@ if err := store.SaveSession(ctx, sampleSession(did)); err != nil { t.Fatalf("SaveSession: %v", err) } + backend.deleteErr = errors.New("keyring daemon unavailable") err := store.DeleteSession(ctx, did, "") if err == nil { @@ -301,6 +438,31 @@ } } +func TestKeyringStore_CompressesOversizedSecrets(t *testing.T) { + backend := newFakeKeyring() + store := testKeyringStore(backend) + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + session := fullyPopulatedSession(t, did) + session.Scopes = make([]string, 300) + for i := range session.Scopes { + session.Scopes[i] = fmt.Sprintf("repo:sh.tangled.collection.%03d", i) + } + if err := store.SaveSession(context.Background(), session); err != nil { + t.Fatalf("SaveSession: %v", err) + } + raw := backend.secrets[backendKey(keyringService, sessionKey(did.String()))] + if !strings.HasPrefix(raw, compressedSecretPrefix) { + t.Fatalf("oversized secret was not compressed") + } + got, err := store.GetSession(context.Background(), did, "") + if err != nil { + t.Fatalf("GetSession: %v", err) + } + if !reflect.DeepEqual(*got, session) { + t.Fatal("compressed session did not round-trip") + } +} + // TestKeyringStore_SessionRoundTrip_EmptyScopesAndRevocation verifies the // omitempty/empty-slice edge cases (empty scopes slice, empty revocation // endpoint) round-trip without losing the distinction that matters. @@ -374,11 +536,7 @@ } } -// TestKeyringStore_GetSessionIgnoresDID verifies the singleton contract the -// codebase relies on: the did and sessionID arguments are ignored, and the -// stored session is returned regardless of what is requested. This nails down -// the design so a future multi-session refactor is caught. -func TestKeyringStore_GetSessionIgnoresDID(t *testing.T) { +func TestKeyringStore_GetSessionUsesDID(t *testing.T) { store := testKeyringStore(newFakeKeyring()) ctx := context.Background() savedDID := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") @@ -387,12 +545,9 @@ } otherDID := mustDID(t, "did:plc:zzzzzzzzzzzzzzzzzzzzzzzz") - got, err := store.GetSession(ctx, otherDID, "nonexistent-session") - if err != nil { - t.Fatalf("GetSession with different DID: %v", err) - } - if got.AccountDID != savedDID { - t.Errorf("AccountDID = %q, want %q (singleton ignores requested DID)", got.AccountDID, savedDID) + _, err := store.GetSession(ctx, otherDID, "nonexistent-session") + if !errors.Is(err, keyring.ErrNotFound) { + t.Fatalf("GetSession with different DID = %v, want keyring.ErrNotFound", err) } } @@ -402,10 +557,13 @@ func TestKeyringStore_GetSessionMalformedJSON(t *testing.T) { backend := newFakeKeyring() store := testKeyringStore(backend) - // Seed a corrupt entry directly under the session key. - backend.secrets[backendKey(keyringService, currentSessionKey)] = "not-json{" + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + index := accountIndex{ActiveDID: did.String(), Accounts: []Account{{DID: did.String(), Method: AuthMethodOAuth}}} + data, _ := json.Marshal(index) + backend.secrets[backendKey(keyringService, accountIndexKey)] = string(data) + backend.secrets[backendKey(keyringService, sessionKey(did.String()))] = "not-json{" - _, err := store.GetSession(context.Background(), syntax.DID(""), "") + _, err := store.GetSession(context.Background(), did, "") if err == nil { t.Fatal("expected error for corrupt session, got nil") } diff --git a/internal/cli/auth_list.go b/internal/cli/auth_list.go new file mode 100644 --- /dev/null +++ b/internal/cli/auth_list.go @@ -0,0 +1,44 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var authListCmd = &cobra.Command{ + Use: "list", + Short: "List authenticated accounts", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + accounts, activeDID, err := auth.Accounts() + if err != nil { + return fmt.Errorf("list accounts: %w", err) + } + results := make([]authAccountResult, 0, len(accounts)) + for _, account := range accounts { + handle := account.Handle + resolved := resolveAuthor(cmd.Context(), account.DID) + if resolved.Handle != account.DID { + handle = resolved.Handle + } + results = append(results, authAccountResult{ + Active: account.DID == activeDID, + DID: account.DID, Handle: handle, Method: account.Method, + }) + } + return output(results, func(items []authAccountResult) { + if len(items) == 0 { + fmt.Println("No accounts.") + return + } + for _, item := range items { + marker := " " + if item.Active { + marker = "*" + } + fmt.Printf("%s %s %s %s\n", marker, item.Handle, item.DID, item.Method) + } + }) + }, +} diff --git a/internal/cli/auth_logout.go b/internal/cli/auth_logout.go --- a/internal/cli/auth_logout.go +++ b/internal/cli/auth_logout.go @@ -8,11 +8,18 @@ "github.com/spf13/cobra" ) +var authLogoutAll bool + var authLogoutCmd = &cobra.Command{ Use: "logout", Short: "Log out of your AT Protocol account", RunE: func(cmd *cobra.Command, args []string) error { - err := auth.Logout(cmd.Context()) + var err error + if authLogoutAll { + err = auth.LogoutAll(cmd.Context()) + } else { + err = auth.Logout(cmd.Context()) + } wasLoggedIn := true if err != nil { if errors.Is(err, atproto.ErrNotAuthenticated) { @@ -29,4 +36,8 @@ } }) }, +} + +func init() { + authLogoutCmd.Flags().BoolVar(&authLogoutAll, "all", false, "Log out all accounts") } diff --git a/internal/cli/auth_switch.go b/internal/cli/auth_switch.go new file mode 100644 --- /dev/null +++ b/internal/cli/auth_switch.go @@ -0,0 +1,25 @@ +package cli + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var authSwitchCmd = &cobra.Command{ + Use: "switch ", + Short: "Select the active account", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + account, err := auth.SelectAccount(args[0]) + if err != nil { + return fmt.Errorf("select account %q: %w", args[0], err) + } + resolved := resolveAuthor(cmd.Context(), account.DID) + return output(authAccountResult{ + Active: true, DID: account.DID, Handle: resolved.Handle, Method: account.Method, + }, func(item authAccountResult) { + fmt.Printf("Switched to %s\n", item.Handle) + }) + }, +} diff --git a/internal/cli/config.go b/internal/cli/config.go --- a/internal/cli/config.go +++ b/internal/cli/config.go @@ -38,6 +38,7 @@ config.SetEnvKeyReplacer(strings.NewReplacer(".", "_", "-", "_")) config.AutomaticEnv() config.SetDefault("appview", defaultAppview) + config.SetDefault("account", "") if err := config.ReadInConfig(); err != nil { if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); ok { diff --git a/internal/cli/output.go b/internal/cli/output.go --- a/internal/cli/output.go +++ b/internal/cli/output.go @@ -115,3 +115,10 @@ // when there was nothing to log out (not a failure; the command still exits 0). WasLoggedIn bool `json:"wasLoggedIn"` } + +type authAccountResult struct { + Active bool `json:"active"` + DID string `json:"did"` + Handle string `json:"handle"` + Method string `json:"method"` +} diff --git a/internal/cli/root.go b/internal/cli/root.go --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -33,6 +33,7 @@ SilenceUsage: true, PersistentPreRun: func(cmd *cobra.Command, args []string) { client.Client.Host = config.GetString("appview") + auth.SetAccount(config.GetString("account")) }, } @@ -46,14 +47,18 @@ rootCmd.PersistentFlags().StringVar(&configPath, "config", "", "Path to config file (default: $XDG_CONFIG_HOME/tg/config.toml)") rootCmd.PersistentFlags().BoolVar(&jsonOutput, "json", false, "Output in JSON format") rootCmd.PersistentFlags().String("appview", defaultAppview, "Appview host URL (overrides config file and TG_APPVIEW)") + rootCmd.PersistentFlags().String("account", "", "Account handle or DID to use (overrides the active account and TG_ACCOUNT)") config.BindPFlag("appview", rootCmd.PersistentFlags().Lookup("appview")) + config.BindPFlag("account", rootCmd.PersistentFlags().Lookup("account")) rootCmd.AddCommand(authCmd) authCmd.AddCommand(authLoginCmd) authCmd.AddCommand(authLogoutCmd) authCmd.AddCommand(authStatusCmd) authCmd.AddCommand(authTokenCmd) + authCmd.AddCommand(authListCmd) + authCmd.AddCommand(authSwitchCmd) rootCmd.AddCommand(issueCmd) issueCmd.AddCommand(issueListCmd)