diff --git a/atproto/auth.go b/atproto/auth.go index 73fe141..9c5dbd5 100644 --- a/atproto/auth.go +++ b/atproto/auth.go @@ -83,29 +83,86 @@ var DefaultScopes = []string{ type AuthManager struct { app *oauth.ClientApp store *KeyringStore + insecureStore *insecureFileStore client *http.Client selector string pendingIdentifier string } +// sessionSource identifies which credential store backs the active session. +type sessionSource int + +const ( + sessionSourceKeyring sessionSource = iota + sessionSourceInsecureFile +) + func (m *AuthManager) SetAccount(selector string) { m.selector = selector } +// Accounts lists stored accounts from both credential stores. A file account +// lets this succeed when the keyring is unavailable; otherwise keyring errors +// are returned. func (m *AuthManager) Accounts() ([]Account, string, error) { - return m.store.Accounts() + accounts, activeDID, keyringErr := m.store.Accounts() + fileAccount, hasFileAccount, err := m.findInsecureFileAccount() + if err != nil { + return nil, "", err + } + if hasFileAccount { + accounts = append(accounts, fileAccount) + if activeDID == "" { + activeDID = fileAccount.DID + } + } + if keyringErr != nil && !errors.Is(keyringErr, keyring.ErrNotFound) && !hasFileAccount { + return nil, "", keyringErr + } + return accounts, activeDID, nil } func (m *AuthManager) SelectAccount(selector string) (Account, error) { - return m.store.SelectAccount(selector) + account, err := m.store.SelectAccount(selector) + if err == nil { + return account, nil + } + fileAccount, hasFileAccount, fileErr := m.findInsecureFileAccount() + if fileErr != nil { + return Account{}, fileErr + } + if hasFileAccount && selectorMatches(selector, fileAccount) { + m.selector = selector + return fileAccount, nil + } + return Account{}, err } -func (m *AuthManager) activeAccount() (Account, error) { +func (m *AuthManager) activeAccount() (Account, sessionSource, error) { + // Prefer the keyring when an account exists in both stores. account, err := m.store.Account(m.selector) - if errors.Is(err, keyring.ErrNotFound) { - return Account{}, ErrNotAuthenticated + if err == nil { + return account, sessionSourceKeyring, nil + } + fileAccount, hasFileAccount, fileErr := m.findInsecureFileAccount() + if fileErr != nil { + return Account{}, sessionSourceKeyring, fileErr + } + if hasFileAccount && selectorMatches(m.selector, fileAccount) { + return fileAccount, sessionSourceInsecureFile, nil } - return account, err + if !errors.Is(err, keyring.ErrNotFound) { + return Account{}, sessionSourceKeyring, err + } + return Account{}, sessionSourceKeyring, ErrNotAuthenticated +} + +// findInsecureFileAccount returns the sole account stored in the insecure credential file. +func (m *AuthManager) findInsecureFileAccount() (Account, bool, error) { + if m.insecureStore == nil { + return Account{}, false, nil + } + return m.insecureStore.FindAccount() } func NewAuthManager(callbackURL string) *AuthManager { @@ -120,25 +177,40 @@ func NewAuthManagerWithClient(callbackURL string, httpClient *http.Client) *Auth store := NewKeyringStore() app := oauth.NewClientApp(&config, store) app.Client = httpClient + // Best-effort: if the credentials directory cannot be created (e.g. no home + // directory), --insecure login will surface a clear error instead. + insecureStore, _ := newInsecureFileStore() return &AuthManager{ - app: app, - store: store, - client: httpClient, + app: app, + store: store, + insecureStore: insecureStore, + client: httpClient, } } // 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 for this account. -func (m *AuthManager) LoginWithPassword(ctx context.Context, identifier, password string) error { +// resulting session. When useInsecureFileStore is true the session is written to +// plaintext instead of the keyring; this is intended +// for headless systems without a Secret Service provider. Keyring-backed logins +// replace any existing OAuth session for the account. +func (m *AuthManager) LoginWithPassword(ctx context.Context, identifier, password string, useInsecureFileStore bool) error { + if useInsecureFileStore && m.insecureStore == nil { + return errors.New("insecure credential storage is unavailable") + } parsedIdentifier, err := syntax.ParseAtIdentifier(identifier) if err != nil { return err } ctx, cancel := m.requestContext(ctx) defer cancel() + savePasswordSession := func(data atclient.PasswordSessionData) error { + if useInsecureFileStore { + return m.insecureStore.SavePasswordSession(identifier, data) + } + return m.store.SavePasswordSession(context.Background(), data) + } persist := func(_ context.Context, data atclient.PasswordSessionData) { - _ = m.store.SavePasswordSession(context.Background(), data) + _ = savePasswordSession(data) } client, err := atclient.LoginWithPassword(ctx, identity.DefaultDirectory(), parsedIdentifier, password, "", persist) if err != nil { @@ -149,7 +221,14 @@ func (m *AuthManager) LoginWithPassword(ctx context.Context, identifier, passwor return errors.New("password login returned an unexpected auth type") } client.Client = m.client - if err := m.store.SavePasswordSession(ctx, passwordAuth.Session); err != nil { + if useInsecureFileStore { + if err := savePasswordSession(passwordAuth.Session); err != nil { + return err + } + m.selector = passwordAuth.Session.AccountDID.String() + return nil + } + if err := savePasswordSession(passwordAuth.Session); err != nil { return err } did := passwordAuth.Session.AccountDID.String() @@ -195,7 +274,7 @@ func (m *AuthManager) CancelLogin() { } func (m *AuthManager) CurrentDID(ctx context.Context) (syntax.DID, error) { - account, err := m.activeAccount() + account, source, err := m.activeAccount() if err != nil { return "", err } @@ -203,6 +282,9 @@ func (m *AuthManager) CurrentDID(ctx context.Context) (syntax.DID, error) { if err != nil { return "", err } + if source == sessionSourceInsecureFile { + return did, nil + } if account.Method == AuthMethodOAuth { session, err := m.app.ResumeSession(ctx, did, "") if err != nil { @@ -224,11 +306,13 @@ func (m *AuthManager) CurrentDID(ctx context.Context) (syntax.DID, error) { } func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, error) { - account, err := m.activeAccount() + account, source, err := m.activeAccount() if err != nil { return nil, err } - if account.Method != AuthMethodOAuth { + // The file store only holds app-password sessions, so there is no OAuth + // ClientSession to return. Callers fall back to APIClient for token access. + if source == sessionSourceInsecureFile || account.Method != AuthMethodOAuth { return nil, ErrNotAuthenticated } did, err := syntax.ParseDID(account.DID) @@ -246,13 +330,29 @@ func (m *AuthManager) CurrentSession(ctx context.Context) (*oauth.ClientSession, } // APIClient returns an API client and the account DID for the active session, -// whether OAuth or app-password. Token refreshes are persisted back to the -// keyring. +// whether OAuth or app-password and whether backed by the keyring or the +// insecure file store. Token refreshes are persisted back to whichever store +// backs the session. func (m *AuthManager) APIClient(ctx context.Context) (*atclient.APIClient, syntax.DID, error) { - account, err := m.activeAccount() + account, source, err := m.activeAccount() if err != nil { return nil, "", err } + if source == sessionSourceInsecureFile { + session, _, err := m.insecureStore.GetPasswordSession() + if err != nil { + if errors.Is(err, keyring.ErrNotFound) { + return nil, "", ErrNotAuthenticated + } + return nil, "", err + } + persist := func(_ context.Context, data atclient.PasswordSessionData) { + _ = m.insecureStore.SavePasswordSession(account.Handle, data) + } + client := atclient.ResumePasswordSession(*session, persist) + client.Client = m.client + return client, session.AccountDID, nil + } did, err := syntax.ParseDID(account.DID) if err != nil { return nil, "", err @@ -313,15 +413,18 @@ func (m *AuthManager) SessionStatus(ctx context.Context) (string, syntax.DID, er return SessionStatusUnknown, did, nil } -// Logout removes the active account's credentials from the local keyring. -// Server-side revocation is best-effort; the local entry is removed even -// if the PDS rejects the revoke request. Returns ErrNotAuthenticated when -// there is no active account. +// Logout removes the active account's credentials from whichever store backs +// it (keyring or insecure file). Server-side revocation is best-effort; the +// local entry is removed even if the PDS rejects the revoke request. Returns +// ErrNotAuthenticated when there is no active account. func (m *AuthManager) Logout(ctx context.Context) error { - account, err := m.activeAccount() + account, source, err := m.activeAccount() if err != nil { return err } + if source == sessionSourceInsecureFile { + return m.logoutFile(ctx) + } did, err := syntax.ParseDID(account.DID) if err != nil { return err @@ -353,12 +456,36 @@ func (m *AuthManager) Logout(ctx context.Context) error { return nil } +// logoutFile revokes the file-backed session server-side (best-effort) and +// deletes the credentials file. +func (m *AuthManager) logoutFile(ctx context.Context) error { + session, _, _ := m.insecureStore.GetPasswordSession() + if session != nil { + client := atclient.ResumePasswordSession(*session, nil) + client.Client = m.client + if passwordAuth, ok := client.Auth.(*atclient.PasswordAuth); ok { + _ = passwordAuth.Logout(ctx, client.Client) + } + } + if err := m.insecureStore.DeletePasswordSession(); err != nil { + return fmt.Errorf("clear local session: %w", err) + } + return nil +} + func (m *AuthManager) LogoutAll(ctx context.Context) error { - accounts, _, err := m.store.Accounts() + accounts, _, keyringErr := m.store.Accounts() + _, hasFileAccount, err := m.findInsecureFileAccount() if err != nil { return err } - if len(accounts) == 0 { + if keyringErr != nil && !errors.Is(keyringErr, keyring.ErrNotFound) { + if !hasFileAccount { + return keyringErr + } + accounts = nil + } + if len(accounts) == 0 && !hasFileAccount { return ErrNotAuthenticated } originalSelector := m.selector @@ -370,5 +497,20 @@ func (m *AuthManager) LogoutAll(ctx context.Context) error { errs = append(errs, fmt.Errorf("logout %s: %w", account.DID, err)) } } + if hasFileAccount { + if err := m.logoutFile(ctx); err != nil { + errs = append(errs, fmt.Errorf("logout file session: %w", err)) + } + } return errors.Join(errs...) } + +// selectorMatches reports whether selector (empty, DID, or handle) identifies +// the given account, mirroring findAccount's matching for the single-account +// file store. +func selectorMatches(selector string, account Account) bool { + if selector == "" { + return true + } + return account.DID == selector || strings.EqualFold(account.Handle, selector) +} diff --git a/atproto/file_store.go b/atproto/file_store.go new file mode 100644 index 0000000..0ebddaf --- /dev/null +++ b/atproto/file_store.go @@ -0,0 +1,136 @@ +package atproto + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/bluesky-social/indigo/atproto/atclient" + "github.com/zalando/go-keyring" +) + +const ( + credentialsDirectoryMode = 0o700 + credentialsFileMode = 0o600 + credentialsFileName = "credentials.json" +) + +// insecureFileStore persists a single app-password session to a plaintext file for +// environments without a Secret Service provider (e.g. headless servers). It is +// opt-in via `tg auth login --insecure` and stores exactly one account; +// re-login overwrites the previous entry. +type insecureFileStore struct { + path string + mu sync.Mutex +} + +// credentialRecord is the on-disk JSON shape: the account handle alongside the +// session, so the active account can be reported without resolving the DID. +type credentialRecord struct { + Handle string `json:"handle"` + Session atclient.PasswordSessionData `json:"session"` +} + +// newInsecureFileStore creates a store rooted at $XDG_DATA_HOME/tg/credentials.json +// (default ~/.local/share/tg/credentials.json), creating the directory with +// 0700 permissions. Returns an error if the home directory cannot be located or +// the directory cannot be created. +func newInsecureFileStore() (*insecureFileStore, error) { + dir, err := insecureCredentialsDir() + if err != nil { + return nil, err + } + if err := os.MkdirAll(dir, credentialsDirectoryMode); err != nil { + return nil, fmt.Errorf("create credentials directory: %w", err) + } + return &insecureFileStore{path: filepath.Join(dir, credentialsFileName)}, nil +} + +func insecureCredentialsDir() (string, error) { + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, "tg"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("locate home directory: %w", err) + } + return filepath.Join(home, ".local", "share", "tg"), nil +} + +// load reads and decodes the credential file. A missing file is reported as +// keyring.ErrNotFound so callers can share the same not-found handling as the +// keyring path. +func (s *insecureFileStore) load() (*credentialRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + data, err := os.ReadFile(s.path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil, keyring.ErrNotFound + } + return nil, fmt.Errorf("read credentials file: %w", err) + } + var record credentialRecord + if err := json.Unmarshal(data, &record); err != nil { + return nil, fmt.Errorf("parse credentials file: %w", err) + } + return &record, nil +} + +// save writes the record to disk with restrictive permissions, replacing any existing entry. +func (s *insecureFileStore) save(record credentialRecord) error { + s.mu.Lock() + defer s.mu.Unlock() + data, err := json.Marshal(record) + if err != nil { + return fmt.Errorf("marshal credentials: %w", err) + } + if err := os.WriteFile(s.path, data, credentialsFileMode); err != nil { + return fmt.Errorf("write credentials file: %w", err) + } + return nil +} + +// GetPasswordSession returns the stored session and the handle it was saved +// under. +func (s *insecureFileStore) GetPasswordSession() (*atclient.PasswordSessionData, string, error) { + record, err := s.load() + if err != nil { + return nil, "", err + } + session := record.Session + return &session, record.Handle, nil +} + +// FindAccount returns the account described by the stored app-password session. +func (s *insecureFileStore) FindAccount() (Account, bool, error) { + session, handle, err := s.GetPasswordSession() + if errors.Is(err, keyring.ErrNotFound) { + return Account{}, false, nil + } + if err != nil { + return Account{}, false, err + } + return Account{DID: session.AccountDID.String(), Handle: handle, Method: AuthMethodPassword}, true, nil +} + +// SavePasswordSession writes the session for the given handle, overwriting any +// existing entry. +func (s *insecureFileStore) SavePasswordSession(handle string, session atclient.PasswordSessionData) error { + return s.save(credentialRecord{Handle: handle, Session: session}) +} + +// DeletePasswordSession removes the credential file. A missing file is not an +// error. +func (s *insecureFileStore) DeletePasswordSession() error { + s.mu.Lock() + defer s.mu.Unlock() + err := os.Remove(s.path) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("delete credentials file: %w", err) + } + return nil +} diff --git a/atproto/file_store_test.go b/atproto/file_store_test.go new file mode 100644 index 0000000..7524826 --- /dev/null +++ b/atproto/file_store_test.go @@ -0,0 +1,344 @@ +package atproto + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/zalando/go-keyring" +) + +func newTestInsecureFileStore(t *testing.T) *insecureFileStore { + t.Helper() + t.Setenv("XDG_DATA_HOME", t.TempDir()) + store, err := newInsecureFileStore() + if err != nil { + t.Fatalf("newInsecureFileStore: %v", err) + } + return store +} + +func TestFileStore_RoundTrip(t *testing.T) { + store := newTestInsecureFileStore(t) + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + session := samplePasswordSession(did, "https://pds.example.com") + + if err := store.SavePasswordSession("alice.example.com", session); err != nil { + t.Fatalf("SavePasswordSession: %v", err) + } + + got, handle, err := store.GetPasswordSession() + if err != nil { + t.Fatalf("GetPasswordSession: %v", err) + } + if handle != "alice.example.com" { + t.Errorf("handle = %q, want %q", handle, "alice.example.com") + } + if got.AccountDID != did { + t.Errorf("AccountDID = %v, want %v", got.AccountDID, did) + } + if got.AccessToken != "access" { + t.Errorf("AccessToken = %q, want %q", got.AccessToken, "access") + } +} + +func TestFileStore_NotFoundWhenAbsent(t *testing.T) { + store := newTestInsecureFileStore(t) + _, _, err := store.GetPasswordSession() + if !errors.Is(err, keyring.ErrNotFound) { + t.Fatalf("expected keyring.ErrNotFound, got %v", err) + } +} + +func TestFileStore_DeleteIsIdempotent(t *testing.T) { + store := newTestInsecureFileStore(t) + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + if err := store.SavePasswordSession("alice.example.com", samplePasswordSession(did, "https://pds.example.com")); err != nil { + t.Fatalf("SavePasswordSession: %v", err) + } + if err := store.DeletePasswordSession(); err != nil { + t.Fatalf("first DeletePasswordSession: %v", err) + } + if err := store.DeletePasswordSession(); err != nil { + t.Fatalf("second DeletePasswordSession: %v", err) + } +} + +func TestFileStore_FilePermissions(t *testing.T) { + store := newTestInsecureFileStore(t) + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + if err := store.SavePasswordSession("alice.example.com", samplePasswordSession(did, "https://pds.example.com")); err != nil { + t.Fatalf("SavePasswordSession: %v", err) + } + + info, err := os.Stat(store.path) + if err != nil { + t.Fatalf("stat credentials file: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("file mode = %o, want 0600", perm) + } + + dir := filepath.Dir(store.path) + dirInfo, err := os.Stat(dir) + if err != nil { + t.Fatalf("stat credentials dir: %v", err) + } + if perm := dirInfo.Mode().Perm(); perm != 0o700 { + t.Errorf("dir mode = %o, want 0700", perm) + } +} + +func TestFileStore_OverwritesPreviousEntry(t *testing.T) { + store := newTestInsecureFileStore(t) + first := mustDID(t, "did:plc:111111111111111111111111") + second := mustDID(t, "did:plc:222222222222222222222222") + + if err := store.SavePasswordSession("alice.example.com", samplePasswordSession(first, "https://pds.example.com")); err != nil { + t.Fatalf("save first: %v", err) + } + if err := store.SavePasswordSession("bob.example.com", samplePasswordSession(second, "https://pds.example.com")); err != nil { + t.Fatalf("save second: %v", err) + } + + got, handle, err := store.GetPasswordSession() + if err != nil { + t.Fatalf("GetPasswordSession: %v", err) + } + if got.AccountDID != second { + t.Errorf("AccountDID = %v, want %v (latest entry)", got.AccountDID, second) + } + if handle != "bob.example.com" { + t.Errorf("handle = %q, want %q", handle, "bob.example.com") + } +} + +// newAuthManagerWithFileStore builds an AuthManager backed by a file store +// rooted in a temp directory and a fake keyring (so keyring lookups always miss +// unless a test seeds them). +func newAuthManagerWithFileStore(t *testing.T, callbackURL string) *AuthManager { + t.Helper() + t.Setenv("XDG_DATA_HOME", t.TempDir()) + insecureStore, err := newInsecureFileStore() + if err != nil { + t.Fatalf("newInsecureFileStore: %v", err) + } + manager := newAuthManagerForTest(callbackURL, testKeyringStore(newFakeKeyring())) + manager.insecureStore = insecureStore + return manager +} + +func saveFileSession(t *testing.T, manager *AuthManager, did syntax.DID) { + t.Helper() + if err := manager.insecureStore.SavePasswordSession("alice.example.com", samplePasswordSession(did, "https://pds.example.com")); err != nil { + t.Fatalf("SavePasswordSession: %v", err) + } +} + +func makeKeyringUnavailable(t *testing.T, manager *AuthManager) { + t.Helper() + backend, ok := manager.store.backend.(*fakeKeyring) + if !ok { + t.Fatal("test manager does not use a fake keyring") + } + backend.getErr = errors.New("Secret Service is unavailable") +} + +func TestAuthManager_CurrentDIDFromFile(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, did) + + got, err := manager.CurrentDID(context.Background()) + if err != nil { + t.Fatalf("CurrentDID: %v", err) + } + if got != did { + t.Errorf("CurrentDID = %v, want %v", got, did) + } +} + +func TestAuthManager_CurrentDIDFromFileWhenKeyringUnavailable(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, did) + makeKeyringUnavailable(t, manager) + + got, err := manager.CurrentDID(context.Background()) + if err != nil { + t.Fatalf("CurrentDID: %v", err) + } + if got != did { + t.Errorf("CurrentDID = %v, want %v", got, did) + } +} + +func TestAuthManager_PrefersKeyringAccountOverFileAccount(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + keyringDID := mustDID(t, "did:plc:111111111111111111111111") + fileDID := mustDID(t, "did:plc:222222222222222222222222") + if err := manager.store.SavePasswordSession(context.Background(), samplePasswordSession(keyringDID, "https://pds.example.com")); err != nil { + t.Fatalf("SavePasswordSession: %v", err) + } + saveFileSession(t, manager, fileDID) + + account, source, err := manager.activeAccount() + if err != nil { + t.Fatalf("activeAccount: %v", err) + } + if source != sessionSourceKeyring { + t.Errorf("source = %v, want keyring", source) + } + if account.DID != keyringDID.String() { + t.Errorf("account DID = %q, want %q", account.DID, keyringDID) + } +} + +func TestAuthManager_CurrentSessionFileIsNotOAuth(t *testing.T) { + // The file store is password-only, so CurrentSession must report + // ErrNotAuthenticated so callers (e.g. AccessToken) fall back to APIClient. + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, did) + _, err := manager.CurrentSession(context.Background()) + if !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("expected ErrNotAuthenticated for file-backed session, got %v", err) + } +} + +func TestAuthManager_APIClientReadsFromFile(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, did) + + client, gotDID, err := manager.APIClient(context.Background()) + if err != nil { + t.Fatalf("APIClient: %v", err) + } + if gotDID != did { + t.Errorf("APIClient DID = %v, want %v", gotDID, did) + } + if client == nil { + t.Fatal("APIClient returned nil client") + } +} + +func TestAuthManager_LogoutDeletesFile(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, did) + + if err := manager.Logout(context.Background()); err != nil { + t.Fatalf("Logout: %v", err) + } + if _, _, err := manager.insecureStore.GetPasswordSession(); !errors.Is(err, keyring.ErrNotFound) { + t.Fatalf("expected credentials file deleted, got %v", err) + } +} + +func TestAuthManager_LogoutWithoutAnySession(t *testing.T) { + // Neither keyring nor file store has a session. + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + err := manager.Logout(context.Background()) + if !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("expected ErrNotAuthenticated, got %v", err) + } +} + +func TestAuthManager_AccountsIncludesFileAccount(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, did) + + accounts, activeDID, err := manager.Accounts() + if err != nil { + t.Fatalf("Accounts: %v", err) + } + if len(accounts) != 1 { + t.Fatalf("expected 1 account, got %d", len(accounts)) + } + if accounts[0].DID != did.String() { + t.Errorf("account DID = %q, want %q", accounts[0].DID, did.String()) + } + if accounts[0].Method != AuthMethodPassword { + t.Errorf("account method = %q, want %q", accounts[0].Method, AuthMethodPassword) + } + if activeDID != did.String() { + t.Errorf("activeDID = %q, want %q", activeDID, did.String()) + } +} + +func TestAuthManager_SelectAccountFindsFileAccount(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, did) + + account, err := manager.SelectAccount(did.String()) + if err != nil { + t.Fatalf("SelectAccount by DID: %v", err) + } + if account.DID != did.String() { + t.Errorf("account DID = %q, want %q", account.DID, did.String()) + } + + account, err = manager.SelectAccount("alice.example.com") + if err != nil { + t.Fatalf("SelectAccount by handle: %v", err) + } + if account.DID != did.String() { + t.Errorf("account DID = %q, want %q", account.DID, did.String()) + } +} + +func TestAuthManager_SelectAccountFindsFileAccountWhenKeyringUnavailable(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, did) + makeKeyringUnavailable(t, manager) + + account, err := manager.SelectAccount("alice.example.com") + if err != nil { + t.Fatalf("SelectAccount: %v", err) + } + if account.DID != did.String() { + t.Errorf("account DID = %q, want %q", account.DID, did.String()) + } +} + +func TestAuthManager_LogoutAllClearsFileAndKeyring(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + fileDID := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, fileDID) + + if err := manager.LogoutAll(context.Background()); err != nil { + t.Fatalf("LogoutAll: %v", err) + } + if _, _, err := manager.insecureStore.GetPasswordSession(); !errors.Is(err, keyring.ErrNotFound) { + t.Fatalf("expected credentials file deleted after LogoutAll, got %v", err) + } +} + +func TestAuthManager_LogoutAllClearsFileWhenKeyringUnavailable(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + did := mustDID(t, "did:plc:aaaabbbbccccddddeeeeffff") + saveFileSession(t, manager, did) + makeKeyringUnavailable(t, manager) + + if err := manager.LogoutAll(context.Background()); err != nil { + t.Fatalf("LogoutAll: %v", err) + } + if _, _, err := manager.insecureStore.GetPasswordSession(); !errors.Is(err, keyring.ErrNotFound) { + t.Fatalf("expected credentials file deleted after LogoutAll, got %v", err) + } +} + +func TestAuthManager_LogoutAllWithoutAnySession(t *testing.T) { + manager := newAuthManagerWithFileStore(t, "http://127.0.0.1:8095/callback") + err := manager.LogoutAll(context.Background()) + if !errors.Is(err, ErrNotAuthenticated) { + t.Fatalf("expected ErrNotAuthenticated, got %v", err) + } +} diff --git a/docs/authentication.md b/docs/authentication.md index b6f8022..b740780 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -43,5 +43,20 @@ Secret Service provider to be running; on a headless system without a D-Bus session bus (e.g. a server or container), install and start `gnome-keyring-daemon` or `kwalletd`, or set `DBUS_SESSION_BUS_ADDRESS`. +### Insecure file storage (`--insecure`) + +For an app-password login without a keyring, use `--insecure`: + +```bash +tg auth login alice.example.com xxxx-xxxx-xxxx-xxxx --insecure +``` + +This stores one account in plaintext at `$XDG_DATA_HOME/tg/credentials.json` +(default `~/.local/share/tg/credentials.json`); logging in again overwrites it. +The directory and file are created with `0700` and `0600` permissions. + +Use the keyring when available. `--insecure` requires an app password; it does +not support OAuth. + See the [command reference](commands/tg_auth.md) for the full `tg auth` subcommand list. diff --git a/internal/app/auth.go b/internal/app/auth.go index 1fb0597..4554a15 100644 --- a/internal/app/auth.go +++ b/internal/app/auth.go @@ -18,9 +18,10 @@ const ( SessionStatusUnknown = atproto.SessionStatusUnknown ) -// LoginWithPassword authenticates an account with an app password. -func (s *Service) LoginWithPassword(ctx context.Context, identifier, password string) error { - return s.auth.LoginWithPassword(ctx, identifier, password) +// LoginWithPassword authenticates an account with an app password. When +// useInsecureFileStore is true, the session is stored in plaintext instead of the keyring. +func (s *Service) LoginWithPassword(ctx context.Context, identifier, password string, useInsecureFileStore bool) error { + return s.auth.LoginWithPassword(ctx, identifier, password, useInsecureFileStore) } // CurrentDID returns the DID for the active account. diff --git a/internal/cli/auth_login.go b/internal/cli/auth_login.go index 1d317c4..079320e 100644 --- a/internal/cli/auth_login.go +++ b/internal/cli/auth_login.go @@ -16,6 +16,7 @@ import ( func newAuthLoginCommand(service *app.Service) *cobra.Command { var passwordStdin bool + var useInsecureFileStore bool command := &cobra.Command{ Use: "login [app-password]", @@ -28,8 +29,11 @@ func newAuthLoginCommand(service *app.Service) *cobra.Command { if err != nil { return err } + if useInsecureFileStore && !usePassword { + return fmt.Errorf("--insecure requires an app password; pass one as the second argument or use --password-stdin") + } if usePassword { - if err := service.LoginWithPassword(cmd.Context(), identifier, password); err != nil { + if err := service.LoginWithPassword(cmd.Context(), identifier, password, useInsecureFileStore); err != nil { return err } did, err := service.CurrentDID(cmd.Context()) @@ -78,6 +82,7 @@ func newAuthLoginCommand(service *app.Service) *cobra.Command { }, } command.Flags().BoolVar(&passwordStdin, "password-stdin", false, "Read the app password from standard input") + command.Flags().BoolVar(&useInsecureFileStore, "insecure", false, "Store credentials in a file (~/.local/share/tg/credentials.json) instead of the system keyring. Requires an app password.") return command }